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