fix(consent): hold a port open so the consent ask reaches the worker - #109
Conversation
chrome.runtime.sendMessage from an offscreen document does not dependably START a terminated MV3 service worker. The send resolves nowhere, nothing is thrown, and the caller's await hangs forever. That is the remaining failure. A task-consent request arrived on the approver inbox, verified, passed both dedup gates, and was acked to the mediator -- deleting its queued copy -- and then nothing: no prompt, no error, no decision. #108 proved it was not throwing; the same message sent by hand from the offscreen console, with the worker already awake, raised the window correctly. Inspect views showed "service worker (Inactive)". chrome.runtime.connect does start the worker, and an open port keeps it alive for the connection's lifetime. That also covers the second half: requestTaskConsent awaits a human decision that can run minutes past the ~30s idle teardown, with the resolver held in the worker's memory -- so even a delivered ask could be discarded mid-decision. Both offscreen consent sites open the port before asking and disconnect in finally, so an answered, denied or failed prompt never leaves the worker pinned awake. The background accepts the port and does nothing else; accepting it is the entire purpose. The name lives in bridge-protocol.ts with the message types it belongs beside. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…ndow (#110) A prompt window that must appear at an arbitrary moment is the least reliable thing this extension can attempt. It needs a live service worker, a live offscreen document, and a promise chain spanning both, on a runtime free to kill either at any time. Every failure chased in this series -- a create() that hung (#106), a swallowed throw (#108), a send that could not start a terminated worker (#109) -- was a different edge of that one design problem. A badge has none of those dependencies. It is derived from the durable record the inbound path already writes before it acks the mediator (pending.ts), so it is correct after any teardown, and it gives the user a way IN rather than depending on a window finding its way OUT. refreshPendingBadge() counts approver-bound pending records and sets the action badge. It runs on service-worker startup and when a consent port opens or closes -- every wake is a chance to be correct. Idempotent and cheap, so no single missed call can leave it lying, and a failure is swallowed with a warn: a cosmetic surface must never break a wake path. The window remains the fast path. This is the floor beneath it: the first thing in this flow that does not depend on two ephemeral contexts being alive simultaneously. First step of the larger inversion -- making the durable record the spine of the flow rather than crash recovery for it. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
🛡️ AI Agentic Security Review
📊 Summary
|
| Field | Value |
|---|---|
| Repository | OpenVTC/vta-browser-plugin |
| Branch | fix/consent-keepalive-port → main |
| Validated | 2026-08-06 |
| Scan ID | d9892d80 |
| Validator | AI Security Validation Agent |
🗺️ Scan Coverage
Modules scanned: 1 · with findings: 1 · files: 3 · findings: 7
| Module | Files scanned | Findings |
|---|---|---|
packages/extension |
3 | 7 |
Executive Summary
| Category | Confirmed | Must-Review-By-Human | False Positive | Duplicate | Not Applicable | Total |
|---|---|---|---|---|---|---|
| Security Issues | 0 | 4 | 0 | 3 | 0 | 7 |
⚠️ 4 finding(s) need human review. These could not be conclusively confirmed or dismissed automatically (insufficient evidence). They are not dismissed — a developer / security team member must read and decide.
🔒 Security Issues
⚠️ Must-Review-By-Human (4)
Validated up to a point, but inconclusive — a human must read the code and make the final call. Reported (not dismissed) so developers and the security team receive them.
🟡 Missing sender authentication on CONSENT_KEEPALIVE_PORT onConnect listener
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | packages/extension/src/background.ts:91 |
| Finding ID | github_pr-c88337238810 |
| CWE | CWE-306, CWE-400 |
| OWASP | A04:2021 - Insecure Design |
| MITRE ATT&CK | T1499 |
| CAPEC | CAPEC-125, CAPEC-227 |
| CVSS 4.0 | 5.3 (CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N) |
| DREAD | 5.6 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | conceptual |
| Detection Source | skill_scan |
Summary: The background.ts onConnect listener accepts connections named 'pnm/consent-keepalive' from any sender without validating sender.id or sender.url. Because this constant is exported and visible in the bundled source, any code that can reach the extension's messaging surface can open unlimited ports and pin the MV3 service worker permanently awake, causing sustained resource consumption.
📝 Description:
An attacker with any messaging access to the extension (another malicious extension, or a compromised content script if externally_connectable is configured broadly) can keep the wallet's background worker permanently active, increasing battery/CPU/memory consumption on the user's device and potentially masking or interfering with legitimate keepalive semantics relied upon by the consent flow.
🧪 Proof of Concept:
The only check performed is a string equality on port.name. There is no verification of port.sender.id, port.sender.url, or an upper bound on concurrently accepted ports, so any caller matching the name string is trusted.
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== CONSENT_KEEPALIVE_PORT) return;
port.onDisconnect.addListener(() => {
// Nothing to clean up — the port exists only to hold the worker awake.
});
});
Vulnerable lines: 91, 97
🔎 Evidence: packages/extension/src/background.ts:91
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== CONSENT_KEEPALIVE_PORT) return;
port.onDisconnect.addListener(() => {
// Nothing to clean up — the port exists only to hold the worker awake.
});
});
💥 Impact:
An attacker with any messaging access to the extension (another malicious extension, or a compromised content script if externally_connectable is configured broadly) can keep the wallet's background worker permanently active, increasing battery/CPU/memory consumption on the user's device and potentially masking or interfering with legitimate keepalive semantics relied upon by the consent flow.
🧭 Reachability:
- Network exposure: internal
- Auth barrier: none
- Attack path: EP-001 (chrome.runtime.onConnect) → background.ts:91-97 port.name check only → worker pinned awake
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | medium |
| Business impact | low |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: Any code able to reach chrome.runtime.connect against this extension can pin its MV3 service worker awake indefinitely due to missing sender validation.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Adds sender.id validation to ensure only the extension's own offscreen document can open the keepalive port, and caps concurrent ports to bound resource impact from any residual abuse.
Vulnerable code:
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== CONSENT_KEEPALIVE_PORT) return;
port.onDisconnect.addListener(() => {});
});
Secure code:
const MAX_KEEPALIVE_PORTS = 8;
let activeKeepAlivePorts = 0;
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== CONSENT_KEEPALIVE_PORT) return;
// Only accept connections from this extension's own contexts.
if (!port.sender || port.sender.id !== chrome.runtime.id) {
port.disconnect();
return;
}
if (activeKeepAlivePorts >= MAX_KEEPALIVE_PORTS) {
port.disconnect();
return;
}
activeKeepAlivePorts++;
port.onDisconnect.addListener(() => {
activeKeepAlivePorts--;
});
});
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 55%
- AI Validation Evidence: EVIDENCE FOUND: background.ts adds
chrome.runtime.onConnect.addListener((port) => { if (port.name !== CONSENT_KEEPALIVE_PORT) return; port.onDisconnect.addListener(() => {}); });with no check of port.sender.id/url. CONSENT_KEEPALIVE_PORT = "pnm/consent-keepalive" is a plain exported string constant in bridge-protocol.ts, discoverable by anyone with the source. EVIDENCE NOT FOUND: manifest.json (externally_connectable config) was not provided, so I cannot confirm whether any external origin/extension can actually reach chrome.runtime.connect against this extension; without that the actual exploitability (vs. same-extension-only contexts, which are already trusted) cannot be settled. CHANGED VS PRE-EXISTING: CHANGED — the onConnect listener and CONSENT_KEEPALIVE_PORT constant are both newly added by this MR in background.ts and bridge-protocol.ts per the diff. VERDICT JUSTIFICATION: The listener itself is exactly as described (no sender check), but real-world impact hinges on externally_connectable/manifest exposure which is not in evidence, so this is inconclusive rather than a confirmed exploitable vuln — kept for human review.- 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.
🟡 Unbounded human-decision wait extends service-worker pinning window (resource exhaustion)
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | packages/extension/src/offscreen.ts:707 |
| Finding ID | github_pr-fc8411fcdbc5 |
| CWE | CWE-400, CWE-664 |
| OWASP | A04:2021 - Insecure Design |
| MITRE ATT&CK | T1499 |
| CAPEC | CAPEC-125, CAPEC-490 |
| CVSS 4.0 | 5.9 (CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N) |
| DREAD | 4.8 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | conceptual |
| Detection Source | skill_scan |
Summary: The consent relay logic opens a keepalive port before waiting on a human decision that can take minutes, with no enforced timeout. A repeated or stalled flow (attacker-triggered or accidental) can keep the MV3 worker pinned awake for extended, uncontrolled periods, and multiple concurrent flows multiply the effect.
📝 Description:
Repeated or stalled consent requests keep the wallet's background service worker resident far beyond intended MV3 idle-teardown, increasing memory/CPU consumption and battery drain, and potentially interacting poorly with the extension's assumption that keepalive ports are short-lived relative to normal use.
🧪 Proof of Concept:
No Promise.race/timeout wraps the sendMessage await; the keepAlive port's disconnect is gated entirely on that promise settling, so an indefinitely pending human decision keeps the worker pinned with no upper bound.
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const result = (await chrome.runtime.sendMessage({
type: RUNTIME_TASK_CONSENT,
...
}));
...
} catch (e) {
console.error("[pnm inbound] task-consent handling failed:", e);
} finally {
keepAlive.disconnect();
activeConsentDigests.delete(parsed.request.payloadDigest);
}
Vulnerable lines: 1928, 1940
🔎 Evidence: packages/extension/src/offscreen.ts:707
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const result = (await chrome.runtime.sendMessage({
type: RUNTIME_TASK_CONSENT,
...
}));
💥 Impact:
Repeated or stalled consent requests keep the wallet's background service worker resident far beyond intended MV3 idle-teardown, increasing memory/CPU consumption and battery drain, and potentially interacting poorly with the extension's assumption that keepalive ports are short-lived relative to normal use.
🧭 Reachability:
- Network exposure: internal
- Auth barrier: basic
- Attack path: EP-002/EP-003 (RUNTIME_TASK_CONSENT) → offscreen.ts maybeRelayConsentLocally/handleTaskConsent → chrome.runtime.connect(CONSENT_KEEPALIVE_PORT) → unbounded await chrome.runtime.sendMessage
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | medium |
| Business impact | low |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: Flooding or stalling consent requests keeps the keepalive port (and thus the service worker) pinned indefinitely because the human-decision await has no timeout.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Wrapping the sendMessage await in Promise.race with an explicit timeout guarantees the keepalive port and its worker-pinning effect are bounded, treating an unresolved human decision as an implicit denial after a fixed period.
Vulnerable code:
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const result = await chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, ... });
} finally {
keepAlive.disconnect();
}
Secure code:
const CONSENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('consent-timeout')), CONSENT_TIMEOUT_MS)
);
const result = await Promise.race([
chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, ... }),
timeout,
]);
} catch (e) {
// Treat timeout as denial; log distinctly from other errors.
} finally {
keepAlive.disconnect();
}
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 50%
- AI Validation Evidence: EVIDENCE FOUND:
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT }); try { const result = (await chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, ... }));— the await has no visible timeout in the diff; cleanup occurs only infinally { keepAlive.disconnect(); ... }after the promise settles. EVIDENCE NOT FOUND: the full body of requestTaskConsent/consent popup lifecycle handling (e.g. whether the popup enforces its own timeout that would resolve the sendMessage promise) is not shown; cannot confirm the wait is truly unbounded end-to-end. CHANGED VS PRE-EXISTING: CHANGED — this exact keepalive+await pattern is newly introduced in offscreen.ts by this MR (maybeRelayConsentLocally and handleTaskConsent). VERDICT JUSTIFICATION: The code as shown does lack an explicit timeout wrapper, supporting the finding, but since a bounded human-decision UI mechanism elsewhere could mitigate this and wasn't provided, confidence is only moderate — kept for human review rather than auto-validated.- 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.
🟡 Consent request de-duplication uses non-atomic Set, widening race window under new keepalive lifetime
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | packages/extension/src/offscreen.ts:693 |
| Finding ID | github_pr-198a876da306 |
| CWE | CWE-362, CWE-367 |
| OWASP | A04:2021 - Insecure Design |
| MITRE ATT&CK | T1499 |
| CAPEC | CAPEC-25, CAPEC-26 |
| CVSS 4.0 | 5.1 (CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N) |
| DREAD | 3.8 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | theoretical |
| Detection Source | skill_scan |
Summary: Two separate functions (maybeRelayConsentLocally, handleTaskConsent) each add/delete the same payloadDigest to a shared Set without atomic check-and-set semantics. The PR under review extends the in-flight duration of each entry from message-passing latency to full human-decision time (minutes), materially widening the window in which a racing duplicate request could be treated as new by the other code path.
📝 Description:
A user could be shown duplicate consent prompts for what is meant to be a single-use, de-duplicated approval, undermining the 'single-use approval, nothing to remember' guarantee documented for RUNTIME_TASK_CONSENT, and potentially enabling confusion-based approval of an unintended action.
🧪 Proof of Concept:
add()/delete() on a bare Set provide no atomicity guarantee across the two independent functions that both reference activeConsentDigests; the widened await window (now human-decision-scale) increases the practical likelihood of a race being triggered.
activeConsentDigests.add(outcome.payloadDigest);
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const result = (await chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, ... }));
...
} finally {
keepAlive.disconnect();
activeConsentDigests.delete(outcome.payloadDigest);
}
Vulnerable lines: 693, 711
🔎 Evidence: packages/extension/src/offscreen.ts:693
activeConsentDigests.add(outcome.payloadDigest);
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const result = (await chrome.runtime.sendMessage({
type: RUNTIME_TASK_CONSENT,
...
}));
💥 Impact:
A user could be shown duplicate consent prompts for what is meant to be a single-use, de-duplicated approval, undermining the 'single-use approval, nothing to remember' guarantee documented for RUNTIME_TASK_CONSENT, and potentially enabling confusion-based approval of an unintended action.
🧭 Reachability:
- Network exposure: internal
- Auth barrier: basic
- Attack path: EP-004/EP-005 → offscreen.ts activeConsentDigests.add() (non-atomic) → keepAlive port opened → long await window → activeConsentDigests.delete() only in finally
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | low |
| Business impact | medium |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: A widened race window (due to the human-decision-scale keepalive lifetime) between two independent code paths sharing a non-atomic Set could allow duplicate/racing consent processing for the same request.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Replacing the bare Set with a Map of in-flight promises keyed by digest, shared consistently across both call sites, makes the check-and-set atomic and ensures a second concurrent request for the same digest awaits the first's outcome instead of racing independently.
Vulnerable code:
activeConsentDigests.add(outcome.payloadDigest);
... await ...
finally { activeConsentDigests.delete(outcome.payloadDigest); }
Secure code:
// Use a Map of in-flight Promises keyed by digest for atomic check-and-set
const inFlightConsents = new Map<string, Promise<unknown>>();
async function withConsentLock(digest: string, work: () => Promise<unknown>) {
if (inFlightConsents.has(digest)) {
return inFlightConsents.get(digest);
}
const promise = work().finally(() => inFlightConsents.delete(digest));
inFlightConsents.set(digest, promise);
return promise;
}
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 45%
- AI Validation Evidence: EVIDENCE FOUND:
activeConsentDigests.add(outcome.payloadDigest);occurs before the keepAlive connect/await, and cleanupactiveConsentDigests.delete(outcome.payloadDigest);happens only infinally, in both maybeRelayConsentLocally and handleTaskConsent — a shared mutable Set used for check-then-act de-dup across two functions. EVIDENCE NOT FOUND: The actual check (has()) prior to the add() shown is not visible in the provided hunk for either function, so the precise race window and whether any locking/atomic primitive is used elsewhere cannot be fully confirmed. CHANGED VS PRE-EXISTING: CHANGED — the keepalive port wrapping (which the finding says widens the pre-existing race window) is newly added around the pre-existing activeConsentDigests Set logic in offscreen.ts by this MR. VERDICT JUSTIFICATION: The widened race window is a plausible consequence of the new keepalive pattern, but since the underlying Set is pre-existing and the check-then-act logic itself isn't fully visible, this is inconclusive for a definitive confirmation.- 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.
🔵 Keepalive port resource not always released on abnormal termination paths
| Field | Detail |
|---|---|
| Severity | LOW |
| Location | packages/extension/src/offscreen.ts:1 |
| Finding ID | github_pr-dfbbfaa2615c |
| CWE | CWE-460 |
| OWASP | A04:2021-Insecure Design |
| Detection Source | threat_model |
📝 Description:
The keepAlive port is disconnected in a finally block, which mitigates most leaks, but if chrome.runtime.connect itself throws or the offscreen document is torn down/reloaded before the try block executes, the port may not be cleaned up, potentially pinning the worker.
🌱 Root Cause: Port lifecycle cleanup relies solely on a finally block within a single function invocation; there is no timeout-based or watchdog cleanup for ports whose owning document context disappears unexpectedly.
🔎 Evidence: packages/extension/src/offscreen.ts:1
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const result = (await chrome.runtime.sendMessage({
type: RUNTIME_TASK_CONSENT,
🎯 Attack Scenario:
If the offscreen document crashes or is forcibly closed between opening the keepalive port and reaching the finally block, the port may remain open on the background side (until Chrome detects disconnection), keeping the worker alive longer than intended and delaying idle teardown.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 40%
- AI Validation Evidence: EVIDENCE FOUND:
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT }); try { const result = (await chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, ...— connect() is called as a standalone statement before the try block, so if connect() itself throws or context is invalidated between connect() and try entry, the finally'skeepAlive.disconnect()would not run for that call. EVIDENCE NOT FOUND: No evidence that chrome.runtime.connect() can realistically throw synchronously in this MV3 context; the claim is speculative about an edge case not demonstrated in the code. CHANGED VS PRE-EXISTING: CHANGED — this exact connect-before-try pattern is newly introduced in offscreen.ts by this MR. VERDICT JUSTIFICATION: Real code pattern with a plausible (but narrow/edge-case) low-severity gap; insufficient evidence of practical exploitability to validate outright, kept for human review at low severity as originally scored.- 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.
🔁 Duplicates (3)
Real findings already reported once elsewhere — kept out of the false-positive count. Each is a repeat of another finding at the same code path:
-
Unauthenticated keepalive port accepted from any extension context (medium,
packages/extension/src/background.ts)🔍 Validation Log
- Verdict: 🔁 Duplicate
- Confidence: 90%
- AI Validation Evidence: DUPLICATE of github_pr-c88337238810: same CWE-306, same file packages/extension/src/background.ts, same exact code snippet
chrome.runtime.onConnect.addListener((port) => { if (port.name !== CONSENT_KEEPALIVE_PORT) return; ... });, same root cause (no sender.id/url validation on the onConnect listener). - Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.
-
Uncontrolled resource consumption via unbounded keepalive port connections (medium,
packages/extension/src/offscreen.ts)🔍 Validation Log
- Verdict: 🔁 Duplicate
- Confidence: 85%
- AI Validation Evidence: DUPLICATE of github_pr-fc8411fcdbc5: same CWE-400, same file packages/extension/src/offscreen.ts, same code construct
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT }); try { const result = (await chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, ..., same root cause (unbounded/uncapped keepalive port lifetime per consent request). - Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.
-
Race condition between digest de-duplication check and set insertion (medium,
packages/extension/src/offscreen.ts)🔍 Validation Log
- Verdict: 🔁 Duplicate
- Confidence: 85%
- AI Validation Evidence: DUPLICATE of github_pr-198a876da306: same CWE-362, same file packages/extension/src/offscreen.ts, same code construct
activeConsentDigests.add(outcome.payloadDigest); // Open a port to the background BEFORE asking..., same root cause (non-atomic check-then-act on activeConsentDigests widened by the new keepalive lifetime). - Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.
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.
🛡️ Threat Model & Affect Analysis (supplementary — theoretical threats and MR impact analysis)
🛡️ Open full Threat Model & Affect Analysis — threat-modelling_affect-analysis_report_PR109_2026-08-07T17-56-55.md
🛡️ Threat Model & Affect Analysis — PR #109
| Field | Value |
|---|---|
| Repository | OpenVTC/vta-browser-plugin |
| Branch | fix/consent-keepalive-port → main |
| Generated | 2026-08-07 |
ℹ️ This report contains theoretical threats and impact analysis for the MR.
Unlike the Security Validation 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 reliability bug where consent-request messages from the offscreen document could silently fail to wake or could outlive a terminated MV3 service worker, by introducing a dedicated keepalive port (CONSENT_KEEPALIVE_PORT) that guarantees the worker starts and stays alive for the duration of a human consent decision.
Diff: +62 / -0 lines
Types: bugfix, infrastructure
Risk Assessment
- Overall Risk: low
- Review Priority: medium
- Pentest Needed: false
- Security Review Needed: true
- Breaking Changes: false
This is a small, well-contained, additive reliability fix for an MV3 service-worker keepalive/wake-up race affecting a security-relevant consent/authorization gate. No cryptographic material, secrets, or authorization logic itself is changed — only the transport reliability that ensures the existing consent request reaches an awake worker. The primary residual concern is that the new onConnect listener performs no sender validation (CWE-306), which is low severity in isolation but should be confirmed safe against the extension's externally_connectable manifest configuration. A secondary, purely informational concern is potential raw-exception logging. Given the low severity and bounded blast radius (non-data-bearing port), a full penetration test is not warranted, but a focused security code review of sender validation and manifest exposure is recommended before merge.
Review Focus Areas:
- Sender validation (or lack thereof) on the new chrome.runtime.onConnect listener in background.ts
- Manifest externally_connectable settings relative to this new port name
- Complete, untruncated bodies of maybeRelayConsentLocally and handleTaskConsent for exhaustive finally/cleanup coverage
- Logging statements that pass raw exception objects to console.error
Pentest Focus:
- Confirm manifest.json externally_connectable configuration does not expose the CONSENT_KEEPALIVE_PORT channel to untrusted web origins or other extensions
- Attempt to open and indefinitely hold the keepalive port from an external context (if reachable) to assess worker-pinning/resource-exhaustion impact
- Exercise failure paths in maybeRelayConsentLocally and handleTaskConsent to confirm keepAlive.disconnect() and activeConsentDigests cleanup occur under all exception conditions
⚠️ Security Implications
🔵 Unauthenticated onConnect listener accepts keepalive port from any connectable context
Unauthenticated onConnect listener accepts keepalive port from any connectable context
Action: Validate that port.sender.id === chrome.runtime.id before accepting the connection unless external connectivity is an explicit requirement; audit manifest.json's externally_connectable field; consider a bounded maximum lifetime for accepted keepalive ports.
⚪ Raw exception object passed to console.error in task-consent failure path
Raw exception object passed to console.error in task-consent failure path
Action: Log only a sanitized error message/code (e.g., e instanceof Error ? e.message : String(e)) instead of the raw exception object.
⚪ Keepalive port introduces new but non-data-bearing IPC channel
Keepalive port introduces new but non-data-bearing IPC channel
Action: No independent action beyond the sender-validation hardening; document the channel's intended same-extension-only scope.
⚪ Reliability fix reduces silent loss of consent prompts
Reliability fix reduces silent loss of consent prompts
Action: Verify (in the untruncated code) that if chrome.runtime.sendMessage still fails despite the keepalive fix, the caller fails closed (treats as denial) rather than hanging indefinitely.
🧩 Affected Components
| Component | Impact | Change | What Changed |
|---|---|---|---|
| MV3 Service Worker Lifecycle Management | medium | modified | Introduced a dedicated, lifecycle-only keepalive port mechanism to reliably wake and hold the MV3 background service worker alive during con |
| Consent/Authorization Flow (task consent, step-up, trust signing) | high | modified | Both consent-request call sites (local relay and inbound handler) now guarantee the worker is awake and stays alive for the full human decis |
📁 File Classifications
packages/extension/src/background.ts
- Type: security-critical
packages/extension/src/bridge-protocol.ts
- Type: security-critical
packages/extension/src/offscreen.ts
- Type: business-logic
💡 Recommendations
- MUST — Add sender validation (port.sender.id === chrome.runtime.id) to the new chrome.runtime.onConnect listener in background.ts before accepting the CONSENT_KEEPALIVE_PORT connection (effort: small)
- Prevents unauthenticated external contexts from opening and holding this port, closing the CWE-306 gap
- MUST — Confirm manifest.json's externally_connectable configuration does not expose this extension's runtime messaging to untrusted web origins or arbitrary extension IDs (effort: trivial)
- Determines actual exploitability of the unauthenticated onConnect listener
- SHOULD — Review the complete, untruncated bodies of maybeRelayConsentLocally and handleTaskConsent to verify every exception/early-return path disconnects the keepalive port and clears activeConsentDigests (effort: small)
- Partial hunks were reviewed; incomplete cleanup could leak ports or leave stale de-duplication state on unexpected failures
- SHOULD — Sanitize the exception object before logging in handleTaskConsent's catch block (log message/code only, not the raw object) (effort: trivial)
- Avoids potential leakage of consent payload/PII into console logs (CWE-532)
- CONSIDER — Add a bounded timeout/fail-closed behavior around the awaited chrome.runtime.sendMessage calls guarding consent requests (effort: medium)
- Ensures that any residual reliability failure results in an explicit denial rather than an indefinite hang, preserving fail-closed semantics for the authorization control
- CONSIDER — Cap the maximum lifetime of an accepted keepalive port connection in background.ts (e.g., force-disconnect after several minutes) (effort: small)
- Defense-in-depth against bugs or malicious callers that never disconnect, preventing indefinite worker pinning
✅ Positive Observations
- Correctly disconnects the keepalive port in a finally block at both call sites, avoiding indefinite worker pinning after the consent decision resolves
- Fixes a genuine, well-diagnosed silent-failure mode in a security-relevant human-consent authorization gate, improving the integrity of the overall consent workflow
- Change is minimal, additive, and localized — no existing function signatures, payloads, or protocols were altered, keeping the fix low-risk and easy to review
- Introduces a single shared, well-documented constant (CONSENT_KEEPALIVE_PORT) rather than duplicating string literals across files, reducing risk of drift
- Extensive in-code comments clearly explain the MV3 lifecycle rationale, aiding future maintainers and reviewers
🛡️ STRIDE Threat Model
Identified Threats (10)
⚪ STRIDE-1: Unauthenticated Port Connection Flooding in CONSENT_KEEPALIVE_PORT Listener
| Field | Detail |
|---|---|
| Category | Spoofing, Denial of Service |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 5.3 CVSS:4.0/AV:L/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-306,CWE-400 |
| CAPEC | CAPEC-125,CAPEC-227 |
| OWASP | A04:2021 - Insecure Design |
Description: chrome.runtime.onConnect listener in background.ts allows unauthenticated port-name spoofing due to missing sender validation on port.name equality check, resulting in denial-of-service pinning of the MV3 service worker
Evidence: packages/extension/src/background.ts:91-105
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== CONSENT_KEEPALIVE_PORT) return;
port.onDisconnect.addListener(() => {
// Nothing to clean up — the port exists only to hold the worker awake.
});
});
Attack Scenario:
- Any extension component, content script, or malicious page with extension messaging access calls
chrome.runtime.connect({name: CONSENT_KEEPALIVE_PORT})since the literal string valuepnm/consent-keepaliveis exported and discoverable frombridge-protocol.ts. - The listener in
background.ts(chrome.runtime.onConnect.addListener) only checksport.name !== CONSENT_KEEPALIVE_PORT, performing noport.senderorigin/id validation. - Attacker opens many such ports and never disconnects them, keeping the MV3 service worker artificially alive indefinitely.
- Because the listener body is intentionally empty (
// Nothing to clean up), there is no rate limiting, count cap, or sender check to reject illegitimate callers. - Sustained worker liveness increases background CPU/memory usage and can mask/interfere with legitimate consent-keepalive lifecycle expectations, degrading extension responsiveness or resource consumption on the host browser.
🔎 Threat Clue: Derived from COMP-001 via EP-001
- Data Flows: offscreen-to-background keepalive port
Preconditions: Attacker can load or inject code capable of calling chrome.runtime.connect against this extension (e.g., another installed extension with externally_connectable, or a compromised content script if the port is exposed cross-context), No sender authentication is enforced on the onConnect handler
Existing Controls: Port name must match the exact exported constant string
Recommended Mitigations: Validate port.sender.id === chrome.runtime.id (or expected extension/content-script origin) before accepting the connection • Impose a maximum concurrent keepalive port count and reject/disconnect excess connections • Add telemetry/logging for repeated or anomalous connect attempts to CONSENT_KEEPALIVE_PORT
⚪ STRIDE-2: Service Worker Pinning via Unbounded Keepalive Port Lifetime
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.9 CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-400,CWE-664 |
| CAPEC | CAPEC-125,CAPEC-490 |
| OWASP | A04:2021 - Insecure Design |
Description: CONSENT_KEEPALIVE_PORT in offscreen.ts allows resource exhaustion via a stuck human-decision keepalive due to unconditional 'await chrome.runtime.sendMessage' with no timeout, resulting in indefinite service-worker pinning and potential battery/resource drain
Evidence: packages/extension/src/offscreen.ts:694-711
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const result = (await chrome.runtime.sendMessage({
type: RUNTIME_TASK_CONSENT,
...
}));
} finally {
keepAlive.disconnect();
activeConsentDigests.delete(outcome.payloadDigest);
}
Attack Scenario:
maybeRelayConsentLocallyandhandleTaskConsentinoffscreen.tsopenkeepAlive = chrome.runtime.connect({name: CONSENT_KEEPALIVE_PORT})before awaitingchrome.runtime.sendMessage({type: RUNTIME_TASK_CONSENT, ...}).- The
awaitonsendMessageblocks on a human decision that the code comments state can take 'minutes' — there is no timeout, max-wait, or cancellation path visible in the diff. - If the consent popup is never shown, is silently discarded by the OS, or the user simply never interacts (intentionally or via a stuck/crashed popup), the
keepAliveport remains connected indefinitely becausedisconnect()only happens in thefinallyblock after the awaited promise resolves. - The service worker remains pinned awake for the entire stall, consuming background resources and defeating MV3's intended idle-teardown resource model.
- An attacker who can trigger many concurrent task-consent requests (e.g., a malicious relying party or compromised executor repeatedly issuing DID/trust tasks) can multiply this effect, each with its own open keepalive port, to sustain a broader resource-exhaustion condition against the extension host process.
🔎 Threat Clue: Derived from COMP-001, COMP-003 via EP-002, EP-003
- Data Flows: offscreen RUNTIME_TASK_CONSENT request/response
Preconditions: Attacker or malfunction can trigger RUNTIME_TASK_CONSENT flows repeatedly without the user resolving them, No timeout enforced on the human-decision wait
Existing Controls: finally block disconnects the keepalive port once the awaited promise settles, bounding the leak to the duration of a single stalled request
Recommended Mitigations: Add an explicit timeout to the consent wait (e.g., Promise.race with a max wait of N minutes) that force-resolves as denial and disconnects the keepalive port • Cap the number of concurrent outstanding consent requests / keepalive ports per session • Add popup-liveness detection (e.g., chrome.windows.onRemoved) to force resolution when the consent UI is closed unexpectedly
⚪ STRIDE-3: Missing Sender Verification on RUNTIME_TASK_CONSENT Message Handler
| Field | Detail |
|---|---|
| Category | Spoofing, Elevation of Privilege |
| Severity | High |
| Likelihood | Possible |
| CVSS | 7.1 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-346,CWE-290 |
| CAPEC | CAPEC-98,CAPEC-141 |
| OWASP | A07:2021 - Identification and Authentication Failures |
Description: chrome.runtime.onMessage handler for RUNTIME_TASK_CONSENT in background.ts allows message spoofing due to inferred absence of sender.id/origin verification in the shared code path invoked by handleTaskConsent, resulting in unauthorized consent prompt triggering or consent-state confusion
Evidence: packages/extension/src/offscreen.ts:1913-1970
activeConsentDigests.add(parsed.request.payloadDigest);
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const result = (await chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, ... }));
...
} catch (e) {
console.error("[pnm inbound] task-consent handling
Attack Scenario:
RUNTIME_TASK_CONSENTis sent viachrome.runtime.sendMessagefrom the offscreen document to the background worker, matching the exported constant string visible inbridge-protocol.ts.- Because the string constant and message shape are visible in source and the recon indicates
auth_required:falseon the raw onMessage entry point (EP-002), any context able to callchrome.runtime.sendMessageinto this extension (e.g., a malicious externally_connectable caller or a compromised content script with messaging bridge access) could attempt to replay or forge a similarly shaped message. - If
handleTaskConsent/the background listener does not strictly validatesender.id,sender.url, and the offscreen-document origin before processing, a forged message could trigger consent UI prompts out of the legitimate flow, causing user confusion (prompt injection) or being leveraged to desynchronizeactiveConsentDigestsdedup state. - This could be chained with UI-based social engineering: repeated bogus consent prompts train the user to click through consent dialogs (prompt fatigue), increasing likelihood of approving an actually malicious task-consent request.
- Impact is contingent on whether deeper source (not shown in this reduced diff) implements sender pinning; flagged as a plausible gap given the visible code only performs de-duplication via
payloadDigest, not sender authentication.
🔎 Threat Clue: Derived from COMP-001, COMP-003 via EP-002, EP-004
- Data Flows: RUNTIME_TASK_CONSENT message
Preconditions: Attacker-controlled code can dispatch runtime messages into the extension's message bus (e.g., via externally_connectable, a compromised content script, or another installed extension), Background/offscreen handler does not perform strict sender-identity checks (unconfirmed from the reduced source, inferred as a gap given no such check appears in the diff context)
Existing Controls: payloadDigest-based de-duplication via activeConsentDigests prevents exact-duplicate reprocessing • Consent decision requires human-in-the-loop approval
Recommended Mitigations: Enforce sender.id === chrome.runtime.id and expected document URL checks on all onMessage handlers processing RUNTIME_TASK_CONSENT • Bind consent requests to a signed/authenticated session/task identifier issued by a trusted internal source, not just message type matching • Add rate limiting / prompt-fatigue detection to avoid repeated consent solicitation from unverified callers
⚪ STRIDE-4: Keepalive Port Leak on Unhandled Exception Prior to Try Block in handleTaskConsent
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 3.1 CVSS:4.0/AV:L/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-460,CWE-772 |
| CAPEC | CAPEC-125 |
| OWASP | A04:2021 - Insecure Design |
Description: handleTaskConsent in offscreen.ts allows a port descriptor leak due to keepAlive port creation occurring outside the try/finally guarding disconnect, resulting in an un-disconnected port if an exception occurs between connect() and the try block
Evidence: packages/extension/src/offscreen.ts:1928-1930
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const result = (await chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, ... }));
Attack Scenario:
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });executes as a statement before the subsequenttry { ... } finally { keepAlive.disconnect(); }block in bothmaybeRelayConsentLocallyandhandleTaskConsent.chrome.runtime.connectitself is synchronous and unlikely to throw under normal conditions, but if any code were later inserted between theconnect()call and thetry, or ifconnect()throws (e.g., extension context invalidated mid-navigation, a known Chrome extension failure mode), thefinallyblock'skeepAlive.disconnect()would never execute for that in-flight port object.- Repeated occurrences of this failure mode (e.g., during extension reload/update races) could leave transient port objects unmanaged, each contributing a small keepalive footprint until the underlying connection is torn down by the browser's own MV3 context invalidation.
- While individually low-impact, this is a maintainability/resilience gap: the pattern relies on
connect()never throwing, which is not guaranteed across all Chrome versions and extension-context-invalidation edge cases. - Under extension update/reload storms (attacker-triggerable by forcing frequent extension context invalidation via crafted navigation patterns is not directly possible, but naturally occurring MV3 lifecycle churn amplifies this), the aggregate effect could contribute to elevated background resource usage.
🔎 Threat Clue: Derived from COMP-003 via EP-005
- Data Flows: offscreen internal keepalive lifecycle
Preconditions: chrome.runtime.connect() throws or the extension context is invalidated between port creation and try block entry, Occurs primarily under abnormal MV3 lifecycle conditions (extension reload/update)
Existing Controls: try/finally pattern used for the awaited sendMessage portion of the flow
Recommended Mitigations: Wrap the chrome.runtime.connect call itself inside the try block so any construction failure is also handled uniformly • Add a top-level try/catch around the entire keepalive lifecycle including port creation • Add defensive null-checks before calling keepAlive.disconnect() in finally
⚪ STRIDE-5: Race Condition Between Duplicate Consent Requests Sharing payloadDigest Window
| Field | Detail |
|---|---|
| Category | Tampering, Repudiation |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.1 CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-362,CWE-367 |
| CAPEC | CAPEC-25,CAPEC-26 |
| OWASP | A04:2021 - Insecure Design |
Description: activeConsentDigests de-duplication in offscreen.ts allows a TOCTOU race between digest-add and digest-delete due to concurrent invocation of maybeRelayConsentLocally and handleTaskConsent for the same payloadDigest during the keepalive-port lifetime, resulting in potential double-processing or inconsistent consent state during the new port lifecycle window
Evidence: packages/extension/src/offscreen.ts:691-711,1913-1930
activeConsentDigests.add(outcome.payloadDigest);
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const result = (await chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, ... }));
...
} finally {
keepAlive.disconnect();
activeConsentDigests.delete(outco
Attack Scenario:
- Both
maybeRelayConsentLocallyandhandleTaskConsentindependently callactiveConsentDigests.add(...)then open akeepAliveport and awaitchrome.runtime.sendMessage, deleting the digest only infinally. - The newly introduced keepalive port extends the time window during which a given
payloadDigestis 'in flight' (now bounded by human decision time rather than message-passing latency), widening the race window for a second, concurrently-arriving consent request carrying a colliding or attacker-replayed digest. - If an attacker (e.g., a malicious relying party or executor) can trigger two nearly simultaneous task-consent flows before the first's
finallyexecutes, and the de-duplication check occurs only via a simple Set membership test without atomic locking, there is a window where a second identical request could be processed as new by a different code path (e.g.,maybeRelayConsentLocallyvs.handleTaskConsent) since each function/module maintains its own timing relative to the sharedactiveConsentDigestsset. - This could allow a user to be shown two separate consent prompts for what should be a single de-duplicated action, undermining the 'single-use approval' guarantee described in the code comments for
RUNTIME_TASK_CONSENT. - Because the added keepalive-port logic prolongs the in-flight duration (from message-passing-scale to human-decision-scale, i.e., minutes), it materially increases the exploitable race window versus the pre-patch behavior, which is a direct consequence of this PR's change.
🔎 Threat Clue: Derived from COMP-003 via EP-004, EP-005
- Data Flows: activeConsentDigests shared state
Preconditions: Attacker can trigger two consent requests carrying the same or attacker-controlled payloadDigest in rapid succession, No mutex/lock beyond simple Set membership guards the digest lifecycle
Existing Controls: activeConsentDigests Set-based de-duplication check before processing
Recommended Mitigations: Use an atomic check-and-set primitive (or a Map storing in-flight Promises) instead of a bare Set add/delete to prevent racing entries • Consolidate consent handling into a single code path/module rather than two (maybeRelayConsentLocally and handleTaskConsent) sharing the same de-dup set • Add explicit request-sequencing/locking keyed by payloadDigest with await-based mutual exclusion
⚪ STRIDE-6: Silent Message Loss Masking via Undocumented sendMessage-to-Terminated-Worker Failure Mode
| Field | Detail |
|---|---|
| Category | Repudiation, Denial of Service |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 3.7 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N |
| Residual Severity | Low |
| CWE | CWE-778,CWE-755 |
| CAPEC | CAPEC-268 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: chrome.runtime.sendMessage usage in offscreen.ts allows silent failure masking due to the underlying MV3 worker-not-started behavior described in code comments, resulting in a repudiation gap where a consent request can vanish with no log or error on either side absent the new keepalive fix being correctly deployed everywhere
Evidence: packages/extension/src/offscreen.ts:680-711
// `chrome.runtime.sendMessage` from an offscreen document does
// not dependably start a terminated MV3 service worker — the send resolves
// nowhere, nothing throws, and this await hangs forever.
Attack Scenario:
- The extensive code comments confirm a previously-observed failure mode:
chrome.runtime.sendMessagefrom an offscreen document can silently fail to wake a terminated MV3 service worker, with the promise never resolving and no exception thrown. - This PR patches the two call sites shown (
maybeRelayConsentLocally,handleTaskConsent) by adding aCONSENT_KEEPALIVE_PORTconnect-before-send pattern, but any other current or future call site invokingchrome.runtime.sendMessagetoward the background worker without the same keepalive pattern remains vulnerable to the identical silent-loss condition. - An attacker or fault-injection scenario that forces the service worker to terminate (idle timeout) immediately before another unpatched sendMessage call would reproduce the original bug: the request 'vanishes' with no prompt, no error, and — critically — no log entry proving it was ever sent, creating a repudiation gap for security-relevant consent flows.
- This undermines auditability of the consent system: an administrator or incident responder cannot distinguish between 'user was never asked' and 'message silently died in transit' without independent worker-liveness telemetry.
- Because the fix is applied ad hoc per call site rather than through a centralized, enforced wrapper, future code changes reintroducing unwrapped
sendMessagecalls to the background worker would silently regress this exact class of bug.
🔎 Threat Clue: Derived from COMP-001, COMP-003 via EP-002, EP-003
- Data Flows: offscreen-to-background RUNTIME_TASK_CONSENT messaging
Preconditions: A future or existing unpatched call site sends RUNTIME_* messages to the background worker without the keepalive-port pattern, Service worker is in a terminated/idle state at time of send
Existing Controls: The two shown call sites now use the keepalive-port pattern to mitigate the specific failure • Console error logging exists on catch of the awaited sendMessage in handleTaskConsent
Recommended Mitigations: Centralize the connect-before-send keepalive pattern into a single reusable helper function used by all background-directed sendMessage calls • Add worker-side heartbeat/ack logging to independently verify message receipt regardless of caller-side keepalive correctness • Add automated tests simulating a terminated service worker to catch regressions of this exact failure mode
⚪ STRIDE-7: Cross-Context Port Name Collision Enabling Keepalive Hijack
| Field | Detail |
|---|---|
| Category | Tampering, Denial of Service |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 2.8 CVSS:4.0/AV:L/AC:H/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-406,CWE-330 |
| CAPEC | CAPEC-664 |
| OWASP | A04:2021 - Insecure Design |
Description: CONSENT_KEEPALIVE_PORT string constant in bridge-protocol.ts allows port-name collision due to a single shared, non-namespaced, non-nonced connection name used across all offscreen consent flows, resulting in ambiguous port ownership if multiple concurrent consent flows or extension contexts open ports with the identical name
Evidence: packages/extension/src/bridge-protocol.ts:268-275
export const CONSENT_KEEPALIVE_PORT = "pnm/consent-keepalive" as const;
Attack Scenario:
CONSENT_KEEPALIVE_PORT = "pnm/consent-keepalive"is a single static string used for every keepalive connection, regardless of which specific consent request (payloadDigest) it is associated with.- The background listener's
onConnecthandler accepts any port named exactly this string without associating it to a specific in-flightpayloadDigestor request context — the empty listener body performs no correlation. - If two consent flows are concurrently in-flight (e.g.,
maybeRelayConsentLocallyfor one digest andhandleTaskConsentfor another), each opens its ownkeepAliveport with the identical name; because the background side does not track per-request port identity, it cannot distinguish which underlying request a given port belongs to. - A logic bug or race in a future refactor that closes 'a' keepalive port instead of 'the' correct one (since there's no explicit per-request handle passed to the background) could inadvertently release worker-pinning for the wrong in-flight request, causing a premature idle-teardown risk for a still-pending human decision.
- While the currently shown code keeps the port reference local to each async function (mitigating direct hijack), the lack of any correlation identifier in the port name/protocol is an architectural weakness that could be exploited if the protocol is extended (e.g., via
port.postMessage) without adding request binding.
🔎 Threat Clue: Derived from COMP-001, COMP-003 via EP-001
- Data Flows: CONSENT_KEEPALIVE_PORT connections
Preconditions: Multiple concurrent consent flows are in-flight simultaneously, Future protocol extensions rely on port identity without adding correlation IDs
Existing Controls: Each async function holds its own local keepAlive variable reference, limiting direct cross-request interference in the current code
Recommended Mitigations: Include the payloadDigest or a per-request nonce as part of the port name or an initial port.postMessage handshake • Track open keepalive ports in a background-side Map keyed by request identifier for observability and correct correlation • Add assertions/logging when multiple concurrent keepalive ports are open simultaneously
⚪ STRIDE-8: Missing Structured Logging for Consent Denial and Keepalive Lifecycle Events
| Field | Detail |
|---|---|
| Category | Repudiation |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 2.3 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:L |
| Residual Severity | Low |
| CWE | CWE-778 |
| CAPEC | CAPEC-81 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: handleTaskConsent and maybeRelayConsentLocally in offscreen.ts allow insufficient forensic traceability due to reliance on console.info/console.error rather than structured, persisted audit logging for consent decisions and keepalive port lifecycle, resulting in an inability to reconstruct or prove after the fact which consent requests were approved, denied, or lost
Evidence: packages/extension/src/offscreen.ts:708-712
conn.send(outer);
console.info("[pnm consent relay] decision relayed over the worker session");
Attack Scenario:
- Consent decisions and errors are logged only via
console.info/console.error(e.g.,console.info("[pnm consent relay] decision relayed over the worker session"),console.error("[pnm inbound] task-consent handling failed:", e)), which are ephemeral, dev-tools-only outputs not persisted across browser sessions. - Because MV3 service workers and offscreen documents are frequently torn down and recreated, console output from a given execution context is lost once that context is destroyed, with no captured audit trail server-side or in durable storage.
- A user who approved a malicious task, or an attacker who caused a consent request to be silently dropped, could later dispute what happened; there is no durable, tamper-resistant record correlating
payloadDigest, timestamp, decision, and keepalive port lifecycle to refute or confirm the claim. - This gap is amplified by this PR: the new keepalive mechanism introduces additional lifecycle events (port open/close) that are also not logged to persistent storage, reducing the ability to diagnose whether a future regression of the original 'vanishing consent request' bug has occurred.
- Without durable logs, incident response for a compromised or over-permissive consent grant relies solely on transient in-memory state and console output, which is insufficient for compliance-grade non-repudiation of high-privilege wallet/DID actions.
🔎 Threat Clue: Derived from COMP-003 via EP-004, EP-005
- Data Flows: consent decision result relay
Preconditions: Investigation of a disputed consent action occurs after the relevant background/offscreen execution context has been recycled, No external persistent audit sink is wired to these console statements (unconfirmed from reduced source, inferred gap)
Existing Controls: Basic console.info/console.error statements exist at key decision points
Recommended Mitigations: Persist consent lifecycle events (request received, verified, prompted, decided, keepalive opened/closed) to chrome.storage or an equivalent durable, tamper-evident log • Include payloadDigest, timestamp, and outcome in every persisted log entry • Expose an audit-log viewer or export capability for compliance/incident-response needs
⚪ STRIDE-9: Prompt Fatigue and User-Consent Bypass via Repeated Task-Consent Solicitation
| Field | Detail |
|---|---|
| Category | Elevation of Privilege, Spoofing |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.5 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-1021,CWE-451 |
| CAPEC | CAPEC-416,CAPEC-98 |
| OWASP | A04:2021 - Insecure Design |
Description: RUNTIME_TASK_CONSENT prompt flow in offscreen.ts allows social-engineering-driven consent bypass due to no visible rate limiting or anomaly detection on how frequently a human is asked to approve tasks, resulting in click-through approval of an unintended or malicious task under decision fatigue
Evidence: packages/extension/src/offscreen.ts:693-711
const keepAlive = chrome.runtime.connect({ name: CONSENT_KEEPALIVE_PORT });
try {
const result = (await chrome.runtime.sendMessage({ type: RUNTIME_TASK_CONSENT, ... }));
Attack Scenario:
- A malicious or compromised 'enrolled executor' (per the code comment describing RUNTIME_TASK_CONSENT as trusted-executor-only, single-use approval) repeatedly issues legitimate-looking but attacker-chosen task-consent requests.
- Each request opens a new keepalive port and awaits a fresh human decision, per the newly added code; there is no visible throttling of how many consent prompts can be shown to the user within a given time window.
- Users conditioned by frequent, expected consent prompts (a known UX antipattern in wallet/DID software) become more likely to approve prompts reflexively without reading task details, especially since the human decision can be requested repeatedly in quick succession thanks to the now-reliable keepalive mechanism (ironically, fixing the reliability bug increases the volume of prompts that can successfully reach the user).
- An attacker exploits this fatigue to get a high-privilege task (e.g., trust-signing, step-up VTA approval) approved that the user would have rejected under normal scrutiny.
- Because the fix in this PR specifically makes consent delivery MORE reliable (eliminating the prior silent-drop failure), it also removes what was inadvertently acting as a natural rate limiter on how many prompts an attacker-controlled executor could successfully deliver to the user.
🔎 Threat Clue: Derived from COMP-001, COMP-003 via EP-002, EP-003
- Data Flows: RUNTIME_TASK_CONSENT prompt delivery
Preconditions: A malicious or compromised task-issuing executor can generate many consent requests, No rate limiting or cooldown between consecutive RUNTIME_TASK_CONSENT prompts, User is susceptible to decision/prompt fatigue
Existing Controls: Each consent is single-use and requires explicit human approval per the code's design intent • payloadDigest de-duplication prevents exact replay of the identical request
Recommended Mitigations: Implement rate limiting / cooldown periods between consecutive consent prompts from the same or different executors • Add task-detail summarization and risk-scoring in the consent UI to highlight high-privilege actions distinctly • Add anomaly detection for abnormal consent-request volume and alert or throttle accordingly
⚪ STRIDE-10: Comment-Embedded Instruction Injection Attempt in Source Code Comments
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | Informational |
| Likelihood | Very Unlikely |
| 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-1078 |
| CAPEC | CAPEC-242 |
| OWASP | N/A |
Description: Source code comments in background.ts and offscreen.ts allow narrative-style prose insertion due to unusually detailed first-person debugging narrative embedded directly in production code comments, resulting in a benign but notable prompt-injection-style pattern that any automated tool (including this analysis) must treat strictly as inert data
Evidence: packages/extension/src/background.ts:91-104
// That is exactly how a consent request went missing — arriving, verifying, de-duplicating, being acked to the mediator, and then vanishing with no prompt and no error
Attack Scenario:
- The diff contains long, narrative, first-person-style comments (e.g., 'That is exactly how a consent request went missing...') embedded directly in the shipped TypeScript source rather than in commit messages or external documentation.
- While this specific content is legitimate engineering rationale and not an actual attempt to manipulate an LLM or automated reviewer, the pattern of embedding extensive free-form prose inside code comments is exactly the vector that could be abused in a future, less benign contribution to instruct or mislead automated code-review/security-scanning tooling (e.g., 'ignore this pattern', 'mark as false positive') that naively treats comment text as trusted guidance.
- This analysis explicitly treats all comment content as untrusted data describing intent, not as instructions to alter scanning behavior, and flags the pattern itself as a code-hygiene/process observation rather than a finding of active malicious intent.
- No exploitation path exists today; this is recorded as a low-noise, informational governance observation to encourage moving verbose rationale into commit messages/ADRs and keeping in-code comments concise, reducing future risk of instruction-injection-style abuse against AI-assisted review tooling.
- No further action is required beyond code-review hygiene guidance; this does not indicate the PR itself is malicious.
🔎 Threat Clue: Derived from COMP-001, COMP-003 via N/A
- Data Flows: N/A
Preconditions: An automated reviewer or LLM-based tool that does not properly sandbox code comments as data could, in a future PR, be misled by similarly styled but maliciously crafted comment text
Existing Controls: This analysis process explicitly treats all source and comment content as untrusted data per its operating directive
Recommended Mitigations: Encourage moving extended rationale/narrative into commit messages, PR descriptions, or ADRs rather than long in-code comments • Ensure any automated review tooling explicitly treats code comments as data, never as instructions
🍝 PASTA Threat Model
Application Purpose
A Chrome MV3 browser extension implementing a decentralized-identity (DID/DIDComm) wallet that mediates trust tasks and step-up consent between relying parties, an offscreen document, and a background service worker, providing users cryptographic identity, signing, and consent-approval capabilities.
Inherent Risks
- MV3 service workers are ephemeral by design and inherently prone to message-delivery races during idle teardown.
- The wallet mediates high-privilege actions (trust signing, RP DID verification, step-up consent) making its consent flow a high-value target.
- Cross-context messaging (offscreen document, background worker, content scripts) is inherently exposed to spoofing if sender identity is not strictly verified at every hop.
Objectives
Risk: Limit exposure to denial-of-service via unauthenticated port connections or unbounded keepalive lifetimes; Limit the race-condition window for de-duplicated consent requests
Business: Provide a trustworthy DID/verifiable-credential wallet experience embedded in the browser; Maintain user confidence that consent prompts reflect genuine, verified requests
Security: Guarantee that only legitimate, enrolled executors can trigger consent prompts; Guarantee non-repudiable, auditable logging of every consent decision and keepalive lifecycle event
Financial: Avoid costly incident response and reputational damage from compromised wallet approvals; Minimize support burden from missed or duplicated consent prompts
Compliance: Support auditability requirements associated with identity/credential wallet software (e.g., non-repudiation of consent decisions)
Functional: Reliably deliver task-consent requests from offscreen document to background worker regardless of MV3 service-worker lifecycle state; Ensure each consent approval is single-use and tied to a specific verified request
Operational: Keep the MV3 service worker alive only as long as necessary to complete a pending human decision; Ensure keepalive ports are always released to avoid unnecessary background resource consumption
Business Impact Analysis (2)
BIA-1: Task Consent Approval Flow (Critical)
The end-to-end process by which an enrolled executor's task request is verified, deduplicated, relayed to the background worker, presented to the human user, and resolved as approved or denied.
MTD: 00 days 00:30 hours | RTO: 00 days 00:05 hours | RPO: 00 days 00:01 hours
- Stakeholders: Enrolled Executors / End Users / Extension Maintainers / Relying Parties
- Dependencies: Chrome MV3 Service Worker Runtime / CONSENT_KEEPALIVE_PORT Messaging Channel / Offscreen Document Process / activeConsentDigests In-Memory State
- Disruptions: Service worker idle-teardown before consent resolution / Unauthenticated port flooding pinning the worker awake / Race condition causing duplicate or lost consent prompts / Silent message loss for any unpatched sendMessage call site
- Impacts: Loss of user trust if consent requests silently vanish or duplicate / Potential unauthorized task approval via prompt fatigue or spoofed messages / Increased background resource consumption degrading browser performance / Support/help-desk cost increase from confused users
BIA-2: Step-Up Consent and RP DID Verification Flow (High)
The process verifying a relying party's DID and requiring an additional human step-up consent before proceeding with a sensitive action such as trust signing.
MTD: 00 days 01:00 hours | RTO: 00 days 00:10 hours | RPO: 00 days 00:05 hours
- Stakeholders: End Users / Relying Parties / Extension Maintainers
- Dependencies: RUNTIME_STEP_UP_CONSENT Messaging Channel / RUNTIME_VERIFY_RP_DID Verification Logic / Offscreen Document Process
- Disruptions: Delayed or lost step-up consent messages due to worker idle-teardown / Spoofed step-up requests bypassing RP DID verification trust boundary
- Impacts: Unauthorized signing of trust tasks if step-up consent is bypassed or spoofed / Regulatory/compliance exposure for identity-assurance failures
Technical Scope
Roles (3): RO-1 End User / Wallet Holder · RO-2 Enrolled Executor · RO-3 Relying Party
Actors (3): AC-1 Wallet User · AC-2 Background Service Worker Process · AC-3 Offscreen Document Process
Entry Points (5): EP-001 Consent Keepalive Port Listener · EP-002 Task Consent Message Handler · EP-003 Task Consent Message Sender · EP-004 handleTaskConsent Internal Entry · EP-005 maybeRelayConsentLocally Internal Entry
Threat Actors (3): TA-1 Malicious Installed Extension · TA-2 Compromised or Malicious Enrolled Executor · TA-3 Malicious Relying Party
Infrastructure (1): IF-1 Chrome Browser Extension Host
Trust Boundaries (3): TB-1 Browser Extension Internal Messaging Boundary · TB-2 Relying Party / Executor External Boundary · TB-3 Browser Platform Boundary
External Entities (2): EE-1 Enrolled Task Executor · EE-2 Relying Party (RP)
System Components (4): SC-1 Background Service Worker · SC-2 Offscreen Document · SC-3 Bridge Protocol Constants Module · SC-4 activeConsentDigests In-Memory Store
Resources And Assets (3): RA-1 Consent Decision State · RA-2 CONSENT_KEEPALIVE_PORT Connection Object · RA-3 Trust Task Signing Payload
Technologies And Dependencies (2): TD-1 Chrome Extension Manifest V3 Runtime APIs · TD-2 Custom Bridge Protocol Module
Use Cases (2)
- Task Consent Approval: An enrolled executor requests approval for a task; the offscreen document verifies and deduplicates the request, opens a keepalive port to ensure the background worker stays alive, and the user is pro
- Step-Up Consent for Trust Signing: A relying party's DID is verified before the wallet requests an additional human step-up consent, gating access to trust-signing operations.
📋 Risk Registry (4)
| ID | Title | Severity | Residual | Priority | Effort |
|---|---|---|---|---|---|
| RISK-001 | Unauthenticated actors can pin the MV3 service worker awake or spoof consent-related messaging via the new keepalive port and message handlers. | High | Medium | Immediate | Medium |
| RISK-002 | The human-decision wait introduced by the keepalive pattern has no timeout, allowing indefinite worker pinning and widening the consent-digest race window. | Medium | Medium | Short-Term | Medium |
| RISK-003 | Fixing the silent message-loss bug removes a natural rate limiter on consent prompt delivery, increasing exposure to prompt-fatigue-driven approval of malicious tasks. | Medium | Medium | Short-Term | Medium |
| RISK-004 | Lack of durable, tamper-evident audit logging for consent decisions and keepalive lifecycle events limits non-repudiation and incident-response capability. | Low | Low | Medium-Term | Low |
⚔️ Attack Scenarios (2)
SC-1: Background Service Worker
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC1@{ shape: rect, label: "SC-1: Background Service Worker" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE306@{ shape: rect, label: "CWE-306: Missing Authentication for Critical Function" }
CWE400@{ shape: rect, label: "CWE-400: Uncontrolled Resource Consumption" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC125@{ shape: rect, label: "CAPEC-125: Flooding" }
CAPEC98@{ shape: rect, label: "CAPEC-98: Phishing" }
end
subgraph SL4["4. Threats"]
direction LR
S1@{ shape: rect, label: "STRIDE-1: Unauthenticated Port Connection Flooding<br><i>Medium / Likely</i>" }
S3@{ shape: rect, label: "STRIDE-3: Missing Sender Verification on RUNTIME_TASK_CONSENT<br><i>High / Possible</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Malicious Installed Extension<br><i>Hijack or spoof internal messaging</i>" }
end
SC1 --> CWE306
SC1 --> CWE400
CWE306 --> CAPEC98
CWE400 --> CAPEC125
CAPEC98 --> S3
CAPEC125 --> S1
S3 --> TA1
S1 --> TA1
linkStyle 0 stroke:#FF0000,stroke-width:2px
linkStyle 1 stroke:#FFA500,stroke-width:2px
linkStyle 2 stroke:#FF0000,stroke-width:2px
linkStyle 3 stroke:#FFA500,stroke-width:2px
linkStyle 4 stroke:#FF0000,stroke-width:2px
linkStyle 5 stroke:#FFA500,stroke-width:2px
linkStyle 6 stroke:#FF0000,stroke-width:2px
linkStyle 7 stroke:#FFA500,stroke-width:2px
SC-2: Offscreen Document
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1b["1. System Component"]
direction LR
SC2@{ shape: rect, label: "SC-2: Offscreen Document" }
end
subgraph SL2b["2. Weaknesses"]
direction LR
CWE362@{ shape: rect, label: "CWE-362: Race Condition" }
CWE1021@{ shape: rect, label: "CWE-1021: Improper Restriction of Rendered UI Layers or Frames" }
CWE778@{ shape: rect, label: "CWE-778: Insufficient Logging" }
end
subgraph SL3b["3. Attack Patterns"]
direction LR
CAPEC26@{ shape: rect, label: "CAPEC-26: Leveraging Race Conditions" }
CAPEC416@{ shape: rect, label: "CAPEC-416: Manipulate Human Behavior" }
CAPEC81@{ shape: rect, label: "CAPEC-81: Web Server Logs Tampering" }
end
subgraph SL4b["4. Threats"]
direction LR
S5@{ shape: rect, label: "STRIDE-5: Race Condition Between Duplicate Consent Requests<br><i>Medium / Possible</i>" }
S9@{ shape: rect, label: "STRIDE-9: Prompt Fatigue and Consent Bypass<br><i>Medium / Possible</i>" }
S8@{ shape: rect, label: "STRIDE-8: Missing Structured Logging<br><i>Low / Possible</i>" }
end
subgraph SL5b["5. Threat Actors"]
direction LR
TA2@{ shape: rect, label: "TA-2: Compromised Enrolled Executor<br><i>Flood or manipulate consent flow</i>" }
end
SC2 --> CWE362
SC2 --> CWE1021
SC2 --> CWE778
CWE362 --> CAPEC26
CWE1021 --> CAPEC416
CWE778 --> CAPEC81
CAPEC26 --> S5
CAPEC416 --> S9
CAPEC81 --> S8
S5 --> TA2
S9 --> TA2
S8 --> TA2
linkStyle 0 stroke:#FFA500,stroke-width:2px
linkStyle 1 stroke:#FFA500,stroke-width:2px
linkStyle 2 stroke:#00FF00,stroke-width:2px
linkStyle 3 stroke:#FFA500,stroke-width:2px
linkStyle 4 stroke:#FFA500,stroke-width:2px
linkStyle 5 stroke:#00FF00,stroke-width:2px
linkStyle 6 stroke:#FFA500,stroke-width:2px
linkStyle 7 stroke:#FFA500,stroke-width:2px
linkStyle 8 stroke:#00FF00,stroke-width:2px
linkStyle 9 stroke:#FFA500,stroke-width:2px
linkStyle 10 stroke:#FFA500,stroke-width:2px
linkStyle 11 stroke:#00FF00,stroke-width:2px
📊 Risk Summary
Total Threats: 10
By Severity: Low: 4 · High: 1 · Medium: 4 · Informational: 1
By Category: Unknown: 10
🎯 Attack Surface
Kill Chain 1: An attacker-controlled extension or injected script discovers the exported CONSENT_KEEPALIVE_PORT string constant from bridge-protocol.ts and repeatedly opens ports against the background worker's unauthenticated onConnect listener (STRIDE-1), sustaining an artificially awake service worker; combined with the absence of sender verification on the RUNTIME_TASK_CONSENT message handler (STRIDE-3), the same actor could attempt to inject or replay consent-shaped messages into the now-reliably-awake worker, increasing the chance of triggering unintended consent UI state. Kill Chain 2: A compromised or malicious enrolled executor exploits the very reliability fix this PR introduces — since chrome.runtime.sendMessage calls no longer silently vanish (STRIDE-6), the executor can now dependably flood the user with task-consent prompts (STRIDE-9), inducing prompt fatigue; simultaneously, the widened in-flight window created by the keepalive port's human-decision wait raises the probability of the TOCTOU race in activeConsentDigests (STRIDE-5) being hit by near-simultaneous requests, potentially causing duplicate or inconsistent consent state that further confuses the user during the fatigue-induced approval. Kill Chain 3: Absent durable audit logging (STRIDE-8) and with only best-effort finally-based cleanup guarding keepalive ports and digest state (STRIDE-4, STRIDE-7), a successful exploitation of Kill Chain 1 or Kill Chain 2 would leave little forensic trac
🛡️ Risk Mitigation Strategy
Priority 1: Immediately add sender-identity verification (sender.id === chrome.runtime.id plus expected document URL checks) to both the CONSENT_KEEPALIVE_PORT onConnect listener and the RUNTIME_TASK_CONSENT onMessage handler, closing the unauthenticated-actor gap (STRIDE-1, STRIDE-3) that this PR's new listener otherwise leaves open by design ('the listener body is deliberately empty'). Priority 2: Within the short term, add an explicit timeout (e.g., Promise.race) around the human-decision sendMessage await in both maybeRelayConsentLocally and handleTaskConsent, and replace the bare activeConsentDigests Set with an atomic check-and-set or per-digest lock, directly addressing the unbounded worker-pinning (STRIDE-2) and TOCTOU race (STRIDE-5) risks introduced or widened by the keepalive pattern. Priority 3: Also in the short term, introduce rate limiting/cooldown on consent-prompt delivery per executor and enrich the consent UI with risk-scored task summaries to counter the prompt-fatigue exposure (STRIDE-9) that the reliability fix inadvertently increases, and centralize the connect-before-send keepalive pattern into one audited helper to prevent regression of the original silent-message-loss bug (STRIDE-6) at any future call site. Priority 4: In the medium term, implement durable, tamper-evident logging of every consent lifecycle event (request received, verified, prompted, decided, keepalive opened/closed) keyed by payloadDigest and timestamp, and add per-request correlation identifiers to keepalive ports plus defensive guarding of the connect() call within the try block, closing the residual repudiation and port-management gaps (STRIDE-8, STRIDE-4, STRIDE-7) to support compliance-grade non-repudiation for this identity-wallet extension.
Generated by Agentic Sec — Threat Model & Affect Analysis Agent
🔧 What to do
| # | Action |
|---|---|
| 1 | 📥 Download attached reports and review the findings and threat model |
| 2 | 🤖 Feed reports to your IDE copilot for fixes or security hardening suggestions |
| 3 | 🛡️ Review threat model for potential risks and recommended countermeasures |
| 4 | 🆘 Questions? Reach out to the Security team |
🛡️ Agentic Sec — AI Security Validation Agent
chrome.runtime.sendMessagefrom an offscreen document does not dependably start a terminated MV3 service worker. The send resolves nowhere, nothing is thrown, and the caller'sawaithangs forever.This is the remaining failure
A
task-consent/request/0.1arrived on the approver inbox, verified against the enrolled executor, passed both dedup gates, and was acked to the mediator — deleting its queued copy — and then nothing. No prompt, no error, no decision.The evidence that isolated it:
[pnm inbound] received (approver inbox) type=…/task-consent/request/0.1refusing task-consent request:skipping replayed/already promptinghandling threw(#108)consent window opened/could not open(#106, #107)requestTaskConsentchrome.windows.create,consentWindowBoundsservice worker (Inactive)The manual send worked because clicking around
chrome://extensionshad already woken the worker. The real path hits it cold.The fix
chrome.runtime.connectdoes start the worker, and an open port keeps it alive for the connection's lifetime.That also covers the second half of the problem, which would have bitten next:
requestTaskConsentawaits a human decision that can run minutes past the ~30s idle teardown, with the resolver held inpendingConsents— an in-memory Map. Even a delivered ask could be discarded mid-decision, losing an approval the user had already given.Both offscreen consent sites open the port before asking and
disconnect()infinally, so an answered, denied or failed prompt never leaves the worker pinned awake. The background accepts the port and does nothing else — accepting it is the entire purpose, and there is no protocol to get wrong.The port name lives in
bridge-protocol.tsalongside the message types it belongs with, rather than in either endpoint.Lint clean, 223 tests pass, builds.