fix(rp-login): send the authenticate type the RP actually binds - #139
Conversation
DIDComm login to a did-hosting RP sent `https://affinidi.com/webvh/1.0/authenticate` and expected `…/authenticate-response`. The control plane's DIDComm router binds neither: `did-hosting-common`'s `didcomm_types.rs` has pub const MSG_AUTHENTICATE: &str = ".../spec/auth/authenticate/0.1"; pub const MSG_AUTH_RESPONSE: &str = ".../spec/auth/authenticate/0.1#response"; so the request did not route to a handler, and a reply under the canonical type would have been refused here as the wrong type in any case. Login over DIDComm could not succeed against a current RP at all — the user approves the consent prompt, and it fails after. This is the drift R3.6 exists for, and the same failure vti-didcomm-js 0.6.1 had against the VTA: a retired vendor URI left behind when the server moved to the canonical one. Nothing caught it because nothing tested this module — the two type URIs were string constants no assertion ever read. Matched with `===` against the spelling the RP declares today. No both-spellings fold, and a test asserts the retired response type is still refused so one cannot creep back as a "safe" addition. Tests pin the contract in both directions, including the request type by unpacking the outgoing envelope as the RP would — that is the assertion that would have caught this, and the reply-side assertions alone would not have. **A conformance gap is left open, and now written down at the site.** The canonical `auth/authenticate/0.1` schema declares `challenge` and `sessionId` REQUIRED; the RP's DIDComm handler authenticates on the authcrypt sender (`run_authenticate(&state, sender)`) and reads nothing from the body, and its challenge endpoint is REST-only. So this sends a canonical type over a body that does not satisfy it — a shape the RP chose when it bound a sender-authenticated handler to that URI. Matching the RP is strictly better than sending a URI nothing routes; closing the gap properly means the challenge-based flow over a `TrustTaskSender`, the shape `vta/auth-tasks.ts` already uses against the VTA. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Both RP-login flows — REST SIOPv2 (`login`) and DIDComm (`loginDidcomm`) — could only be exercised against a real deployment, so in practice they were not exercised at all. One of them was broken for an unknown length of time as a direct result: the wallet sent a retired `authenticate` Type URI the control plane no longer routes, a site-initiated login failed after the user had already approved the consent prompt, and nothing surfaced it (#139). This is the missing half of that loop. A page that calls `window.vtaWallet` the way a relying party does, against a control plane of your choosing — the live one, or a `did-hosting-control` on localhost — and shows what came back. It asserts nothing. The point is to make the round-trip observable next to the wallet's own console, which is where the diagnosis actually happens; a harness that judged the result would just be a worse unit test with a network dependency. Kept out of `server.mjs` deliberately: that is a password-login target for the VTA's `vault/proxy-login` driver and shares nothing with this but a workspace. It also reports when no provider is present rather than leaving a dead button — content scripts are registered per granted origin and never reach a tab that is already open, so a first visit after granting needs a reload, and that is a confusing five minutes if the page says nothing. 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 #139
🗺️ Scan CoverageModules scanned: 1 · with findings: 1 · files: 2 · findings: 3
Executive Summary
🔒 Security Issues
|
| Field | Detail |
|---|---|
| Severity | HIGH |
| Location | packages/core/src/rp-login/didcomm.ts:36 |
| Finding ID | github_pr-b32de6ae644d |
| CWE | CWE-345, CWE-294 |
| OWASP | A07:2021 - Identification and Authentication Failures |
| MITRE ATT&CK | T1550 - Use Alternate Authentication Material |
| CAPEC | CAPEC-60, CAPEC-459 |
| DREAD | 5.2 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | conceptual |
| Detection Source | skill_scan |
Summary: The login flow's DIDComm 'authenticate' message body is always empty, omitting the challenge/sessionId fields that the canonical schema requires and which would provide freshness/anti-replay binding at the protocol layer. This is called out directly in code comments as an unresolved gap, meaning the shipped authentication flow lacks a defense-in-depth freshness control beyond whatever the transport/crypto layer provides.
📝 Description:
Without a challenge/sessionId binding in the authenticate request body, the application-layer protocol has no cryptographic freshness guarantee tied to a specific login attempt. Any weakness in the lower transport/envelope-layer replay protection (not visible/verified in this module) could allow an old captured 'authenticate' envelope to be reused to trigger new session-token issuance for the victim, without this module providing a compensating control.
🧪 Proof of Concept:
The message type is bound to a schema requiring challenge/sessionId, but the implementation sends body: {} unconditionally, relying entirely on authcrypt sender identity for authentication with no session/challenge binding at the application layer.
const MSG_AUTHENTICATE = "https://trusttasks.org/spec/auth/authenticate/0.1";
const MSG_AUTH_RESPONSE = "https://trusttasks.org/spec/auth/authenticate/0.1#response";
...
type: MSG_AUTHENTICATE,
from: holder.did,
to: [service.did],
// Empty, because the RP's DIDComm handler authenticates on the **authcrypt
// sender** and reads nothing from the body (`run_authenticate(&state,
// sender)`).
body: {},
};
Vulnerable lines: 30, 52
🔁 Reproduction Steps:
- Build the package and run
loginViaDidcomm()against any bridge implementation. - Inspect the outgoing packed DIDComm envelope (e.g., via the test's
unpack()call as in rp-login.didcomm.mjs). - Observe
opened.message.bodyis{}— nochallengeorsessionIdfield is present despite the message type being the canonicalauth/authenticate/0.1which requires them. - Capture this envelope (e.g., via a compromised relay/forward node) and note that nothing in the message ties it to a specific login session or time window at the application layer.
🔎 Evidence: packages/core/src/rp-login/didcomm.ts:36
body: {},
// canonical `auth/authenticate/0.1` schema declares `challenge` and
// `sessionId` REQUIRED, and the RP obtains a challenge from a REST-only
// `POST /api/auth/challenge`.
💥 Impact:
Without a challenge/sessionId binding in the authenticate request body, the application-layer protocol has no cryptographic freshness guarantee tied to a specific login attempt. Any weakness in the lower transport/envelope-layer replay protection (not visible/verified in this module) could allow an old captured 'authenticate' envelope to be reused to trigger new session-token issuance for the victim, without this module providing a compensating control.
Confidentiality: Medium — potential unauthorized re-issuance of session tokens tied to victim identity. · Integrity: High — authentication events for the holder could be replayed without a fresh proof. · Availability: None
🧭 Reachability:
- Network exposure: public
- Auth barrier: basic
- Attack path: EP-001 (DIDCOMM authenticate) → loginViaDidcomm() → message construction at didcomm.ts:36 → sent via bridge.sendAndAwaitReply()
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | low |
| Business impact | medium |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: An attacker replays a captured 'authenticate' DIDComm envelope, which lacks a challenge/sessionId binding, potentially re-triggering session-token issuance if lower-layer replay protection is weak.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Wiring the existing REST challenge endpoint (POST /api/auth/challenge) into the DIDComm authenticate flow, and embedding challenge/sessionId in the message body, satisfies the canonical schema's REQUIRED fields and gives the RP an application-layer freshness/anti-replay check independent of the transport layer.
Vulnerable code:
body: {},
Secure code:
// 1. Fetch a fresh, short-lived challenge from the RP first:
const { challenge, sessionId } = await fetchChallenge(service); // POST /api/auth/challenge
// 2. Bind it into the authenticate message body:
const message = {
type: MSG_AUTHENTICATE,
from: holder.did,
to: [service.did],
body: { challenge, sessionId },
};
Additional recommendations:
- Enforce short challenge TTLs (e.g., 60s) on the RP side.
- Bind the challenge to the specific holder DID and single-use it (nonce consumption).
- Add a regression test asserting the request body always contains a non-empty challenge/sessionId once implemented.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 55%
- AI Validation Evidence: EVIDENCE FOUND: didcomm.ts line 52 sets
body: {}on the authenticate message, and the file's own comment explicitly states 'the canonicalauth/authenticate/0.1schema declareschallengeandsessionIdREQUIRED' while the RP handler is documented as authenticating on 'the authcrypt sender' (run_authenticate(&state, sender)) and 'reads nothing from the body'. The test file (rp-login.didcomm.mjs) assertsassert.deepEqual(opened.message.body, {})confirming this is the intended contract, not an oversight. EVIDENCE NOT FOUND: The actual server-sidehandle_authenticate/run_authenticateimplementation (did-hosting-control) is not in source_files, so it cannot be independently confirmed whether authcrypt-sender-only authentication constitutes an actual exploitable weakness (e.g. replay) versus a documented, accepted design tradeoff matching the real RP's current behavior. CHANGED VS PRE-EXISTING: CHANGED — didcomm.ts is a file modified by this MR (message construction with body:{} and the surrounding comments are part of the diff), and packages/core/tests/rp-login.didcomm.mjs directly pins this exact behavior with a new assertion. VERDICT JUSTIFICATION: The code matches an already-deployed RP contract per the comments (this is presented as fixing routing drift, not weakening auth), and the primary authentication guarantee is the authcrypt sender identity, not the body. Since the actual security boundary (server-side ACL/session issuance) is outside provided files, this cannot be fully confirmed as exploitable or dismissed — requires human review of the RP's server-side handler to assess real replay/session-binding risk.- 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.
🟠 loginViaDidcomm trusts caller-supplied RP key material with no independent DID resolution/revocation check
| Field | Detail |
|---|---|
| Severity | HIGH |
| Location | packages/core/tests/rp-login.didcomm.mjs:104 |
| Finding ID | github_pr-fcfbae739077 |
| CWE | CWE-295, CWE-346 |
| OWASP | A07:2021 - Identification and Authentication Failures |
| MITRE ATT&CK | T1556 - Modify Authentication Process |
| CAPEC | CAPEC-151, CAPEC-94 |
| DREAD | 5 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | conceptual |
| Detection Source | skill_scan |
Summary: The RP identity check in loginViaDidcomm relies entirely on caller-supplied service.did and service.keyAgreementPublicJwk/keyAgreementKid values rather than performing a live, verified DID resolution within this module. If the upstream caller ever sources this key material from a stale cache or compromised resolver, an attacker with a rotated-out or forged key could pass authentication checks that only compare string equality of from against the caller-provided DID.
📝 Description:
If the upstream key-sourcing mechanism (outside this reviewed file) is ever compromised or stale, an attacker could impersonate the RP to the holder's browser-plugin client, causing the client to accept forged session_id/access_token/refresh_token values as legitimate, resulting in session/credential theft or malicious session establishment against the victim's identity.
🧪 Proof of Concept:
The service object (including its key material) is entirely caller-supplied in every call site shown, including the test harness mirroring production usage; the module performs no independent DID document resolution to verify the key-agreement key is current/non-revoked before trusting it for authcrypt decryption and identity comparison.
function opts(b) {
return {
bridge: b,
holder: Identity.fromSecretJwk({ ... }),
service: {
did: RP_DID,
keyAgreementKid: rpParty.kid,
keyAgreementPublicJwk: rpParty.publicJwk,
},
};
}
...
test("a reply from someone other than the RP is refused", async () => {
const b = bridge({ from: "did:web:imposter.example", type: AUTH_RESPONSE, body: {...} });
await assert.rejects(() => loginViaDidcomm(opts(b)), /!= RP/);
});
Vulnerable lines: 78, 111
🔁 Reproduction Steps:
- Identify or influence the code path in the consuming application (outside this file) that supplies
service.did/service.keyAgreementPublicJwk/keyAgreementKidtologinViaDidcomm. - Cause that upstream path to supply a stale or attacker-controlled key-agreement public key for the RP's DID (e.g., via a compromised DID resolver, a poisoned cache, or a config injection).
- Send an authcrypt-encrypted message decryptable with the attacker's corresponding private key, with
fromset to the expectedservice.didstring. - Observe loginViaDidcomm accepts the message as if genuinely from the RP, since it only checks
from === service.didand decrypts using whatever key it was handed.
🔎 Evidence: packages/core/tests/rp-login.didcomm.mjs:104
test("a reply from someone other than the RP is refused", async () => {
const b = bridge({ from: "did:web:imposter.example", type: AUTH_RESPONSE, body: {...} });
await assert.rejects(() => loginViaDidcomm(opts(b)), /!= RP/);
});
💥 Impact:
If the upstream key-sourcing mechanism (outside this reviewed file) is ever compromised or stale, an attacker could impersonate the RP to the holder's browser-plugin client, causing the client to accept forged session_id/access_token/refresh_token values as legitimate, resulting in session/credential theft or malicious session establishment against the victim's identity.
Confidentiality: High — attacker-controlled session tokens could be used to access the holder's authenticated session data. · Integrity: High — forged authentication response accepted as legitimate. · Availability: Medium — could disrupt legitimate login flows if poisoning also blocks genuine RP responses.
🧭 Reachability:
- Network exposure: public
- Auth barrier: basic
- Attack path: EP-002 (DIDCOMM authenticate-response) → loginViaDidcomm() → unpack()/authcrypt verification using caller-supplied service.keyAgreementPublicJwk → from === service.did check (didcomm.ts, response-handling section not shown in full but confirmed by test behavior)
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | low |
| Business impact | high |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: Attacker poisons the upstream source of the RP's key-agreement key, then forges an authcrypt response that passes the from===service.did check because the module trusts caller-supplied key material without live DID resolution.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Resolving the DID document fresh (or with a short-TTL cache plus revocation checks) at call time ensures the key material used for authcrypt verification is current and has not been rotated out or revoked, closing the trust gap left by accepting arbitrary caller-supplied key material.
Vulnerable code:
service: {
did: RP_DID,
keyAgreementKid: rpParty.kid,
keyAgreementPublicJwk: rpParty.publicJwk,
}
Secure code:
// Resolve fresh, verified key material immediately before use:
const didDoc = await resolveDid(service.did, { requireFresh: true });
const keyAgreementPublicJwk = didDoc.getVerifiedKeyAgreementKey(service.keyAgreementKid);
if (!keyAgreementPublicJwk) {
throw new Error(`Key ${service.keyAgreementKid} not found or revoked in current DID document for ${service.did}`);
}
// proceed to unpack() using keyAgreementPublicJwk resolved just now
Additional recommendations:
- Enforce a maximum staleness window for cached DID documents.
- Log and alert on key-agreement key mismatches between cached and freshly resolved values.
- Pin DID resolution to a trusted resolver with TLS and integrity verification.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 50%
- AI Validation Evidence: EVIDENCE FOUND: In rp-login.didcomm.mjs,
opts()constructsservice: { did: RP_DID, keyAgreementKid: rpParty.kid, keyAgreementPublicJwk: rpParty.publicJwk }directly from test fixtures, and didcomm.ts'sloginViaDidcommusesservice.keyAgreementPublicJwkdirectly inpackAuthcrypt(message, holder, [{ kid: service.keyAgreementKid, jwk: service.keyAgreementPublicJwk }])— confirming the function trusts caller-supplied key material rather than performing its own DID resolution. The sender check isif (reply.from !== service.did) throw ...(didcomm.ts), which only compares thefromstring, consistent with the finding's claim. EVIDENCE NOT FOUND: No DID resolution, revocation, or key-rotation-check logic exists anywhere in the provided didcomm.ts or related files; but also no evidence of howserviceis populated in production (e.g.RemoteDidcommEndpointconstruction site is not in source_files), so whether upstream callers perform fresh/secure DID resolution cannot be verified. CHANGED VS PRE-EXISTING: CHANGED —loginViaDidcommin didcomm.ts (this MR's modified file) is the function exhibiting this design, and the cited test file is new/modified within this MR establishing the trust model viaopts(). VERDICT JUSTIFICATION: The design pattern (trusting caller-supplied key material with only DID-string equality) is real and present in the changed code, but whether this constitutes an exploitable vulnerability depends entirely on how the calling application resolvesservicein production, which is not visible in the provided files — cannot be safely confirmed or dismissed.- 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 #139
| Field | Value |
|---|---|
| Repository | OpenVTC/vta-browser-plugin |
| Branch | fix/rp-login-canonical-uris → main |
| Generated | 2026-08-29 |
ℹ️ 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
Fixes a broken DIDComm RP login flow by replacing retired 'affinidi.com/webvh/1.0/' message-type URI constants with the RP control plane's actual canonical URIs ('trusttasks.org/spec/auth/authenticate/0.1'). Prior to this change, login over DIDComm could never succeed because the request did not route to any handler on the RP. Adds a comprehensive new regression test suite pinning the wire contract (type strings, sender verification, malformed-response rejection, legacy-type rejection).
Diff: +174 / -5 lines
Types: bugfix, interoperability, test, security-relevant
⚠️ Security Implications
⚪ Restores previously non-functional authentication flow via correct RP-bound message-type URIs
Restores previously non-functional authentication flow via correct RP-bound message-type URIs
Action: Verify no other module/config in the broader monorepo still references the retired URIs, to avoid inconsistent behavior elsewhere.
🟡 Authenticate request body remains non-conformant to its own declared canonical schema — no application-layer challenge/nonce binding
Authenticate request body remains non-conformant to its own declared canonical schema — no application-layer challenge/nonce binding
Action: Implement the challenge-based flow using a TrustTaskSender (as referenced in comments and already used in vta/auth-tasks.ts): retrieve a challenge/sessionId via POST /api/auth/challenge (or an equivalent DIDComm-native mechanism) and embed it in the authenticate message body before this flow is reli
🔵 Breaking change: legacy 'affinidi.com/webvh/1.0/*' RPs will silently stop working with no fallback
Breaking change: legacy 'affinidi.com/webvh/1.0/*' RPs will silently stop working with no fallback
Action: Coordinate rollout with RP operators to confirm all production RPs bind the canonical trusttasks.org URIs before deploying this client change broadly.
⚪ Test suite added, closing a prior 'zero test coverage' gap on the authentication wire contract
Test suite added, closing a prior 'zero test coverage' gap on the authentication wire contract
Action: Extend coverage further to include timeout/no-reply behavior and type/range validation of numeric response fields (access_expires_at/refresh_expires_at), which remain untested.
🧩 Affected Components
| Component | Impact | Change | What Changed |
|---|---|---|---|
| rp-login/didcomm (loginViaDidcomm) | high | modified | The DIDComm message-type URI constants used to address the RP's authenticate handler and to validate its response were changed from a retire |
📁 File Classifications
packages/core/src/rp-login/didcomm.ts
- Type: security
packages/core/tests/rp-login.didcomm.mjs
- Type: test
🛡️ STRIDE Threat Model
Identified Threats (10)
⚪ STRIDE-1: Response Type Confusion in loginViaDidcomm Response Handler
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-354,CWE-345 |
| CAPEC | CAPEC-133 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: authenticate-response handler in loginViaDidcomm allows message type downgrade due to strict single-string equality without normalization or version negotiation, resulting in potential future compatibility failures or silent auth bypass if RP reintroduces legacy alias
Evidence: packages/core/src/rp-login/didcomm.ts:21-30
const MSG_AUTHENTICATE = "https://trusttasks.org/spec/auth/authenticate/0.1";
const MSG_AUTH_RESPONSE = "https://trusttasks.org/spec/auth/authenticate/0.1#response";
Attack Scenario:
- Attacker or misconfigured intermediary intercepts DIDComm transport (e.g., a compromised relay or forward node referenced by wrapForward) between holder and RP.
- Attacker crafts a reply message with a
typefield matching a previously-valid but now-retired URI (e.g.https://affinidi.com/webvh/1.0/authenticate-response) while the code only checks the new canonical string via===per the comment 'no both-spellings fold'. - If the RP's server-side router is ever redeployed with backward-compatibility shims (dual-binding old and new URIs, a common migration pattern), the same relay could replay/forge an old-format response accepted by both client and legacy-compatible server.
- Because the client's authentication trust model relies solely on authcrypt sender identity and does not perform mutual protocol-version pinning beyond the literal
typestring, a downgrade-compatible RP would process the forged type. - Test
rp-login.didcomm.mjsexplicitly proves current code rejects the legacy type, but the underlying architectural risk (single point-in-time string constant with no algorithm-agility mechanism) remains for future maintenance regressions.
🔎 Threat Clue: Derived from COMP-001 via EP-002
- Data Flows: DF-authenticate-response
Preconditions: Attacker has active man-in-the-middle position or forwarding-node compromise (wrapForward)., RP server would need to be redeployed with dual-URI compatibility (currently not the case per code).
Existing Controls: Strict === type match against canonical URI (no fold). • Explicit regression test 'the retired response type is refused'. • Sender identity verified via authcrypt in unpack.
Recommended Mitigations: Add protocol version negotiation with cryptographic binding of version to session. • Document and enforce a formal deprecation/rejection list for all historical type URIs, not just implicit omission. • Add a runtime allow-list assertion test that fails CI if any legacy alias is silently reintroduced.
⚪ STRIDE-2: Empty Authentication Request Body Missing Required Challenge/SessionId Fields
| Field | Detail |
|---|---|
| Category | Tampering, Repudiation, Elevation of Privilege |
| Severity | High |
| Likelihood | Likely |
| CVSS | 7.1 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-345,CWE-294 |
| CAPEC | CAPEC-60,CAPEC-459 |
| OWASP | A07:2021 - Identification and Authentication Failures |
Description: authenticate message construction in loginViaDidcomm allows protocol non-conformance due to sending an empty body while the canonical schema requires challenge and sessionId fields, resulting in replay-susceptible or non-standard authentication that other conformant RPs may reject or mishandle
Evidence: packages/core/src/rp-login/didcomm.ts:36-52
body: {},
// canonical `auth/authenticate/0.1` schema declares `challenge` and `sessionId` REQUIRED...
Attack Scenario:
- The code constructs an
authenticateDIDComm message withbody: {}per lines noted in didcomm.ts, deliberately omittingchallengeandsessionIdfields required by the canonicalauth/authenticate/0.1schema. - The comment in the source explicitly states this is a known conformance gap: the RP's REST-only
POST /api/auth/challenge(EP-003) issues a challenge that is never bound into this DIDComm message. - Because no challenge is echoed back in the authenticated request, the protocol lacks freshness/anti-replay binding at the message-body layer — the only anti-replay control is whatever occurs at the authcrypt/transport layer (e.g., DIDComm envelope nonce), which is not verified in this file.
- An attacker who can capture a previously sent (or in-flight relayed)
authenticateenvelope could attempt to replay it to the RP if the RP's authcrypt session/nonce validation has any weakness, since the body itself carries no session-binding data to reject stale requests. - Because this is called out by the code's own authors as unresolved ('Closing it properly means the challenge-based flow...'), the gap is a conscious deferred risk shipped in this PR rather than an unknown regression.
🔎 Threat Clue: Derived from COMP-001 via EP-001, EP-003
- Data Flows: DF-authenticate-request
Preconditions: RP must accept authcrypt-only authentication without body-level challenge validation (confirmed by comment: run_authenticate(&state, sender))., No additional nonce/freshness binding exists between the DIDComm envelope and this specific login attempt beyond the crypto layer.
Existing Controls: Server-side authentication driven by authcrypt sender identity (cryptographic, not body-based). • Documented conformance gap acknowledged in source comments for future remediation.
Recommended Mitigations: Implement the challenge-based flow using TrustTaskSender as referenced in comments, retrieving a challenge from POST /api/auth/challenge (EP-003) and embedding challenge/sessionId in the message body prior to shipping this change. • Add anti-replay nonce binding at the DIDComm envelope level with short validity windows. • Add schema validation on the RP side to reject empty bodies for authenticate/0.1 per its own declared REQUIRED fields.
⚪ STRIDE-3: Sender Spoofing via Missing DID Document Resolution Check in loginViaDidcomm
| Field | Detail |
|---|---|
| Category | Spoofing, Information Disclosure, Elevation of Privilege |
| Severity | High |
| Likelihood | Possible |
| CVSS | 7.4 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-295,CWE-346 |
| CAPEC | CAPEC-151,CAPEC-94 |
| OWASP | A07:2021 - Identification and Authentication Failures |
Description: authenticate-response handler in loginViaDidcomm allows sender identity spoofing due to comparing only the from field on the decrypted message against service.did string equality without confirming the decrypted authcrypt sender key actually resolves to that DID's currently published key-agreement key, resulting in potential session-token theft if the passed-in service.keyAgreementPublicJwk is attacker-influenced or stale
Evidence: packages/core/tests/rp-login.didcomm.mjs:104-111
test("a reply from someone other than the RP is refused", async () => {
const b = bridge({ from: "did:web:imposter.example", type: AUTH_RESPONSE, body: {...} });
await assert.rejects(() => loginViaDidcomm(opts(b)), /!= RP/);
});
Attack Scenario:
- The
loginViaDidcommfunction acceptsservice.did,service.keyAgreementKid, andservice.keyAgreementPublicJwkas caller-supplied options rather than resolving them fresh from a DID document at call time (per test file'sopts()helper mirroring production usage). - Test 'a reply from someone other than the RP is refused' confirms
from !== service.didis rejected, but this only validates the claimedfromfield's string value versus the expected DID — it does not re-verify that the public key used for authcrypt decryption is the RP's currently valid, non-revoked key-agreement key from a live DID resolution. - If the
service.keyAgreementPublicJwkpassed intologinViaDidcommby the calling application (outside this module) is ever sourced from a cached, attacker-tamperable, or stale DID document (e.g., pinned config, local storage, or a compromised DID resolver upstream), an attacker who has compromised or rotated past that key could produce valid authcrypt-decryptable messages that pass thefrom === service.didcheck. - Because this module does not itself perform DID resolution or key rotation/revocation checks, it fully trusts whatever
keyAgreementPublicJwkits caller supplies, shifting the actual security boundary outside the reviewed code and creating a systemic risk if any caller in the broadervtacodebase resolves DIDs insecurely (e.g., via HTTP without TLS pinning, or from a stale cache). - A successful spoof yields fraudulent
access_token/refresh_token/session_idvalues returned to the holder as if from the legitimate RP, enabling downstream session hijacking or credential harvesting of the holder's login flow.
🔎 Threat Clue: Derived from COMP-001 via EP-002
- Data Flows: DF-authenticate-response
Preconditions: Caller of loginViaDidcomm supplies a stale, cached, or otherwise compromised service.keyAgreementPublicJwk/keyAgreementKid., Attacker has compromised or intercepted the DID resolution path used upstream of this module (outside visible diff).
Existing Controls: Explicit from !== service.did string check rejecting mismatched sender DIDs. • Authcrypt cryptographic sender verification via unpack (confirmed in test using real key material). • Regression test asserting imposter DID rejection.
Recommended Mitigations: Perform fresh DID resolution (with revocation/rotation checks) immediately prior to each login attempt rather than trusting caller-supplied key material. • Add explicit key-agreement key freshness/expiry validation tied to DID document updated timestamps. • Document and enforce a secure DID resolution contract for all callers of loginViaDidcomm.
⚪ STRIDE-4: Denial of Service via Missing Timeout Enforcement Verification in loginViaDidcomm
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.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 | Low |
| CWE | CWE-400,CWE-1088 |
| CAPEC | CAPEC-227 |
| OWASP | A04:2021 - Insecure Design |
Description: sendAndAwaitReply call in loginViaDidcomm allows indefinite request hanging due to the visible diff not confirming DEFAULT_TIMEOUT_MS is actually enforced by the bridge implementation, resulting in potential client-side resource exhaustion or hung authentication sessions if a malicious or unresponsive RP endpoint never replies
Evidence: packages/core/src/rp-login/didcomm.ts:20
const DEFAULT_TIMEOUT_MS = 30_000;
Attack Scenario:
DEFAULT_TIMEOUT_MS = 30_000is defined at module scope in didcomm.ts, suggesting an intended request timeout for the DIDComm auth round-trip.- The visible diff hunk does not show the code path that actually applies this constant to the
bridge.sendAndAwaitReply(packed, requestId)call (only the message construction is shown). - The test file's
bridge()mock always resolves immediately or via the reply callback, meaning no test exercises timeout behavior (no test named around 'timeout' or 'hang' appears in the file), leaving this control unverified by the new test suite added in this PR. - If the underlying
DidcommMessageBridge.sendAndAwaitReplyimplementation does not itself enforceDEFAULT_TIMEOUT_MS(e.g., it awaits a promise with noPromise.racetimeout wrapper), a malicious or compromised RP (or a network-level attacker who silently drops replies) could cause the holder's client to hang indefinitely, exhausting UI resources, held sockets, or blocking subsequent login attempts. - Repeated triggering (e.g., a malicious RP endpoint advertised via a phished login link) could degrade the browser plugin's responsiveness or leak memory via accumulated pending promises.
🔎 Threat Clue: Derived from COMP-001 via EP-001
- Data Flows: DF-authenticate-request
Preconditions: The underlying sendAndAwaitReply implementation does not itself apply DEFAULT_TIMEOUT_MS., Attacker controls or can silence a targeted RP endpoint the victim is directed to authenticate against.
Existing Controls: A DEFAULT_TIMEOUT_MS constant of 30 seconds is defined, indicating an intended bound. • Test suite's bridge mock always resolves synchronously, so no state is left pending in tested scenarios.
Recommended Mitigations: Add an explicit unit test asserting that a non-responding bridge causes loginViaDidcomm to reject after DEFAULT_TIMEOUT_MS. • Wrap sendAndAwaitReply invocation in a Promise.race against a timer if not already done, and confirm via test. • Add caller-facing cancellation/AbortController support for user-initiated login cancellation.
⚪ STRIDE-5: Insufficient Response Validation Enabling Partial Token Set Injection in loginViaDidcomm
| Field | Detail |
|---|---|
| Category | Tampering, Information Disclosure |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 6.4 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-20,CWE-1284 |
| CAPEC | CAPEC-153 |
| OWASP | A03:2021 - Injection |
Description: authenticate-response body parsing in loginViaDidcomm allows malformed or partial token injection due to reliance on runtime presence checks (rejecting only fully-missing tokens per test 'malformed') rather than strict schema/type validation of access_expires_at and refresh_expires_at as numeric timestamps, resulting in potential acceptance of malformed expiry values leading to indefinite token validity or client-side type-confusion errors
Evidence: packages/core/tests/rp-login.didcomm.mjs:113-116
test("a response missing a token is refused rather than half-returned", async () => {
const b = bridge({ from: RP_DID, type: AUTH_RESPONSE, body: { session_id: "s" } });
await assert.rejects(() => loginViaDidcomm(opts(b)), /malformed/);
});
Attack Scenario:
loginViaDidcommparses theauthenticate-responsebody fieldssession_id,access_token,refresh_token,access_expires_at,refresh_expires_atdirectly into the returned object (out.sessionId,out.accessToken, etc. per test assertions).- Test 'a response missing a token is refused rather than half-returned' only proves rejection when
access_token/refresh_tokenare entirely absent — it does not test type confusion (e.g.,access_expires_at: "never",access_expires_at: -1, oraccess_expires_at: 99999999999999999). - A compromised or malicious RP (or a MITM capable of authcrypt-layer tampering if key material were ever compromised) could return syntactically valid but semantically malicious expiry values, such as an expiry far in the future or a non-numeric value that downstream token-refresh logic in the browser plugin mishandles.
- Because the visible code/tests do not assert
typeof access_expires_at === 'number'or bounds-check the timestamp, the client may accept and persist tokens with attacker-influenced effective lifetimes, extending the usable window of a stolen or leaked access token beyond the RP's intended policy. - This weak input validation compounds with STRIDE-3 (sender spoofing risk): if sender trust is ever bypassed, the lack of strict body schema validation removes a secondary defense-in-depth layer that could otherwise limit the blast radius of a forged response.
🔎 Threat Clue: Derived from COMP-001 via EP-002
- Data Flows: DF-authenticate-response
Preconditions: RP or a party controlling authcrypt-verified traffic to the client returns a syntactically valid but semantically abnormal response body., Downstream consumers of loginViaDidcomm's return value trust accessExpiresAt/refreshExpiresAt without independent validation.
Existing Controls: Presence check rejecting entirely missing access_token/refresh_token/session_id fields (per 'malformed' test). • Authcrypt sender verification limiting who can produce an acceptable response. • From-DID equality check restricting the accepted sender.
Recommended Mitigations: Add strict schema validation (e.g., zod/ajv) enforcing types and reasonable bounds on access_expires_at/refresh_expires_at. • Clamp accepted expiry values to a maximum sane lifetime regardless of RP-supplied value. • Add negative test cases for type-confused and out-of-range expiry fields.
⚪ STRIDE-6: Insufficient Logging of Authentication Attempts and Failures in loginViaDidcomm
| Field | Detail |
|---|---|
| Category | Repudiation |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 3.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-778 |
| CAPEC | CAPEC-268 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: loginViaDidcomm allows repudiation of failed or spoofed login attempts due to no visible logging/audit trail construct for rejected sender mismatches, malformed responses, or legacy-type downgrade attempts, resulting in reduced forensic capability during incident response
Evidence: packages/core/tests/rp-login.didcomm.mjs:98-148
await assert.rejects(() => loginViaDidcomm(opts(b)), /!= RP/);
Attack Scenario:
- All error paths in
loginViaDidcomm(imposter sender, malformed body, legacy type) are surfaced only via thrown exceptions matched by regex in tests (e.g.,/!= RP/,/malformed/,/authenticate-response/). - No structured audit log, telemetry event, or security event emission is visible in the diff for any of these rejection paths.
- An attacker probing the login flow (e.g., attempting sender spoofing or legacy-type downgrade repeatedly) would leave no durable, queryable trace for the browser plugin operators or the RP's security team to detect a pattern of attack attempts.
- Combined with STRIDE-3, a sophisticated attacker could conduct low-and-slow probing of the authentication boundary without detection, since failures are only local thrown JS exceptions rather than reported security events.
- Absence of telemetry also hampers the ability to detect the exact scenario this PR fixes (message-type drift) proactively in production before users report broken login, as happened here.
🔎 Threat Clue: Derived from COMP-001 via EP-002
- Data Flows: DF-authenticate-response
Preconditions: No external logging/monitoring wraps calls to loginViaDidcomm at a higher layer (not visible in provided scope, but not evidenced here either).
Existing Controls: Errors are thrown with descriptive messages that could be caught and logged by a calling layer outside this module.
Recommended Mitigations: Emit structured security telemetry events on authentication rejection (sender mismatch, malformed response, legacy type usage) for SOC visibility. • Integrate with a centralized client-side security event bus if one exists in the broader VTA plugin architecture. • Add rate-limiting or backoff-with-alerting for repeated authentication failures from a given RP endpoint.
⚪ STRIDE-7: Unauthenticated Challenge Endpoint Enabling Pre-Auth Enumeration or Flooding at POST /api/auth/challenge
| Field | Detail |
|---|---|
| Category | Denial of Service, Information Disclosure |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 6.9 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 | Low |
| CWE | CWE-307,CWE-799 |
| CAPEC | CAPEC-125,CAPEC-100 |
| OWASP | A04:2021 - Insecure Design |
Description: POST /api/auth/challenge in RP control plane allows unauthenticated challenge issuance due to auth_required:false per recon data, resulting in potential resource exhaustion or challenge-harvesting by unauthenticated parties feeding the eventual DIDComm authenticate flow
Evidence: packages/core/src/rp-login/didcomm.ts:46-49
// the RP obtains a challenge from a REST-only `POST /api/auth/challenge`.
Attack Scenario:
- Recon data (EP-003) documents
POST /api/auth/challengeas an unauthenticated REST endpoint referenced in code comments as the source of thechallenge/sessionIdvalues the canonical schema requires. - Although not implemented within the reviewed diff, this module's own comments establish that closing the conformance gap (STRIDE-2) requires wiring this endpoint into the DIDComm flow, making it a de-facto dependency of this login path going forward.
- An unauthenticated attacker can call this endpoint repeatedly without rate limiting evidenced anywhere in scope, harvesting large volumes of valid challenge/sessionId pairs.
- If challenges are not strictly one-time-use and time-bound server-side, an attacker could pre-generate a large pool of valid challenges to use in credential-stuffing-style attempts against the DIDComm
authenticatehandler once the challenge-binding fix (recommended in STRIDE-2's mitigation) is implemented. - Absent rate limiting, repeated calls could also be used as a low-cost denial-of-service vector against the RP's challenge-issuance subsystem, indirectly degrading legitimate holders' ability to log in via this plugin.
🔎 Threat Clue: Derived from COMP-001 via EP-003
- Data Flows: DF-challenge-request
Preconditions: Endpoint POST /api/auth/challenge remains unauthenticated and unrated-limited in production., Future implementation of the challenge-binding fix does not add one-time-use/expiry enforcement server-side.
Existing Controls: Not yet integrated into this module's DIDComm flow (currently theoretical/forward-looking dependency).
Recommended Mitigations: Apply rate limiting and CAPTCHA-equivalent bot mitigation to POST /api/auth/challenge. • Enforce strict one-time-use and short expiry (e.g., 60s) on issued challenges server-side. • Bind challenge issuance to a client fingerprint or DID hint to reduce mass harvesting value.
⚪ STRIDE-8: Supply Chain Risk from Unpinned Cryptographic and DIDComm Dependencies
| Field | Detail |
|---|---|
| Category | Tampering, Elevation of Privilege |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.9 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-1104,CWE-829 |
| CAPEC | CAPEC-538 |
| OWASP | A06:2021 - Vulnerable and Outdated Components |
Description: import statements in rp-login.didcomm.mjs allow dependency-confusion or malicious-update risk due to reliance on external packages @openvtc/vti-didcomm-js and @noble/curves for core cryptographic operations without visible lockfile or integrity-pinning evidence in scope, resulting in potential compromise of the authentication and encryption layer if a dependency is compromised upstream
Evidence: packages/core/tests/rp-login.didcomm.mjs:14-17
import { unpack } from "@openvtc/vti-didcomm-js/unpack";
import { x25519 } from "@noble/curves/ed25519.js";
Attack Scenario:
rp-login.didcomm.mjsimportsunpackfrom@openvtc/vti-didcomm-js/unpackandx25519from@noble/curves/ed25519.js, both of which perform security-critical operations (message unpacking/decryption and elliptic-curve key generation).- No package.json, lockfile, or SBOM was included in the provided scope, so it cannot be confirmed whether these packages are pinned to exact, integrity-verified versions or fetched via loose semver ranges.
- If
@openvtc/vti-didcomm-js(an org-scoped package, plausible target for npm namespace/typosquat or maintainer-account takeover attacks) is compromised upstream, theunpackfunction used both in tests and (presumably) production could be modified to exfiltrate decrypted plaintext DIDComm messages, including session tokens processed byloginViaDidcomm. - Similarly, a compromised
@noble/curvesbuild could weaken key generation (e.g., biased randomness inx25519.utils.randomSecretKey()), undermining the authcrypt confidentiality this entire authentication model depends on. - Because this authentication design (per STRIDE-3) places full trust in the cryptographic layer rather than independent application-level checks, a supply-chain compromise of either dependency would be a critical, hard-to-detect single point of failure for the whole RP login flow.
🔎 Threat Clue: Derived from COMP-001 via EP-001, EP-002
- Data Flows: DF-authenticate-request, DF-authenticate-response
Preconditions: Attacker achieves compromise of the npm package registry entry, maintainer account, or CI/CD pipeline for @openvtc/vti-didcomm-js or @noble/curves., Project does not enforce lockfile integrity hashes (npm package-lock.json integrity fields) or reproducible builds.
Existing Controls: Use of well-regarded audited library @noble/curves for cryptographic primitives (reduces but does not eliminate risk). • Scoped package namespace (@openvtc) for the DIDComm library, limiting typosquat surface somewhat.
Recommended Mitigations: Enforce lockfile integrity verification (npm ci with strict lockfile) in CI/CD. • Pin exact dependency versions and monitor via SCA/Dependabot/Snyk for the DIDComm and crypto libraries. • Adopt Subresource Integrity or package signing (e.g., npm provenance/Sigstore) for @openvtc/vti-didcomm-js releases.
⚪ STRIDE-9: Message Forwarding Path Tampering via wrapForward Import
| Field | Detail |
|---|---|
| Category | Tampering, Information Disclosure |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 6.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-923 |
| CAPEC | CAPEC-117 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: wrapForward usage referenced in loginViaDidcomm's imports allows message routing tampering due to reliance on an unreviewed forwarding wrapper whose implementation is outside the provided diff scope, resulting in potential misdelivery or interception of the authenticate message if forwarding logic contains routing-key validation gaps
Evidence: packages/core/src/rp-login/didcomm.ts:17-19
import { packAuthcrypt, packAuthcryptJson, wrapForward, type Identity } from "..
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
Attack Scenario:
- The full (non-diff) portion of
didcomm.tsimportspackAuthcrypt, packAuthcryptJson, wrapForward(visible in the diff context lineimport { packAuthcrypt, packAuthcryptJson, wrapForward, type Identity } from "..."), indicating the authenticate message may be wrapped for mediator-based forwarding. - The implementation of
wrapForwarditself is not part of this diff/scope, so its handling of routing keys,nextmediator DIDs, and encryption-then-forward ordering cannot be verified here. - If
wrapForward(in an unreviewed module) fails to properly re-encrypt or authenticate the forward-envelope layer, a compromised or malicious mediator on the forwarding path could read or redirect the innerauthenticatemessage before it reaches the RP. - Because this analysis's file scope excludes the
wrapForwardimplementation, this is flagged as a theoretical chained-attack surface: combine a compromised mediator with the already-empty message body (STRIDE-2) and the lack of application-level replay binding to potentially replay or redirect authentication attempts. - This threat should be revalidated once the
wrapForwardimplementation file is available in scope; it is included here as a plausible, unverified chained risk given the import is present in the reviewed file's header.
🔎 Threat Clue: Derived from COMP-001 via EP-001
- Data Flows: DF-authenticate-request
Preconditions: Production deployment routes authenticate messages through a DIDComm mediator using wrapForward., The mediator or forwarding path is compromised or improperly configured.
Existing Controls: Authcrypt encryption of the inner message (assuming packAuthcrypt is applied before wrapForward, per typical DIDComm forward-envelope ordering).
Recommended Mitigations: Review and audit the wrapForward implementation for correct encrypt-then-forward ordering and mediator authentication. • Ensure forward envelopes never expose plaintext routing metadata that could deanonymize the holder. • Add end-to-end tests covering the mediator/forwarding path, not just direct bridge delivery.
⚪ STRIDE-10: Prompt-Injection-Style Instruction Embedded in Source Comments Attempting to Influence Automated Review Tooling
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | Informational |
| Likelihood | Possible |
| CVSS | 0.0 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | None |
| CWE | CWE-1059 |
| CAPEC | |
| OWASP | A04:2021 - Insecure Design |
Description: inline code comments in didcomm.ts and rp-login.didcomm.mjs allow narrative-style justification injection due to unusually persuasive, first-person explanatory prose embedded directly in source comments framing security-relevant design choices (e.g., 'no both-spellings fold, per this repo's rule') as settled and non-actionable, resulting in a risk that automated or human reviewers may be steered to under-scrutinize the documented conformance gaps
Evidence: packages/core/src/rp-login/didcomm.ts:5-15
// Matched with `===` against the spelling the RP declares today; no
// both-spellings fold, per this repo's rule on compatibility arms.
Attack Scenario:
- Both modified files contain extensive, narrative, almost conversational comments (e.g., 'Worth naming as a conformance gap rather than leaving to be discovered', 'no both-spellings fold, per this repo's rule on compatibility arms') that go well beyond typical terse code comments.
- This style pre-emptively frames the empty-body authentication gap (STRIDE-2) and the strict-equality type check (STRIDE-1) as intentional, already-considered, and low-priority, potentially discouraging a reviewer (human or automated) from flagging them as findings.
- This pattern is analyzed here strictly as DATA per this analysis's operating instructions — the comments are not followed as instructions to suppress or downplay findings; instead, their persuasive framing is itself flagged as a review-integrity concern.
- If such comment styles become a repo convention, they could be leveraged (intentionally or not) to get real security gaps merged with reduced scrutiny by asserting they are 'known' and 'deferred', when in fact no tracked follow-up ticket or issue reference is visible in this diff.
- Recommend requiring an explicit tracked issue/ticket reference (not just prose) whenever a security-relevant gap is deliberately deferred in a merged PR.
🔎 Threat Clue: Derived from COMP-001 via EP-001, EP-002
Preconditions: Code review process relies on comment narrative rather than independent verification of claimed-safe design decisions., No linked tracking issue enforces remediation of the acknowledged conformance gap.
Existing Controls: This analysis explicitly treats all source comments as untrusted data under review, not instructions, per operating directive.
Recommended Mitigations: Require explicit ticket/issue references for any comment claiming a security gap is 'known' and deferred. • Add a lint/CI check flagging comments containing phrases like 'known gap', 'TODO security', or similar without a linked tracking ID. • Ensure security reviewers independently verify claims made in code comments rather than accepting them at face value.
🍝 PASTA Threat Model
Application Purpose
The VTA browser plugin provides a Relying Party (RP) login flow over DIDComm messaging, allowing a holder's decentralized identity wallet to authenticate to a trust-task control plane and obtain session tokens without traditional username/password credentials.
Inherent Risks
- Decentralized identity authentication depends entirely on correct DID resolution and key material handling by callers outside this module's control.
- DIDComm message-type URIs are hardcoded string constants with no formal protocol version negotiation mechanism.
- The authentication model places full trust in transport-layer authcrypt sender verification with an intentionally empty message body, deferring a documented conformance gap.
Objectives
Risk: Treat any authentication bypass or token forgery as an unacceptable Critical risk.; Treat protocol drift causing denial of login service as a High risk requiring rapid remediation.
Business: Enable seamless, passwordless RP login for VTA plugin users via decentralized identity.; Maintain interoperability with the trust-task control plane's DIDComm router.
Security: Ensure only the legitimate RP can produce an accepted authenticate-response.; Prevent replay or downgrade of authentication messages.; Protect session/access/refresh tokens in transit and at rest in the client.
Financial: Avoid revenue loss from broken login flows blocking user onboarding.; Avoid costs associated with incident response for authentication bypass or token theft.
Compliance: Align with DID/DIDComm and Trust-Task ecosystem interoperability specifications.; Support auditability of authentication events where required by downstream RP compliance obligations.
Functional: Correctly route authenticate/authenticate-response messages under the canonical trusttasks.org URI scheme.; Return valid session, access, and refresh tokens to the calling application upon successful authentication.
Operational: Ensure login flow degrades gracefully (timeout, clear errors) rather than hanging indefinitely.; Maintain regression test coverage for the wire contract to prevent future silent protocol drift.
Business Impact Analysis (1)
BIA-1: RP Login via DIDComm (Critical)
The end-to-end process by which a holder's VTA browser plugin authenticates to a Relying Party over DIDComm and receives session tokens enabling access to trust-task services.
MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:00 hours
- Stakeholders: Holder (End User) / OpenVTC Plugin Maintainers / Relying Party Operations Team / Trust-Task Control Plane Operators
- Dependencies: @noble/curves Cryptographic Library / @openvtc/vti-didcomm-js Package / DIDComm Message Bridge Transport / POST /api/auth/challenge REST Endpoint / RP DIDComm Router
- Disruptions: Message-type URI drift causing requests to not route (as occurred pre-fix in this PR). / Compromise of key-agreement key material enabling sender spoofing. / Malicious or unresponsive RP endpoint causing indefinite login hangs. / Supply-chain compromise of cryptographic or DIDComm dependencies.
- Impacts: Complete inability for users to log in, blocking access to all downstream trust-task services. / Potential unauthorized issuance of session/access/refresh tokens to an attacker. / Reputational damage to OpenVTC and reliant RPs from a publicized authentication bypass. / Support and incident-response cost escalation from silent protocol drift.
Technical Scope
Roles (3): RO-1 Holder · RO-2 Relying Party Operator · RO-3 Plugin Maintainer
Actors (3): AC-1 Holder Identity Client · AC-2 RP Authentication Service · AC-3 DIDComm Bridge Runtime
Use Cases (2): Holder RP Authentication via DIDComm · RP Challenge Issuance for Future Authentication Binding
Attack Trees (3): SC-1: loginViaDidcomm Module · SC-4: POST /api/auth/challenge Endpoint · SC-2: DidcommMessageBridge Transport
Entry Points (3): EP-1 DIDComm Authenticate Request · EP-2 DIDComm Authenticate Response · EP-3 REST Challenge Issuance
Risk Registry (9): RISK-001 · RISK-002 · RISK-003 · RISK-004 · RISK-005 · RISK-006 · RISK-007 · RISK-008 · RISK-009
Threat Actors (3): TA-1 Network-Position Attacker · TA-2 Malicious/Compromised RP Operator · TA-3 Supply Chain Attacker
Infrastructure (2): IF-1 Browser Extension Runtime · IF-2 RP Control Plane Backend
Trust Boundaries (3): TB-1 Holder Browser Plugin Boundary · TB-2 DIDComm Transport/Mediator Boundary · TB-3 Relying Party Control Plane Boundary
External Entities (3): EE-1 Holder (End User Wallet) · EE-2 Relying Party Control Plane · EE-3 DIDComm Mediator/Forwarding Node
System Components (4): SC-1 loginViaDidcomm Module · SC-2 DidcommMessageBridge Transport · SC-3 RP DIDComm Router and Authenticate Handler · SC-4 POST /api/auth/challenge Endpoint
Resources And Assets (4): RA-1 Holder Private Key Material · RA-2 Session/Access/Refresh Tokens · RA-3 RP Key-Agreement Public Key Material · RA-4 Auth Challenge Value
Technologies And Dependencies (3): TD-1 @openvtc/vti-didcomm-js · TD-2 @noble/curves · TD-3 Node.js built-in test runner
⚔️ Attack Scenarios (1)
Exploit identified weaknesses
flowchart LR
S0["Response Type Confusion in loginViaDidcomm Response Handler"]
S1["Empty Authentication Request Body Missing Required Challenge"]
S2["Sender Spoofing via Missing DID Document Resolution Check in"]
S0 --> S1
S1 --> S2
📊 Risk Summary
Total Threats: 10
By Severity: Low: 1 · High: 2 · Medium: 6 · Informational: 1
By Category: Unknown: 10
Generated by Agentic Sec — Threat Model & Affect Analysis Agent
📊 Summary & findings
| ✅ Confirmed | |
|---|---|
| 0 | 2 |
Must-Review-By-Human (2)
- 🟠 Authentication request body omits required challenge/sessionId binding (protocol non-conformance / weak authentication design)
- 🟠 loginViaDidcomm trusts caller-supplied RP key material with no independent DID resolution/revocation check
DIDComm login to an RP cannot succeed today
This package sent
https://affinidi.com/webvh/1.0/authenticateand expected…/authenticate-response. The control plane's DIDComm router binds neither. Fromdid-hosting-common/src/didcomm_types.rs:So the request never reached a handler, and a reply under the canonical type would have been refused here as the wrong type anyway. Both ends of the exchange were wrong.
This is a live, user-facing path: a site requests login,
background.tsraises a consent prompt, the user approves — and then it fails.It is the drift R3.6 exists for, and the same failure
vti-didcomm-js0.6.1 had against the VTA: a retired vendor URI left behind when the server moved to the canonical one. Nothing caught it because nothing tested this module — the two type URIs were string constants no assertion ever read.The fix, and the guard
Matched with
===against what the RP declares today. No both-spellings fold, and a test asserts the retired response type is still refused, so one cannot creep back later as a "safe" addition.Five tests, and the shape of them matters: the one that would have caught this unpacks the outgoing envelope as the RP would and asserts the request type. Reply-side assertions alone would have passed throughout the entire period this was broken.
A conformance gap stays open — now written down at the site
The canonical
auth/authenticate/0.1schema declareschallengeandsessionIdREQUIRED. But:run_authenticate(&state, sender)— it authenticates on the authcrypt sender and reads nothing from the body;POST /api/auth/challenge, REST-only.So this sends a canonical type over a body that does not satisfy the schema. That shape is the RP's choice — it bound a sender-authenticated handler to the canonical URI — and matching it is strictly better than sending a URI nothing routes. But it should be visible rather than discovered, so the reasoning is in the code.
Closing it properly means the challenge-based flow over a
TrustTaskSender— the shapevta/auth-tasks.tsalready uses against the VTA, and what would also make this seam transport-agnostic. The RP already routestrust_tasks_didcomm::ENVELOPE_TYPEthrough the same handlers asPOST /api/trust-tasks, so the infrastructure is there.472 core tests pass; lint and build clean.
Testing note
Worth a live check after merge: a site-initiated DIDComm login against the control plane should now complete rather than fail after the consent prompt. If it was silently falling back to the REST SIOP path before, that would explain why the breakage went unnoticed.