From 27279ae7c6896b1409fc08dd60e1c2fb76d3825d Mon Sep 17 00:00:00 2001 From: lockbitchat Date: Wed, 5 Aug 2026 23:02:20 -0400 Subject: [PATCH] feat(crypto): Double Ratchet forward secrecy; hardening pass; release v5.7.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Double Ratchet (Signal's design) on top of the existing ECDH session keys, so message protection no longer rests on one set of keys lasting the whole conversation. Every message gets its own key, derived through a one-way function and discarded after use, and each change of direction introduces a fresh ECDH key pair that re-keys the session root. The ratchet needed no handshake change: both peers already hold each other's authenticated ECDH public key, and the safety code compared during verification covers exactly those keys. Its root is derived from the existing shared secret through its own branch of the key schedule. Support is negotiated in the invitation and response and used only when both sides have it; a peer on an earlier release falls back to per-session keys. The security panel reports which of the two is actually in force. Out-of-order delivery is supported within fixed bounds (512 skipped keys per chain, 1024 retained, five-minute expiry), and inbound frames are authenticated before any ratchet state is committed, so a malformed frame cannot desynchronise a live session. Also in this release: - Verification is enforced as a gate, not a label: control frames (reconnection signalling, call setup, message deletion, delivery receipts) are acted on only after both peers have compared the safety code, and verified state is set in a single guarded place. - Chat content reaches the interface through one authenticated path; an older, weaker inbound path was retired. - The security panel measures what it displays — several checks previously returned a fixed result and now exercise the subsystem they describe. - Invitation data is no longer kept in local storage, and entries left by earlier versions are cleared on first launch. - View-once and disappearing messages no longer place their text in system notifications. - Shared-secret buffers are overwritten once derivation completes; scanned QR codes are decompressed with a size limit; voice notes are validated against audio type and size budgets before skipping the consent prompt; the master password is collected by the app rather than a browser dialog. - Connection setup no longer fails on networks where STUN/TURN are unreachable: it proceeds as soon as usable candidates exist and only waits while there are none. Test suite grows from 27 to 41 files, covering forward secrecy, post-compromise re-keying, out-of-order delivery across ratchet steps, the skipped-key bounds, tamper resistance, negotiation fallback, and byte-level key-derivation compatibility with 5.6.0. --- .gitignore | 8 + CHANGELOG.md | 138 ++ README.md | 20 +- dist/app-boot.js | 1394 +++++++++++++---- dist/app-boot.js.map | 8 +- dist/app.js | 44 +- dist/app.js.map | 4 +- dist/qr-local.js | 32 +- dist/qr-local.js.map | 4 +- doc/API.md | 27 + doc/CRYPTOGRAPHY.md | 58 +- doc/SECURITY-ARCHITECTURE.md | 41 +- index.html | 44 +- meta.json | 14 +- package.json | 4 +- src/app.jsx | 67 +- src/crypto/DoubleRatchet.js | 555 +++++++ src/crypto/EnhancedSecureCryptoUtils.js | 289 +++- src/crypto/cose-qr.js | 60 +- src/network/EnhancedSecureWebRTCManager.js | 856 +++++++--- src/notifications/NotificationIntegration.js | 19 +- src/scripts/app-boot.js | 21 + src/transfer/EnhancedSecureFileTransfer.js | 67 +- sw.js | 2 +- tests/control-frame-authorization.test.mjs | 195 +++ tests/double-ratchet.test.mjs | 293 ++++ tests/ice-gathering-patience.test.mjs | 150 ++ tests/inbound-message-rate-limit.test.mjs | 51 +- tests/key-derivation-compat.test.mjs | 93 ++ tests/key-exchange-e2e.test.mjs | 110 ++ tests/legacy-offer-purge.test.mjs | 68 + tests/notification-ephemeral-privacy.test.mjs | 91 ++ tests/qr-zip-bomb.test.mjs | 76 + tests/ratchet-integration.test.mjs | 213 +++ tests/sas-verification.test.mjs | 19 +- tests/secure-chat-features.test.mjs | 35 +- tests/security-level-shape.test.mjs | 33 + tests/voice-auto-accept.test.mjs | 119 ++ 38 files changed, 4570 insertions(+), 752 deletions(-) create mode 100644 src/crypto/DoubleRatchet.js create mode 100644 tests/control-frame-authorization.test.mjs create mode 100644 tests/double-ratchet.test.mjs create mode 100644 tests/ice-gathering-patience.test.mjs create mode 100644 tests/key-derivation-compat.test.mjs create mode 100644 tests/key-exchange-e2e.test.mjs create mode 100644 tests/legacy-offer-purge.test.mjs create mode 100644 tests/notification-ephemeral-privacy.test.mjs create mode 100644 tests/qr-zip-bomb.test.mjs create mode 100644 tests/ratchet-integration.test.mjs create mode 100644 tests/voice-auto-accept.test.mjs diff --git a/.gitignore b/.gitignore index dec89e8..f2aa22d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,11 @@ npm-debug.log* # Operator ICE override holds TURN credentials — never commit it. # Use config/ice-servers.example.js as the template. config/ice-servers.js + +# Internal security review notes. These describe attack paths against specific +# releases in enough detail to reproduce them, which is useful in private and +# harmful in a public repository — users who have not updated yet would be the +# ones exposed. Keep them out of the tree. +SECURITY_AUDIT.md +SECURITY_AUDIT*.md +audit/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c04dd..404f0eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,143 @@ # Changelog +## v5.7.1 — Forward secrecy now engages for both sides of a chat + +The Double Ratchet introduced in 5.7.0 was only taking effect for the peer who +joined a conversation; the peer who created the invitation stayed on the +previous per-session key scheme. Both sides now negotiate and run it, so a +conversation is protected symmetrically end to end. + +If you installed 5.7.0, updating is worthwhile — it is what makes per-message +forward secrecy apply to your whole conversation rather than one direction of it. + +### Internal + +- The ratchet's test suites now construct the peer's public key exactly as the + handshake delivers it (exported and re-imported, non-extractable) rather than + reusing a locally generated one. Locally generated public keys are always + extractable in WebCrypto, so the earlier tests exercised a key shape the app + never actually produces. + +## v5.7.0 — Double Ratchet: forward secrecy for every message + +Sessions previously derived one set of keys during the handshake and used them +for the whole conversation. This release adds the Double Ratchet (Signal's +design) on top of that, so protection no longer rests on a single set of keys +lasting the entire chat. + +### Added + +- **A separate key for every message.** Each message key is derived from a chain + key through a one-way function and discarded immediately after use, so keys + that exist now cannot be used to reconstruct earlier ones. +- **A Diffie–Hellman step on every change of direction.** Each reply introduces a + fresh ECDH key pair and mixes a new shared secret into the root key. A session + therefore re-keys itself continuously as the conversation goes back and forth. +- **Bounded handling of out-of-order messages.** Keys for messages that have not + arrived yet are held so they can still be read, with firm limits on how many + are kept (512 per chain, 1024 in total, expiring after five minutes) and a + fixed ceiling on how far ahead a message number may jump. + +The ratchet required no change to the handshake. Both peers already hold each +other's authenticated ECDH public key, and the safety code compared during +verification covers exactly those keys. The ratchet's root is derived from the +existing shared secret through its own branch of the key schedule, keeping it +separate from the session's other keys. + +### Compatibility + +Support is advertised in the invitation and the response and used only when both +sides have it. A peer on an earlier release negotiates it away and the session +runs on the previous scheme — with no server in the design there is no way to +update both ends at once, and connecting with the earlier protection is better +than not connecting. The security panel shows which of the two is actually in +use, rather than what the client is capable of. + +One behaviour worth knowing: the peer who joins has no sending chain until the +inviting peer's first message arrives — that is inherent to the ratchet, since +both sides derive it from the same exchange. The app sends a presence update from +both sides as soon as verification completes, so those first frames use the +session keys and everything afterwards is ratcheted. + +### Improved + +- **Connection setup on restrictive networks.** Gathering network candidates only + finishes once every configured STUN/TURN server has replied or timed out, which + behind a VPN or a strict firewall may not happen at all. Setup now proceeds as + soon as there are usable candidates and only keeps waiting while there are + none, up to a longer ceiling. A network that genuinely yields nothing now + explains what to try instead of failing without explanation. + +## v5.6.2 — Restore connectivity after the 5.6.1 key-handling change + +5.6.1 changed how the shared secret is handled in memory and missed a matching +adjustment to key generation, which prevented sessions from being established. +Anyone on 5.6.1 should update. + +Key agreement is unchanged on the wire, so 5.6.0 sessions remain compatible. + +### Internal + +- Added an end-to-end test that drives the real key generator and derivation + rather than constructing its own keys, which is what allowed the mismatch + through. + +## v5.6.1 — Hardening pass + +A review of the client produced a set of improvements to how the session is +verified, how peer input is handled and what the app stores. Updating is +recommended. + +### Improved — verification and peer input + +- **The safety-code comparison is now the only route to a verified session.** + Verification state is set in exactly one place, and the checks that guard it + cannot be reached around. +- **Control messages are honoured only after verification.** Reconnection + signalling, call setup, message deletion and delivery receipts all wait until + both people have compared the safety code. The verification exchange itself + continues to work beforehand, as it must. +- **A single path for incoming chat content.** An older, weaker inbound code path + was retired so that everything shown in a conversation has been authenticated. + +### Improved — accuracy of what the app reports + +- **The security panel now measures what it displays.** Several checks previously + reported a fixed result; they now exercise the subsystem they describe and can + report a failure. As a result the score reflects the session more precisely, + and may read lower than before on the same connection. +- **Forward-secrecy reporting matches reality.** In 5.6.1 the panel reported the + session-level guarantee accurately rather than implying per-message protection; + 5.7.0 adds the per-message protection itself. +- **Clearer memory-handling semantics.** Operations that cannot clear a value in + JavaScript — immutable strings, non-extractable keys — now say so instead of + reporting success. + +### Improved — what stays on the device + +- **Invitation data is no longer kept in local storage.** An unused + reference-based QR path wrote session invitation details to local storage + without removing them; the path has been removed and existing entries are + cleared on first launch after updating. +- **Ephemeral messages stay ephemeral.** View-once and disappearing messages no + longer place their text in system notifications, where the operating system + would retain it beyond the app's control. Ordinary messages are unchanged. + +### Improved — hardening + +- **Shared-secret handling in memory.** The value is derived into a buffer that is + overwritten once it is no longer needed. +- **Scanned QR codes are decompressed with a size limit,** so a malformed or + hostile code cannot exhaust memory. +- **Voice notes are validated before being accepted automatically.** Only genuine + audio types within a size limit skip the consent prompt; anything else goes + through the normal confirmation, which also bounds how much a peer can send + unattended. +- **The master-password prompt now comes from the app's own interface** rather + than a browser dialog. +- **Clearer handling of DTLS fingerprints,** with the local and remote values kept + separate and reported accurately. + ## v5.6.0 — Survive a dropped connection A chat no longer dies when the network moves under it. Switching Wi-Fi → LTE, diff --git a/README.md b/README.md index 38d5c69..b8187c1 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ No accounts. No servers storing your messages. No installation required. [![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-5.6.0-3ecf8e.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-5.7.1-3ecf8e.svg)](CHANGELOG.md) [![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app) [![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model) +[![Forward secrecy](https://img.shields.io/badge/forward%20secrecy-Double%20Ratchet-3ecf8e.svg)](#forward-secrecy) [Features](#features) · [How it works](#how-it-works) · [Security](#security-model) · [Quick start](#quick-start) · [Documentation](#documentation) @@ -33,6 +34,7 @@ It is designed for people who need a small, auditable, zero-infrastructure way t ** Encryption & verification** - ECDH P-384 key agreement with derived per-session keys, AES-256-GCM payloads, and DTLS-protected transport. +- **Double Ratchet forward secrecy** — every message is encrypted with its own key, and the session re-keys itself each time the conversation changes direction. See [Forward secrecy](#forward-secrecy). - Interactive **Short Authentication String (SAS)** verification — you confirm a code out-of-band before the session is trusted, defeating man-in-the-middle attacks. - Replay protection, message integrity (HMAC), and a live security report you can open at any time during a call. @@ -95,6 +97,7 @@ SecureBit never sees your conversation. A session is built directly between the | Layer | Mechanism | | --- | --- | | Key agreement | ECDH (P-384), per-session derived keys | +| Forward secrecy | Double Ratchet — per-message keys, DH re-key on each reply | | Transport | WebRTC data channel over DTLS | | Message encryption | AES-256-GCM, end-to-end | | Authentication | Interactive SAS bound to both peers' DTLS fingerprints | @@ -102,7 +105,17 @@ SecureBit never sees your conversation. A session is built directly between the | Sanitization | DOMPurify text-only rendering boundary | | Local storage | Encrypted key metadata in IndexedDB | -A session is **not** treated as verified until both peers complete the SAS flow. This is the step that protects you against a man-in-the-middle: the code must be compared through a channel an attacker cannot impersonate. +A session is **not** treated as verified until both peers complete the SAS flow. This is the step that protects you against a man-in-the-middle: the code must be compared through a channel an attacker cannot impersonate. Until it is completed, the session will not act on control messages from the peer. + +### Forward secrecy + +Message protection does not rest on the keys agreed during the handshake. On top of them SecureBit runs the **Double Ratchet** — the design used by Signal: + +- **Every message gets its own key.** It is derived from a chain key through a one-way function and discarded as soon as the message is encrypted or read, so keys held now cannot reconstruct earlier ones. Recovering the live state of a session does not expose what was said before. +- **Each change of direction re-keys the session.** Every reply introduces a fresh ECDH key pair and mixes a new shared secret into the root key, so the conversation continuously moves away from any state an attacker may have captured. +- **Out-of-order messages are handled within fixed bounds.** Keys are held for messages that have not arrived yet, capped at 512 per chain and 1024 in total and expiring after five minutes, with a limit on how far ahead a message may claim to be. + +The ratchet is negotiated during the handshake and used when both peers support it. If one side is on an older release, the session falls back to per-session keys and the security panel reports which of the two is actually in use — it shows the state of your connection, not the capabilities of your client. > [!WARNING] > SecureBit.chat is privacy software, not a guarantee. View-once and disappearing messages are cooperative (not screenshot-proof), and a TURN relay can observe both peers' IPs and traffic timing — though never message contents. See [`SECURITY_DISCLAIMER.md`](SECURITY_DISCLAIMER.md). @@ -162,9 +175,10 @@ npm run dev # build and serve locally ```text src/network/ WebRTC connection and session lifecycle src/transfer/ secure file-transfer implementation -src/crypto/ cryptographic utilities +src/crypto/ cryptographic utilities and the Double Ratchet src/components/ React UI components src/styles/ component styles +tests/ node:assert suites, run by `npm test` doc/ technical documentation dist/ built bundles served in production ``` diff --git a/dist/app-boot.js b/dist/app-boot.js index 012da5c..6d45c04 100644 --- a/dist/app-boot.js +++ b/dist/app-boot.js @@ -543,7 +543,7 @@ var require_NotificationIntegration = __commonJS({ this.originalOnMessage = this.webrtcManager.onMessage; this.originalOnStatusChange = this.webrtcManager.onStatusChange; this.webrtcManager.onMessage = (message, type, ...rest) => { - this.handleIncomingMessage(message, type); + this.handleIncomingMessage(message, type, rest[0]); if (this.originalOnMessage) { this.originalOnMessage(message, type, ...rest); } @@ -557,7 +557,7 @@ var require_NotificationIntegration = __commonJS({ if (this.webrtcManager.deliverMessageToUI) { this.originalDeliverMessageToUI = this.webrtcManager.deliverMessageToUI.bind(this.webrtcManager); this.webrtcManager.deliverMessageToUI = (message, type, ...rest) => { - this.handleIncomingMessage(message, type); + this.handleIncomingMessage(message, type, rest[0]); this.originalDeliverMessageToUI(message, type, ...rest); }; } @@ -573,7 +573,7 @@ var require_NotificationIntegration = __commonJS({ * @param {string} type - Message type * @private */ - handleIncomingMessage(message, type) { + handleIncomingMessage(message, type, meta) { try { const messageKey = `${type}:${typeof message === "string" ? message : JSON.stringify(message)}`; if (this.processedMessages.has(messageKey)) { @@ -592,9 +592,11 @@ var require_NotificationIntegration = __commonJS({ if (!messageInfo) { return; } + const isEphemeral = !!meta && typeof meta === "object" && (meta.once === true || Number.isFinite(meta.ttl) && meta.ttl > 0); + const notificationText = isEphemeral ? "Sent you a private message" : messageInfo.text; const notificationResult = this.notificationManager.notify( messageInfo.senderName, - messageInfo.text, + notificationText, { icon: messageInfo.senderAvatar, senderId: messageInfo.senderId, @@ -2243,6 +2245,31 @@ var EnhancedSecureCryptoUtils = class _EnhancedSecureCryptoUtils { throw new Error(`Hex conversion error: ${error.message}`); } } + /** + * Overwrite a buffer holding key material once it is no longer needed. + * + * This is a genuine wipe, unlike the manager's _secureWipeString / + * _secureWipeCryptoKey, which cannot wipe anything (JS strings are immutable + * and a non-extractable CryptoKey has no JS-visible bytes) and only ever + * dropped a reference while reporting success. Here the bytes really are + * ours: overwrite them so the shared secret does not linger in the heap + * waiting for a garbage collector that may never run before a heap snapshot + * or a memory-reading extension gets there first. + * + * Random first, then zeros: on the off chance a copying GC has already moved + * the buffer, the random pass at least destroys the plaintext value at the + * old address as well as the new one. + */ + static zeroizeBuffer(buffer) { + try { + if (!buffer) return; + const view = buffer instanceof Uint8Array ? buffer : buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : null; + if (!view || view.length === 0) return; + crypto.getRandomValues(view); + view.fill(0); + } catch (_) { + } + } static async encryptData(data, password) { try { const dataString = typeof data === "string" ? data : JSON.stringify(data); @@ -2617,7 +2644,7 @@ var EnhancedSecureCryptoUtils = class _EnhancedSecureCryptoUtils { for (const testData of testCases) { const encoder = new TextEncoder(); const testBuffer = encoder.encode(testData); - const hmac = await crypto.subtle.sign( + const hmac2 = await crypto.subtle.sign( { name: "HMAC", hash: "SHA-256" }, securityManager.macKey, testBuffer @@ -2625,7 +2652,7 @@ var EnhancedSecureCryptoUtils = class _EnhancedSecureCryptoUtils { const isValid = await crypto.subtle.verify( { name: "HMAC", hash: "SHA-256" }, securityManager.macKey, - hmac, + hmac2, testBuffer ); if (!isValid) { @@ -2638,24 +2665,82 @@ var EnhancedSecureCryptoUtils = class _EnhancedSecureCryptoUtils { return { passed: false, details: `Message integrity test failed: ${error.message}` }; } } - // Additional verification functions + // Additional verification functions. + // + // These used to be three `return { passed: true }` stubs — a quarter of the + // reported score awarded for checks that never ran, under a UI that calls the + // result "Real cryptographic tests". A security indicator that cannot fail + // tells the user nothing; worse, it keeps reading green after the subsystem + // it claims to measure breaks. Each one below now exercises the thing it + // names and is expected to be able to fail. static async verifyRateLimiting(securityManager) { try { - return { passed: true, details: "Rate limiting is active and working" }; + const limiter = _EnhancedSecureCryptoUtils.rateLimiter; + if (!limiter || typeof limiter.checkMessageRate !== "function") { + return { passed: false, details: "Rate limiter is not available" }; + } + const probeId = `selftest_${crypto.getRandomValues(new Uint32Array(1))[0]}`; + const limit = 3; + for (let i = 0; i < limit; i++) { + const allowed = await limiter.checkMessageRate(probeId, limit, 6e4); + if (!allowed) { + return { passed: false, details: `Rate limiter refused message ${i + 1} of ${limit} while under the limit` }; + } + } + const shouldBeBlocked = await limiter.checkMessageRate(probeId, limit, 6e4); + limiter.messages.delete(`msg_${probeId}`); + if (shouldBeBlocked) { + return { passed: false, details: "Rate limiter did not block a message over the limit" }; + } + return { passed: true, details: `Rate limiting verified: ${limit} allowed, the next refused` }; } catch (error) { return { passed: false, details: `Rate limiting test failed: ${error.message}` }; } } static async verifyMetadataProtection(securityManager) { try { - return { passed: true, details: "Metadata protection is working correctly" }; + const metadataKey = securityManager?.metadataKey; + if (!metadataKey || !(metadataKey instanceof CryptoKey)) { + return { passed: false, details: "Metadata encryption key not available" }; + } + if (metadataKey.algorithm?.name !== "AES-GCM") { + return { passed: false, details: `Metadata key has the wrong algorithm: ${metadataKey.algorithm?.name}` }; + } + if (metadataKey.extractable) { + return { passed: false, details: "Metadata key is extractable" }; + } + if (securityManager.encryptionKey === metadataKey) { + return { passed: false, details: "Metadata key is not separated from the message key" }; + } + const iv = crypto.getRandomValues(new Uint8Array(12)); + const probe = new TextEncoder().encode("metadata-protection-selftest"); + const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, metadataKey, probe); + const opened = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, metadataKey, sealed); + if (new TextDecoder().decode(opened) !== "metadata-protection-selftest") { + return { passed: false, details: "Metadata encryption round-trip mismatch" }; + } + return { passed: true, details: "Metadata is encrypted under a separate non-extractable key" }; } catch (error) { return { passed: false, details: `Metadata protection test failed: ${error.message}` }; } } static async verifyPerfectForwardSecrecy(securityManager) { try { - return { passed: true, details: "Perfect Forward Secrecy is configured and active" }; + const hasEphemeralKeys = !!securityManager?.ecdhKeyPair?.privateKey && securityManager.ecdhKeyPair.privateKey.extractable === false; + if (!hasEphemeralKeys) { + return { passed: false, details: "No non-extractable ephemeral ECDH key pair for this session" }; + } + if (securityManager?.isRatchetActive?.()) { + const state = securityManager._ratchet?.getState?.() || {}; + return { + passed: true, + details: `Double Ratchet active: per-message keys destroyed after use, DH re-key on each reply (sent ${state.sendCount ?? 0}, received ${state.receiveCount ?? 0} on the current chain)` + }; + } + return { + passed: false, + details: "Session-level PFS only: keys are ephemeral per session, but the Double Ratchet is not active for this connection (peer on an older version), so a compromised session key exposes the whole conversation" + }; } catch (error) { return { passed: false, details: `PFS test failed: ${error.message}` }; } @@ -2756,13 +2841,21 @@ var EnhancedSecureCryptoUtils = class _EnhancedSecureCryptoUtils { } } static async verifyNonExtractableKeys(securityManager) { - try { - if (!securityManager.encryptionKey) return false; - const keyData = await crypto.subtle.exportKey("raw", securityManager.encryptionKey); - return keyData && keyData.byteLength > 0; - } catch (error) { - return true; + const keys = [ + ["encryptionKey", securityManager?.encryptionKey], + ["macKey", securityManager?.macKey], + ["metadataKey", securityManager?.metadataKey] + ]; + for (const [name, key] of keys) { + if (!key || !(key instanceof CryptoKey)) { + return false; + } + if (key.extractable !== false) { + _EnhancedSecureCryptoUtils.secureLog.log("error", "Session key is extractable", { keyName: name }); + return false; + } } + return true; } static async verifyEnhancedValidation(securityManager) { try { @@ -3014,7 +3107,14 @@ var EnhancedSecureCryptoUtils = class _EnhancedSecureCryptoUtils { }, false, // Non-extractable for enhanced security - ["deriveKey"] + // 'deriveBits' is REQUIRED: deriveSharedKeys() uses deriveBits so + // the shared secret lands in a buffer we can overwrite, instead of + // being exported out of an extractable key and left in the heap. + // Without this usage WebCrypto rejects the derivation outright and + // no session can be established. Usages are local to the CryptoKey + // and are not part of the exported SPKI, so this does not change + // anything on the wire. + ["deriveKey", "deriveBits"] ); return keyPair; } catch (p384Error) { @@ -3026,7 +3126,7 @@ var EnhancedSecureCryptoUtils = class _EnhancedSecureCryptoUtils { }, false, // Non-extractable for enhanced security - ["deriveKey"] + ["deriveKey", "deriveBits"] ); return keyPair; } @@ -3593,37 +3693,38 @@ var EnhancedSecureCryptoUtils = class _EnhancedSecureCryptoUtils { const saltBytes = new Uint8Array(salt); const encoder = new TextEncoder(); let rawSharedSecret; + let sharedSecretBits = null; try { - const rawKeyMaterial = await crypto.subtle.deriveKey( + sharedSecretBits = await crypto.subtle.deriveBits( { name: "ECDH", public: publicKey }, privateKey, - { - name: "AES-GCM", - length: 256 - }, - true, - // Extractable - ["encrypt", "decrypt"] + 256 ); - const rawKeyData = await crypto.subtle.exportKey("raw", rawKeyMaterial); rawSharedSecret = await crypto.subtle.importKey( "raw", - rawKeyData, + sharedSecretBits, { name: "HKDF", hash: "SHA-256" }, false, - ["deriveKey"] + // deriveBits is required for the fingerprint material below; + // without it that call fails with an InvalidAccessError. + ["deriveKey", "deriveBits"] ); } catch (error) { _EnhancedSecureCryptoUtils.secureLog.log("error", "ECDH derivation failed", { error: error.message }); throw error; + } finally { + if (sharedSecretBits) { + _EnhancedSecureCryptoUtils.zeroizeBuffer(sharedSecretBits); + sharedSecretBits = null; + } } let messageKey; messageKey = await crypto.subtle.deriveKey( @@ -3693,25 +3794,39 @@ var EnhancedSecureCryptoUtils = class _EnhancedSecureCryptoUtils { // Non-extractable ["encrypt", "decrypt"] ); - let fingerprintKey; - fingerprintKey = await crypto.subtle.deriveKey( + const ratchetRootBits = await crypto.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt: saltBytes, - info: encoder.encode("fingerprint-generation-v4") + info: encoder.encode("double-ratchet-root-v1") }, rawSharedSecret, - { - name: "AES-GCM", - length: 256 - }, - true, - // Extractable only for fingerprint - ["encrypt", "decrypt"] + 256 ); - const fingerprintKeyData = await crypto.subtle.exportKey("raw", fingerprintKey); - const fingerprint = await _EnhancedSecureCryptoUtils.generateKeyFingerprint(Array.from(new Uint8Array(fingerprintKeyData))); + const ratchetRoot = new Uint8Array(ratchetRootBits); + let fingerprintBits = null; + let fingerprint; + try { + fingerprintBits = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: saltBytes, + info: encoder.encode("fingerprint-generation-v4") + }, + rawSharedSecret, + 256 + ); + fingerprint = await _EnhancedSecureCryptoUtils.generateKeyFingerprint( + new Uint8Array(fingerprintBits) + ); + } finally { + if (fingerprintBits) { + _EnhancedSecureCryptoUtils.zeroizeBuffer(fingerprintBits); + fingerprintBits = null; + } + } if (!(messageKey instanceof CryptoKey)) { _EnhancedSecureCryptoUtils.secureLog.log("error", "Derived message key is not a CryptoKey", { messageKeyType: typeof messageKey, @@ -3747,6 +3862,10 @@ var EnhancedSecureCryptoUtils = class _EnhancedSecureCryptoUtils { pfsKey, // Added Perfect Forward Secrecy key metadataKey, + // Raw bytes on purpose: a ratchet has to chain KDFs itself, which + // WebCrypto cannot do behind a non-extractable handle. The caller + // must hand this to DoubleRatchet.init() and zeroize it. + ratchetRoot, fingerprint, timestamp: Date.now(), version: "4.0" @@ -4524,6 +4643,9 @@ var EnhancedSecureFileTransfer = class { this.incomingTransferChunkLimiters = /* @__PURE__ */ new Map(); this.MAX_INCOMING_CHUNKS_PER_TRANSFER_PER_MINUTE = 3e4; this.MAX_PENDING_INCOMING_TRANSFERS = 3; + this.MAX_AUTO_ACCEPT_VOICE_SIZE = 4 * 1024 * 1024; + this.MAX_AUTO_ACCEPT_VOICE_SESSION_BYTES = 64 * 1024 * 1024; + this.autoAcceptedVoiceBytes = 0; this.sessionKeys = /* @__PURE__ */ new Map(); this.processedChunks = /* @__PURE__ */ new Set(); this.transferNonces = /* @__PURE__ */ new Map(); @@ -4618,7 +4740,38 @@ var EnhancedSecureFileTransfer = class { }); if (!validation.isValid) errors.push(...validation.errors); } - return { isValid: errors.length === 0, errors, displayName }; + const claimsVoice = !!metadata?.isVoice; + const voiceRejection = claimsVoice ? this.rejectVoiceAutoAcceptReason(metadata) : null; + return { + isValid: errors.length === 0, + errors, + displayName, + isVoice: claimsVoice && !voiceRejection, + voiceRejection + }; + } + /** + * Why a transfer claiming to be a voice note may not skip the consent card. + * Returns null when it may. The generic MIME types that validateFile accepts + * for ordinary uploads (application/octet-stream and friends) are explicitly + * NOT enough here: they are what lets an arbitrary blob wear a `.mp4` name. + */ + rejectVoiceAutoAcceptReason(metadata) { + const mimeType = String(metadata?.fileType || "").toLowerCase(); + const size = metadata?.fileSize; + if (!mimeType.startsWith("audio/")) { + return `not an audio MIME type (${mimeType || "absent"})`; + } + if (!this.FILE_TYPE_RESTRICTIONS.voice.mimeTypes.includes(mimeType)) { + return `unsupported audio MIME type (${mimeType})`; + } + if (!Number.isSafeInteger(size) || size <= 0 || size > this.MAX_AUTO_ACCEPT_VOICE_SIZE) { + return `too large to auto-accept (${this.formatFileSize(size || 0)} > ${this.formatFileSize(this.MAX_AUTO_ACCEPT_VOICE_SIZE)})`; + } + if (this.autoAcceptedVoiceBytes + size > this.MAX_AUTO_ACCEPT_VOICE_SESSION_BYTES) { + return "session auto-accept budget for voice notes is exhausted"; + } + return null; } formatFileSize(bytes) { if (bytes === 0) return "0 B"; @@ -5261,12 +5414,20 @@ var EnhancedSecureFileTransfer = class { if (this.pendingIncomingTransfers.size >= this.MAX_PENDING_INCOMING_TRANSFERS) { throw new Error("Too many pending incoming file requests"); } + if (validation.voiceRejection) { + console.warn(`Voice auto-accept declined, falling back to consent: ${validation.voiceRejection}`); + } const pendingMetadata = { ...metadata, + // Never carry the sender's claim forward — only our own verdict. + isVoice: validation.isVoice, fileName: validation.displayName, receivedAt: Date.now() }; this.pendingIncomingTransfers.set(metadata.fileId, pendingMetadata); + if (validation.isVoice) { + this.autoAcceptedVoiceBytes += metadata.fileSize; + } if (typeof this.onIncomingFileRequest === "function") { this.onIncomingFileRequest({ fileId: pendingMetadata.fileId, @@ -5274,7 +5435,8 @@ var EnhancedSecureFileTransfer = class { fileSize: pendingMetadata.fileSize, mimeType: pendingMetadata.fileType || "application/octet-stream", // Voice notes auto-accept and render inline (no consent card). - isVoice: !!pendingMetadata.isVoice, + // This flag is the receiver's decision, not the sender's. + isVoice: validation.isVoice, voice: pendingMetadata.voice || null }); } else { @@ -6430,10 +6592,10 @@ async function configureAudioSender(sender, options = {}) { const cfg = { ...AUDIO_CONFIG.sender, ...options }; const params = sender.getParameters(); if (!params.encodings || params.encodings.length === 0) params.encodings = [{}]; - for (const enc of params.encodings) { - enc.maxBitrate = cfg.maxBitrate; - enc.priority = cfg.priority; - enc.networkPriority = cfg.networkPriority; + for (const enc2 of params.encodings) { + enc2.maxBitrate = cfg.maxBitrate; + enc2.priority = cfg.priority; + enc2.networkPriority = cfg.networkPriority; } await sender.setParameters(params); return true; @@ -6495,12 +6657,12 @@ async function configureVideoSender(sender, options = {}) { if (!params.encodings || params.encodings.length === 0) params.encodings = [{}]; const simulcast = params.encodings.length > 1; if (simulcast) { - for (const enc of params.encodings) enc.networkPriority = VIDEO_CONFIG.networkPriority; + for (const enc2 of params.encodings) enc2.networkPriority = VIDEO_CONFIG.networkPriority; } else { - const enc = params.encodings[0]; - enc.maxBitrate = plan.maxBitrate; - enc.networkPriority = VIDEO_CONFIG.networkPriority; - if (plan.scalabilityMode) enc.scalabilityMode = plan.scalabilityMode; + const enc2 = params.encodings[0]; + enc2.maxBitrate = plan.maxBitrate; + enc2.networkPriority = VIDEO_CONFIG.networkPriority; + if (plan.scalabilityMode) enc2.scalabilityMode = plan.scalabilityMode; } if (plan.degradationPreference) params.degradationPreference = plan.degradationPreference; try { @@ -6684,6 +6846,430 @@ var NetworkAdaptationController = class { } }; +// src/crypto/DoubleRatchet.js +var ROOT_INFO = "SecureBit-DR-Root-v1"; +var MESSAGE_INFO = "SecureBit-DR-Message-v1"; +var INIT_INFO = "SecureBit-DR-Init-v1"; +var MK_SEED = Uint8Array.of(1); +var CK_SEED = Uint8Array.of(2); +var enc = new TextEncoder(); +var dec = new TextDecoder(); +var RATCHET_LIMITS = Object.freeze({ + // How far ahead of the expected number a single message may jump. + MAX_SKIP_PER_CHAIN: 512, + // Total retained keys for messages that never arrived, across all chains. + MAX_SKIPPED_KEYS: 1024, + // Retained keys older than this are dropped: the data channel is reliable + // and ordered, so a gap that has not resolved in minutes never will. + SKIPPED_KEY_TTL_MS: 5 * 60 * 1e3 +}); +function b64(bytes) { + let binary = ""; + const view = new Uint8Array(bytes); + for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]); + return btoa(binary); +} +function unb64(text2) { + const binary = atob(text2); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} +function zeroize(bytes) { + try { + if (bytes && bytes.length) { + crypto.getRandomValues(bytes); + bytes.fill(0); + } + } catch (_) { + } +} +async function hkdf(ikm, salt, info, lengthBytes) { + const key = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt, info: enc.encode(info) }, + key, + lengthBytes * 8 + ); + return new Uint8Array(bits); +} +async function hmac(keyBytes, data) { + const key = await crypto.subtle.importKey( + "raw", + keyBytes, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + return new Uint8Array(await crypto.subtle.sign("HMAC", key, data)); +} +async function advanceChain(chainKey) { + const messageKey = await hmac(chainKey, MK_SEED); + const nextChainKey = await hmac(chainKey, CK_SEED); + return { messageKey, nextChainKey }; +} +async function advanceRoot(rootKey, dhOutput) { + const derived = await hkdf(dhOutput, rootKey, ROOT_INFO, 64); + const nextRoot = derived.slice(0, 32); + const chainKey = derived.slice(32, 64); + zeroize(derived); + return { nextRoot, chainKey }; +} +var DoubleRatchet = class { + constructor() { + this._rootKey = null; + this._sendingChainKey = null; + this._receivingChainKey = null; + this._selfKeyPair = null; + this._remotePublicKey = null; + this._remotePublicKeyB64 = null; + this._sendCount = 0; + this._receiveCount = 0; + this._previousSendCount = 0; + this._skipped = /* @__PURE__ */ new Map(); + this._namedCurve = "P-384"; + this._initialised = false; + } + /** + * @param {object} options + * @param {Uint8Array} options.sharedSecret ECDH output from the handshake. + * @param {Uint8Array} options.sessionSalt The session's 64-byte salt. + * @param {CryptoKey} options.selfPrivateKey Our handshake ECDH private key. + * @param {CryptoKey} options.remotePublicKey Peer's handshake ECDH public key. + * @param {boolean} options.isInitiator True for the side that created the offer. + */ + async init({ sharedSecret, sessionSalt, selfPrivateKey, remotePublicKey, isInitiator }) { + if (!(sharedSecret instanceof Uint8Array) || sharedSecret.length === 0) { + throw new Error("DoubleRatchet: a shared secret is required"); + } + if (!(selfPrivateKey instanceof CryptoKey) || !(remotePublicKey instanceof CryptoKey)) { + throw new Error("DoubleRatchet: handshake ECDH keys are required"); + } + this._namedCurve = selfPrivateKey.algorithm?.namedCurve || "P-384"; + this._rootKey = await hkdf(sharedSecret, sessionSalt ?? new Uint8Array(0), INIT_INFO, 32); + if (isInitiator) { + this._selfKeyPair = await this._generateKeyPair(); + this._remotePublicKey = remotePublicKey; + this._remotePublicKeyB64 = null; + const dh = await this._dh(this._selfKeyPair.privateKey, this._remotePublicKey); + const { nextRoot, chainKey } = await advanceRoot(this._rootKey, dh); + zeroize(dh); + zeroize(this._rootKey); + this._rootKey = nextRoot; + this._sendingChainKey = chainKey; + } else { + this._selfKeyPair = { privateKey: selfPrivateKey, publicKey: null }; + this._remotePublicKey = null; + this._remotePublicKeyB64 = null; + } + this._initialised = true; + } + get isInitialised() { + return this._initialised; + } + /** + * False on the responder until the initiator's first message arrives. + * + * This is inherent to the Double Ratchet, not an implementation gap: the + * responder's sending chain is only defined once it has seen the initiator's + * ratchet key, because both sides must derive it from the same DH. Callers + * have to check this rather than assume, or the responder's first message — + * which the app sends automatically as a presence update the moment + * verification completes — throws instead of going out. + */ + get canEncrypt() { + return this._initialised && this._sendingChainKey !== null; + } + /** Diagnostics only — deliberately exposes no key material. */ + getState() { + return { + initialised: this._initialised, + sending: this._sendingChainKey !== null, + receiving: this._receivingChainKey !== null, + sendCount: this._sendCount, + receiveCount: this._receiveCount, + previousSendCount: this._previousSendCount, + skippedKeys: this._skipped.size + }; + } + async _generateKeyPair() { + return crypto.subtle.generateKey( + { name: "ECDH", namedCurve: this._namedCurve }, + false, + ["deriveKey", "deriveBits"] + ); + } + async _dh(privateKey, publicKey) { + const bits = await crypto.subtle.deriveBits( + { name: "ECDH", public: publicKey }, + privateKey, + 256 + ); + return new Uint8Array(bits); + } + async _selfPublicKeyB64() { + if (!this._selfKeyPair?.publicKey) return null; + return b64(await crypto.subtle.exportKey("spki", this._selfKeyPair.publicKey)); + } + async _importPublic(spkiB64) { + return crypto.subtle.importKey( + "spki", + unb64(spkiB64), + { name: "ECDH", namedCurve: this._namedCurve }, + true, + [] + ); + } + /** Derive the AES-GCM key and IV for one message, then forget the message key. */ + async _messageCipher(messageKey) { + const material = await hkdf(messageKey, new Uint8Array(32), MESSAGE_INFO, 44); + const key = await crypto.subtle.importKey( + "raw", + material.slice(0, 32), + { name: "AES-GCM" }, + false, + ["encrypt", "decrypt"] + ); + const iv = material.slice(32, 44); + zeroize(material); + return { key, iv }; + } + /** + * @param {string} plaintext + * @returns {Promise<{header: string, ciphertext: string}>} header is the exact + * string that must be transmitted and fed back to decrypt(): it doubles as + * the AAD, so re-serialising it on the far side could change a byte and + * fail authentication for no reason. + */ + async encrypt(plaintext) { + if (!this._initialised) throw new Error("DoubleRatchet: not initialised"); + if (!this._sendingChainKey) { + throw new Error("DoubleRatchet: no sending chain \u2014 awaiting the peer's first message"); + } + const { messageKey, nextChainKey } = await advanceChain(this._sendingChainKey); + zeroize(this._sendingChainKey); + this._sendingChainKey = nextChainKey; + const header = JSON.stringify({ + dh: await this._selfPublicKeyB64(), + pn: this._previousSendCount, + n: this._sendCount + }); + this._sendCount += 1; + const { key, iv } = await this._messageCipher(messageKey); + zeroize(messageKey); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv, additionalData: enc.encode(header) }, + key, + enc.encode(plaintext) + ); + return { header, ciphertext: b64(ciphertext) }; + } + /** + * @param {string} header Exactly the string produced by encrypt(). + * @param {string} ciphertext Base64 body. + * @returns {Promise} plaintext + */ + async decrypt(header, ciphertext) { + if (!this._initialised) throw new Error("DoubleRatchet: not initialised"); + let parsed; + try { + parsed = JSON.parse(header); + } catch (_) { + throw new Error("DoubleRatchet: malformed header"); + } + const { dh, pn, n } = parsed; + if (typeof dh !== "string" || !Number.isSafeInteger(n) || n < 0 || !Number.isSafeInteger(pn) || pn < 0) { + throw new Error("DoubleRatchet: invalid header fields"); + } + this._pruneSkipped(); + const skippedId = `${dh}|${n}`; + const retained = this._skipped.get(skippedId); + if (retained) { + const plaintext2 = await this._open(retained.key, header, ciphertext); + this._skipped.delete(skippedId); + zeroize(retained.key); + return plaintext2; + } + const staged = await this._stageReceive(dh, pn, n); + let plaintext; + try { + plaintext = await this._open(staged.messageKey, header, ciphertext); + } catch (error) { + staged.discard(); + throw error; + } + staged.commit(); + return plaintext; + } + /** + * Work out which key opens this message and what the resulting state would + * be, without touching `this`. Returns the candidate key plus commit/discard. + */ + async _stageReceive(dh, pn, n) { + const isNewChain = dh !== this._remotePublicKeyB64; + const pending = []; + const toZeroOnCommit = []; + let ratchet = null; + let chainKey; + let receiveCount; + let remoteB64; + if (isNewChain) { + if (this._receivingChainKey) { + const carried = await this._collectSkipped( + this._receivingChainKey, + this._receiveCount, + pn, + this._remotePublicKeyB64 + ); + pending.push(...carried.keys); + toZeroOnCommit.push(carried.finalChainKey); + } + ratchet = await this._stageDhRatchet(dh); + chainKey = ratchet.receivingChainKey; + receiveCount = 0; + remoteB64 = dh; + } else { + chainKey = this._receivingChainKey; + receiveCount = this._receiveCount; + remoteB64 = this._remotePublicKeyB64; + } + if (!chainKey) { + throw new Error("DoubleRatchet: no receiving chain for this message"); + } + const gap = await this._collectSkipped(chainKey, receiveCount, n, remoteB64); + pending.push(...gap.keys); + const { messageKey, nextChainKey } = await advanceChain(gap.finalChainKey); + if (gap.finalChainKey !== chainKey) toZeroOnCommit.push(gap.finalChainKey); + return { + messageKey, + commit: () => { + if (ratchet) ratchet.apply(); + if (this._receivingChainKey && this._receivingChainKey !== nextChainKey) { + zeroize(this._receivingChainKey); + } + for (const key of toZeroOnCommit) zeroize(key); + this._receivingChainKey = nextChainKey; + this._receiveCount = n + 1; + this._remotePublicKeyB64 = remoteB64; + for (const { id, key } of pending) this._rememberSkipped(id, key); + zeroize(messageKey); + }, + discard: () => { + if (ratchet) ratchet.discard(); + for (const { key } of pending) zeroize(key); + for (const key of toZeroOnCommit) zeroize(key); + zeroize(nextChainKey); + zeroize(messageKey); + } + }; + } + /** + * Derive the keys for messages `from`..`until-1` without mutating state. + * `until` comes off the wire, so the jump is bounded here rather than trusted. + */ + async _collectSkipped(chainKey, from, until, remoteB64) { + if (until < from) { + throw new Error("DoubleRatchet: message number is behind the current chain"); + } + if (until - from > RATCHET_LIMITS.MAX_SKIP_PER_CHAIN) { + throw new Error( + `DoubleRatchet: refusing to skip ${until - from} messages (limit ${RATCHET_LIMITS.MAX_SKIP_PER_CHAIN})` + ); + } + const keys = []; + let current = chainKey; + for (let i = from; i < until; i++) { + const { messageKey, nextChainKey } = await advanceChain(current); + if (current !== chainKey) zeroize(current); + current = nextChainKey; + keys.push({ id: `${remoteB64}|${i}`, key: messageKey }); + } + return { keys, finalChainKey: current }; + } + async _open(messageKey, header, ciphertext) { + const { key, iv } = await this._messageCipher(messageKey); + let opened; + try { + opened = await crypto.subtle.decrypt( + { name: "AES-GCM", iv, additionalData: enc.encode(header) }, + key, + unb64(ciphertext) + ); + } catch (_) { + throw new Error("DoubleRatchet: authentication failed"); + } + return dec.decode(opened); + } + _rememberSkipped(id, key) { + while (this._skipped.size >= RATCHET_LIMITS.MAX_SKIPPED_KEYS) { + const oldest = this._skipped.keys().next().value; + const evicted = this._skipped.get(oldest); + this._skipped.delete(oldest); + if (evicted) zeroize(evicted.key); + } + this._skipped.set(id, { key, storedAt: Date.now() }); + } + _pruneSkipped() { + const cutoff = Date.now() - RATCHET_LIMITS.SKIPPED_KEY_TTL_MS; + for (const [id, entry] of this._skipped) { + if (entry.storedAt < cutoff) { + zeroize(entry.key); + this._skipped.delete(id); + } + } + } + /** + * Compute the DH-ratchet step without applying it. The caller applies it only + * after the triggering message has authenticated — see _stageReceive. + */ + async _stageDhRatchet(remotePublicKeyB64) { + const remotePublicKey = await this._importPublic(remotePublicKeyB64); + const receiveDh = await this._dh(this._selfKeyPair.privateKey, remotePublicKey); + const received = await advanceRoot(this._rootKey, receiveDh); + zeroize(receiveDh); + const nextSelfKeyPair = await this._generateKeyPair(); + const sendDh = await this._dh(nextSelfKeyPair.privateKey, remotePublicKey); + const sending = await advanceRoot(received.nextRoot, sendDh); + zeroize(sendDh); + return { + receivingChainKey: received.chainKey, + apply: () => { + zeroize(this._rootKey); + zeroize(received.nextRoot); + if (this._sendingChainKey) zeroize(this._sendingChainKey); + this._rootKey = sending.nextRoot; + this._sendingChainKey = sending.chainKey; + this._selfKeyPair = nextSelfKeyPair; + this._remotePublicKey = remotePublicKey; + this._remotePublicKeyB64 = remotePublicKeyB64; + this._previousSendCount = this._sendCount; + this._sendCount = 0; + }, + discard: () => { + zeroize(received.nextRoot); + zeroize(received.chainKey); + zeroize(sending.nextRoot); + zeroize(sending.chainKey); + } + }; + } + /** Destroy every piece of key material this object holds. */ + destroy() { + zeroize(this._rootKey); + zeroize(this._sendingChainKey); + zeroize(this._receivingChainKey); + for (const entry of this._skipped.values()) zeroize(entry.key); + this._skipped.clear(); + this._rootKey = null; + this._sendingChainKey = null; + this._receivingChainKey = null; + this._selfKeyPair = null; + this._remotePublicKey = null; + this._remotePublicKeyB64 = null; + this._initialised = false; + } +}; + // src/network/EnhancedSecureWebRTCManager.js var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // ============================================ @@ -6707,7 +7293,12 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { CLEANUP_CHECK_INTERVAL: 6e4, // 1 minute (cleanup check) ICE_GATHERING_TIMEOUT: 1e4, - // 10 seconds + // 10 seconds — soft: enough on a healthy network + // Hard ceiling used only when the soft deadline passes with NOTHING to + // export. Blocked STUN/TURN keeps gathering "in progress" indefinitely, so + // giving up at 10 s turned a slow network into a failed handshake. + ICE_GATHERING_HARD_TIMEOUT: 25e3, + // 25 seconds DISCONNECT_CLEANUP_DELAY: 500, // 500ms PEER_DISCONNECT_CLEANUP: 2e3, @@ -6829,6 +7420,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // Regular messages MESSAGE: "message", ENHANCED_MESSAGE: "enhanced_message", + // Chat content under the Double Ratchet: a per-message key that is + // destroyed after use. Carries its own plaintext header (ratchet public + // key, chain position) which AES-GCM authenticates as AAD. + RATCHET_MESSAGE: "ratchet_message", // Per-message control (unsend / disappearing sync) MESSAGE_DELETE: "message_delete", // Delivery receipt: recipient acks a chat message by id (WhatsApp ✓✓). @@ -6864,8 +7459,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // the SCTP association that carries this data channel survive it, so the // session keys, the SAS verification and the message history all stay // valid. The renegotiation SDP therefore rides the existing E2E channel — - // still no signalling server, and an attacker cannot inject a restart - // without already holding the session keys. + // still no signalling server. Note that holding the session keys is NOT + // itself proof of identity: a MITM who completed the handshake holds them + // too. These frames are accepted only after SAS verification (see + // POST_VERIFICATION_CONTROL_TYPES). ICE_RESTART_OFFER: "ice_restart_offer", ICE_RESTART_ANSWER: "ice_restart_answer", // Sent by the answerer side, which must not create offers itself (glare): @@ -6879,7 +7476,27 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { FILE_MESSAGE: "FILE_MESSAGE_FILTERED", SYSTEM_MESSAGE: "SYSTEM_MESSAGE_FILTERED" }; + // Control frames that may only be acted on once the session is SAS-verified. + // Deliberately an allowlist: an unknown type is not a control frame and is + // rejected by the chat channel's default-deny branch. The verification + // handshake itself (verification*, heartbeat) is excluded — it has to work + // before verification exists, which is what makes it worth reviewing closely. + static POST_VERIFICATION_CONTROL_TYPES = /* @__PURE__ */ new Set([ + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_DELETE, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_RECEIPT, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_OFFER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ANSWER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ICE, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_DECLINE, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_END, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_ANSWER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST + ]); static PROTOCOL_VERSION = "4.1"; + // Double Ratchet wire version. Bump only on an incompatible ratchet change; + // peers compare it and fall back to static keys when it is absent or unknown. + static RATCHET_VERSION = 1; static MAX_SAS_ATTEMPTS = 3; static DEFAULT_ICE_SERVERS = Object.freeze([ // Keep multiple independent public STUN defaults so one provider-side @@ -7018,6 +7635,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.remoteVerificationConfirmed = false; this.bothVerificationsConfirmed = false; this.expectedDTLSFingerprint = null; + this._peerDTLSFingerprint = null; this.strictDTLSValidation = true; this.ephemeralKeyPairs = /* @__PURE__ */ new Map(); this.sessionStartTime = Date.now(); @@ -7033,6 +7651,8 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.sessionId = null; this.connectionId = Array.from(crypto.getRandomValues(new Uint8Array(8))).map((b) => b.toString(16).padStart(2, "0")).join(""); this.peerPublicKey = null; + this._ratchet = null; + this._peerSupportsRatchet = false; this.rateLimiterId = null; this.intentionalDisconnect = false; this._sessionAlive = true; @@ -9236,36 +9856,57 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { /** * No string wiping - strings are immutable in JS */ + /** + * NOT a wipe, and deliberately named in the log as what it is. + * + * JavaScript strings are immutable: there is no way to overwrite the + * characters of an existing string, so any secret that was ever held as a + * string stays in the heap until the GC collects it — and we cannot force + * that. The caller can only drop its reference. + * + * This used to report success at debug level, which made the emergency wipe + * path (see _emergencyWipeOnFingerprintMismatch) look like it had scrubbed + * key material when it had scrubbed nothing. The real fix is upstream: keep + * secrets in ArrayBuffers, which CAN be overwritten — see + * EnhancedSecureCryptoUtils.zeroizeBuffer. + */ _secureWipeString(str, context) { - this._secureLog("debug", "\u{1F512} String reference removed (strings are immutable)", { + this._secureLog("debug", "String secret cannot be wiped in JS (immutable) \u2014 reference dropped only", { context, length: str ? str.length : 0 }); + return false; } /** * CryptoKey cleanup - store in WeakMap for proper GC */ + /** + * Also not a wipe. A non-extractable CryptoKey has no bytes visible to JS — + * the material lives in the browser's crypto implementation, and dropping the + * handle is the only lever we have. Whether the browser then zeroes its copy + * is up to the browser. + * + * The previous implementation was worse than a no-op: it ADDED the key to a + * WeakMap (i.e. took a new reference to the thing it was asked to destroy) + * and logged success. Callers that believed it — notably the emergency wipe + * on a fingerprint mismatch — were reporting a scrub that never happened. + * + * Non-extractability is what actually protects these keys; it is verified by + * EnhancedSecureCryptoUtils.verifyNonExtractableKeys. + */ _secureWipeCryptoKey(key, context) { - if (!key || !(key instanceof CryptoKey)) return; - try { - if (!this._cryptoKeyStorage) { - this._cryptoKeyStorage = /* @__PURE__ */ new WeakMap(); - } - this._cryptoKeyStorage.set(key, { - context, - timestamp: Date.now(), - type: key.type - }); - this._secureLog("debug", "\u{1F512} CryptoKey stored in WeakMap for cleanup", { - context, - type: key.type - }); - } catch (error) { - this._secureLog("error", "\u274C Failed to store CryptoKey for cleanup", { - context, - errorType: error.constructor.name + if (!key || !(key instanceof CryptoKey)) return false; + this._secureLog("debug", "CryptoKey cannot be wiped from JS \u2014 handle dropped, material is non-extractable", { + context, + type: key.type, + extractable: key.extractable + }); + if (key.extractable) { + this._secureLog("error", "Extractable key reached the wipe path \u2014 material may persist in memory", { + context }); } + return false; } /** * Secure wipe for objects @@ -9344,6 +9985,14 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.connectionId = null; } this._clearPendingOfferContext(); + if (this._ratchet) { + try { + this._ratchet.destroy(); + } catch (_) { + } + this._ratchet = null; + } + this._peerSupportsRatchet = false; this._secureLog("info", "\u{1F512} Cryptographic materials securely cleaned up"); } catch (error) { this._secureLog("error", "\u274C Failed to cleanup cryptographic materials", { @@ -10009,14 +10658,22 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { messageIntegrity: !!this.hmacKey, // Advanced security features - using the exact property names expected by EnhancedSecureCryptoUtils replayProtection: this.replayProtectionEnabled, - dtlsFingerprint: !!this.expectedDTLSFingerprint, - sasCode: !!this.verificationCode, + // Both fingerprints must be known for the SAS to bind this session + // to this pair of endpoints; having only our own proves nothing. + dtlsFingerprint: !!(this.expectedDTLSFingerprint && this._peerDTLSFingerprint), + // The SAS matters once the USER has compared it, not once we have + // computed it — an unconfirmed code is not authentication. + sasCode: !!this.verificationCode && this.localVerificationConfirmed === true, metadataProtection: true, // Always enabled trafficObfuscation: true, // Always enabled - perfectForwardSecrecy: true, - // Always enabled + // True only while the Double Ratchet is actually running. A peer on + // an older build negotiates it away, and the panel must show that + // rather than the capability we shipped. + // Optional-called on purpose: a status report must never throw and + // take down the panel it exists to populate. Unknown reads as off. + perfectForwardSecrecy: this.isRatchetActive?.() === true, // Rate limiting rateLimiter: true, // Always enabled @@ -10107,14 +10764,6 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } const normalizedReceived = receivedFingerprint.toLowerCase().replace(/:/g, ""); const normalizedExpected = expectedFingerprint.toLowerCase().replace(/:/g, ""); - if (this.sessionMode === "ratchet" && normalizedExpected === normalizedReceived) { - this._secureLog("info", "Same fingerprint detected \u2014 skip MITM warning (ratchet mode)", { - context, - timestamp: Date.now() - }); - this.isVerified = true; - return true; - } if (normalizedReceived !== normalizedExpected) { this._secureLog("error", "DTLS fingerprint mismatch - possible MITM attack", { context, @@ -10150,7 +10799,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { if (!keyMaterialRaw) missing.push("keyMaterialRaw"); throw new Error(`Missing required parameters for SAS computation: ${missing.join(", ")}`); } - const enc = new TextEncoder(); + const enc2 = new TextEncoder(); const normalizeFingerprintForSAS = (fingerprint, label) => { if (typeof fingerprint !== "string" || fingerprint.trim().length === 0) { throw new Error( @@ -10161,7 +10810,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { }; const normalizedLocalFP = normalizeFingerprintForSAS(localFP, "localFP"); const normalizedRemoteFP = normalizeFingerprintForSAS(remoteFP, "remoteFP"); - const salt = enc.encode( + const salt = enc2.encode( "webrtc-sas|" + [normalizedLocalFP, normalizedRemoteFP].sort().join("|") ); let keyBuffer; @@ -10186,7 +10835,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { false, ["deriveBits"] ); - const info = enc.encode("p2p-sas-v1"); + const info = enc2.encode("p2p-sas-v1"); const bits = await crypto.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt, info }, key, @@ -10255,41 +10904,23 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.keyFingerprint = null; this.connectionId = null; this.expectedDTLSFingerprint = null; + this._peerDTLSFingerprint = null; this.disconnect(); this.deliverMessageToUI("\u{1F6A8} SECURITY BREACH: Connection terminated due to fingerprint mismatch. Possible MITM attack detected!", "system"); } catch (error) { this._secureLog("error", "Failed to perform emergency wipe", { error: error.message }); } } + // REMOVED: setExpectedDTLSFingerprint(). It overwrote `expectedDTLSFingerprint` + // with a caller-supplied value, and that field is our OWN local fingerprint — + // the localFP that _computeSAS mixes into the safety code. Writing a peer's + // fingerprint into it would silently produce a SAS that no longer matches the + // session, i.e. break the very check it claimed to strengthen. It had no + // callers and was not part of any documented API. Out-of-band pinning, if it + // is ever wanted, belongs in its own field alongside _peerDTLSFingerprint. /** - * Set expected DTLS fingerprint via out-of-band channel - * This should be called after receiving the fingerprint through a secure channel - * (e.g., QR code, voice call, in-person exchange, etc.) - */ - setExpectedDTLSFingerprint(fingerprint, source = "out_of_band") { - try { - if (!fingerprint || typeof fingerprint !== "string") { - throw new Error("Invalid fingerprint provided"); - } - const normalizedFingerprint = fingerprint.toLowerCase().replace(/:/g, ""); - if (!/^[a-f0-9]{40,64}$/.test(normalizedFingerprint)) { - throw new Error("Invalid fingerprint format - must be hex string"); - } - this.expectedDTLSFingerprint = normalizedFingerprint; - this._secureLog("info", "Expected DTLS fingerprint set via out-of-band channel", { - source, - fingerprint: normalizedFingerprint, - timestamp: Date.now() - }); - this.deliverMessageToUI(`\u2705 DTLS fingerprint set via ${source}. MITM protection enabled.`, "system"); - } catch (error) { - this._secureLog("error", "Failed to set expected DTLS fingerprint", { error: error.message }); - throw error; - } - } - /** - * Get current DTLS fingerprint for out-of-band verification - * This should be shared through a secure channel (QR code, voice, etc.) + * Our own DTLS fingerprint, for the user to share out of band if they want to + * compare it manually. This is the local endpoint's value, not the peer's. */ getCurrentDTLSFingerprint() { try { @@ -10327,6 +10958,63 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { * Generate ephemeral ECDH keys for Perfect Forward Secrecy * This ensures each session has unique, non-persistent keys */ + /** + * Bring up the Double Ratchet once the handshake has produced a shared + * secret. Both peers must have advertised support (`dr` in the offer and the + * answer): a session where only one side ratchets cannot decrypt anything, so + * a missing flag means the peer is on an older build and both sides stay on + * the static-key path. + * + * Failure here is deliberately not fatal. Forward secrecy is a large + * improvement, but a session that falls back to the previous scheme is the + * behaviour of every release up to 5.6.x — refusing to connect at all would + * be a worse outcome than connecting with the security users already had. + * The status is surfaced so the difference is visible rather than silent. + */ + async _initializeRatchet(derivedKeys, isInitiator) { + const ratchetRoot = derivedKeys?.ratchetRoot; + if (!ratchetRoot) return false; + if (!this._peerSupportsRatchet) { + this._secureLog("warn", "Peer did not advertise Double Ratchet \u2014 falling back to static session keys", { + localVersion: _EnhancedSecureWebRTCManager.RATCHET_VERSION + }); + window.EnhancedSecureCryptoUtils.zeroizeBuffer(ratchetRoot); + return false; + } + try { + const peerPublicKey = this.peerPublicKey || this.peerECDHPublicKey; + if (!peerPublicKey || !this.ecdhKeyPair?.privateKey) { + throw new Error("handshake ECDH keys unavailable"); + } + const ratchet = new DoubleRatchet(); + await ratchet.init({ + sharedSecret: ratchetRoot, + sessionSalt: new Uint8Array(this.sessionSalt || []), + selfPrivateKey: this.ecdhKeyPair.privateKey, + remotePublicKey: peerPublicKey, + isInitiator + }); + this._ratchet = ratchet; + this.securityFeatures.hasPFS = true; + this._secureLog("info", "\u{1F510} Double Ratchet active \u2014 per-message forward secrecy enabled", { + role: isInitiator ? "initiator" : "responder" + }); + return true; + } catch (error) { + this._ratchet = null; + this.securityFeatures.hasPFS = false; + this._secureLog("error", "Double Ratchet initialisation failed \u2014 continuing with static session keys", { + errorType: error?.constructor?.name || "Unknown" + }); + return false; + } finally { + window.EnhancedSecureCryptoUtils.zeroizeBuffer(ratchetRoot); + } + } + /** True when messages are protected by the ratchet rather than static keys. */ + isRatchetActive() { + return !!this._ratchet?.isInitialised; + } async _generateEphemeralECDHKeys() { try { this._secureLog("info", "\u{1F511} Generating ephemeral ECDH keys for PFS", { @@ -12384,59 +13072,65 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { return; } } - if (parsed.type === "message") { - this._secureLog("debug", "\u{1F4DD} Regular user message detected in processMessage"); - if (!this._checkInboundRateLimit("processMessage:message")) { + if (parsed.type === _EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE) { + this._secureLog("error", "Rejected unencrypted frame in processMessage", { + messageType: "message" + }); + return; + } + if (parsed.type && _EnhancedSecureWebRTCManager.POST_VERIFICATION_CONTROL_TYPES.has(parsed.type)) { + if (!this._enforceVerificationGate("control_frame_receive", false)) { + this._secureLog("error", "Dropped control frame received before verification", { + messageType: parsed.type + }); return; } - if (this.onMessage && parsed.data) { - this.deliverMessageToUI(parsed.data, "received", parsed.meta); - } - return; - } - if (parsed.type === _EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_DELETE) { - const messageId = parsed?.data?.messageId ?? parsed?.messageId; - if (typeof messageId === "string" && messageId) { - try { - this.onMessageDelete?.(messageId.slice(0, 64)); - } catch (_) { + const T = _EnhancedSecureWebRTCManager.MESSAGE_TYPES; + if (parsed.type === T.MESSAGE_DELETE) { + const messageId = parsed?.data?.messageId ?? parsed?.messageId; + if (typeof messageId === "string" && messageId) { + try { + this.onMessageDelete?.(messageId.slice(0, 64)); + } catch (_) { + } } + return; } - return; - } - if (parsed.type === _EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_RECEIPT) { - const messageId = parsed?.data?.messageId ?? parsed?.messageId; - if (typeof messageId === "string" && messageId) { - try { - this.onMessageDelivered?.(messageId.slice(0, 64)); - } catch (_) { + if (parsed.type === T.MESSAGE_RECEIPT) { + const messageId = parsed?.data?.messageId ?? parsed?.messageId; + if (typeof messageId === "string" && messageId) { + try { + this.onMessageDelivered?.(messageId.slice(0, 64)); + } catch (_) { + } } + return; } - return; - } - if (parsed.type && [ - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_OFFER, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ANSWER, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ICE, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_DECLINE, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_END - ].includes(parsed.type)) { - try { - await this._handleCallSignal(parsed.type, parsed.data || {}); - } catch (e) { - this._secureLog("error", "\u274C Call signal handling failed", { errorType: e?.constructor?.name }); + if ([ + T.CALL_OFFER, + T.CALL_ANSWER, + T.CALL_ICE, + T.CALL_DECLINE, + T.CALL_END + ].includes(parsed.type)) { + try { + await this._handleCallSignal(parsed.type, parsed.data || {}); + } catch (e) { + this._secureLog("error", "\u274C Call signal handling failed", { errorType: e?.constructor?.name }); + } + return; } - return; - } - if (parsed.type && [ - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_ANSWER, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST - ].includes(parsed.type)) { - try { - await this._handleIceRestartSignal(parsed.type, parsed.data || {}); - } catch (e) { - this._secureLog("error", "\u274C ICE restart signal handling failed", { errorType: e?.constructor?.name }); + if ([ + T.ICE_RESTART_OFFER, + T.ICE_RESTART_ANSWER, + T.ICE_RESTART_REQUEST + ].includes(parsed.type)) { + try { + await this._handleIceRestartSignal(parsed.type, parsed.data || {}); + } catch (e) { + this._secureLog("error", "\u274C ICE restart signal handling failed", { errorType: e?.constructor?.name }); + } + return; } return; } @@ -12449,12 +13143,9 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { return; } } catch (jsonError) { - if (!this._checkInboundRateLimit("processMessage:text")) { - return; - } - if (this.onMessage) { - this.deliverMessageToUI(data, "received"); - } + this._secureLog("error", "Rejected malformed (non-JSON) frame in processMessage", { + dataLength: typeof data === "string" ? data.length : 0 + }); return; } } @@ -12533,9 +13224,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } catch (e) { } } - if (this.onMessage && messageText) { - this._secureLog("debug", "\u{1F4E4} Calling message handler with", { message: messageText.substring(0, 100) }); - this.deliverMessageToUI(messageText, "received"); + if (messageText) { + this._secureLog("error", "Rejected unauthenticated payload at the end of processMessage", { + messageLength: typeof messageText === "string" ? messageText.length : 0 + }); } } catch (error) { this._secureLog("error", "\u274C Failed to process message:", { errorType: error?.constructor?.name || "Unknown" }); @@ -12866,6 +13558,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this._sasRemoteFingerprint = null; this.keyFingerprint = null; this.expectedDTLSFingerprint = null; + this._peerDTLSFingerprint = null; this.connectionId = null; this.processedMessageIds.clear(); this.verificationNotificationSent = false; @@ -13291,48 +13984,58 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this._secureLog("error", "No file transfer system available for:", { errorType: parsed.type?.constructor?.name || "Unknown" }); return; } - if (parsed.type === _EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_DELETE) { - const messageId = parsed?.data?.messageId ?? parsed?.messageId; - if (typeof messageId === "string" && messageId) { + if (parsed.type && _EnhancedSecureWebRTCManager.POST_VERIFICATION_CONTROL_TYPES.has(parsed.type)) { + if (!this._enforceVerificationGate("control_frame_receive", false)) { + this._secureLog("error", "Dropped control frame received before verification", { + messageType: parsed.type + }); + return; + } + const T = _EnhancedSecureWebRTCManager.MESSAGE_TYPES; + if (parsed.type === T.MESSAGE_DELETE) { + const messageId = parsed?.data?.messageId ?? parsed?.messageId; + if (typeof messageId === "string" && messageId) { + try { + this.onMessageDelete?.(messageId.slice(0, 64)); + } catch (_) { + } + } + return; + } + if (parsed.type === T.MESSAGE_RECEIPT) { + const messageId = parsed?.data?.messageId ?? parsed?.messageId; + if (typeof messageId === "string" && messageId) { + try { + this.onMessageDelivered?.(messageId.slice(0, 64)); + } catch (_) { + } + } + return; + } + if ([ + T.CALL_OFFER, + T.CALL_ANSWER, + T.CALL_ICE, + T.CALL_DECLINE, + T.CALL_END + ].includes(parsed.type)) { try { - this.onMessageDelete?.(messageId.slice(0, 64)); + await this._handleCallSignal(parsed.type, parsed.data || {}); } catch (_) { } + return; } - return; - } - if (parsed.type === _EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_RECEIPT) { - const messageId = parsed?.data?.messageId ?? parsed?.messageId; - if (typeof messageId === "string" && messageId) { + if ([ + T.ICE_RESTART_OFFER, + T.ICE_RESTART_ANSWER, + T.ICE_RESTART_REQUEST + ].includes(parsed.type)) { try { - this.onMessageDelivered?.(messageId.slice(0, 64)); - } catch (_) { + await this._handleIceRestartSignal(parsed.type, parsed.data || {}); + } catch (e) { + this._secureLog("error", "\u274C ICE restart signal handling failed", { errorType: e?.constructor?.name }); } - } - return; - } - if (parsed.type && [ - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_OFFER, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ANSWER, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ICE, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_DECLINE, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_END - ].includes(parsed.type)) { - try { - await this._handleCallSignal(parsed.type, parsed.data || {}); - } catch (_) { - } - return; - } - if (parsed.type && [ - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_ANSWER, - _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST - ].includes(parsed.type)) { - try { - await this._handleIceRestartSignal(parsed.type, parsed.data || {}); - } catch (e) { - this._secureLog("error", "\u274C ICE restart signal handling failed", { errorType: e?.constructor?.name }); + return; } return; } @@ -13340,6 +14043,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.handleSystemMessage(parsed); return; } + if (parsed.type === _EnhancedSecureWebRTCManager.MESSAGE_TYPES.RATCHET_MESSAGE) { + await this._processRatchetMessage(parsed); + return; + } if (parsed.type === "enhanced_message" && parsed.data) { await this._processEnhancedMessageWithoutMutex(parsed); return; @@ -13408,6 +14115,45 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this._secureLog("error", "Error processing binary data:", { errorType: error?.constructor?.name || "Unknown" }); } } + /** + * Inbound chat under the Double Ratchet. + * + * No sequence-number check is needed or wanted here: replay protection is a + * property of the ratchet itself, since a message key is destroyed on use and + * a number behind the current chain has no key left to open it. Layering the + * old sliding window on top would reject legitimate out-of-order frames that + * the ratchet can still read. + */ + async _processRatchetMessage(parsedMessage) { + try { + if (!this._checkInboundRateLimit("ratchet_message")) { + return; + } + if (!this.isRatchetActive()) { + this._secureLog("error", "Received a ratchet message but no ratchet is active"); + return; + } + if (typeof parsedMessage?.h !== "string" || typeof parsedMessage?.c !== "string") { + this._secureLog("error", "Malformed ratchet message frame"); + return; + } + const plaintext = await this._ratchet.decrypt(parsedMessage.h, parsedMessage.c); + try { + const content = JSON.parse(plaintext); + if (content.type === "fake" || content.isFakeTraffic === true) return; + if (content && content.type === "message" && typeof content.data === "string") { + this.deliverMessageToUI(content.data, "received", content.meta); + return; + } + } catch (_) { + } + this.deliverMessageToUI(plaintext, "received"); + } catch (error) { + this._secureLog("error", "Failed to decrypt ratchet message", { + errorType: error?.constructor?.name || "Unknown" + }); + } + } // FIX 3: New method for processing enhanced messages WITHOUT mutex async _processEnhancedMessageWithoutMutex(parsedMessage) { try { @@ -14841,7 +15587,11 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { const offerCandidateSummary = this._summarizeIceCandidatesInSDP(this.peerConnection.localDescription?.sdp); const offerCandidateCount = offerCandidateSummary.total; if (!offerIceGatheringCompleted && offerCandidateCount === 0) { - throw new Error("ICE gathering did not produce candidates before invitation export"); + this.deliverMessageToUI( + "No network candidates could be gathered, so the invitation would not be usable. This usually means a VPN or firewall is blocking STUN/TURN. Try turning the VPN off, switching network, or adding your own TURN server in Advanced network settings.", + "system" + ); + throw new Error("ICE gathering produced no candidates \u2014 check VPN/firewall or configure a TURN server"); } this._secureLog(offerCandidateCount > 0 ? "info" : "warn", "ICE candidates captured for offer export", { candidateSummary: offerCandidateSummary, @@ -14919,6 +15669,11 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // Security metadata (simplified) slv: "MAX", // securityLevel + // Double Ratchet support. Advertised rather than assumed so a + // peer still on 5.6.x keeps working on the static-key path + // instead of failing to decrypt anything: with no server there + // is no way to roll both ends at once. Absent = not supported. + dr: _EnhancedSecureWebRTCManager.RATCHET_VERSION, // Key fingerprints (shortened) kf: { e: ecdhFingerprint.substring(0, 12), @@ -15099,6 +15854,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { throw new Error(`Version mismatch: expected protocol ${_EnhancedSecureWebRTCManager.PROTOCOL_VERSION}, received ${protocolVersion}`); } this.sessionSalt = offerData.sl || offerData.salt; + this._peerSupportsRatchet = offerData.dr === _EnhancedSecureWebRTCManager.RATCHET_VERSION; if (!Array.isArray(this.sessionSalt)) { throw new Error("Invalid session salt format - must be array"); } @@ -15207,6 +15963,11 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { derivedKeys.metadataKey, derivedKeys.fingerprint ); + await this._initializeRatchet( + derivedKeys, + /* isInitiator */ + false + ); if (!(this.encryptionKey instanceof CryptoKey) || !(this.macKey instanceof CryptoKey) || !(this.metadataKey instanceof CryptoKey)) { this._secureLog("error", "Invalid key types after derivation", { operationId, @@ -15271,18 +16032,9 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.createPeerConnection(); if (this.strictDTLSValidation) { try { - const receivedFingerprint = this._extractDTLSFingerprintFromSDP(offerData.sdp); - if (this.expectedDTLSFingerprint) { - await this._validateDTLSFingerprint(receivedFingerprint, this.expectedDTLSFingerprint, "offer_validation"); - } else { - this.expectedDTLSFingerprint = receivedFingerprint; - this._secureLog("info", "Stored DTLS fingerprint for future validation", { - fingerprint: receivedFingerprint, - context: "first_connection" - }); - } + this._peerDTLSFingerprint = this._extractDTLSFingerprintFromSDP(offerData.sdp); } catch (error) { - this._secureLog("warn", "DTLS fingerprint validation failed - continuing in fallback mode", { + this._secureLog("warn", "Could not extract peer DTLS fingerprint from offer", { error: error.message, context: "offer_validation" }); @@ -15361,7 +16113,11 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { const answerCandidateSummary = this._summarizeIceCandidatesInSDP(this.peerConnection.localDescription?.sdp); const answerCandidateCount = answerCandidateSummary.total; if (!answerIceGatheringCompleted && answerCandidateCount === 0) { - throw new Error("ICE gathering did not produce candidates before response export"); + this.deliverMessageToUI( + "No network candidates could be gathered, so the response would not be usable. This usually means a VPN or firewall is blocking STUN/TURN. Try turning the VPN off, switching network, or adding your own TURN server in Advanced network settings.", + "system" + ); + throw new Error("ICE gathering produced no candidates \u2014 check VPN/firewall or configure a TURN server"); } this._secureLog(answerCandidateCount > 0 ? "info" : "warn", "ICE candidates captured for answer export", { candidateSummary: answerCandidateSummary, @@ -15450,6 +16206,8 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // Security metadata (simplified) slv: "MAX", // securityLevel + // Double Ratchet support (see the note on the offer package). + dr: _EnhancedSecureWebRTCManager.RATCHET_VERSION, // Session confirmation (simplified) sc: { sf: saltFingerprint.substring(0, 12), @@ -15781,6 +16539,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { throw new Error("Peer ECDH public key is not a CryptoKey"); } this.peerPublicKey = peerPublicKey; + this._peerSupportsRatchet = answerData.dr === _EnhancedSecureWebRTCManager.RATCHET_VERSION; if (!this.connectionId) { this.connectionId = Array.from(crypto.getRandomValues(new Uint8Array(8))).map((b) => b.toString(16).padStart(2, "0")).join(""); } @@ -15793,6 +16552,11 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.macKey = derivedKeys.macKey; this.metadataKey = derivedKeys.metadataKey; this.keyFingerprint = derivedKeys.fingerprint; + await this._initializeRatchet( + derivedKeys, + /* isInitiator */ + true + ); this.sequenceNumber = 0; this.expectedSequenceNumber = 0; this.messageCounter = 0; @@ -15854,18 +16618,9 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } if (this.strictDTLSValidation) { try { - const receivedFingerprint = this._extractDTLSFingerprintFromSDP(answerData.sdp || answerData.s); - if (this.expectedDTLSFingerprint) { - await this._validateDTLSFingerprint(receivedFingerprint, this.expectedDTLSFingerprint, "answer_validation"); - } else { - this.expectedDTLSFingerprint = receivedFingerprint; - this._secureLog("info", "Stored DTLS fingerprint for future validation", { - fingerprint: receivedFingerprint, - context: "first_connection" - }); - } + this._peerDTLSFingerprint = this._extractDTLSFingerprintFromSDP(answerData.sdp || answerData.s); } catch (error) { - this._secureLog("warn", "DTLS fingerprint validation failed - continuing in fallback mode", { + this._secureLog("warn", "Could not extract peer DTLS fingerprint from answer", { error: error.message, context: "answer_validation" }); @@ -16351,21 +17106,32 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { throw new Error("_createMessageAAD method is not available in sendSecureMessage. Manager may not be fully initialized."); } const aad = message.aad || this._createMessageAAD("enhanced_message", { content: sanitizedMessage }); - const encryptedData = await window.EnhancedSecureCryptoUtils.encryptMessage( - sanitizedMessage, - this.encryptionKey, - this.macKey, - this.metadataKey, - messageId, - JSON.parse(aad).sequenceNumber - // Use sequence number from AAD - ); - const payload = { - type: "enhanced_message", - data: encryptedData, - keyVersion: this.currentKeyVersion, - version: "4.0" - }; + let payload; + if (this._ratchet?.canEncrypt) { + const { header, ciphertext } = await this._ratchet.encrypt(sanitizedMessage); + payload = { + type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.RATCHET_MESSAGE, + h: header, + c: ciphertext, + version: "5.0" + }; + } else { + const encryptedData = await window.EnhancedSecureCryptoUtils.encryptMessage( + sanitizedMessage, + this.encryptionKey, + this.macKey, + this.metadataKey, + messageId, + JSON.parse(aad).sequenceNumber + // Use sequence number from AAD + ); + payload = { + type: "enhanced_message", + data: encryptedData, + keyVersion: this.currentKeyVersion, + version: "4.0" + }; + } this.dataChannel.send(JSON.stringify(payload)); if (typeof validation.sanitizedData === "string") { this.deliverMessageToUI(validation.sanitizedData, "sent"); @@ -16807,7 +17573,13 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } const offer = await pc.createOffer({ iceRestart: true }); await pc.setLocalDescription(offer); - await this.waitForIceGathering(_EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING); + await this.waitForIceGathering( + _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING, + // Recovery keeps a hard 4 s budget: a restart round-trip has to fit + // inside ICE_RESTART_TIMEOUT, so the extra patience the handshake + // gets would push the whole cycle past its own deadline. + _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING + ); await this.sendSystemMessage({ type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER, sdp: pc.localDescription.sdp, @@ -16864,7 +17636,13 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { await pc.setRemoteDescription({ type: "offer", sdp: data.sdp }); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); - await this.waitForIceGathering(_EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING); + await this.waitForIceGathering( + _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING, + // Recovery keeps a hard 4 s budget: a restart round-trip has to + // fit inside ICE_RESTART_TIMEOUT, so the extra patience the + // handshake gets would push the cycle past its own deadline. + _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING + ); await this.sendSystemMessage({ type: T.ICE_RESTART_ANSWER, sdp: pc.localDescription.sdp, @@ -16946,25 +17724,77 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { * one than the initial handshake: a restart round-trip must finish well * inside the retry backoff, or the next attempt cancels the one in flight. */ - waitForIceGathering(timeoutMs = _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT) { + /** + * Wait for ICE gathering, but do not confuse "finished" with "usable". + * + * Gathering only reaches 'complete' once EVERY configured STUN/TURN server has + * answered or timed out. On a network that blocks them — a VPN, a captive + * portal, an interface the browser cannot route from — that never happens + * inside the budget, even though host candidates are available immediately and + * are enough to connect on a LAN. The old code waited a flat 10 s and then + * hard-failed the whole handshake if the SDP happened to be empty at that + * instant, which made success a race: the same device would fail one attempt + * and connect on the next with gathering still in progress. + * + * So: return as soon as gathering completes, and otherwise keep waiting past + * the soft deadline only while there is still nothing to export. `hardMs` + * bounds that extra patience so a truly dead network still fails, just later + * and for a real reason. + */ + waitForIceGathering(timeoutMs = _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT, hardMs = _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_HARD_TIMEOUT) { return new Promise((resolve) => { - if (this.peerConnection.iceGatheringState === "complete") { + const pc = this.peerConnection; + if (!pc) { + resolve(false); + return; + } + if (pc.iceGatheringState === "complete") { resolve(true); return; } - const checkState = () => { - if (this.peerConnection && this.peerConnection.iceGatheringState === "complete") { - this.peerConnection.removeEventListener("icegatheringstatechange", checkState); - resolve(true); + let settled = false; + let softTimer = null; + let hardTimer = null; + const hasCandidates = () => { + try { + const sdp = this.peerConnection?.localDescription?.sdp; + if (!sdp) return false; + return this._summarizeIceCandidatesInSDP(sdp).total > 0; + } catch (_) { + return false; } }; - this.peerConnection.addEventListener("icegatheringstatechange", checkState); - setTimeout(() => { - if (this.peerConnection) { - this.peerConnection.removeEventListener("icegatheringstatechange", checkState); + const finish = (completed) => { + if (settled) return; + settled = true; + if (softTimer) { + clearTimeout(softTimer); + this._untrackActiveTimer?.(softTimer); + } + if (hardTimer) { + clearTimeout(hardTimer); + this._untrackActiveTimer?.(hardTimer); + } + try { + pc.removeEventListener("icegatheringstatechange", onStateChange); + } catch (_) { + } + resolve(completed); + }; + const onStateChange = () => { + if (this.peerConnection?.iceGatheringState === "complete") { + finish(true); + } + }; + pc.addEventListener("icegatheringstatechange", onStateChange); + softTimer = setTimeout(() => { + if (hasCandidates()) { + finish(false); } - resolve(this.peerConnection?.iceGatheringState === "complete"); }, timeoutMs); + this._trackActiveTimer?.(softTimer); + hardTimer = setTimeout(() => finish(false), Math.max(hardMs, timeoutMs)); + this._trackActiveTimer?.(hardTimer); }); } retryConnection() { @@ -18148,15 +18978,34 @@ var SecureKeyStorage = class { } }, 100); } + /** + * SecureKeyStorage calls this._secureLog() in a dozen places but never + * defined it, so every one of those calls threw a TypeError instead of + * logging — including the integrity-violation and key-storage-failure paths, + * i.e. exactly the reports worth having. Delegate to the shared sanitising + * logger, which redacts key-shaped values before anything reaches the console. + */ + _secureLog(level, message, context = {}) { + try { + const logger = typeof window !== "undefined" && window.EnhancedSecureCryptoUtils?.secureLog || null; + if (logger && typeof logger.log === "function") { + logger.log(level, `[KeyStorage] ${message}`, context); + return; + } + } catch (_) { + } + if (level === "error") console.error(`[KeyStorage] ${message}`); + else if (level === "warn") console.warn(`[KeyStorage] ${message}`); + } /** * Setup callbacks for master key manager */ _setupMasterKeyCallbacks() { this._masterKeyManager.setPasswordRequiredCallback((isRetry, callback) => { - const password = prompt( - isRetry ? "Incorrect password. Please enter your master password:" : "Please enter your master password to unlock secure storage:" - ); - callback(password); + this._secureLog("error", "Master key password requested but no password UI is installed", { + isRetry: !!isRetry + }); + callback(null); }); this._masterKeyManager.setSessionExpiredCallback((reason) => { console.warn(`Master key session expired: ${reason}`); @@ -21612,7 +22461,24 @@ window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils; window.EnhancedSecureWebRTCManager = EnhancedSecureWebRTCManager; window.EnhancedSecureFileTransfer = EnhancedSecureFileTransfer; window.NotificationIntegration = import_NotificationIntegration.NotificationIntegration; +var purgeLegacyOfferRecords = () => { + try { + const stale = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith("qr_offer_")) stale.push(key); + } + for (const key of stale) { + try { + localStorage.removeItem(key); + } catch (_) { + } + } + } catch (_) { + } +}; var start = () => { + purgeLegacyOfferRecords(); if (typeof window.initializeApp === "function") { window.initializeApp(); } else if (window.DEBUG_MODE) { diff --git a/dist/app-boot.js.map b/dist/app-boot.js.map index 75d67ba..256f752 100644 --- a/dist/app-boot.js.map +++ b/dist/app-boot.js.map @@ -1,7 +1,7 @@ { "version": 3, - "sources": ["../src/notifications/SecureNotificationManager.js", "../src/notifications/NotificationIntegration.js", "../node_modules/dompurify/src/utils.ts", "../node_modules/dompurify/src/tags.ts", "../node_modules/dompurify/src/attrs.ts", "../node_modules/dompurify/src/regexp.ts", "../node_modules/dompurify/src/purify.ts", "../src/crypto/EnhancedSecureCryptoUtils.js", "../src/transfer/EnhancedSecureFileTransfer.js", "../src/network/webrtc/config.js", "../src/network/webrtc/sdp.js", "../src/network/webrtc/audio.js", "../src/network/webrtc/video.js", "../src/network/webrtc/adaptation/metrics.js", "../src/network/webrtc/adaptation/controller.js", "../src/network/EnhancedSecureWebRTCManager.js", "../src/scripts/app-boot.js", "../src/components/ui/Header.jsx", "../src/components/ui/DownloadApps.jsx", "../src/components/ui/BecomePartner.jsx", "../src/components/ui/UniqueFeatureSlider.jsx", "../src/components/ui/Roadmap.jsx", "../src/components/ui/CommunityCTA.jsx", "../src/components/ui/FileTransfer.jsx", "../src/network/iceServers.js", "../src/components/ui/IceServerSettings.jsx", "../src/components/ui/CallUI.jsx"], - "sourcesContent": ["/**\n * Secure and Reliable Notification Manager for P2P WebRTC Chat\n * Follows best practices: OWASP, MDN, Chrome DevRel\n * \n * @version 1.0.0\n * @author SecureBit Team\n * @license MIT\n */\n\nclass SecureChatNotificationManager {\n constructor(config = {}) {\n // Safely read Notification permission (iOS Safari may not define Notification)\n this.permission = (typeof Notification !== 'undefined' && Notification && typeof Notification.permission === 'string')\n ? Notification.permission\n : 'denied';\n this.isTabActive = this.checkTabActive(); // Initialize with proper check\n this.unreadCount = 0;\n this.originalTitle = document.title;\n this.notificationQueue = [];\n this.maxQueueSize = config.maxQueueSize || 5;\n this.rateLimitMs = config.rateLimitMs || 2000; // Spam protection\n this.lastNotificationTime = 0;\n this.trustedOrigins = config.trustedOrigins || [];\n \n // Secure context flag\n this.isSecureContext = window.isSecureContext;\n \n // Cross-browser compatibility for Page Visibility API\n this.hidden = this.getHiddenProperty();\n this.visibilityChange = this.getVisibilityChangeEvent();\n \n this.initVisibilityTracking();\n this.initSecurityChecks();\n }\n\n /**\n * Initialize security checks and validation\n * @private\n */\n initSecurityChecks() {\n // Security checks are performed silently\n }\n\n /**\n * Get hidden property name for cross-browser compatibility\n * @returns {string} Hidden property name\n * @private\n */\n getHiddenProperty() {\n if (typeof document.hidden !== \"undefined\") {\n return \"hidden\";\n } else if (typeof document.msHidden !== \"undefined\") {\n return \"msHidden\";\n } else if (typeof document.webkitHidden !== \"undefined\") {\n return \"webkitHidden\";\n }\n return \"hidden\"; // fallback\n }\n\n /**\n * Get visibility change event name for cross-browser compatibility\n * @returns {string} Visibility change event name\n * @private\n */\n getVisibilityChangeEvent() {\n if (typeof document.hidden !== \"undefined\") {\n return \"visibilitychange\";\n } else if (typeof document.msHidden !== \"undefined\") {\n return \"msvisibilitychange\";\n } else if (typeof document.webkitHidden !== \"undefined\") {\n return \"webkitvisibilitychange\";\n }\n return \"visibilitychange\"; // fallback\n }\n\n /**\n * Check if tab is currently active using multiple methods\n * @returns {boolean} True if tab is active\n * @private\n */\n checkTabActive() {\n // Primary method: Page Visibility API\n if (this.hidden && typeof document[this.hidden] !== \"undefined\") {\n return !document[this.hidden];\n }\n \n // Fallback method: document.hasFocus()\n if (typeof document.hasFocus === \"function\") {\n return document.hasFocus();\n }\n \n // Ultimate fallback: assume active\n return true;\n }\n\n /**\n * Initialize page visibility tracking (Page Visibility API)\n * @private\n */\n initVisibilityTracking() {\n // Primary method: Page Visibility API with cross-browser support\n if (typeof document.addEventListener !== \"undefined\" && typeof document[this.hidden] !== \"undefined\") {\n document.addEventListener(this.visibilityChange, () => {\n this.isTabActive = this.checkTabActive();\n \n if (this.isTabActive) {\n this.resetUnreadCount();\n this.clearNotificationQueue();\n }\n });\n }\n\n // Fallback method: Window focus/blur events\n window.addEventListener('focus', () => {\n this.isTabActive = this.checkTabActive();\n if (this.isTabActive) {\n this.resetUnreadCount();\n }\n });\n\n window.addEventListener('blur', () => {\n this.isTabActive = this.checkTabActive();\n });\n\n // Page unload cleanup\n window.addEventListener('beforeunload', () => {\n this.clearNotificationQueue();\n });\n }\n\n /**\n * Request notification permission (BEST PRACTICE: Only call in response to user action)\n * Never call on page load!\n * @returns {Promise} Permission granted status\n */\n async requestPermission() {\n // Secure context check\n if (!this.isSecureContext || !('Notification' in window)) {\n return false;\n }\n\n if (this.permission === 'granted') {\n return true;\n }\n\n if (this.permission === 'denied') {\n return false;\n }\n\n try {\n this.permission = await Notification.requestPermission();\n return this.permission === 'granted';\n } catch (error) {\n return false;\n }\n }\n\n /**\n * Update page title with unread count\n * @private\n */\n updateTitle() {\n if (this.unreadCount > 0) {\n document.title = `(${this.unreadCount}) ${this.originalTitle}`;\n } else {\n document.title = this.originalTitle;\n }\n }\n\n /**\n * XSS Protection: Sanitize input text\n * @param {string} text - Text to sanitize\n * @returns {string} Sanitized text\n * @private\n */\n sanitizeText(text) {\n if (typeof text !== 'string') {\n return '';\n }\n \n // Remove HTML tags and potentially dangerous characters\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n .substring(0, 500); // Length limit\n }\n\n /**\n * Validate icon URL (XSS protection)\n * @param {string} url - URL to validate\n * @returns {string|null} Validated URL or null\n * @private\n */\n validateIconUrl(url) {\n if (!url) return null;\n \n try {\n const parsedUrl = new URL(url, window.location.origin);\n \n // Only allow HTTPS and data URLs\n if (parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'data:') {\n // Check trusted origins if specified\n if (this.trustedOrigins.length > 0) {\n const isTrusted = this.trustedOrigins.some(origin => \n parsedUrl.origin === origin\n );\n return isTrusted ? parsedUrl.href : null;\n }\n return parsedUrl.href;\n }\n \n return null;\n } catch (error) {\n return null;\n }\n }\n\n /**\n * Rate limiting for spam protection\n * @returns {boolean} Rate limit check passed\n * @private\n */\n checkRateLimit() {\n const now = Date.now();\n if (now - this.lastNotificationTime < this.rateLimitMs) {\n return false;\n }\n this.lastNotificationTime = now;\n return true;\n }\n\n /**\n * Send secure notification\n * @param {string} senderName - Name of message sender\n * @param {string} message - Message content\n * @param {Object} options - Notification options\n * @returns {Notification|null} Created notification or null\n */\n notify(senderName, message, options = {}) {\n // Abort if Notifications API is not available (e.g., iOS Safari)\n if (typeof Notification === 'undefined') {\n return null;\n }\n // Update tab active state before checking\n this.isTabActive = this.checkTabActive();\n \n // Only show if tab is NOT active (user is on another tab or minimized)\n if (this.isTabActive) {\n return null;\n }\n\n // Permission check\n if (this.permission !== 'granted') {\n return null;\n }\n\n // Rate limiting\n if (!this.checkRateLimit()) {\n return null;\n }\n\n // Data sanitization (XSS Protection)\n const safeSenderName = this.sanitizeText(senderName || 'Unknown');\n const safeMessage = this.sanitizeText(message || '');\n const safeIcon = this.validateIconUrl(options.icon) || '/logo/icon-192x192.png';\n\n // Queue overflow protection\n if (this.notificationQueue.length >= this.maxQueueSize) {\n this.clearNotificationQueue();\n }\n\n try {\n \n const notification = new Notification(\n `${safeSenderName}`,\n {\n body: safeMessage.substring(0, 200), // Length limit\n icon: safeIcon,\n badge: safeIcon,\n tag: `chat-${options.senderId || 'unknown'}`, // Grouping\n requireInteraction: false, // Don't block user\n silent: options.silent || false,\n // Vibrate only for mobile and if supported\n vibrate: navigator.vibrate ? [200, 100, 200] : undefined,\n // Safe metadata\n data: {\n senderId: this.sanitizeText(options.senderId),\n timestamp: Date.now(),\n // Don't include sensitive data!\n }\n }\n );\n\n // Increment counter\n this.unreadCount++;\n this.updateTitle();\n\n // Add to queue for management\n this.notificationQueue.push(notification);\n\n // Safe click handler\n notification.onclick = (event) => {\n event.preventDefault(); // Prevent default behavior\n window.focus();\n notification.close();\n \n // Safe callback\n if (typeof options.onClick === 'function') {\n try {\n options.onClick(options.senderId);\n } catch (error) {\n console.error('[Notifications] Error in onClick handler:', error);\n }\n }\n };\n\n // Error handler\n notification.onerror = (event) => {\n console.error('[Notifications] Error showing notification:', event);\n };\n\n // Auto-close after reasonable time\n const autoCloseTimeout = Math.min(options.autoClose || 5000, 10000);\n setTimeout(() => {\n notification.close();\n this.removeFromQueue(notification);\n }, autoCloseTimeout);\n\n return notification;\n \n } catch (error) {\n console.error('[Notifications] Failed to create notification:', error);\n return null;\n }\n }\n\n /**\n * Remove notification from queue\n * @param {Notification} notification - Notification to remove\n * @private\n */\n removeFromQueue(notification) {\n const index = this.notificationQueue.indexOf(notification);\n if (index > -1) {\n this.notificationQueue.splice(index, 1);\n }\n }\n\n /**\n * Clear all notifications\n */\n clearNotificationQueue() {\n this.notificationQueue.forEach(notification => {\n try {\n notification.close();\n } catch (error) {\n // Ignore errors when closing\n }\n });\n this.notificationQueue = [];\n }\n\n /**\n * Reset unread counter\n */\n resetUnreadCount() {\n this.unreadCount = 0;\n this.updateTitle();\n }\n\n /**\n * Get current status\n * @returns {Object} Current notification status\n */\n getStatus() {\n return {\n permission: this.permission,\n isTabActive: this.isTabActive,\n unreadCount: this.unreadCount,\n isSecureContext: this.isSecureContext,\n queueSize: this.notificationQueue.length\n };\n }\n}\n\n/**\n * Secure integration with WebRTC\n */\nclass SecureP2PChat {\n constructor() {\n this.notificationManager = new SecureChatNotificationManager({\n maxQueueSize: 5,\n rateLimitMs: 2000,\n trustedOrigins: [\n window.location.origin,\n // Add other trusted origins for CDN icons\n ]\n });\n \n this.dataChannel = null;\n this.peerConnection = null;\n this.remotePeerName = 'Peer';\n this.messageHistory = [];\n this.maxHistorySize = 100;\n }\n\n /**\n * Initialize when user connects\n */\n async init() {\n // Initialize notification manager silently\n }\n\n /**\n * Method for manual permission request (called on click)\n * @returns {Promise} Permission granted status\n */\n async enableNotifications() {\n const granted = await this.notificationManager.requestPermission();\n return granted;\n }\n\n /**\n * Setup DataChannel with security checks\n * @param {RTCDataChannel} dataChannel - WebRTC data channel\n */\n setupDataChannel(dataChannel) {\n if (!dataChannel) {\n console.error('[Chat] Invalid DataChannel');\n return;\n }\n\n this.dataChannel = dataChannel;\n \n // Setup handlers\n this.dataChannel.onmessage = (event) => {\n this.handleIncomingMessage(event.data);\n };\n\n this.dataChannel.onerror = (error) => {\n // Handle error silently\n };\n }\n\n /**\n * XSS Protection: Validate incoming messages\n * @param {string|Object} data - Message data\n * @returns {Object|null} Validated message or null\n * @private\n */\n validateMessage(data) {\n try {\n const message = typeof data === 'string' ? JSON.parse(data) : data;\n \n // Check message structure\n if (!message || typeof message !== 'object') {\n throw new Error('Invalid message structure');\n }\n\n // Check required fields\n if (!message.text || typeof message.text !== 'string') {\n throw new Error('Invalid message text');\n }\n\n // Message length limit (DoS protection)\n if (message.text.length > 10000) {\n throw new Error('Message too long');\n }\n\n return {\n text: message.text,\n senderName: message.senderName || 'Unknown',\n senderId: message.senderId || 'unknown',\n timestamp: message.timestamp || Date.now(),\n senderAvatar: message.senderAvatar || null\n };\n \n } catch (error) {\n console.error('[Chat] Message validation failed:', error);\n return null;\n }\n }\n\n /**\n * Secure handling of incoming messages\n * @param {string|Object} data - Message data\n * @private\n */\n handleIncomingMessage(data) {\n const message = this.validateMessage(data);\n \n if (!message) {\n return;\n }\n\n // Save to history (with limit)\n this.messageHistory.push(message);\n if (this.messageHistory.length > this.maxHistorySize) {\n this.messageHistory.shift();\n }\n\n // Display in UI (with sanitization)\n this.displayMessage(message);\n\n // Send notification only if tab is inactive\n this.notificationManager.notify(\n message.senderName,\n message.text,\n {\n icon: message.senderAvatar,\n senderId: message.senderId,\n onClick: (senderId) => {\n this.scrollToLatestMessage();\n }\n }\n );\n\n // Optional: sound (with check)\n if (!this.notificationManager.isTabActive) {\n this.playNotificationSound();\n }\n }\n\n /**\n * XSS Protection: Safe message display\n * @param {Object} message - Message to display\n * @private\n */\n displayMessage(message) {\n const container = document.getElementById('messages');\n if (!container) {\n return;\n }\n\n const messageEl = document.createElement('div');\n messageEl.className = 'message';\n \n // Use textContent to prevent XSS\n const nameEl = document.createElement('strong');\n nameEl.textContent = message.senderName + ': ';\n \n const textEl = document.createElement('span');\n textEl.textContent = message.text;\n textEl.style.wordWrap = 'break-word';\n textEl.style.overflowWrap = 'break-word';\n textEl.style.whiteSpace = 'normal';\n \n const timeEl = document.createElement('small');\n timeEl.textContent = new Date(message.timestamp).toLocaleTimeString();\n \n messageEl.appendChild(nameEl);\n messageEl.appendChild(textEl);\n messageEl.appendChild(document.createElement('br'));\n messageEl.appendChild(timeEl);\n \n container.appendChild(messageEl);\n this.scrollToLatestMessage();\n }\n\n /**\n * Safe sound playback\n * @private\n */\n playNotificationSound() {\n try {\n // Use only local audio files\n const audio = new Audio('/assets/audio/notification.mp3');\n audio.volume = 0.3; // Moderate volume\n \n // Error handling\n audio.play().catch(error => {\n // Handle audio error silently\n });\n } catch (error) {\n // Handle audio creation error silently\n }\n }\n\n /**\n * Scroll to latest message\n * @private\n */\n scrollToLatestMessage() {\n const container = document.getElementById('messages');\n if (container) {\n container.scrollTop = container.scrollHeight;\n }\n }\n\n /**\n * Get status\n * @returns {Object} Current chat status\n */\n getStatus() {\n return {\n notifications: this.notificationManager.getStatus(),\n messageCount: this.messageHistory.length,\n connected: this.dataChannel?.readyState === 'open'\n };\n }\n}\n\n// Export for use in other modules\nif (typeof module !== 'undefined' && module.exports) {\n module.exports = { SecureChatNotificationManager, SecureP2PChat };\n}\n\n// Global export for browser usage\nif (typeof window !== 'undefined') {\n window.SecureChatNotificationManager = SecureChatNotificationManager;\n window.SecureP2PChat = SecureP2PChat;\n}\n", "/**\n * Notification Integration Module for SecureBit WebRTC Chat\n * Integrates secure notifications with existing WebRTC architecture\n * \n * @version 1.0.0\n * @author SecureBit Team\n * @license MIT\n */\n\nimport { SecureChatNotificationManager } from './SecureNotificationManager.js';\n\nclass NotificationIntegration {\n constructor(webrtcManager) {\n this.webrtcManager = webrtcManager;\n this.notificationManager = new SecureChatNotificationManager({\n maxQueueSize: 10,\n rateLimitMs: 1000, // Reduced from 2000ms to 1000ms\n trustedOrigins: [\n window.location.origin,\n // Add other trusted origins for CDN icons\n ]\n });\n \n this.isInitialized = false;\n this.originalOnMessage = null;\n this.originalOnStatusChange = null;\n this.processedMessages = new Set(); // Track processed messages to avoid duplicates\n }\n\n /**\n * Initialize notification integration\n * @returns {Promise} Initialization success\n */\n async init() {\n try {\n if (this.isInitialized) {\n return true;\n }\n\n // Store original callbacks\n this.originalOnMessage = this.webrtcManager.onMessage;\n this.originalOnStatusChange = this.webrtcManager.onStatusChange;\n\n\n // Wrap the original onMessage callback.\n // IMPORTANT: forward ALL arguments (incl. per-message `meta`) so the app\n // still receives view-once / disappearing / unsend metadata.\n this.webrtcManager.onMessage = (message, type, ...rest) => {\n this.handleIncomingMessage(message, type);\n\n // Call original callback if it exists\n if (this.originalOnMessage) {\n this.originalOnMessage(message, type, ...rest);\n }\n };\n\n // Wrap the original onStatusChange callback\n this.webrtcManager.onStatusChange = (status) => {\n this.handleStatusChange(status);\n \n // Call original callback if it exists\n if (this.originalOnStatusChange) {\n this.originalOnStatusChange(status);\n }\n };\n\n // Also hook into the deliverMessageToUI method if it exists.\n // IMPORTANT: forward ALL arguments (incl. per-message `meta`) to the\n // original, otherwise view-once / disappearing / unsend metadata is lost.\n if (this.webrtcManager.deliverMessageToUI) {\n this.originalDeliverMessageToUI = this.webrtcManager.deliverMessageToUI.bind(this.webrtcManager);\n this.webrtcManager.deliverMessageToUI = (message, type, ...rest) => {\n this.handleIncomingMessage(message, type);\n this.originalDeliverMessageToUI(message, type, ...rest);\n };\n }\n\n this.isInitialized = true;\n return true;\n\n } catch (error) {\n return false;\n }\n }\n\n /**\n * Handle incoming messages and trigger notifications\n * @param {*} message - Message content\n * @param {string} type - Message type\n * @private\n */\n handleIncomingMessage(message, type) {\n try {\n // Create a unique key for this message to avoid duplicates\n const messageKey = `${type}:${typeof message === 'string' ? message : JSON.stringify(message)}`;\n \n // Skip if we've already processed this message\n if (this.processedMessages.has(messageKey)) {\n return;\n }\n \n // Mark message as processed\n this.processedMessages.add(messageKey);\n \n // Clean up old processed messages (keep only last 100)\n if (this.processedMessages.size > 100) {\n const messagesArray = Array.from(this.processedMessages);\n this.processedMessages.clear();\n messagesArray.slice(-50).forEach(msg => this.processedMessages.add(msg));\n }\n \n \n // Only process chat messages, not system messages\n if (type === 'system' || type === 'file-transfer' || type === 'heartbeat') {\n return;\n }\n\n // Extract message information\n const messageInfo = this.extractMessageInfo(message, type);\n if (!messageInfo) {\n return;\n }\n\n // Send notification\n const notificationResult = this.notificationManager.notify(\n messageInfo.senderName,\n messageInfo.text,\n {\n icon: messageInfo.senderAvatar,\n senderId: messageInfo.senderId,\n onClick: (senderId) => {\n this.focusChatWindow();\n }\n }\n );\n\n } catch (error) {\n // Handle error silently\n }\n }\n\n /**\n * Handle status changes\n * @param {string} status - Connection status\n * @private\n */\n handleStatusChange(status) {\n try {\n // Clear notifications when connection is lost\n if (status === 'disconnected' || status === 'failed') {\n this.notificationManager.clearNotificationQueue();\n this.notificationManager.resetUnreadCount();\n }\n } catch (error) {\n // Handle error silently\n }\n }\n\n /**\n * Extract message information for notifications\n * @param {*} message - Message content\n * @param {string} type - Message type\n * @returns {Object|null} Extracted message info or null\n * @private\n */\n extractMessageInfo(message, type) {\n try {\n let messageData = message;\n\n // Handle different message formats\n if (typeof message === 'string') {\n try {\n messageData = JSON.parse(message);\n } catch (e) {\n // Plain text message\n return {\n senderName: 'Peer',\n text: message,\n senderId: 'peer',\n senderAvatar: null\n };\n }\n }\n\n // Handle structured message data\n if (typeof messageData === 'object' && messageData !== null) {\n return {\n senderName: messageData.senderName || messageData.name || 'Peer',\n text: messageData.text || messageData.message || messageData.content || '',\n senderId: messageData.senderId || messageData.id || 'peer',\n senderAvatar: messageData.senderAvatar || messageData.avatar || null\n };\n }\n\n return null;\n } catch (error) {\n return null;\n }\n }\n\n /**\n * Focus chat window when notification is clicked\n * @private\n */\n focusChatWindow() {\n try {\n window.focus();\n \n // Scroll to bottom of messages if container exists\n const messagesContainer = document.getElementById('messages');\n if (messagesContainer) {\n messagesContainer.scrollTop = messagesContainer.scrollHeight;\n }\n } catch (error) {\n // Handle error silently\n }\n }\n\n /**\n * Request notification permission\n * @returns {Promise} Permission granted status\n */\n async requestPermission() {\n try {\n return await this.notificationManager.requestPermission();\n } catch (error) {\n return false;\n }\n }\n\n /**\n * Get notification status\n * @returns {Object} Notification status\n */\n getStatus() {\n return this.notificationManager.getStatus();\n }\n\n /**\n * Clear all notifications\n */\n clearNotifications() {\n this.notificationManager.clearNotificationQueue();\n this.notificationManager.resetUnreadCount();\n }\n\n /**\n * Cleanup integration\n */\n cleanup() {\n try {\n if (this.isInitialized) {\n // Restore original callbacks\n if (this.originalOnMessage) {\n this.webrtcManager.onMessage = this.originalOnMessage;\n }\n if (this.originalOnStatusChange) {\n this.webrtcManager.onStatusChange = this.originalOnStatusChange;\n }\n if (this.originalDeliverMessageToUI) {\n this.webrtcManager.deliverMessageToUI = this.originalDeliverMessageToUI;\n }\n\n // Clear notifications\n this.clearNotifications();\n\n this.isInitialized = false;\n }\n } catch (error) {\n // Handle error silently\n }\n }\n}\n\n// Export for use in other modules\nif (typeof module !== 'undefined' && module.exports) {\n module.exports = { NotificationIntegration };\n}\n\n// Global export for browser usage\nif (typeof window !== 'undefined') {\n window.NotificationIntegration = NotificationIntegration;\n}\n", "const {\n entries,\n setPrototypeOf,\n isFrozen,\n getPrototypeOf,\n getOwnPropertyDescriptor,\n} = Object;\n\nlet { freeze, seal, create } = Object; // eslint-disable-line import/no-mutable-exports\nlet { apply, construct } = typeof Reflect !== 'undefined' && Reflect;\n\nif (!freeze) {\n freeze = function (x: T): T {\n return x;\n };\n}\n\nif (!seal) {\n seal = function (x: T): T {\n return x;\n };\n}\n\nif (!apply) {\n apply = function (\n func: (thisArg: any, ...args: any[]) => T,\n thisArg: any,\n ...args: any[]\n ): T {\n return func.apply(thisArg, args);\n };\n}\n\nif (!construct) {\n construct = function (Func: new (...args: any[]) => T, ...args: any[]): T {\n return new Func(...args);\n };\n}\n\nconst arrayForEach = unapply(Array.prototype.forEach);\nconst arrayIndexOf = unapply(Array.prototype.indexOf);\nconst arrayLastIndexOf = unapply(Array.prototype.lastIndexOf);\nconst arrayPop = unapply(Array.prototype.pop);\nconst arrayPush = unapply(Array.prototype.push);\nconst arraySlice = unapply(Array.prototype.slice);\nconst arraySplice = unapply(Array.prototype.splice);\nconst arrayIsArray = Array.isArray;\n\nconst stringToLowerCase = unapply(String.prototype.toLowerCase);\nconst stringToString = unapply(String.prototype.toString);\nconst stringMatch = unapply(String.prototype.match);\nconst stringReplace = unapply(String.prototype.replace);\nconst stringIndexOf = unapply(String.prototype.indexOf);\nconst stringTrim = unapply(String.prototype.trim);\n\nconst numberToString = unapply(Number.prototype.toString);\nconst booleanToString = unapply(Boolean.prototype.toString);\nconst bigintToString =\n typeof BigInt === 'undefined' ? null : unapply(BigInt.prototype.toString);\nconst symbolToString =\n typeof Symbol === 'undefined' ? null : unapply(Symbol.prototype.toString);\n\nconst objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty);\nconst objectToString = unapply(Object.prototype.toString);\n\nconst regExpTest = unapply(RegExp.prototype.test);\n\nconst typeErrorCreate = unconstruct(TypeError);\n\n/**\n * Creates a new function that calls the given function with a specified thisArg and arguments.\n *\n * @param func - The function to be wrapped and called.\n * @returns A new function that calls the given function with a specified thisArg and arguments.\n */\nfunction unapply(\n func: (thisArg: any, ...args: any[]) => T\n): (thisArg: any, ...args: any[]) => T {\n return (thisArg: any, ...args: any[]): T => {\n if (thisArg instanceof RegExp) {\n thisArg.lastIndex = 0;\n }\n\n return apply(func, thisArg, args);\n };\n}\n\n/**\n * Creates a new function that constructs an instance of the given constructor function with the provided arguments.\n *\n * @param func - The constructor function to be wrapped and called.\n * @returns A new function that constructs an instance of the given constructor function with the provided arguments.\n */\nfunction unconstruct(\n Func: new (...args: any[]) => T\n): (...args: any[]) => T {\n return (...args: any[]): T => construct(Func, args);\n}\n\n/**\n * Add properties to a lookup table\n *\n * @param set - The set to which elements will be added.\n * @param array - The array containing elements to be added to the set.\n * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set.\n * @returns The modified set with added elements.\n */\nfunction addToSet(\n set: Record,\n array: readonly unknown[],\n transformCaseFunc: ReturnType> = stringToLowerCase\n): Record {\n if (setPrototypeOf) {\n // Make 'in' and truthy checks like Boolean(set.constructor)\n // independent of any properties defined on Object.prototype.\n // Prevent prototype setters from intercepting set as a this value.\n setPrototypeOf(set, null);\n }\n\n if (!arrayIsArray(array)) {\n return set;\n }\n\n let l = array.length;\n while (l--) {\n let element = array[l];\n\n if (typeof element === 'string') {\n const lcElement = transformCaseFunc(element);\n\n if (lcElement !== element) {\n // Config presets (e.g. tags.js, attrs.js) are immutable.\n if (!isFrozen(array)) {\n (array as unknown[])[l] = lcElement;\n }\n\n element = lcElement;\n }\n }\n\n set[element as string] = true;\n }\n\n return set;\n}\n\n/**\n * Clean up an array to harden against CSPP\n *\n * @param array - The array to be cleaned.\n * @returns The cleaned version of the array\n */\nfunction cleanArray(array: T[]): Array {\n for (let index = 0; index < array.length; index++) {\n const isPropertyExist = objectHasOwnProperty(array, index);\n\n if (!isPropertyExist) {\n array[index] = null;\n }\n }\n\n return array;\n}\n\n/**\n * Shallow clone an object\n *\n * @param object - The object to be cloned.\n * @returns A new object that copies the original.\n */\nfunction clone>(object: T): T {\n const newObject = create(null);\n\n for (const [property, value] of entries(object)) {\n const isPropertyExist = objectHasOwnProperty(object, property);\n\n if (isPropertyExist) {\n if (arrayIsArray(value)) {\n newObject[property] = cleanArray(value);\n } else if (\n value &&\n typeof value === 'object' &&\n value.constructor === Object\n ) {\n newObject[property] = clone(value);\n } else {\n newObject[property] = value;\n }\n }\n }\n\n return newObject;\n}\n\n/**\n * Convert non-node values into strings without depending on direct property access.\n *\n * @param value - The value to stringify.\n * @returns A string representation of the provided value.\n */\nfunction stringifyValue(value: unknown): string {\n switch (typeof value) {\n case 'string': {\n return value;\n }\n\n case 'number': {\n return numberToString(value);\n }\n\n case 'boolean': {\n return booleanToString(value);\n }\n\n case 'bigint': {\n return bigintToString ? bigintToString(value) : '0';\n }\n\n case 'symbol': {\n return symbolToString ? symbolToString(value) : 'Symbol()';\n }\n\n case 'undefined': {\n return objectToString(value);\n }\n\n case 'function':\n case 'object': {\n if (value === null) {\n return objectToString(value);\n }\n\n const valueAsRecord = value as Record;\n const valueToString = lookupGetter(valueAsRecord, 'toString');\n\n if (typeof valueToString === 'function') {\n const stringified = valueToString(valueAsRecord);\n\n return typeof stringified === 'string'\n ? stringified\n : objectToString(stringified);\n }\n\n return objectToString(value);\n }\n\n default: {\n return objectToString(value);\n }\n }\n}\n\n/**\n * This method automatically checks if the prop is function or getter and behaves accordingly.\n *\n * @param object - The object to look up the getter function in its prototype chain.\n * @param prop - The property name for which to find the getter function.\n * @returns The getter function found in the prototype chain or a fallback function.\n */\nfunction lookupGetter>(\n object: T,\n prop: string\n): ReturnType> | (() => null) {\n while (object !== null) {\n const desc = getOwnPropertyDescriptor(object, prop);\n\n if (desc) {\n if (desc.get) {\n return unapply(desc.get);\n }\n\n if (typeof desc.value === 'function') {\n return unapply(desc.value);\n }\n }\n\n object = getPrototypeOf(object);\n }\n\n function fallbackValue(): null {\n return null;\n }\n\n return fallbackValue;\n}\n\nfunction isRegex(value: unknown): value is RegExp {\n try {\n regExpTest(value as RegExp, '');\n return true;\n } catch {\n return false;\n }\n}\n\nexport {\n // Array\n arrayForEach,\n arrayIndexOf,\n arrayIsArray,\n arrayLastIndexOf,\n arrayPop,\n arrayPush,\n arraySlice,\n arraySplice,\n // Object\n entries,\n freeze,\n getPrototypeOf,\n getOwnPropertyDescriptor,\n isFrozen,\n setPrototypeOf,\n seal,\n clone,\n create,\n objectHasOwnProperty,\n objectToString,\n // RegExp\n regExpTest,\n isRegex,\n // String\n stringIndexOf,\n stringMatch,\n stringReplace,\n stringToLowerCase,\n stringToString,\n stringTrim,\n // Other conversion\n stringifyValue,\n // Errors\n typeErrorCreate,\n // Other\n lookupGetter,\n addToSet,\n // Reflect\n unapply,\n unconstruct,\n};\n", "import { freeze } from './utils.js';\n\nexport const html = freeze([\n 'a',\n 'abbr',\n 'acronym',\n 'address',\n 'area',\n 'article',\n 'aside',\n 'audio',\n 'b',\n 'bdi',\n 'bdo',\n 'big',\n 'blink',\n 'blockquote',\n 'body',\n 'br',\n 'button',\n 'canvas',\n 'caption',\n 'center',\n 'cite',\n 'code',\n 'col',\n 'colgroup',\n 'content',\n 'data',\n 'datalist',\n 'dd',\n 'decorator',\n 'del',\n 'details',\n 'dfn',\n 'dialog',\n 'dir',\n 'div',\n 'dl',\n 'dt',\n 'element',\n 'em',\n 'fieldset',\n 'figcaption',\n 'figure',\n 'font',\n 'footer',\n 'form',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'head',\n 'header',\n 'hgroup',\n 'hr',\n 'html',\n 'i',\n 'img',\n 'input',\n 'ins',\n 'kbd',\n 'label',\n 'legend',\n 'li',\n 'main',\n 'map',\n 'mark',\n 'marquee',\n 'menu',\n 'menuitem',\n 'meter',\n 'nav',\n 'nobr',\n 'ol',\n 'optgroup',\n 'option',\n 'output',\n 'p',\n 'picture',\n 'pre',\n 'progress',\n 'q',\n 'rp',\n 'rt',\n 'ruby',\n 's',\n 'samp',\n 'search',\n 'section',\n 'select',\n 'shadow',\n 'slot',\n 'small',\n 'source',\n 'spacer',\n 'span',\n 'strike',\n 'strong',\n 'style',\n 'sub',\n 'summary',\n 'sup',\n 'table',\n 'tbody',\n 'td',\n 'template',\n 'textarea',\n 'tfoot',\n 'th',\n 'thead',\n 'time',\n 'tr',\n 'track',\n 'tt',\n 'u',\n 'ul',\n 'var',\n 'video',\n 'wbr',\n] as const);\n\nexport const svg = freeze([\n 'svg',\n 'a',\n 'altglyph',\n 'altglyphdef',\n 'altglyphitem',\n 'animatecolor',\n 'animatemotion',\n 'animatetransform',\n 'circle',\n 'clippath',\n 'defs',\n 'desc',\n 'ellipse',\n 'enterkeyhint',\n 'exportparts',\n 'filter',\n 'font',\n 'g',\n 'glyph',\n 'glyphref',\n 'hkern',\n 'image',\n 'inputmode',\n 'line',\n 'lineargradient',\n 'marker',\n 'mask',\n 'metadata',\n 'mpath',\n 'part',\n 'path',\n 'pattern',\n 'polygon',\n 'polyline',\n 'radialgradient',\n 'rect',\n 'stop',\n 'style',\n 'switch',\n 'symbol',\n 'text',\n 'textpath',\n 'title',\n 'tref',\n 'tspan',\n 'view',\n 'vkern',\n] as const);\n\nexport const svgFilters = freeze([\n 'feBlend',\n 'feColorMatrix',\n 'feComponentTransfer',\n 'feComposite',\n 'feConvolveMatrix',\n 'feDiffuseLighting',\n 'feDisplacementMap',\n 'feDistantLight',\n 'feDropShadow',\n 'feFlood',\n 'feFuncA',\n 'feFuncB',\n 'feFuncG',\n 'feFuncR',\n 'feGaussianBlur',\n 'feImage',\n 'feMerge',\n 'feMergeNode',\n 'feMorphology',\n 'feOffset',\n 'fePointLight',\n 'feSpecularLighting',\n 'feSpotLight',\n 'feTile',\n 'feTurbulence',\n] as const);\n\n// List of SVG elements that are disallowed by default.\n// We still need to know them so that we can do namespace\n// checks properly in case one wants to add them to\n// allow-list.\nexport const svgDisallowed = freeze([\n 'animate',\n 'color-profile',\n 'cursor',\n 'discard',\n 'font-face',\n 'font-face-format',\n 'font-face-name',\n 'font-face-src',\n 'font-face-uri',\n 'foreignobject',\n 'hatch',\n 'hatchpath',\n 'mesh',\n 'meshgradient',\n 'meshpatch',\n 'meshrow',\n 'missing-glyph',\n 'script',\n 'set',\n 'solidcolor',\n 'unknown',\n 'use',\n] as const);\n\nexport const mathMl = freeze([\n 'math',\n 'menclose',\n 'merror',\n 'mfenced',\n 'mfrac',\n 'mglyph',\n 'mi',\n 'mlabeledtr',\n 'mmultiscripts',\n 'mn',\n 'mo',\n 'mover',\n 'mpadded',\n 'mphantom',\n 'mroot',\n 'mrow',\n 'ms',\n 'mspace',\n 'msqrt',\n 'mstyle',\n 'msub',\n 'msup',\n 'msubsup',\n 'mtable',\n 'mtd',\n 'mtext',\n 'mtr',\n 'munder',\n 'munderover',\n 'mprescripts',\n] as const);\n\n// Similarly to SVG, we want to know all MathML elements,\n// even those that we disallow by default.\nexport const mathMlDisallowed = freeze([\n 'maction',\n 'maligngroup',\n 'malignmark',\n 'mlongdiv',\n 'mscarries',\n 'mscarry',\n 'msgroup',\n 'mstack',\n 'msline',\n 'msrow',\n 'semantics',\n 'annotation',\n 'annotation-xml',\n 'mprescripts',\n 'none',\n] as const);\n\nexport const text = freeze(['#text'] as const);\n", "import { freeze } from './utils.js';\n\nexport const html = freeze([\n 'accept',\n 'action',\n 'align',\n 'alt',\n 'autocapitalize',\n 'autocomplete',\n 'autopictureinpicture',\n 'autoplay',\n 'background',\n 'bgcolor',\n 'border',\n 'capture',\n 'cellpadding',\n 'cellspacing',\n 'checked',\n 'cite',\n 'class',\n 'clear',\n 'color',\n 'cols',\n 'colspan',\n 'command',\n 'commandfor',\n 'controls',\n 'controlslist',\n 'coords',\n 'crossorigin',\n 'datetime',\n 'decoding',\n 'default',\n 'dir',\n 'disabled',\n 'disablepictureinpicture',\n 'disableremoteplayback',\n 'download',\n 'draggable',\n 'enctype',\n 'enterkeyhint',\n 'exportparts',\n 'face',\n 'for',\n 'headers',\n 'height',\n 'hidden',\n 'high',\n 'href',\n 'hreflang',\n 'id',\n 'inert',\n 'inputmode',\n 'integrity',\n 'ismap',\n 'kind',\n 'label',\n 'lang',\n 'list',\n 'loading',\n 'loop',\n 'low',\n 'max',\n 'maxlength',\n 'media',\n 'method',\n 'min',\n 'minlength',\n 'multiple',\n 'muted',\n 'name',\n 'nonce',\n 'noshade',\n 'novalidate',\n 'nowrap',\n 'open',\n 'optimum',\n 'part',\n 'pattern',\n 'placeholder',\n 'playsinline',\n 'popover',\n 'popovertarget',\n 'popovertargetaction',\n 'poster',\n 'preload',\n 'pubdate',\n 'radiogroup',\n 'readonly',\n 'rel',\n 'required',\n 'rev',\n 'reversed',\n 'role',\n 'rows',\n 'rowspan',\n 'spellcheck',\n 'scope',\n 'selected',\n 'shape',\n 'size',\n 'sizes',\n 'slot',\n 'span',\n 'srclang',\n 'start',\n 'src',\n 'srcset',\n 'step',\n 'style',\n 'summary',\n 'tabindex',\n 'title',\n 'translate',\n 'type',\n 'usemap',\n 'valign',\n 'value',\n 'width',\n 'wrap',\n 'xmlns',\n] as const);\n\nexport const svg = freeze([\n 'accent-height',\n 'accumulate',\n 'additive',\n 'alignment-baseline',\n 'amplitude',\n 'ascent',\n 'attributename',\n 'attributetype',\n 'azimuth',\n 'basefrequency',\n 'baseline-shift',\n 'begin',\n 'bias',\n 'by',\n 'class',\n 'clip',\n 'clippathunits',\n 'clip-path',\n 'clip-rule',\n 'color',\n 'color-interpolation',\n 'color-interpolation-filters',\n 'color-profile',\n 'color-rendering',\n 'cx',\n 'cy',\n 'd',\n 'dx',\n 'dy',\n 'diffuseconstant',\n 'direction',\n 'display',\n 'divisor',\n 'dur',\n 'edgemode',\n 'elevation',\n 'end',\n 'exponent',\n 'fill',\n 'fill-opacity',\n 'fill-rule',\n 'filter',\n 'filterunits',\n 'flood-color',\n 'flood-opacity',\n 'font-family',\n 'font-size',\n 'font-size-adjust',\n 'font-stretch',\n 'font-style',\n 'font-variant',\n 'font-weight',\n 'fx',\n 'fy',\n 'g1',\n 'g2',\n 'glyph-name',\n 'glyphref',\n 'gradientunits',\n 'gradienttransform',\n 'height',\n 'href',\n 'id',\n 'image-rendering',\n 'in',\n 'in2',\n 'intercept',\n 'k',\n 'k1',\n 'k2',\n 'k3',\n 'k4',\n 'kerning',\n 'keypoints',\n 'keysplines',\n 'keytimes',\n 'lang',\n 'lengthadjust',\n 'letter-spacing',\n 'kernelmatrix',\n 'kernelunitlength',\n 'lighting-color',\n 'local',\n 'marker-end',\n 'marker-mid',\n 'marker-start',\n 'markerheight',\n 'markerunits',\n 'markerwidth',\n 'maskcontentunits',\n 'maskunits',\n 'max',\n 'mask',\n 'mask-type',\n 'media',\n 'method',\n 'mode',\n 'min',\n 'name',\n 'numoctaves',\n 'offset',\n 'operator',\n 'opacity',\n 'order',\n 'orient',\n 'orientation',\n 'origin',\n 'overflow',\n 'paint-order',\n 'path',\n 'pathlength',\n 'patterncontentunits',\n 'patterntransform',\n 'patternunits',\n 'points',\n 'preservealpha',\n 'preserveaspectratio',\n 'primitiveunits',\n 'r',\n 'rx',\n 'ry',\n 'radius',\n 'refx',\n 'refy',\n 'repeatcount',\n 'repeatdur',\n 'restart',\n 'result',\n 'rotate',\n 'scale',\n 'seed',\n 'shape-rendering',\n 'slope',\n 'specularconstant',\n 'specularexponent',\n 'spreadmethod',\n 'startoffset',\n 'stddeviation',\n 'stitchtiles',\n 'stop-color',\n 'stop-opacity',\n 'stroke-dasharray',\n 'stroke-dashoffset',\n 'stroke-linecap',\n 'stroke-linejoin',\n 'stroke-miterlimit',\n 'stroke-opacity',\n 'stroke',\n 'stroke-width',\n 'style',\n 'surfacescale',\n 'systemlanguage',\n 'tabindex',\n 'tablevalues',\n 'targetx',\n 'targety',\n 'transform',\n 'transform-origin',\n 'text-anchor',\n 'text-decoration',\n 'text-rendering',\n 'textlength',\n 'type',\n 'u1',\n 'u2',\n 'unicode',\n 'values',\n 'viewbox',\n 'visibility',\n 'version',\n 'vert-adv-y',\n 'vert-origin-x',\n 'vert-origin-y',\n 'width',\n 'word-spacing',\n 'wrap',\n 'writing-mode',\n 'xchannelselector',\n 'ychannelselector',\n 'x',\n 'x1',\n 'x2',\n 'xmlns',\n 'y',\n 'y1',\n 'y2',\n 'z',\n 'zoomandpan',\n] as const);\n\nexport const mathMl = freeze([\n 'accent',\n 'accentunder',\n 'align',\n 'bevelled',\n 'close',\n 'columnalign',\n 'columnlines',\n 'columnspacing',\n 'columnspan',\n 'denomalign',\n 'depth',\n 'dir',\n 'display',\n 'displaystyle',\n 'encoding',\n 'fence',\n 'frame',\n 'height',\n 'href',\n 'id',\n 'largeop',\n 'length',\n 'linethickness',\n 'lquote',\n 'lspace',\n 'mathbackground',\n 'mathcolor',\n 'mathsize',\n 'mathvariant',\n 'maxsize',\n 'minsize',\n 'movablelimits',\n 'notation',\n 'numalign',\n 'open',\n 'rowalign',\n 'rowlines',\n 'rowspacing',\n 'rowspan',\n 'rspace',\n 'rquote',\n 'scriptlevel',\n 'scriptminsize',\n 'scriptsizemultiplier',\n 'selection',\n 'separator',\n 'separators',\n 'stretchy',\n 'subscriptshift',\n 'supscriptshift',\n 'symmetric',\n 'voffset',\n 'width',\n 'xmlns',\n]);\n\nexport const xml = freeze([\n 'xlink:href',\n 'xml:id',\n 'xlink:title',\n 'xml:space',\n 'xmlns:xlink',\n] as const);\n", "import { seal } from './utils.js';\n\nexport const MUSTACHE_EXPR = seal(/{{[\\w\\W]*|^[\\w\\W]*}}/g);\nexport const ERB_EXPR = seal(/<%[\\w\\W]*|^[\\w\\W]*%>/g);\nexport const TMPLIT_EXPR = seal(/\\${[\\w\\W]*/g);\nexport const DATA_ATTR = seal(/^data-[\\-\\w.\\u00B7-\\uFFFF]+$/); // eslint-disable-line no-useless-escape\nexport const ARIA_ATTR = seal(/^aria-[\\-\\w]+$/); // eslint-disable-line no-useless-escape\nexport const IS_ALLOWED_URI = seal(\n /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\\-:]|$))/i // eslint-disable-line no-useless-escape\n);\nexport const IS_SCRIPT_OR_DATA = seal(/^(?:\\w+script|data):/i);\nexport const ATTR_WHITESPACE = seal(\n /[\\u0000-\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u2029\\u205F\\u3000]/g // eslint-disable-line no-control-regex\n);\nexport const DOCTYPE_NAME = seal(/^html$/i);\nexport const CUSTOM_ELEMENT = seal(/^[a-z][.\\w]*(-[.\\w]+)+$/i);\n\n// Markup-significant character probes used by _sanitizeElements.\n// Shared module-level instances are safe despite the sticky /g flags:\n// unapply() resets lastIndex for RegExp receivers before every call.\nexport const ELEMENT_MARKUP_PROBE = seal(/<[/\\w!]/g);\nexport const COMMENT_MARKUP_PROBE = seal(/<[/\\w]/g);\nexport const FALLBACK_TAG_CLOSE = seal(/<\\/no(script|embed|frames)/i);\nexport const SELF_CLOSING_TAG = seal(/\\/>/i);\n", "/* eslint-disable @typescript-eslint/indent */\n\nimport type { Config, UseProfilesConfig } from './config';\nimport type { DOMPurify, HooksMap, HookFunction, WindowLike } from './types';\nimport * as TAGS from './tags.js';\nimport * as ATTRS from './attrs.js';\nimport * as EXPRESSIONS from './regexp.js';\nimport {\n addToSet,\n clone,\n entries,\n freeze,\n seal,\n arrayForEach,\n arrayIsArray,\n arrayLastIndexOf,\n arrayPop,\n arrayPush,\n arraySplice,\n stringMatch,\n stringReplace,\n stringToLowerCase,\n stringToString,\n stringIndexOf,\n stringTrim,\n regExpTest,\n isRegex,\n typeErrorCreate,\n lookupGetter,\n create,\n objectHasOwnProperty,\n stringifyValue,\n} from './utils.js';\n\nexport type { Config } from './config';\n\nexport type {\n DOMPurify,\n RemovedElement,\n RemovedAttribute,\n HookName,\n NodeHook,\n ElementHook,\n DocumentFragmentHook,\n UponSanitizeElementHook,\n UponSanitizeAttributeHook,\n UponSanitizeElementHookEvent,\n UponSanitizeAttributeHookEvent,\n WindowLike,\n} from './types';\n\ndeclare const VERSION: string;\n\n// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType\nconst NODE_TYPE = {\n element: 1,\n attribute: 2,\n text: 3,\n cdataSection: 4,\n entityReference: 5, // Deprecated\n entityNode: 6, // Deprecated\n processingInstruction: 7,\n comment: 8,\n document: 9,\n documentType: 10,\n documentFragment: 11,\n notation: 12, // Deprecated\n};\n\nconst getGlobal = function (): WindowLike {\n return typeof window === 'undefined' ? null : window;\n};\n\n/**\n * Creates a no-op policy for internal use only.\n * Don't export this function outside this module!\n * @param trustedTypes The policy factory.\n * @param purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix).\n * @return The policy created (or null, if Trusted Types\n * are not supported or creating the policy failed).\n */\nconst _createTrustedTypesPolicy = function (\n trustedTypes: TrustedTypePolicyFactory,\n purifyHostElement: HTMLScriptElement\n) {\n if (\n typeof trustedTypes !== 'object' ||\n typeof trustedTypes.createPolicy !== 'function'\n ) {\n return null;\n }\n\n // Allow the callers to control the unique policy name\n // by adding a data-tt-policy-suffix to the script element with the DOMPurify.\n // Policy creation with duplicate names throws in Trusted Types.\n let suffix = null;\n const ATTR_NAME = 'data-tt-policy-suffix';\n if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) {\n suffix = purifyHostElement.getAttribute(ATTR_NAME);\n }\n\n const policyName = 'dompurify' + (suffix ? '#' + suffix : '');\n\n try {\n return trustedTypes.createPolicy(policyName, {\n createHTML(html) {\n return html;\n },\n createScriptURL(scriptUrl) {\n return scriptUrl;\n },\n });\n } catch (_) {\n // Policy creation failed (most likely another DOMPurify script has\n // already run). Skip creating the policy, as this will only cause errors\n // if TT are enforced.\n console.warn(\n 'TrustedTypes policy ' + policyName + ' could not be created.'\n );\n return null;\n }\n};\n\nconst _createHooksMap = function (): HooksMap {\n return {\n afterSanitizeAttributes: [],\n afterSanitizeElements: [],\n afterSanitizeShadowDOM: [],\n beforeSanitizeAttributes: [],\n beforeSanitizeElements: [],\n beforeSanitizeShadowDOM: [],\n uponSanitizeAttribute: [],\n uponSanitizeElement: [],\n uponSanitizeShadowNode: [],\n };\n};\n\n/**\n * Resolve a set-valued configuration option: a fresh set built from\n * cfg[key] when it is an own array property (seeded with a clone of\n * options.base when given, case-normalized via options.transform),\n * the fallback set otherwise.\n *\n * @param cfg the cloned, prototype-free configuration object\n * @param key the configuration property to read\n * @param fallback the set to use when the option is absent or not an array\n * @param options transform and optional base set to merge into\n * @returns the resolved set\n */\nconst _resolveSetOption = function (\n cfg: Config,\n key: keyof Config,\n fallback: Record,\n options: {\n transform: Parameters[2];\n base?: Record;\n }\n): Record {\n return objectHasOwnProperty(cfg, key) && arrayIsArray(cfg[key])\n ? addToSet(\n options.base ? clone(options.base) : {},\n cfg[key] as readonly unknown[],\n options.transform\n )\n : fallback;\n};\n\nfunction createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {\n const DOMPurify: DOMPurify = (root: WindowLike) => createDOMPurify(root);\n\n DOMPurify.version = VERSION;\n\n DOMPurify.removed = [];\n\n if (\n !window ||\n !window.document ||\n window.document.nodeType !== NODE_TYPE.document ||\n !window.Element\n ) {\n // Not running in a browser, provide a factory function\n // so that you can pass your own Window\n DOMPurify.isSupported = false;\n\n return DOMPurify;\n }\n\n let { document } = window;\n\n const originalDocument = document;\n const currentScript: HTMLScriptElement =\n originalDocument.currentScript as HTMLScriptElement;\n const {\n DocumentFragment,\n HTMLTemplateElement,\n Node,\n Element,\n NodeFilter,\n NamedNodeMap = window.NamedNodeMap || (window as any).MozNamedAttrMap,\n HTMLFormElement,\n DOMParser,\n trustedTypes,\n } = window;\n\n const ElementPrototype = Element.prototype;\n\n const cloneNode = lookupGetter(ElementPrototype, 'cloneNode');\n const remove = lookupGetter(ElementPrototype, 'remove');\n const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');\n const getChildNodes = lookupGetter(ElementPrototype, 'childNodes');\n const getParentNode = lookupGetter(ElementPrototype, 'parentNode');\n const getShadowRoot = lookupGetter(ElementPrototype, 'shadowRoot');\n const getAttributes = lookupGetter(ElementPrototype, 'attributes');\n const getNodeType =\n Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;\n const getNodeName =\n Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;\n\n // As per issue #47, the web-components registry is inherited by a\n // new document created via createHTMLDocument. As per the spec\n // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)\n // a new empty registry is used when creating a template contents owner\n // document, so we use that as our parent document to ensure nothing\n // is inherited.\n if (typeof HTMLTemplateElement === 'function') {\n const template = document.createElement('template');\n if (template.content && template.content.ownerDocument) {\n document = template.content.ownerDocument;\n }\n }\n\n let trustedTypesPolicy;\n let emptyHTML = '';\n\n // The instance's own internal Trusted Types policy. Unlike a caller-supplied\n // `TRUSTED_TYPES_POLICY`, this is created at most once \u2014 Trusted Types throws\n // on duplicate policy names \u2014 and is the only policy allowed to persist\n // across configurations and survive `clearConfig()`.\n let defaultTrustedTypesPolicy;\n let defaultTrustedTypesPolicyResolved = false;\n\n // Tracks whether we are already inside a call to the configured Trusted Types\n // policy (`createHTML` or `createScriptURL`). If a supplied policy callback\n // itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would\n // re-enter the policy and recurse until the stack overflows. We detect that\n // re-entry and throw a clear, actionable error instead. The guard is shared\n // across both callbacks, because either one re-entering `sanitize` triggers\n // the same unbounded recursion.\n let IN_TRUSTED_TYPES_POLICY = 0;\n const _assertNotInTrustedTypesPolicy = function (): void {\n if (IN_TRUSTED_TYPES_POLICY > 0) {\n throw typeErrorCreate(\n 'A configured TRUSTED_TYPES_POLICY callback (createHTML or ' +\n 'createScriptURL) must not call DOMPurify.sanitize, as that causes ' +\n 'infinite recursion. Do not pass a policy whose callbacks wrap ' +\n 'DOMPurify as TRUSTED_TYPES_POLICY; see the \"DOMPurify and Trusted ' +\n 'Types\" section of the README.'\n );\n }\n };\n\n const _createTrustedHTML = function (html: string): string {\n _assertNotInTrustedTypesPolicy();\n\n IN_TRUSTED_TYPES_POLICY++;\n try {\n return trustedTypesPolicy.createHTML(html);\n } finally {\n IN_TRUSTED_TYPES_POLICY--;\n }\n };\n\n const _createTrustedScriptURL = function (scriptUrl: string): string {\n _assertNotInTrustedTypesPolicy();\n\n IN_TRUSTED_TYPES_POLICY++;\n try {\n return trustedTypesPolicy.createScriptURL(scriptUrl);\n } finally {\n IN_TRUSTED_TYPES_POLICY--;\n }\n };\n\n // Lazily resolve (and cache) the instance's internal default policy.\n // Resolution is attempted at most once: a successful `createPolicy` cannot be\n // repeated (Trusted Types throws on duplicate names), and a failed or\n // unsupported attempt must not be retried on every parse.\n const _getDefaultTrustedTypesPolicy = function () {\n if (!defaultTrustedTypesPolicyResolved) {\n defaultTrustedTypesPolicy = _createTrustedTypesPolicy(\n trustedTypes,\n currentScript\n );\n defaultTrustedTypesPolicyResolved = true;\n }\n\n return defaultTrustedTypesPolicy;\n };\n\n const {\n implementation,\n createNodeIterator,\n createDocumentFragment,\n getElementsByTagName,\n } = document;\n const { importNode } = originalDocument;\n\n let hooks = _createHooksMap();\n\n /**\n * Expose whether this browser supports running the full DOMPurify.\n */\n DOMPurify.isSupported =\n typeof entries === 'function' &&\n typeof getParentNode === 'function' &&\n implementation &&\n implementation.createHTMLDocument !== undefined;\n\n const {\n MUSTACHE_EXPR,\n ERB_EXPR,\n TMPLIT_EXPR,\n DATA_ATTR,\n ARIA_ATTR,\n IS_SCRIPT_OR_DATA,\n ATTR_WHITESPACE,\n CUSTOM_ELEMENT,\n } = EXPRESSIONS;\n\n let { IS_ALLOWED_URI } = EXPRESSIONS;\n\n /**\n * We consider the elements and attributes below to be safe. Ideally\n * don't add any new ones but feel free to remove unwanted ones.\n */\n\n /* allowed element names */\n let ALLOWED_TAGS = null;\n const DEFAULT_ALLOWED_TAGS = addToSet({}, [\n ...TAGS.html,\n ...TAGS.svg,\n ...TAGS.svgFilters,\n ...TAGS.mathMl,\n ...TAGS.text,\n ]);\n\n /* Allowed attribute names */\n let ALLOWED_ATTR = null;\n const DEFAULT_ALLOWED_ATTR = addToSet({}, [\n ...ATTRS.html,\n ...ATTRS.svg,\n ...ATTRS.mathMl,\n ...ATTRS.xml,\n ]);\n\n /*\n * Configure how DOMPurify should handle custom elements and their attributes as well as customized built-in elements.\n * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements)\n * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list)\n * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`.\n */\n let CUSTOM_ELEMENT_HANDLING = Object.seal(\n create(null, {\n tagNameCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n attributeNameCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n allowCustomizedBuiltInElements: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: false,\n },\n })\n );\n\n /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */\n let FORBID_TAGS = null;\n\n /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */\n let FORBID_ATTR = null;\n\n /* Config object to store ADD_TAGS/ADD_ATTR functions (when used as functions) */\n const EXTRA_ELEMENT_HANDLING = Object.seal(\n create(null, {\n tagCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n attributeCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n })\n );\n\n /* Decide if ARIA attributes are okay */\n let ALLOW_ARIA_ATTR = true;\n\n /* Decide if custom data attributes are okay */\n let ALLOW_DATA_ATTR = true;\n\n /* Decide if unknown protocols are okay */\n let ALLOW_UNKNOWN_PROTOCOLS = false;\n\n /* Decide if self-closing tags in attributes are allowed.\n * Usually removed due to a mXSS issue in jQuery 3.0 */\n let ALLOW_SELF_CLOSE_IN_ATTR = true;\n\n /* Output should be safe for common template engines.\n * This means, DOMPurify removes data attributes, mustaches and ERB\n */\n let SAFE_FOR_TEMPLATES = false;\n\n /* Output should be safe even for XML used within HTML and alike.\n * This means, DOMPurify removes comments when containing risky content.\n */\n let SAFE_FOR_XML = true;\n\n /* Decide if document with ... should be returned */\n let WHOLE_DOCUMENT = false;\n\n /* Track whether config is already set on this instance of DOMPurify. */\n let SET_CONFIG = false;\n\n /* Decide if all elements (e.g. style, script) must be children of\n * document.body. By default, browsers might move them to document.head */\n let FORCE_BODY = false;\n\n /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html\n * string (or a TrustedHTML object if Trusted Types are supported).\n * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead\n */\n let RETURN_DOM = false;\n\n /* Decide if a DOM `DocumentFragment` should be returned, instead of a html\n * string (or a TrustedHTML object if Trusted Types are supported) */\n let RETURN_DOM_FRAGMENT = false;\n\n /* Try to return a Trusted Type object instead of a string, return a string in\n * case Trusted Types are not supported */\n let RETURN_TRUSTED_TYPE = false;\n\n /* Output should be free from DOM clobbering attacks?\n * This sanitizes markups named with colliding, clobberable built-in DOM APIs.\n */\n let SANITIZE_DOM = true;\n\n /* Achieve full DOM Clobbering protection by isolating the namespace of named\n * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules.\n *\n * HTML/DOM spec rules that enable DOM Clobbering:\n * - Named Access on Window (\u00A77.3.3)\n * - DOM Tree Accessors (\u00A73.1.5)\n * - Form Element Parent-Child Relations (\u00A74.10.3)\n * - Iframe srcdoc / Nested WindowProxies (\u00A74.8.5)\n * - HTMLCollection (\u00A74.2.10.2)\n *\n * Namespace isolation is implemented by prefixing `id` and `name` attributes\n * with a constant string, i.e., `user-content-`\n */\n let SANITIZE_NAMED_PROPS = false;\n const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-';\n\n /* Keep element content when removing element? */\n let KEEP_CONTENT = true;\n\n /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead\n * of importing it into a new Document and returning a sanitized copy */\n let IN_PLACE = false;\n\n /* Allow usage of profiles like html, svg and mathMl */\n let USE_PROFILES: UseProfilesConfig | false = {};\n\n /* Tags to ignore content of when KEEP_CONTENT is true */\n let FORBID_CONTENTS = null;\n const DEFAULT_FORBID_CONTENTS = addToSet({}, [\n 'annotation-xml',\n 'audio',\n 'colgroup',\n 'desc',\n 'foreignobject',\n 'head',\n 'iframe',\n 'math',\n 'mi',\n 'mn',\n 'mo',\n 'ms',\n 'mtext',\n 'noembed',\n 'noframes',\n 'noscript',\n 'plaintext',\n 'script',\n // mirrors the selected