fix(tsp): cache sender resolution on the inbound path - #137
Conversation
An inbound TSP frame is awaited by the transport before it acks and before it takes the next frame (R1.6), so whatever the handler does happens serially on the one socket that also carries replies. `unpackInboundTsp` resolves the sender's DID there — up to two network round-trips **per frame**, uncached. A redelivery burst turns that into hundreds of serial fetches while an in-flight request waits behind them. That is not hypothetical: a client that starts acking a backlog it had never acked gets exactly one such burst on its first connect, and a TSP reply timeout is a hard failure with no fallback, so the request in flight does not degrade — it fails. The observed symptom was a vault list that would not load over TSP immediately after an upgrade, and worked once the backlog had drained. Caching collapses a burst from one resolution per frame to one per peer. TTL'd rather than invalidated on failure. Eviction would need the unpack result plumbed back through a resolver that cannot see it, and redelivery already supplies the retry: a frame refused against a stale key is redelivered — the ack is withheld precisely because the handler threw — so a rotation costs at most one TTL of refusals on a message that was going to be re-sent anyway. Bounded, so a chatty socket cannot grow it without limit. The cache is split over an injected resolver so it is testable as itself. The obvious alternative — a test re-implementing the TTL and the bound and asserting against its own copy — passes whatever the real policy does, which is the one thing a cache test must not do. The bound is likewise asserted through behaviour (the newest entry still hits, the oldest re-resolves) rather than by exporting the Map. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
🛡️ AI Agentic Security Code Review3 AI-confirmed issues, 1 finding needs a human to review/validate. Mandatory to check: 🔒 Security Code Review Report Details🛡️ Security Code Review Report — PR #137
🗺️ Scan CoverageModules scanned: 2 · with findings: 2 · files: 3 · findings: 7
Executive Summary
🔒 Security IssuesConfirmed Vulnerabilities (3)🟡 Stale endpoint credential/key reuse due to TTL-based caching of resolved TSP endpoints
📝 Description: The resolved TSP endpoint (which effectively pins the peer's key/endpoint material used to accept/verify inbound frames) is cached and reused for up to 5 minutes without re-validation, even if the peer's key has rotated in the meantime. 🌱 Root Cause: resolveTspEndpointCachedWith returns a cached endpoint based purely on elapsed time (now() - hit.at < ENDPOINT_TTL_MS) rather than validating whether the underlying DID document / key material is still current, so a rotated key is not honored until the TTL lapses. 🔎 Evidence: 🎯 Attack Scenario: If a peer's signing key is rotated (e.g., due to compromise) the wallet continues trusting the old cached endpoint/key for up to 5 minutes, during which frames signed with the old (potentially compromised) key would still be accepted, extending the window an attacker who obtained the old key can exploit.
🟡 Missing single-flight de-duplication enables cache overwrite race on concurrent first resolution (CWE-367/CWE-345)
Summary: The endpoint cache lacks single-flight de-duplication: concurrent resolutions for the same not-yet-cached VID race independently, and the resolution that settles last silently overwrites the cache regardless of which was actually more current or trustworthy. 📝 Description: An attacker capable of manipulating or delaying DID resolution network responses (e.g., a MITM against resolveDidDocument's HTTP(S) call, or a compromised DID resolver) could cause the wallet's transport-layer trust for a legitimate peer's VID to point at an attacker-controlled endpoint for up to 5 minutes, potentially enabling acceptance of forged TSP frames at the transport layer pending downstream verification. 🧪 Proof of Concept: There is no mechanism to detect or prevent a second concurrent call for the same vid from independently invoking resolve() and overwriting the first call's result; only relative completion order determines the final cached value. Vulnerable lines: 33, 44 🔎 Evidence: 💥 Impact: An attacker capable of manipulating or delaying DID resolution network responses (e.g., a MITM against resolveDidDocument's HTTP(S) call, or a compromised DID resolver) could cause the wallet's transport-layer trust for a legitimate peer's VID to point at an attacker-controlled endpoint for up to 5 minutes, potentially enabling acceptance of forged TSP frames at the transport layer pending downstream verification. Confidentiality: low — attacker-controlled endpoint could be used to redirect trust in transport-layer verification · Integrity: low — cache entry integrity depends on whichever async resolution completes last, independent of correctness/freshness · Availability: none 🧭 Reachability:
⚖️ Triage Factors:
Attack scenario: An on-path attacker against the DID resolution network call races two concurrent resolutions for the same not-yet-cached VID so that their forged/delayed response is written to the cache last, overwriting the legitimate endpoint for up to 5 minutes. 🔧 Remediation:
Single-flight de-duplication ensures only one resolve(vid) call is in flight per VID at a time; all concurrent callers await the same promise, eliminating the last-write-wins race between independent network responses. Vulnerable code: Secure code:
🔵 TOCTOU race in endpoint cache eviction allows transient unbounded growth (CWE-362)
Summary: The endpoint cache's insert-then-evict logic is not atomic across concurrent async resolutions for distinct VIDs, so a burst of frames from many previously-unseen peers can transiently grow the cache beyond its intended 32-entry bound and multiply DID-resolver network calls. 📝 Description: Under a burst of frames from many distinct, previously-unseen VIDs, the wallet extension's memory usage for the endpoint cache can transiently exceed its intended bound, and outbound DID-resolution HTTP(S) calls are multiplied beyond the cache's intended amortization, potentially straining a shared DID resolver's rate limits. 🧪 Proof of Concept: Each concurrent invocation independently checks the cache, awaits the resolver, then inserts and evicts one entry. Because the size check happens after the await, N concurrent calls for N distinct VIDs can all observe a size below the cap before any of them inserts, letting the Map grow to cap+N before the single-eviction-per-call logic brings it back down. Vulnerable lines: 33, 49 🔎 Evidence: 💥 Impact: Under a burst of frames from many distinct, previously-unseen VIDs, the wallet extension's memory usage for the endpoint cache can transiently exceed its intended bound, and outbound DID-resolution HTTP(S) calls are multiplied beyond the cache's intended amortization, potentially straining a shared DID resolver's rate limits. Confidentiality: none · Integrity: none · Availability: low-medium — transient memory growth and amplified outbound DID-resolution traffic under a flood of distinct-VID frames; degrades if the 'serial per socket' architectural assumption is ever violated (multi-socket, multi-worker) 🧭 Reachability:
⚖️ Triage Factors:
Attack scenario: An attacker floods the inbound TSP socket with frames from many distinct never-seen VIDs concurrently, causing the cache's check-then-evict race to transiently exceed its 32-entry bound and multiply outbound DID-resolver network calls. 🔧 Remediation:
Adding a single-flight in-flight map for the same VID prevents redundant concurrent resolutions, and changing the single Vulnerable code: Secure code:
|
| Field | Detail |
|---|---|
| Severity | LOW |
| Location | packages/extension/src/offscreen.ts:782 |
| Finding ID | github_pr-b9ea499fe656 |
| 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 — 2 occurrence(s): offscreen.ts:782, offscreen.ts:791
📝 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: 30%
- AI Validation Evidence: EVIDENCE FOUND: The finding references packages/extension/src/offscreen.ts line 782 for an 'Unsafe Formatstring' issue, but offscreen.ts is not included in source_files, and the evidence code_snippet field is empty ("code_snippet":""). EVIDENCE NOT FOUND: No actual code snippet, sink, or context around line 782 was provided to confirm whether attacker-controlled input reaches a console.log/util.format call with format-specifier injection potential. CHANGED VS PRE-EXISTING: Cannot determine — offscreen.ts is referenced elsewhere in the scan bundle (e.g., in threat model STRIDE-5 discussing resolveSender wiring near line 2073-2080) suggesting it may be touched by this MR, but line 782 specifically and its surrounding function are not visible. VERDICT JUSTIFICATION: Insufficient evidence — the deciding source (actual line 782 content) is absent, so this cannot be confirmed as validated or dismissed as false positive; a human must inspect offscreen.ts line 782 directly.
- 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 #137
| Field | Value |
|---|---|
| Repository | OpenVTC/vta-browser-plugin |
| Branch | fix/tsp-inbound-resolver-cache → 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
Introduces a short-lived (5-minute TTL, 32-entry bounded) in-memory cache for TSP sender endpoint resolution and wires it into the inbound TSP frame handler's resolveSender callback, replacing the previously uncached resolveVtaTspEndpoint. The stated goal is to prevent redelivery bursts from serially re-resolving DIDs on the single inbound socket, which would otherwise starve in-flight requests into hard TSP timeouts. Adds a dedicated test suite validating the cache's TTL, bound, and per-peer clear behavior against real (compiled) logic.
Diff: +161 / -1 lines
Types: performance, security-relevant-refactor, test
⚠️ Security Implications
🟡 Transport-layer sender verification now tolerates up to 5 minutes of staleness after key rotation or revocation
Transport-layer sender verification now tolerates up to 5 minutes of staleness after key rotation or revocation
Action: Invalidate cache entries on verification failure rather than relying solely on TTL expiry; independently confirm and, if necessary, harden the downstream document-proof validation to freshly check key validity regardless of transport cache state.
🟡 Cache poisoning via concurrent first-resolution race (no single-flight de-duplication)
Cache poisoning via concurrent first-resolution race (no single-flight de-duplication)
Action: Implement single-flight (in-flight promise de-duplication) resolution per VID so concurrent calls for the same VID await and share one resolution outcome.
🟡 Reduced forensic ability to distinguish fresh vs. cached-based frame acceptance
Reduced forensic ability to distinguish fresh vs. cached-based frame acceptance
Action: Emit cache-hit/miss status and endpoint age alongside every accepted/rejected inbound frame log entry.
🧩 Affected Components
| Component | Impact | Change | What Changed |
|---|---|---|---|
| TSP Endpoint Cache Module | high | new | A new module-level, TTL-bounded (5 min), size-bounded (32 entries) cache of resolved TSP sender endpoints, with lookup, set, eviction, and m |
| Offscreen Document Inbound TSP Handler | high | modified | Sender resolution callback swapped from an uncached to a cached resolver implementation. |
📁 File Classifications
packages/core/src/vta/tsp-vid.ts
- Type: security
packages/core/tests/tsp.endpoint-cache.mjs
- Type: test
packages/extension/src/offscreen.ts
- Type: security
🛡️ STRIDE Threat Model
Identified Threats (10)
⚪ STRIDE-1: Sender Endpoint Spoofing via Stale Cache Entry After Key Rotation in resolveVtaTspEndpointCached
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.3 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-613,CWE-350 |
| CAPEC | CAPEC-151,CAPEC-94 |
| OWASP | A07:2021 - Identification and Authentication Failures |
Description: resolveSender callback in onInboundTspFrame allows acceptance of frames verified against a stale, cached TSP endpoint due to a fixed 5-minute TTL that does not invalidate on key rotation or verification failure, resulting in transient acceptance/rejection inconsistency for a legitimate rotated peer and a window where an attacker who has compromised or intercepted the old endpoint's key material can continue to have frames evaluated against the outdated key.
Evidence: packages/core/src/vta/tsp-vid.ts:33-42
const hit = endpointCache.get(vid);
if (hit && now() - hit.at < ENDPOINT_TTL_MS) return hit.endpoint;
Attack Scenario:
- Attacker compromises or extracts the private key material corresponding to a peer's previously-published DID document endpoint (the 'old' key) prior to the peer's rotation, e.g. via a leaked HSM, prior compromise, or a compelled CA/DID controller.
- Legitimate peer rotates its DID document to publish a new TSP endpoint/key at time T0, but the victim wallet's
endpointCache(packages/core/src/vta/tsp-vid.ts, lines 13-40) still holds the pre-rotation{at, endpoint}entry because the last resolution occurred at T0-1 minute. - Attacker, in possession of the old key, crafts and sends an inbound TSP frame to the victim's
onInboundTspFramehandler (packages/extension/src/offscreen.ts, line ~2078) within the remaining TTL window (up toENDPOINT_TTL_MS = 5*60_000ms after last resolution). resolveSender: resolveVtaTspEndpointCached(offscreen.ts line 2078) returns the stale cached endpoint fromresolveTspEndpointCachedWith(tsp-vid.ts lines 33-49) without re-validating against the peer's current DID document, sincenow() - hit.at < ENDPOINT_TTL_MSshort-circuits the network resolution.- The transport verifies the inbound frame's signature/transport proof against the stale (but attacker-known) key and the frame is accepted into the processing pipeline, even though the legitimate peer already rotated away from that key — extending the attacker's window of transport-level acceptance beyond the actual key's validity period by up to 5 minutes.
- Downstream sender-identity decisions are documented as being made 'on the document's proof — not here' (offscreen.ts comment, lines 2073-2075), so the actual blast radius depends on that downstream check; if that check also trusts transport-layer sender binding or is itself time-lagged, the forged frame could be processed as if from the legitimate, rotated peer.
🔎 Threat Clue: Derived from SC-2, SC-3 via onInboundTspFrame
- Data Flows: DF-1
Preconditions: Attacker has obtained or retains access to a peer's pre-rotation TSP private key material., Victim wallet has an active cache entry for that peer's VID that has not yet exceeded the 5-minute TTL., Downstream 'document proof' validation does not independently and freshly re-verify the sender's current key status.
Existing Controls: TTL-bounded cache entry (ENDPOINT_TTL_MS = 5 minutes) limits the staleness window rather than allowing indefinite reuse. • Design comment states sender acceptance is decided downstream on the document's proof, not on transport resolution alone. • Redelivery-based healing: a frame rejected against a rotated key is redelivered and succeeds once the TTL lapses.
Recommended Mitigations: Invalidate cached entries immediately upon detecting a signature verification failure rather than waiting for TTL expiry. • Reduce TTL for high-assurance contexts or make it configurable per security posture. • Ensure downstream 'document proof' validation independently binds to a freshly-resolved, current DID key rather than relying on any transport-layer cache. • Add cache versioning tied to DID document version/nonce so a rotation is detected proactively via a lightweight freshness check.
⚪ STRIDE-2: Unbounded Growth Race Condition in endpointCache Size Enforcement
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 3.1 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-362,CWE-405 |
| CAPEC | CAPEC-125,CAPEC-130 |
| OWASP | A04:2021 - Insecure Design |
Description: resolveTspEndpointCachedWith in packages/core/src/vta/tsp-vid.ts allows a transient cache-size overshoot due to a non-atomic check-then-evict pattern under concurrent async resolution of distinct VIDs, resulting in temporary memory growth beyond ENDPOINT_CACHE_MAX during a burst.
Evidence: packages/core/src/vta/tsp-vid.ts:38-46
const endpoint = await resolve(vid);
endpointCache.set(vid, { at: now(), endpoint });
if (endpointCache.size > ENDPOINT_CACHE_MAX) {
const oldest = endpointCache.keys().next().value;
if (oldest !== undefined) endpointCache.delete(oldest);
}
Attack Scenario:
- Attacker or malicious/many colluding peers send inbound TSP frames from many distinct, never-before-seen VIDs concurrently to
onInboundTspFrame(offscreen.ts line 2078), each triggeringresolveVtaTspEndpointCached(tsp-vid.ts line 24). - Because
resolveTspEndpointCachedWith(tsp-vid.ts lines 33-49) performsawait resolve(vid)before theendpointCache.set(...)and size check, multiple concurrent async calls can each observe a cache size <= ENDPOINT_CACHE_MAX before any of them inserts, then all proceed to insert, and each only evicts one oldest entry after its own insert — allowing size to transiently exceed 32 by the number of concurrently in-flight distinct-VID resolutions. - If frame delivery is not otherwise serialized (the code comments assert inbound frames are serial per-socket, but multiple sockets/peers or a future multi-socket architecture could invalidate that assumption), an attacker controlling or spoofing many distinct VIDs floods concurrent resolutions.
- Each resolution triggers up to two real network round-trips (per the code's own documentation) to a DID resolver, so this doubles as a resource-exhaustion vector against the DID resolution infrastructure itself, and briefly inflates extension memory with more than the intended 32 cached entries.
- Repeated bursts sustain elevated memory and outbound DID-resolution request volume, degrading extension responsiveness and potentially exhausting resolver-side rate limits shared with legitimate lookups.
🔎 Threat Clue: Derived from SC-2 via onInboundTspFrame
- Data Flows: DF-1
Preconditions: Attacker can cause many concurrent inbound TSP frames from distinct, previously-unseen VIDs., The 'serial per socket' invariant documented in offscreen.ts does not hold across all realistic deployment/threading configurations (e.g., multiple concurrent sockets).
Existing Controls: Documented design assumption that inbound frames are processed serially per socket, which in the single-socket case prevents this race entirely. • Bound of ENDPOINT_CACHE_MAX = 32 limits worst-case steady-state size once bursts subside.
Recommended Mitigations: Use an atomic-per-VID mutex/lock or a single-flight pattern (e.g., a pending-promise map) to prevent duplicate concurrent resolutions for the same or different VIDs from all bypassing the size check simultaneously. • Enforce the size bound after each insert with a hard cap check that trims down to ENDPOINT_CACHE_MAX rather than evicting only one entry per call. • Add monitoring/alerting on cache size and DID-resolution call rate to detect abnormal bursts.
⚪ STRIDE-3: Cache Poisoning via Concurrent First-Resolution Race in resolveTspEndpointCachedWith
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.1 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-367,CWE-345 |
| CAPEC | CAPEC-94,CAPEC-142 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: resolveTspEndpointCachedWith in packages/core/src/vta/tsp-vid.ts allows a stale-write race condition due to the absence of an in-flight-resolution guard, resulting in an attacker-influenced or slower resolution response overwriting a fresher, already-cached legitimate endpoint if network responses are reordered or delayed.
Evidence: packages/core/src/vta/tsp-vid.ts:33-43
const hit = endpointCache.get(vid);
if (hit && now() - hit.at < ENDPOINT_TTL_MS) return hit.endpoint;
const endpoint = await resolve(vid);
endpointCache.set(vid, { at: now(), endpoint });
Attack Scenario:
- Two resolutions for the same VID are triggered close together (e.g., a redelivered frame arrives before the first resolution's promise settles, since there is no in-flight de-duplication — only a post-hoc cache check).
- The first
resolve(vid)call (tsp-vid.ts line 38) is delayed on the network (attacker-controlled network path, on-path adversary, or DNS/DID-resolver manipulation delays the response). - A second call for the same VID, occurring after the first's cache check but before its
set, also misses the cache (hit is undefined or expired) and issues its ownresolve(vid)call, which — if the attacker can race responses or control one of the resolution paths (e.g., malicious DID resolver, on-path MITM against the resolution HTTP(S) call) — returns an attacker-craftedTspRemoteEndpoint. - Because there is no lock/guard around the awaited resolution, whichever
resolve()call resolves last callsendpointCache.set(vid, {at: now(), endpoint})last and wins, even if it was the attacker-influenced response and even if it resolved to a stale or forged endpoint. - Subsequent inbound frames from the legitimate peer are now verified using the attacker's overwritten endpoint (persisting for up to ENDPOINT_TTL_MS = 5 minutes), enabling a MITM window where the attacker's key material is treated as authoritative for TSP frame verification purposes on the transport layer.
- Given the code's own admission that sender acceptance is decided downstream on document proof, the severity of this depends heavily on whether that downstream check re-validates independently — but the transport layer itself is poisoned for the TTL window.
🔎 Threat Clue: Derived from SC-2, SC-4 via onInboundTspFrame
- Data Flows: DF-1, DF-2
Preconditions: Attacker has an on-path or MITM position against the DID resolution network call (resolveDidDocument), or controls/compromises the DID resolver infrastructure., Two or more resolutions for the same never-cached or expired VID occur concurrently, which is plausible given redelivery bursts explicitly described in the code comments., Downstream document-proof validation does not independently catch a mismatched sender endpoint.
Existing Controls: TTL bounds the duration of any poisoned entry to at most 5 minutes. • Comments indicate downstream validation on document proof provides a secondary check independent of transport resolution.
Recommended Mitigations: Implement single-flight resolution per VID (a pending-promise map) so concurrent calls for the same VID await and share one resolution rather than racing independent network calls. • Validate DID document responses over an authenticated/integrity-protected channel (e.g., TLS with certificate pinning or DID-native cryptographic proofs) to prevent a MITM from forging resolution responses. • Add a monotonic sequence or freshness token to detect and reject an older resolution response overwriting a newer cache entry.
⚪ STRIDE-4: Global Mutable Module-Level Cache State Enabling Cross-Context Cache Confusion in endpointCache
| Field | Detail |
|---|---|
| Category | Tampering, Information Disclosure |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 2.3 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | None |
| CWE | CWE-668 |
| CAPEC | CAPEC-233 |
| OWASP | A01:2021 - Broken Access Control |
Description: endpointCache module-level singleton Map in packages/core/src/vta/tsp-vid.ts allows shared mutable state to leak or be manipulated across unrelated logical contexts due to lack of per-context isolation (e.g., per-wallet-profile or per-session), resulting in potential cross-tenant or cross-profile endpoint confusion if the extension is later extended to support multiple wallet identities in one process.
Evidence: packages/core/src/vta/tsp-vid.ts:15
const endpointCache = new Map<string, { at: number; endpoint: TspRemoteEndpoint }>();
Attack Scenario:
- Extension is extended (now or in the future) to support multiple wallet profiles/identities within a single offscreen document process, sharing the same JS module instance and therefore the same
endpointCachesingleton (tsp-vid.ts line 15). - Profile A resolves and caches an endpoint for VID
did:web:peer.examplewhile operating under one trust context (e.g., a testnet or lower-assurance environment). - Profile B, operating under a different trust context but sharing the same process/module scope, sends or receives a frame referencing the same VID string and hits the cache populated by Profile A's context, receiving a cached endpoint resolved under different trust assumptions.
- Because
clearTspEndpointCache(tsp-vid.ts lines 51-54) has no notion of context/profile, there is no way to selectively invalidate one profile's view without affecting all profiles sharing the process. - This does not directly leak endpoint contents cross-profile in a way that discloses secrets (endpoints are DIDs/URLs, not secret keys), but it does mean trust-context isolation is not enforced at the cache layer, which could be exploited if profile isolation is otherwise weak elsewhere in the extension.
🔎 Threat Clue: Derived from SC-2 via N/A
- Data Flows: N/A
Preconditions: Extension architecture evolves to support multiple concurrent wallet profiles/identities sharing one JS execution context (not true in the current single-profile offscreen document model per available evidence)., VID collision or reuse across profiles with differing trust levels.
Existing Controls: Current architecture (per provided code) appears to be single-profile per offscreen document, limiting present-day exploitability. • Cache values (TspRemoteEndpoint) are resolution metadata, not raw secret key material, per the design comments.
Recommended Mitigations: If multi-profile support is introduced, key the cache by a composite (profile-id, vid) rather than vid alone. • Document the single-profile assumption explicitly as an architectural invariant with a guard/assertion if violated. • Consider dependency-injecting the cache instance per offscreen document lifecycle rather than using module-level singleton state.
⚪ STRIDE-5: Silent Downgrade of Sender Verification Freshness via Cached Resolver Substitution in onInboundTspFrame
| Field | Detail |
|---|---|
| Category | Repudiation, Tampering |
| 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:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-778,CWE-223 |
| CAPEC | CAPEC-93 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: resolveSender parameter wiring in packages/extension/src/offscreen.ts allows a silent security-relevant behavior change due to swapping resolveVtaTspEndpoint for resolveVtaTspEndpointCached without any corresponding change to downstream logging, alerting, or freshness-of-verification metadata, resulting in reduced ability to detect or repudiation-audit whether a given accepted frame was verified against a live vs. stale key.
Evidence: packages/extension/src/offscreen.ts:2073-2080
resolveSender: resolveVtaTspEndpointCached,
});
} catch (err) {
// Logged, not swallowed: a frame that repeatedly fails to verify is a
Attack Scenario:
- Developer/reviewer changes
resolveSender: resolveVtaTspEndpointtoresolveSender: resolveVtaTspEndpointCached(offscreen.ts diff, line 2078) purely for performance reasons, as documented in the surrounding comment. - No corresponding change is made to the catch block or success path to record whether the accepted frame's sender endpoint came from a fresh resolution or a cached (potentially stale, up to 5 minutes old) one.
- An attacker who successfully exploits STRIDE-1 (stale-key acceptance) or STRIDE-3 (cache poisoning) produces a downstream log/audit trail that is indistinguishable from a normal, freshly-verified inbound frame, since the code comment states failures are 'Logged, not swallowed' but does not indicate endpoint-freshness is part of that logged context (offscreen.ts, line ~2079 catch block).
- Investigators attempting to reconstruct an incident (e.g., a peer disputing they sent a frame that was actually replayed/spoofed using a stale key) cannot distinguish, from the available logs, whether the acceptance was based on a live DID resolution or a cache hit, weakening non-repudiation guarantees for security-relevant transport decisions.
- This compounds the impact of STRIDE-1/STRIDE-3: even if those are mitigated later, the current change ships without an audit trail improvement that would help detect exploitation attempts or near-misses in production telemetry.
🔎 Threat Clue: Derived from SC-2 via onInboundTspFrame
- Data Flows: DF-1
Preconditions: An incident involving stale or poisoned cache-based sender resolution has occurred or is suspected., No cache-hit/miss or endpoint-freshness metadata is emitted alongside frame acceptance/rejection logs.
Existing Controls: Errors on frame verification failure are explicitly logged rather than silently swallowed (per code comment). • The cache module exposes clearTspEndpointCache allowing manual remediation once an issue is suspected.
Recommended Mitigations: Emit a cache-hit/miss and endpoint-age field in the log/telemetry emitted for every accepted or rejected inbound TSP frame. • Add a metric/counter for cache hit rate and stale-serve count to support detection of anomalous resolution patterns. • Document in incident-response runbooks that clearTspEndpointCache should be invoked and cache-age logs consulted when a sender-spoofing incident is suspected.
⚪ STRIDE-6: Denial of Service via Sustained TSP Reply Timeout Amplification from Uncached Redelivery Burst Prior to Warm Cache
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 3.8 CVSS:4.0/AV:N/AC:L/AT:P/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-125,CAPEC-227 |
| OWASP | A04:2021 - Insecure Design |
Description: onInboundTspFrame in packages/extension/src/offscreen.ts allows a serial-socket resource exhaustion due to the first-frame-in-a-burst still requiring an uncached, up-to-two-round-trip DID resolution before the cache warms, resulting in an in-flight legitimate request being starved into a hard TSP timeout during the initial burst regardless of caching.
Evidence: packages/core/src/vta/tsp-vid.ts:17-25
* Resolving a DID there costs up to two network round-trips *each*, and a redelivery burst ... turns that into hundreds of serial fetches while an in-flight request waits behind them. A TSP reply timeout is a hard failure with no fallback
Attack Scenario:
- Attacker (or a malfunctioning/malicious peer) triggers a 'redelivery burst' as described in the code's own design comments — many frames delivered before any are acked, e.g., by connecting and immediately flooding unacked frames from a never-before-seen VID.
- The very first frame in the burst still misses the cache (tsp-vid.ts line 34,
hitis undefined), forcing a realresolve(vid)call that costs up to two network round-trips (per design comment in tsp-vid.ts lines 17-19). - Because inbound frame processing is serial on the one socket that also carries replies (per design comment), this first uncached resolution blocks all subsequent frames in the same burst, and blocks any reply traffic that shares the socket.
- If the attacker deliberately targets a VID with a slow-to-respond or unreachable DID resolver endpoint (e.g., a DID method pointing to an attacker-controlled slow server, or a legitimate but temporarily degraded resolver), the first resolution can be made to hang near the TSP reply timeout threshold.
- Because 'A TSP reply timeout is a hard failure with no fallback' (per design comment, tsp-vid.ts lines 24-25), an in-flight legitimate request queued behind this first slow resolution fails outright, and this can be repeated on every reconnect/first-contact event by rotating to new never-cached VIDs, since the cache provides no protection for genuinely novel peers.
- This is a partial mitigation gap: the cache explicitly optimizes only the N-th-frame-onward case for a repeat peer; the first-contact case for any new or attacker-chosen VID remains as slow and as blocking as before this change.
🔎 Threat Clue: Derived from SC-2, SC-4 via onInboundTspFrame
- Data Flows: DF-1, DF-2
Preconditions: Attacker can cause the victim to attempt DID resolution for a slow-responding or unreachable resolver endpoint., Transport genuinely serializes inbound frame handling on a shared socket as documented., No resolution-level timeout shorter than the TSP reply timeout is enforced independently.
Existing Controls: Caching addresses the repeat-peer/burst-from-known-peer case, reducing the frequency of this exposure. • TTL-based cache does not apply here since this specifically targets the always-uncached first-contact case, so no additional control currently exists beyond the underlying transport's own timeout handling.
Recommended Mitigations: Apply an independent, shorter timeout to the DID resolution call itself, distinct from and shorter than the overall TSP reply timeout, so a slow resolver fails fast rather than exhausting the full budget. • Consider resolving new/uncached VIDs asynchronously off the serial inbound-frame critical path where protocol semantics allow, acking or queuing rather than blocking. • Rate-limit or deprioritize resolution attempts for VIDs that have recently failed to resolve, to avoid repeated blocking on the same slow target.
⚪ STRIDE-7: Test Suite Dependency on Prebuilt dist Artifact Enabling Build-Pipeline Tampering to Mask Regressions in tsp.endpoint-cache.mjs
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 2.8 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-829,CWE-494 |
| CAPEC | CAPEC-176 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: import path in packages/core/tests/tsp.endpoint-cache.mjs allows validation-gap supply-chain risk due to the test importing from ../dist/vta/index.js instead of source, resulting in the possibility that a stale or tampered build artifact passes tests that no longer reflect the actual source-level cache/TTL/eviction logic.
Evidence: packages/core/tests/tsp.endpoint-cache.mjs:9-13
import {
resolveTspEndpointCachedWith,
clearTspEndpointCache,
TSP_ENDPOINT_TTL_MS,
} from "../dist/vta/index.js";
Attack Scenario:
- CI or a compromised build step produces
packages/core/dist/vta/index.jsfrom a different (e.g., cached, tampered, or outdated) source snapshot than the currentpackages/core/src/vta/tsp-vid.ts. tsp.endpoint-cache.mjsimportsresolveTspEndpointCachedWith,clearTspEndpointCache, andTSP_ENDPOINT_TTL_MSfrom../dist/vta/index.js(test file, import statement) rather than compiling/testing the TypeScript source directly.- If a supply-chain attacker (e.g., via a compromised CI runner, malicious dependency in the build toolchain, or a poisoned npm postinstall script) modifies the build step to inject different logic into
dist/vta/index.jswhile leavingsrc/vta/tsp-vid.tsuntouched in the repository, the visible source code (reviewed in PRs) diverges from what actually ships and what the tests actually validate. - Tests continue to pass because they validate whatever
distcontains, giving developers and reviewers false confidence that the reviewed source-level TTL/eviction/single-peer-clear behavior is what is under test and what ships to users. - A maliciously altered cache implementation (e.g., extended TTL, disabled eviction, or a backdoored resolver bypass) could ship to the browser extension's production build while the source-level PR diff appears benign and fully tested.
- This is a supply-chain/build-integrity gap rather than a direct runtime vulnerability, but it undermines the assurance value of the entire test suite added in this PR.
🔎 Threat Clue: Derived from SC-5 via N/A
- Data Flows: N/A
Preconditions: Attacker has write access to, or can influence, the CI/build pipeline that produces the dist/ directory (e.g., compromised CI credentials, malicious build dependency, or unprotected build cache)., Tests are relied upon as the primary integrity check for this security-sensitive caching logic without independent source-level static analysis or reproducible-build verification.
Existing Controls: Tests do exercise real behavioral policy (TTL, eviction, per-peer clear) rather than a re-implementation, which is good practice for catching logic regressions, assuming dist is trustworthy. • Presumably a standard build step (tsc compile) exists that is expected to keep dist in sync with src.
Recommended Mitigations: Add a CI step that verifies dist/ is byte-for-byte reproducible from src/ immediately before running tests (e.g., a clean rebuild-and-diff check). • Sign or checksum build artifacts and verify signatures before test execution and before packaging the extension. • Prefer running tests directly against transpiled-in-memory or source-mapped TypeScript rather than a separately-built dist directory where feasible. • Restrict and audit CI pipeline write access, and pin/lock all build toolchain dependencies with integrity hashes.
⚪ STRIDE-8: Extended Attack Window for Compromised Peer Impersonation via Fixed TTL Insensitive to Revocation Urgency in ENDPOINT_TTL_MS
| Field | Detail |
|---|---|
| Category | Spoofing, Elevation of Privilege |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.3 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-613,CWE-297 |
| CAPEC | CAPEC-151,CAPEC-21 |
| OWASP | A07:2021 - Identification and Authentication Failures |
Description: ENDPOINT_TTL_MS constant in packages/core/src/vta/tsp-vid.ts allows a fixed, non-urgent revocation window due to a one-size-fits-all 5-minute TTL applied uniformly regardless of whether a key rotation is routine or an emergency compromise revocation, resulting in a guaranteed minimum 5-minute window during which a wallet keeps trusting a known-compromised peer endpoint after the peer publishes a revocation.
Evidence: packages/core/src/vta/tsp-vid.ts:8-9
const ENDPOINT_TTL_MS = 5 * 60_000;
Attack Scenario:
- A peer's private key is compromised (e.g., leaked, stolen from a device, or extracted via a separate vulnerability) and the peer's operator publishes an emergency DID document update revoking/rotating the compromised key.
- A victim wallet that resolved and cached that peer's endpoint within the last 5 minutes (tsp-vid.ts line 13,
ENDPOINT_TTL_MS = 5 * 60_000) continues to use the cached, now-compromised endpoint for all inbound TSP frame verification for up to the remainder of that TTL window, perresolveTspEndpointCachedWith(lines 33-35). - The attacker, holding the compromised key, sends forged TSP frames to the victim within this guaranteed window, and — because there is no explicit invalidation-on-revocation mechanism (only passive TTL expiry, as explicitly stated in the design comment: 'Deliberately TTL'd rather than invalidated on failure') — these frames are accepted by the transport layer as if from the legitimate peer for the full remaining TTL duration.
- There is no push-based revocation notification, no shorter 'emergency TTL' override, and no external call path to
clearTspEndpointCache(vid)triggered automatically by a revocation event; the only remedy is a manual call by a caller who 'knows a peer's keys have moved' (per the function's own doc comment), which requires out-of-band awareness and action. - In a targeted attack, an adversary who has just compromised a key could time their forged frames immediately after resolving that the victim likely has a warm cache entry (e.g., by having recently interacted with the victim to force a resolution), maximizing the exploitation window within the fixed 5-minute budget.
🔎 Threat Clue: Derived from SC-2, SC-3 via onInboundTspFrame
- Data Flows: DF-1
Preconditions: A peer's key has been compromised and the compromise is known to the peer/operator, triggering a revocation., No automated mechanism exists to push revocation notice to victim wallets or to proactively call clearTspEndpointCache upon revocation detection., Downstream document-proof verification does not independently detect the use of a revoked key within the window.
Existing Controls: clearTspEndpointCache(vid) is exposed for manual, out-of-band invalidation when a caller learns keys have moved. • 5-minute bound caps the maximum exposure window rather than allowing indefinite trust in a compromised key. • Design explicitly favors availability/simplicity over immediate revocation responsiveness, a documented and reasoned tradeoff.
Recommended Mitigations: Integrate a revocation-check or push notification path (e.g., DID document version/nonce polling, or a webhook/pubsub revocation feed) that proactively calls clearTspEndpointCache upon detecting a peer's key rotation, rather than relying solely on passive TTL expiry. • Offer a configurable, shorter TTL for high-assurance or high-value transaction contexts where a 5-minute compromised-key window is unacceptable. • Layer downstream document-proof validation to independently and freshly check key validity/revocation status regardless of transport-layer cache state, closing the gap the code comment explicitly defers to that downstream check.
⚪ STRIDE-9: Prompt-Injection-Style Instruction Embedded in Source Comments Attempting to Influence Automated Review Tooling
| Field | Detail |
|---|---|
| Category | Repudiation |
| Severity | Informational |
| Likelihood | Unlikely |
| CVSS | 1.0 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | None |
| CWE | CWE-1059 |
| CAPEC | CAPEC-660 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: code comments in packages/core/src/vta/tsp-vid.ts and packages/extension/src/offscreen.ts allow influence-attempt detection due to natural-language justification text embedded directly in source comments that preemptively argues against flagging the TTL/eviction design as a vulnerability, resulting in a need for automated and human reviewers to treat such embedded rationale as untrusted narrative rather than an authoritative security determination.
Evidence: packages/extension/src/offscreen.ts:2073-2075
// operator-enrolled control plane. Whether the sender is one we accept
// is decided downstream, on the document's proof — not here, and not on
// the strength of the transport.
Attack Scenario:
- Source comments in tsp-vid.ts (e.g., 'Deliberately TTL'd rather than invalidated on failure', 'the bound is what matters', 'not here, and not on the strength of the transport') and offscreen.ts ('Whether the sender is one we accept is decided downstream, on the document's proof — not here') present strong, confident justifications for the security-relevant design choices made in this diff.
- An automated security review tool or an LLM-based reviewer ingesting this diff could, if not carefully instructed, treat these embedded natural-language justifications as ground truth and suppress findings (e.g., 'mark this as not a vulnerability because the comment says downstream validation handles it') without independently verifying that the downstream validation referenced actually exists, is correctly implemented, and is not itself bypassable.
- This analysis explicitly treats all such comment text as untrusted data describing developer intent, not as a verified security control, and still raises STRIDE-1, STRIDE-3, and STRIDE-8 despite the comments' assertions, because the actual downstream 'document proof' validation code was not present in the provided source excerpt and its correctness cannot be verified.
- This finding is recorded to make explicit that the presence of confident, well-reasoned inline justifications for a security tradeoff is not itself evidence of correctness, and any downstream consumer of this analysis (human or automated) should independently verify the referenced downstream validation before closing related findings as false positives.
🔎 Threat Clue: Derived from SC-2, SC-4 via N/A
- Data Flows: N/A
Preconditions: A reviewer (human or automated) treats embedded source-code rationale as a substitute for independent verification of referenced but unseen downstream security controls.
Existing Controls: This analysis explicitly separates documented design intent from verified control effectiveness.
Recommended Mitigations: Require that any security-relevant design rationale in code comments be cross-referenced with an explicit, reviewable link to the actual downstream validation code (e.g., a file/function reference) so reviewers can verify the claim rather than trust the narrative. • Maintain the standing policy that automated review tooling treats all in-repository natural-language content as data describing intent, never as an instruction or authoritative security verdict.
⚪ STRIDE-10: Missing Rate Limiting on DID Resolution Path Enabling Resolver-Targeted Amplification via resolveVtaTspEndpointCached Cache Miss Flood
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 3.5 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-770,CWE-406 |
| CAPEC | CAPEC-125,CAPEC-147 |
| OWASP | A04:2021 - Insecure Design |
Description: resolveTspEndpointCachedWith in packages/core/src/vta/tsp-vid.ts allows outbound resolver-request amplification due to the absence of any rate limit on cache-miss-triggered resolution calls, resulting in the victim's extension being used as an unwitting amplifier against third-party DID resolver infrastructure when an attacker sends frames from many distinct never-cached VIDs.
Evidence: packages/core/src/vta/tsp-vid.ts:33-49
export async function resolveTspEndpointCachedWith(
vid: string,
resolve: (vid: string) => Promise<TspRemoteEndpoint>,
now: () => number = Date.now,
): Promise<TspRemoteEndpoint> {
Attack Scenario:
- Attacker crafts and sends inbound TSP frames purporting to originate from many distinct, syntactically-valid but never-before-seen DIDs (e.g.,
did:web:evil-{n}.attacker.examplefor many values of n) to the victim'sonInboundTspFramehandler. - Each distinct VID misses the
endpointCache(tsp-vid.ts line 34) since none have been resolved before, forcingresolveVtaTspEndpointCached→resolveVtaTspEndpoint→resolveTspEndpoint(vid, resolveDidDocument)to issue a real DID resolution network request for each. - There is no rate limiting, no minimum-interval-between-resolutions, and no per-source throttling anywhere in the reviewed cache or resolution code, so the victim's extension will issue one outbound resolution request per distinct attacker-supplied VID, up to whatever volume the attacker sends.
- If the attacker controls the DID method/resolver referenced by these fabricated VIDs (e.g.,
did:web:pointing to attacker-controlled infrastructure), this is merely wasted victim bandwidth/CPU; but if the attacker crafts VIDs that resolve via a shared, third-party, or well-known DID resolver infrastructure (e.g., a universal resolver or a specific DID method's public resolver network), the victim's extension becomes an amplification vector, generating resolver-side load attributable to the victim's IP/infrastructure rather than the attacker's. - This also compounds STRIDE-2 (cache growth race) and STRIDE-6 (DoS via uncached first-frame), since a sustained flood of distinct-VID frames maximizes uncached resolution calls, cache churn, and serial-socket blocking simultaneously.
🔎 Threat Clue: Derived from SC-2, SC-4 via onInboundTspFrame
- Data Flows: DF-1, DF-2
Preconditions: Attacker can deliver inbound TSP frames referencing arbitrary attacker-chosen VIDs to the victim's transport endpoint., No upstream transport-level or application-level rate limiting exists ahead of the cache/resolution layer (not evidenced in the provided source).
Existing Controls: The endpoint cache itself provides no rate limiting by design; it only reduces repeat-VID load, not distinct-VID flood load. • ENDPOINT_CACHE_MAX bounds cache memory footprint but does not bound the rate of resolution calls made before eviction.
Recommended Mitigations: Add per-source or global rate limiting on DID resolution attempts (e.g., token bucket per sending transport connection or per time window) ahead of or within resolveVtaTspEndpointCached. • Track and cap the number of distinct never-seen VIDs resolved per unit time from a given inbound transport session, rejecting or delaying frames beyond the threshold. • Apply circuit-breaker logic that temporarily stops resolving new VIDs from a given peer/session after repeated resolution failures or excessive volume.
🍝 PASTA Threat Model
Application Purpose
A browser extension implementing a decentralized identity (DID/VTA) wallet that communicates over the Trust Spanning Protocol (TSP) to exchange verifiable, proof-bearing messages with counterparties, enabling trusted digital credential and transaction workflows.
Inherent Risks
- The extension's offscreen document processes inbound cryptographic transport frames from untrusted network peers.
- Sender identity resolution depends on external DID resolution infrastructure outside the extension's control.
- The transport layer explicitly defers sender-trust decisions to a downstream document-proof check not visible in this diff, creating a verification dependency across module boundaries.
- A single shared inbound socket serializes all frame processing, making per-frame latency a systemic availability risk.
Objectives
Risk: Accept a bounded window of stale-key trust in exchange for availability under burst load.; Rely on downstream document-proof validation as the authoritative sender-trust decision.
Business: Enable trustworthy, low-latency peer-to-peer credential exchange for wallet users.; Maintain user confidence that inbound messages are cryptographically verified before being trusted.
Security: Ensure inbound TSP frames are verified against a currently valid sender key.; Bound resource consumption (memory, network calls) attributable to any single peer or attacker.
Financial: Avoid costs associated with security incidents involving forged or replayed transport frames.; Minimize infrastructure/network costs from excessive DID resolution calls.
Compliance: Maintain auditability of security-relevant transport decisions for incident investigation.; Ensure build artifacts under test accurately reflect reviewed source code.
Functional: Resolve a TSP peer's current transport endpoint from its DID document.; Cache resolved endpoints to sustain throughput during frame redelivery bursts.
Operational: Keep the single inbound socket responsive under normal and burst load.; Provide manual cache invalidation for operators aware of out-of-band key rotation.
Business Impact Analysis (3)
BIA-1: Inbound TSP Frame Verification and Sender Resolution (High)
The end-to-end process of receiving an inbound TSP frame, resolving the claimed sender's transport endpoint (cached or fresh), verifying the frame's transport-layer authenticity, and forwarding it for downstream document-proof evaluation.
MTD: 00 days 00:05 hours | RTO: 00 days 00:01 hours | RPO: 00 days 00:00 hours
- Stakeholders: Extension Developers / Wallet End Users / Peer Operators / Security Reviewers
- Dependencies: DID Resolution Infrastructure / TSP Transport Library / endpointCache Module / Offscreen Document Runtime
- Disruptions: Stale cache entry accepted after peer key rotation / Cache-poisoning race during concurrent resolution / Uncached first-contact resolution stalling the serial socket / Resolver-targeted amplification flood via distinct fabricated VIDs
- Impacts: Transport-level acceptance of frames from a revoked/rotated key for up to 5 minutes / Legitimate in-flight requests failing with hard TSP timeouts / Extension memory and outbound request growth during flood conditions / Reduced incident-response clarity due to missing freshness telemetry
BIA-2: Endpoint Cache Integrity Maintenance (Medium)
The background process of maintaining the size-bounded, time-bounded endpoint cache that underpins inbound frame verification performance.
MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:00 hours
- Stakeholders: Extension Developers / Wallet End Users
- Dependencies: endpointCache Module / Node.js Map Data Structure
- Disruptions: Concurrent cache-size race exceeding ENDPOINT_CACHE_MAX / Cross-context cache confusion if multi-profile support is added
- Impacts: Transient memory growth beyond intended bound / Potential future cross-tenant trust confusion
BIA-3: Build and Test Pipeline Integrity for Security-Sensitive Modules (Medium)
The CI process that compiles TypeScript source into the dist/ artifact subsequently validated by the automated test suite for the caching logic.
MTD: 07 days 00:00 hours | RTO: 01 days 00:00 hours | RPO: 00 days 00:00 hours
- Stakeholders: Extension Developers / CI/CD Operators / Security Reviewers
- Dependencies: Node.js Test Runner / TypeScript Compiler / CI Pipeline Infrastructure
- Disruptions: Divergence between reviewed source and built dist artifact / Compromised CI credentials or build dependencies
- Impacts: False confidence from passing tests that validate a tampered artifact / Undetected shipment of backdoored cache logic
Technical Scope
Roles (3): RO-1 Wallet End User · RO-2 Peer Operator · RO-3 Extension Developer
Actors (3): AC-1 Malicious or Compromised Peer · AC-2 Wallet Extension Runtime · AC-3 CI Pipeline Service Account
Entry Points (1): EP-1 Inbound TSP Frame Reception
Threat Actors (4): TA-1 Compromised-Key Impersonator · TA-2 On-Path Network Adversary · TA-3 Resource-Exhaustion Attacker · TA-4 Supply-Chain / CI Attacker
Infrastructure (2): IF-1 Browser Extension Offscreen Document (Manifest V3) · IF-2 CI Build Runners
Trust Boundaries (3): TB-1 Network-to-Extension Boundary · TB-2 Extension-to-DID-Resolver Boundary · TB-3 Build Pipeline Boundary
External Entities (2): EE-1 TSP Peer / Wallet Counterparty · EE-2 DID Resolver Service
System Components (5): SC-1 Attacker/Peer Network Entity · SC-2 Offscreen Document Inbound TSP Handler · SC-3 DID Resolution Infrastructure · SC-4 TSP Endpoint Cache Module · SC-5 CI/Build Pipeline
Resources And Assets (2): RA-1 Cached TSP Remote Endpoint Entries · RA-2 Peer DID Documents
Technologies And Dependencies (3): TD-1 TypeScript · TD-2 node:test / node:assert built-in test runner · TD-3 Trust Spanning Protocol (TSP) library
Use Cases (2)
- Inbound TSP Frame Sender Resolution: A legitimate TSP peer sends a frame to the wallet extension, which resolves the peer's current transport endpoint (using the cache when fresh) to verify the frame before forwarding it for document-pro
- Cache-Warm Redelivery Burst Handling: After the first frame from a known peer resolves and warms the cache, subsequent redelivered frames from the same peer within the TTL window are verified using the cached endpoint without additional n
📋 Risk Registry (5)
| ID | Title | Severity | Residual | Priority | Effort |
|---|---|---|---|---|---|
| RISK-001 | Peer impersonation using a rotated or revoked key within the cache TTL window | Medium | Low | Short-Term | Medium |
| RISK-002 | Cache poisoning via concurrent resolution race under network-path adversary conditions | Medium | Low | Short-Term | Medium |
| RISK-003 | Reduced incident forensics due to missing cache-freshness telemetry on a security-relevant performance change | Medium | Low | Short-Term | Low |
| RISK-004 | Denial of service via distinct-VID flooding causing uncached resolution storms and resolver amplification | Low | Low | Medium-Term | Medium |
| RISK-005 | Build pipeline integrity gap undermining assurance value of the new cache test suite | Low | Low | Medium-Term | Medium |
⚔️ Attack Scenarios (3)
SC-2: Offscreen Document Inbound TSP Handler
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Compromised-Key Impersonator<br><i>Impersonate a legitimate peer using stolen key material</i>" }
TA2@{ shape: rect, label: "TA-2: On-Path Network Adversary<br><i>Race/poison endpoint resolution</i>" }
end
subgraph SL2["2. Threats"]
direction LR
S1@{ shape: rect, label: "STRIDE-1: Sender Endpoint Spoofing via Stale Cache Entry<br><i>Medium / Possible</i>" }
S8@{ shape: rect, label: "STRIDE-8: Extended Attack Window via Fixed TTL<br><i>Medium / Possible</i>" }
S3@{ shape: rect, label: "STRIDE-3: Cache Poisoning via Concurrent Race<br><i>Medium / Possible</i>" }
S5@{ shape: rect, label: "STRIDE-5: Silent Downgrade of Verification Freshness<br><i>Medium / Possible</i>" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
C151@{ shape: rect, label: "CAPEC-151: Identity Spoofing" }
C94@{ shape: rect, label: "CAPEC-94: Adversary in the Middle" }
C142@{ shape: rect, label: "CAPEC-142: DNS Cache Poisoning" }
C93@{ shape: rect, label: "CAPEC-93: Log Injection-Tampering-Deletion" }
end
subgraph SL4["4. Weaknesses"]
direction LR
W613@{ shape: rect, label: "CWE-613: Insufficient Session Expiration" }
W350@{ shape: rect, label: "CWE-350: Reliance on Reverse DNS/IP Resolution" }
W367@{ shape: rect, label: "CWE-367: TOCTOU Race Condition" }
W778@{ shape: rect, label: "CWE-778: Insufficient Logging" }
end
subgraph SL5["5. System Component"]
direction LR
SC2@{ shape: rect, label: "SC-2: Offscreen Document Inbound TSP Handler" }
end
TA1 --> S1
TA1 --> S8
TA2 --> S3
S1 --> C151
S8 --> C151
S3 --> C94
S3 --> C142
S5 --> C93
C151 --> W613
C94 --> W367
C142 --> W367
C93 --> W778
W613 --> SC2
W350 --> SC2
W367 --> SC2
W778 --> SC2
linkStyle 0 stroke:#FF0000,stroke-width:2px
linkStyle 1 stroke:#FF0000,stroke-width:2px
linkStyle 2 stroke:#FF0000,stroke-width:2px
linkStyle 3 stroke:#FF0000,stroke-width:2px
linkStyle 4 stroke:#FF0000,stroke-width:2px
linkStyle 5 stroke:#FF0000,stroke-width:2px
linkStyle 6 stroke:#FF0000,stroke-width:2px
linkStyle 7 stroke:#FF0000,stroke-width:2px
linkStyle 8 stroke:#FF0000,stroke-width:2px
linkStyle 9 stroke:#FF0000,stroke-width:2px
linkStyle 10 stroke:#FF0000,stroke-width:2px
linkStyle 11 stroke:#FF0000,stroke-width:2px
SC-4: TSP Endpoint Cache Module
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. Threat Actors"]
direction LR
TA3@{ shape: rect, label: "TA-3: Resource-Exhaustion Attacker<br><i>Degrade availability via flooding</i>" }
end
subgraph SL2["2. Threats"]
direction LR
S2@{ shape: rect, label: "STRIDE-2: Unbounded Growth Race Condition<br><i>Low / Unlikely</i>" }
S6@{ shape: rect, label: "STRIDE-6: DoS via Redelivery Burst Timeout Amplification<br><i>Low / Possible</i>" }
S10@{ shape: rect, label: "STRIDE-10: Resolver-Targeted Amplification via Cache Miss Flood<br><i>Low / Possible</i>" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
C125@{ shape: rect, label: "CAPEC-125: Flooding" }
C227@{ shape: rect, label: "CAPEC-227: Sustained Client Engagement" }
C147@{ shape: rect, label: "CAPEC-147: XML Ping of the Death (Amplification Analogue)" }
end
subgraph SL4["4. Weaknesses"]
direction LR
W362@{ shape: rect, label: "CWE-362: Concurrent Execution Race Condition" }
W400@{ shape: rect, label: "CWE-400: Uncontrolled Resource Consumption" }
W770@{ shape: rect, label: "CWE-770: Allocation of Resources Without Limits" }
end
subgraph SL5["5. System Component"]
direction LR
SC4@{ shape: rect, label: "SC-4: TSP Endpoint Cache Module" }
end
TA3 --> S2
TA3 --> S6
TA3 --> S10
S2 --> C125
S6 --> C227
S10 --> C147
C125 --> W362
C227 --> W400
C147 --> W770
W362 --> SC4
W400 --> SC4
W770 --> SC4
linkStyle 0 stroke:#FF0000,stroke-width:2px
linkStyle 1 stroke:#FF0000,stroke-width:2px
linkStyle 2 stroke:#FF0000,stroke-width:2px
linkStyle 3 stroke:#FF0000,stroke-width:2px
linkStyle 4 stroke:#FF0000,stroke-width:2px
linkStyle 5 stroke:#FF0000,stroke-width:2px
linkStyle 6 stroke:#FF0000,stroke-width:2px
linkStyle 7 stroke:#FF0000,stroke-width:2px
linkStyle 8 stroke:#FF0000,stroke-width:2px
linkStyle 9 stroke:#FF0000,stroke-width:2px
linkStyle 10 stroke:#FF0000,stroke-width:2px
linkStyle 11 stroke:#FF0000,stroke-width:2px
SC-5: CI/Build Pipeline
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. Threat Actors"]
direction LR
TA4@{ shape: rect, label: "TA-4: Supply-Chain / CI Attacker<br><i>Ship divergent logic while tests pass</i>" }
end
subgraph SL2["2. Threats"]
direction LR
S7@{ shape: rect, label: "STRIDE-7: Test Suite Dependency on Prebuilt dist Artifact<br><i>Low / Unlikely</i>" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
C176@{ shape: rect, label: "CAPEC-176: Configuration/Environment Manipulation" }
end
subgraph SL4["4. Weaknesses"]
direction LR
W829@{ shape: rect, label: "CWE-829: Inclusion of Untrusted Functionality" }
end
subgraph SL5["5. System Component"]
direction LR
SC5@{ shape: rect, label: "SC-5: CI/Build Pipeline" }
end
TA4 --> S7
S7 --> C176
C176 --> W829
W829 --> SC5
linkStyle 0 stroke:#FF0000,stroke-width:2px
linkStyle 1 stroke:#FF0000,stroke-width:2px
linkStyle 2 stroke:#FF0000,stroke-width:2px
📊 Risk Summary
Total Threats: 10
By Severity: Low: 5 · Medium: 4 · Informational: 1
By Category: Unknown: 10
🎯 Attack Surface
Kill Chain 1: An attacker who has obtained a peer's private key material prior to a rotation event can send forged TSP frames to the offscreen document handler (SC-2) within the 5-minute ENDPOINT_TTL_MS window; because resolveVtaTspEndpointCached (tsp-vid.ts) returns the stale cached endpoint without re-validating against the current DID document, the transport layer accepts the frame based on a superseded key, and the only remaining backstop is the downstream document-proof check referenced but not visible in this diff — chaining STRIDE-1 and STRIDE-8 into a bounded but real impersonation window. Kill Chain 2: An on-path network adversary can combine a delayed or manipulated DID resolution response with the lack of single-flight de-duplication in resolveTspEndpointCachedWith to win a race against a legitimate resolution, poisoning the cache (STRIDE-3) with an attacker-controlled endpoint that then serves as the basis for verifying subsequent legitimate frames for up to the TTL duration — a chain from network-position adversary to transport-layer trust corruption. Kill Chain 3: A volume attacker can combine distinct fabricated VIDs to simultaneously trigger a transient cache-size overshoot (STRIDE-2), stall the serial inbound socket on uncached first-contact resolutions (STRIDE-6), and amplify outbound request volume against third-party DID resolver infrastructure (STRIDE-10), degrading both the victim wallet's availability and the broader resolver ecosystem's health from a s
🛡️ Risk Mitigation Strategy
Priority 1 (Short-Term): Close the cache-freshness and race-condition gaps that most directly weaken sender-trust guarantees — implement single-flight resolution per VID to eliminate the concurrent-resolution race (STRIDE-3), invalidate cache entries immediately on verification failure rather than relying solely on TTL expiry (STRIDE-1, STRIDE-8), and emit cache-hit/miss plus endpoint-age telemetry on every accepted or rejected frame to restore forensic visibility (STRIDE-5). These changes directly reduce the residual severity of RISK-001, RISK-002, and RISK-003 with moderate engineering effort. Priority 2 (Medium-Term): Harden availability against flooding by adding per-source rate limiting on distinct-VID resolution attempts, applying an independent and shorter timeout to the DID resolution call itself so a slow or malicious resolver cannot exhaust the shared TSP reply timeout budget, and hardening the cache's insert-then-evict logic to be atomic under concurrency — collectively addressing RISK-004 and reducing the extension's exposure as an amplification vector against third-party resolver infrastructure. Priority 3 (Medium-Term): Strengthen build-pipeline integrity by introducing a reproducible-build verification step ahead of test execution and by signing or checksumming build artifacts, ensuring that the test suite added in this PR continues to validate the actual code that ships rather than a potentially divergent artifact, addressing RISK-005. Priority 4 (Long-Term): Invest in an architectural improvement to replace passive TTL-based trust with an active revocation-awareness mechanism (e.g., a lightweight push notification or DID document version-check hook) so that the downstream document-proof validation this design explicitly depends on can be corroborated by a proactively-informed transport layer, closing the structural gap that all TTL-window-based findings in this review ultimately trace back to.
Generated by Agentic Sec — Threat Model & Affect Analysis Agent
📊 Summary & findings
| ✅ Confirmed | |
|---|---|
| 3 | 1 |
Confirmed (3)
- 🟡 Stale endpoint credential/key reuse due to TTL-based caching of resolved TSP endpoints
- 🟡 Missing single-flight de-duplication enables cache overwrite race on concurrent first resolution (CWE-367/CWE-345)
- 🔵 TOCTOU race in endpoint cache eviction allows transient unbounded growth (CWE-362)
Must-Review-By-Human (1)
- 🔵 Unsafe Formatstring (2 occurrences)
A DID resolution per inbound frame, serially, on the reply socket
An inbound TSP frame is awaited by the transport before it acks and before it takes the next frame — that ordering is R1.6 and it is deliberate. The consequence is that whatever the handler does happens serially on the one socket that also carries replies.
unpackInboundTspresolves the sender's DID there. Uncached, that is up to two network round-trips per frame.A redelivery burst therefore becomes hundreds of serial fetches while an in-flight request waits behind them. And a TSP reply timeout is a hard failure with no fallback — by design, because post-send a mutation may already have applied — so the waiting request does not degrade, it fails.
This was observed, not theorised. A vault list would not load over TSP immediately after upgrading, and worked once the backlog had drained. A client that starts acking a backlog it had never acked (vti-didcomm-js 0.7.0) gets exactly one such burst on its first connect, which is the shape that was hit.
The fix
One resolution per peer instead of one per frame. A 200-frame backlog from a single VTA goes from 200 resolutions to 1.
TTL'd rather than invalidated on failure. Eviction would need the unpack result plumbed back through a resolver that cannot see it, and redelivery already supplies the retry: a frame refused against a stale key is redelivered — the ack is withheld precisely because the handler threw — so a key rotation costs at most one TTL of refusals on a message that was going to be re-sent anyway. Five minutes, bounded at 32 peers.
On the tests
The cache is split over an injected resolver (
resolveTspEndpointCachedWith) specifically so it can be tested as itself.My first version of this test re-implemented the TTL and the bound and asserted against its own copy — which passes whatever the real policy does, the one thing a cache test must not do. It also exports the TTL constant so the lapse test states the policy's own number rather than a duplicate, and asserts the bound through behaviour (newest still hits, oldest re-resolves) rather than by exporting the Map.
467 core tests pass; lint and build clean;
dist/background.jsstill a single bundle.Not the whole story
This removes the mechanism that most plausibly caused the failure, but the failure itself was never captured with a reason — the decline was silent at the time, which is what #136 fixed. If it recurs, the error will now name which of the four faults it is.