From 5f22b7dab5f2a40ad80edb8c0d7bedc8305fb66d Mon Sep 17 00:00:00 2001 From: lockbitchat Date: Thu, 6 Aug 2026 18:20:48 -0400 Subject: [PATCH] feat(handshake): shrink the connection exchange; release v5.9.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invitation carried the whole session: both public keys, their signatures, a salt, a challenge and the full SDP. It now carries only what brings up DTLS — ICE credentials, the certificate fingerprint, candidates, an expiry — plus a 16-byte commitment to the key material. The key material itself moves to the DataChannel and is checked against that commitment before it is parsed. Measured on the live site: 2274 characters across 4 animated QR frames became 151 characters in a single frame. The safety code is now computed over a transcript of both descriptors and both key blobs, and the HKDF salt is derived from that same transcript instead of being transmitted. authProof is replaced by one signature over it. SBQ2_SEND_ENABLED is the single value that reverts new invitations to SB1. --- CHANGELOG.md | 50 + README.md | 2 +- dist/app-boot.js | 1316 +++++++++++++++++++- dist/app-boot.js.map | 8 +- dist/qr-local.js | 297 ++++- dist/qr-local.js.map | 8 +- doc/CRYPTOGRAPHY.md | 4 +- index.html | 44 +- meta.json | 14 +- package.json | 4 +- src/network/EnhancedSecureWebRTCManager.js | 607 ++++++++- src/network/descriptor/keyexchange.js | 216 ++++ src/scripts/qr-local.js | 52 + sw.js | 2 +- tests/sbq2-key-exchange.test.mjs | 286 +++++ 15 files changed, 2845 insertions(+), 65 deletions(-) create mode 100644 src/network/descriptor/keyexchange.js create mode 100644 tests/sbq2-key-exchange.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd6379..5aa8873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## v5.9.0 — The invitation is now one small QR code + +The connection descriptor moves to SBQ2 and the key material moves onto the +DataChannel. Measured on the live site: the invitation payload went from **2274 +characters across 4 animated QR frames to 151 characters in a single frame**. + +### Changed + +- **Invitations use the SBQ2 format.** The out-of-band code now carries only what + brings up DTLS — ICE credentials, the certificate fingerprint, candidates, an + expiry — plus a 16-byte commitment to the key material. + +- **Key material is exchanged in band.** The ECDH and ECDSA public keys travel as + the first frame on the DataChannel, and are checked against the commitment from + the invitation *before* they are parsed or imported. A mismatch closes the + connection; it is not a warning. + +- **The SAS is computed over a transcript** covering both descriptors verbatim and + both key blobs, with length prefixes. Anything an attacker can alter anywhere in + the handshake, in either direction, changes the digits the two people compare. + +- **The HKDF salt is derived from that transcript instead of transmitted**, so + every session key is bound to both DTLS fingerprints and every candidate, and + neither side can steer it. + +- **`authProof` is replaced by one signature over the transcript.** The old + challenge/response echoed a nonce back across seven fields; the signature proves + the same possession and binds the whole handshake at once. + +- The Double Ratchet starts from the transcript-derived material. SBQ2 postdates + the ratchet entirely, so support is implied by the format rather than advertised + in it — which also removes the silent "peer is old, use static keys" fallback + from this path. + +### Rollback + +`EnhancedSecureWebRTCManager.SBQ2_SEND_ENABLED = false` and redeploy puts every +new invitation back on SB1. Reception of both formats is unconditional and is not +governed by the flag, so a client built with it off still reads SBQ2 invitations. + +### Compatibility + +SB1 invitations are still read, and the animated multi-frame QR path stays for +them. A client older than 5.9.0 cannot read an SBQ2 invitation: from 5.9.0 on, an +unrecognised `SB:` family reports "This invitation was created by a newer +version of SecureBit. Please update the app to connect." Versions 5.8.1 and +earlier predate that check and show a JSON parse error instead — the two ends must +both be on 5.9.0+. + + ## v5.8.1 — SBQ2 rebuilds SDP that Firefox accepts Still nothing in the application calls the SBQ2 descriptor; this fixes defects in diff --git a/README.md b/README.md index 2157c90..fc16d76 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ 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.8.1-3ecf8e.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-5.9.0-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) diff --git a/dist/app-boot.js b/dist/app-boot.js index 0d58b99..2d4ad0b 100644 --- a/dist/app-boot.js +++ b/dist/app-boot.js @@ -1878,8 +1878,8 @@ function createDOMPurify() { let l = attributes.length; const lcTag = transformCaseFunc(currentNode.nodeName); while (l--) { - const attr = attributes[l]; - const name = attr.name, namespaceURI = attr.namespaceURI, attrValue = attr.value; + const attr2 = attributes[l]; + const name = attr2.name, namespaceURI = attr2.namespaceURI, attrValue = attr2.value; const lcName = transformCaseFunc(name); const initValue = attrValue; let value = name === "value" ? initValue : stringTrim(initValue); @@ -2125,12 +2125,12 @@ function createDOMPurify() { trustedTypesPolicy = defaultTrustedTypesPolicy; emptyHTML = ""; }; - DOMPurify.isValidAttribute = function(tag, attr, value) { + DOMPurify.isValidAttribute = function(tag, attr2, value) { if (!CONFIG) { _parseConfig({}); } const lcTag = transformCaseFunc(tag); - const lcName = transformCaseFunc(attr); + const lcName = transformCaseFunc(attr2); return _isValidAttribute(lcTag, lcName, value); }; DOMPurify.addHook = function(entryPoint, hookFunction) { @@ -6592,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 enc2 of params.encodings) { - enc2.maxBitrate = cfg.maxBitrate; - enc2.priority = cfg.priority; - enc2.networkPriority = cfg.networkPriority; + for (const enc4 of params.encodings) { + enc4.maxBitrate = cfg.maxBitrate; + enc4.priority = cfg.priority; + enc4.networkPriority = cfg.networkPriority; } await sender.setParameters(params); return true; @@ -6657,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 enc2 of params.encodings) enc2.networkPriority = VIDEO_CONFIG.networkPriority; + for (const enc4 of params.encodings) enc4.networkPriority = VIDEO_CONFIG.networkPriority; } else { - const enc2 = params.encodings[0]; - enc2.maxBitrate = plan.maxBitrate; - enc2.networkPriority = VIDEO_CONFIG.networkPriority; - if (plan.scalabilityMode) enc2.scalabilityMode = plan.scalabilityMode; + const enc4 = params.encodings[0]; + enc4.maxBitrate = plan.maxBitrate; + enc4.networkPriority = VIDEO_CONFIG.networkPriority; + if (plan.scalabilityMode) enc4.scalabilityMode = plan.scalabilityMode; } if (plan.degradationPreference) params.degradationPreference = plan.degradationPreference; try { @@ -7270,6 +7270,786 @@ var DoubleRatchet = class { } }; +// src/network/descriptor/sbq2.js +var SBQ2_VERSION = 2; +var LIMITS = Object.freeze({ + MAX_PAYLOAD_BYTES: 512, + // ~3.5x the largest descriptor we have ever measured + MAX_CANDIDATES: 8, + MIN_UFRAG: 4, + // RFC 8839: ice-ufrag is 4..256 chars + MAX_UFRAG: 64, + MIN_PWD: 22, + // RFC 8839: ice-pwd is 22..256 chars, >=128 bits of randomness + MAX_PWD: 64, + FINGERPRINT_BYTES: 32, + // SHA-256 + COMMITMENT_BYTES: 16, + // 128-bit second-preimage resistance + BINDING_BYTES: 8, + MAX_LIFETIME_MINUTES: 60, + MAX_EXT_BYTES: 255, + // Byte budget for candidates admitted BEYOND the coverage set (coverage + // itself is never cut — see pruneCandidates). Derived from the acceptance + // target rather than picked: the largest answer head we have measured is + // Firefox's, at 104 bytes (version+flags+expiry+tag+fingerprint+8-char + // ufrag+32-char pwd+count+commitment), and QR version 8 at level M holds + // 152 bytes in byte mode. 152 - 104 = 48. + SURPLUS_CANDIDATE_BYTES: 48, + // Clock-skew allowance, applied in both directions on the expiry check. + // + // Two minutes is chosen against the failure it exists for: a receiver whose + // clock is off. An NTP-synced device is within milliseconds, and an + // unsynced modern device drifts on the order of seconds per day, so two + // minutes swallows every ordinary case. It does NOT swallow a grossly wrong + // clock (manually set, or reset to the epoch by a dead battery) — that is + // deliberate, because such a device cannot be given a meaningful freshness + // guarantee and should be told so. The cost is that the replay window grows + // from the nominal 10 minutes to 12; keeping the tolerance well under the + // lifetime is what bounds that. + CLOCK_SKEW_MS: 12e4 +}); +var EPOCH_MS = Date.UTC(2024, 0, 1); +var MAX_EXPIRY_UNITS = 16777215; +var TYPE = Object.freeze({ OFFER: 0, ANSWER: 1 }); +var SETUP = Object.freeze(["actpass", "active", "passive"]); +var MMS_ENUM = Object.freeze([262144, 1073741823, 65536, null]); +var MMS_EXPLICIT = 3; +var EXT = Object.freeze({ MAX_MESSAGE_SIZE: 1 }); +var KIND = Object.freeze({ + HOST_V4: 0, + HOST_MDNS: 1, + SRFLX_V4: 2, + RELAY_V4: 3, + HOST_V6: 4, + SRFLX_V6: 5, + RELAY_V6: 6 +}); +var KIND_ADDR_LEN = Object.freeze({ 0: 4, 1: 16, 2: 4, 3: 4, 4: 16, 5: 16, 6: 16 }); +var KIND_TYPE = Object.freeze({ 0: "host", 1: "host", 2: "srflx", 3: "relay", 4: "host", 5: "srflx", 6: "relay" }); +var KIND_FAMILY = Object.freeze({ 0: "v4", 1: "mdns", 2: "v4", 3: "v4", 4: "v6", 5: "v6", 6: "v6" }); +var TCPTYPE = Object.freeze([null, "passive", "active", "so"]); +var TYPE_PREF = Object.freeze({ host: 126, srflx: 100, relay: 0 }); +var ICE_CHAR = /^[A-Za-z0-9+/]+$/; +var DescriptorError = class extends Error { + constructor(message, code = "malformed") { + super(message); + this.name = "DescriptorError"; + this.code = code; + } +}; +var fail = (msg, code) => { + throw new DescriptorError(msg, code); +}; +var UUID_RE = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})\.local$/i; +var IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; +function parseIpv4(s) { + const m = IPV4_RE.exec(s); + if (!m) return null; + const out = new Uint8Array(4); + for (let i = 0; i < 4; i++) { + const v = Number(m[i + 1]); + if (!Number.isInteger(v) || v < 0 || v > 255) return null; + out[i] = v; + } + return out; +} +function parseIpv6(s) { + if (!/^[0-9a-fA-F:.]+$/.test(s) || s.length > 45) return null; + let text2 = s; + let tail4 = null; + const lastColon = text2.lastIndexOf(":"); + if (text2.includes(".")) { + tail4 = parseIpv4(text2.slice(lastColon + 1)); + if (!tail4) return null; + text2 = text2.slice(0, lastColon + 1) + "0:0"; + } + const halves = text2.split("::"); + if (halves.length > 2) return null; + const toWords = (part) => part === "" ? [] : part.split(":").map((h) => h.length === 0 || h.length > 4 ? NaN : parseInt(h, 16)); + const head = toWords(halves[0]); + const tail = halves.length === 2 ? toWords(halves[1]) : []; + if ([...head, ...tail].some((w) => !Number.isInteger(w) || w < 0 || w > 65535)) return null; + let words; + if (halves.length === 2) { + const gap = 8 - head.length - tail.length; + if (gap < 1) return null; + words = [...head, ...new Array(gap).fill(0), ...tail]; + } else { + words = head; + } + if (words.length !== 8) return null; + const out = new Uint8Array(16); + words.forEach((w, i) => { + out[i * 2] = w >> 8; + out[i * 2 + 1] = w & 255; + }); + if (tail4) out.set(tail4, 12); + return out; +} +function parseMdns(s) { + const m = UUID_RE.exec(s); + if (!m) return null; + const hex = (m[1] + m[2] + m[3] + m[4] + m[5]).toLowerCase(); + const out = new Uint8Array(16); + for (let i = 0; i < 16; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); + return out; +} +function sdpLines(sdp) { + if (typeof sdp !== "string") fail("SDP must be a string"); + if (sdp.length > 64 * 1024) fail("SDP is too large"); + return sdp.split(/\r\n|\n/).filter((l) => l.length > 0); +} +function attr(lines, name) { + const prefix = `a=${name}:`; + for (const l of lines) if (l.startsWith(prefix)) return l.slice(prefix.length).trim(); + return null; +} +function parseSdp(sdp) { + const lines = sdpLines(sdp); + const ufrag = attr(lines, "ice-ufrag"); + const pwd = attr(lines, "ice-pwd"); + if (!ufrag || !pwd) fail("SDP is missing ICE credentials"); + const fpLine = attr(lines, "fingerprint"); + if (!fpLine) fail("SDP is missing a DTLS fingerprint"); + const [hashAlg, fpHex] = fpLine.split(/\s+/); + if (!hashAlg || hashAlg.toLowerCase() !== "sha-256") { + fail(`unsupported DTLS fingerprint algorithm: ${String(hashAlg).slice(0, 16)}`); + } + const fpBytes = String(fpHex).split(":"); + if (fpBytes.length !== LIMITS.FINGERPRINT_BYTES) fail("DTLS fingerprint has the wrong length"); + const fingerprint = new Uint8Array(LIMITS.FINGERPRINT_BYTES); + fpBytes.forEach((b, i) => { + if (!/^[0-9a-fA-F]{2}$/.test(b)) fail("DTLS fingerprint is not hex"); + fingerprint[i] = parseInt(b, 16); + }); + const setupStr = attr(lines, "setup") || "actpass"; + const setup = SETUP.indexOf(setupStr); + if (setup < 0) fail(`unsupported DTLS setup role: ${setupStr.slice(0, 16)}`); + const mmsStr = attr(lines, "max-message-size"); + const maxMessageSize = mmsStr === null ? 65536 : Number(mmsStr); + if (!Number.isInteger(maxMessageSize) || maxMessageSize < 0) fail("invalid a=max-message-size"); + const candidates = []; + for (const line of lines) { + if (!line.startsWith("a=candidate:")) continue; + const p = line.slice("a=candidate:".length).split(/\s+/); + if (p.length < 8 || p[6] !== "typ") continue; + if (p[1] !== "1") continue; + const transport = p[2].toLowerCase(); + const priority = Number(p[3]); + const addr = p[4]; + const port = Number(p[5]); + const ctype = p[7]; + if (!Number.isInteger(port) || port < 1 || port > 65535) continue; + let tcptype = 0; + if (transport === "tcp") { + const idx = p.indexOf("tcptype"); + const t = idx >= 0 ? TCPTYPE.indexOf(p[idx + 1]) : -1; + if (t <= 0) continue; + tcptype = t; + } else if (transport !== "udp") { + continue; + } + let kind = null; + let bytes = null; + const mdns = parseMdns(addr); + if (mdns && ctype === "host") { + kind = KIND.HOST_MDNS; + bytes = mdns; + } else { + const v4 = parseIpv4(addr); + const v6 = v4 ? null : parseIpv6(addr); + const raw = v4 || v6; + if (!raw) continue; + if (ctype === "host") kind = v4 ? KIND.HOST_V4 : KIND.HOST_V6; + else if (ctype === "srflx" || ctype === "prflx") kind = v4 ? KIND.SRFLX_V4 : KIND.SRFLX_V6; + else if (ctype === "relay") kind = v4 ? KIND.RELAY_V4 : KIND.RELAY_V6; + else continue; + bytes = raw; + } + candidates.push({ + kind, + tcptype, + addr: bytes, + port, + priority: Number.isFinite(priority) ? priority : 0 + }); + } + return { ufrag, pwd, fingerprint, setup, maxMessageSize, candidates }; +} +function candidateSize(c) { + return 1 + KIND_ADDR_LEN[c.kind] + 2; +} +var isConnectable = (c) => c.tcptype !== 2 && c.tcptype !== 3; +function pruneCandidates(candidates, { + maxCandidates = LIMITS.MAX_CANDIDATES, + maxBytes = LIMITS.SURPLUS_CANDIDATE_BYTES, + keepMdns = true, + maxRelays = 2 +} = {}) { + const pool = candidates.filter((c) => keepMdns || c.kind !== KIND.HOST_MDNS); + const uniq = []; + const seen = /* @__PURE__ */ new Set(); + for (const c of pool) { + const key = `${c.kind}:${c.tcptype}:${Array.from(c.addr).join(".")}:${c.port}`; + if (seen.has(key)) continue; + seen.add(key); + uniq.push(c); + } + const byPriority = (a, b) => (b.priority || 0) - (a.priority || 0); + const groupKey = (c) => `${KIND_FAMILY[c.kind]}/${KIND_TYPE[c.kind]}/${c.tcptype === 0 ? "udp" : "tcp"}`; + const groups = /* @__PURE__ */ new Map(); + for (const c of [...uniq].sort(byPriority)) { + if (!isConnectable(c)) continue; + const g = groupKey(c); + if (!groups.has(g)) groups.set(g, []); + groups.get(g).push(c); + } + const chosen = []; + const taken = /* @__PURE__ */ new Set(); + let bytes = 0; + let relays = 0; + const admit = (c) => { + chosen.push(c); + taken.add(c); + bytes += candidateSize(c); + if (KIND_TYPE[c.kind] === "relay") relays++; + }; + for (const list of groups.values()) admit(list[0]); + for (const c of [...uniq].sort(byPriority)) { + if (taken.has(c)) continue; + if (chosen.length >= maxCandidates) break; + if (bytes + candidateSize(c) > maxBytes) continue; + if (KIND_TYPE[c.kind] === "relay" && relays >= maxRelays) continue; + admit(c); + } + return chosen.sort(byPriority); +} +var Writer = class { + constructor() { + this.b = []; + } + u8(v) { + this.b.push(v & 255); + } + u16(v) { + this.b.push(v >> 8 & 255, v & 255); + } + u24(v) { + this.b.push(v >> 16 & 255, v >> 8 & 255, v & 255); + } + u32(v) { + this.b.push(v >>> 24 & 255, v >>> 16 & 255, v >>> 8 & 255, v & 255); + } + bytes(a) { + for (const x of a) this.b.push(x & 255); + } + ascii(s) { + for (let i = 0; i < s.length; i++) this.b.push(s.charCodeAt(i) & 255); + } + done() { + return Uint8Array.from(this.b); + } +}; +function buildExtensions(maxMessageSize) { + if (!Number.isInteger(maxMessageSize) || maxMessageSize < 1024 || maxMessageSize > 2147483647) { + fail("max-message-size must be an integer between 1024 and 2^31-1"); + } + const records = []; + let mmsIndex = MMS_ENUM.indexOf(maxMessageSize); + if (mmsIndex < 0) { + mmsIndex = MMS_EXPLICIT; + const w = new Writer(); + w.u32(maxMessageSize); + records.push({ type: EXT.MAX_MESSAGE_SIZE, value: w.done() }); + } + return { mmsIndex, records }; +} +function encodeDescriptor(d) { + const { type, bindingTag: tag = null, expiresAtMs, sdpFields, commitment = null } = d; + if (type !== TYPE.OFFER && type !== TYPE.ANSWER) fail("invalid descriptor type"); + if (type === TYPE.ANSWER) { + if (!(tag instanceof Uint8Array) || tag.length !== LIMITS.BINDING_BYTES) fail("answer needs an 8-byte binding tag"); + } else if (tag !== null) { + fail("offers do not carry a binding tag"); + } + if (commitment !== null && (!(commitment instanceof Uint8Array) || commitment.length !== LIMITS.COMMITMENT_BYTES)) { + fail("commitment must be 16 bytes"); + } + const { ufrag, pwd, fingerprint, setup, maxMessageSize, candidates } = sdpFields; + if (ufrag.length < LIMITS.MIN_UFRAG || ufrag.length > LIMITS.MAX_UFRAG || !ICE_CHAR.test(ufrag)) fail("invalid ice-ufrag"); + if (pwd.length < LIMITS.MIN_PWD || pwd.length > LIMITS.MAX_PWD || !ICE_CHAR.test(pwd)) fail("invalid ice-pwd"); + if (candidates.length > LIMITS.MAX_CANDIDATES) fail("too many candidates"); + const minutes = Math.ceil((expiresAtMs - EPOCH_MS) / 6e4); + if (!Number.isInteger(minutes) || minutes < 0 || minutes > MAX_EXPIRY_UNITS) fail("expiry out of range"); + const { mmsIndex, records } = buildExtensions(maxMessageSize); + const ext = new Writer(); + for (const r of records) { + if (r.value.length > 255) fail("extension value is too long"); + ext.u8(r.type); + ext.u8(r.value.length); + ext.bytes(r.value); + } + const extBytes = ext.done(); + if (extBytes.length > LIMITS.MAX_EXT_BYTES) fail("extension area is too long"); + const flags = type & 3 | (setup & 3) << 2 | (mmsIndex & 3) << 4 | (commitment ? 64 : 0) | (extBytes.length ? 128 : 0); + const w = new Writer(); + w.u8(SBQ2_VERSION); + w.u8(flags); + w.u24(minutes); + if (type === TYPE.ANSWER) w.bytes(tag); + w.bytes(fingerprint); + w.u8(ufrag.length); + w.ascii(ufrag); + w.u8(pwd.length); + w.ascii(pwd); + w.u8(candidates.length); + for (const c of candidates) { + w.u8((c.kind & 15) << 4 | c.tcptype & 15); + w.bytes(c.addr); + w.u16(c.port); + } + if (commitment) w.bytes(commitment); + if (extBytes.length) { + w.u8(extBytes.length); + w.bytes(extBytes); + } + const out = w.done(); + if (out.length > LIMITS.MAX_PAYLOAD_BYTES) fail("descriptor exceeds the payload limit"); + return out; +} +var Reader = class { + constructor(buf) { + this.buf = buf; + this.i = 0; + } + need(n) { + if (this.i + n > this.buf.length) fail("descriptor is truncated"); + } + u8() { + this.need(1); + return this.buf[this.i++]; + } + u16() { + this.need(2); + const v = this.buf[this.i] << 8 | this.buf[this.i + 1]; + this.i += 2; + return v; + } + u24() { + this.need(3); + const v = this.buf[this.i] << 16 | this.buf[this.i + 1] << 8 | this.buf[this.i + 2]; + this.i += 3; + return v; + } + u32() { + this.need(4); + const v = (this.buf[this.i] << 24 >>> 0) + (this.buf[this.i + 1] << 16) + (this.buf[this.i + 2] << 8) + this.buf[this.i + 3]; + this.i += 4; + return v >>> 0; + } + bytes(n) { + this.need(n); + return this.buf.slice(this.i, this.i += n); + } + ascii(n) { + this.need(n); + let s = ""; + for (let k = 0; k < n; k++) { + const c = this.buf[this.i + k]; + if (c < 32 || c > 126) fail("non-printable byte in a text field"); + s += String.fromCharCode(c); + } + this.i += n; + return s; + } + get rest() { + return this.buf.length - this.i; + } +}; +function decodeExt(buf) { + const r = new Reader(buf); + const out = /* @__PURE__ */ new Map(); + let lastType = -1; + while (r.rest > 0) { + const type = r.u8(); + const len = r.u8(); + const value = r.bytes(len); + if (type <= lastType) fail("extension records must be in ascending type order without duplicates"); + lastType = type; + switch (type) { + case EXT.MAX_MESSAGE_SIZE: { + if (len !== 4) fail("extension 0x01 must be 4 bytes"); + const v = new Reader(value).u32(); + if (v < 1024 || v > 2147483647) fail("extension 0x01 value is out of range"); + if (MMS_ENUM.includes(v)) fail("extension 0x01 duplicates a value the flags already encode"); + out.set(type, v); + break; + } + default: + fail(`unknown extension type 0x${type.toString(16).padStart(2, "0")}`, "unknown_extension"); + } + } + return out; +} +function decodeDescriptor(buf, { nowMs = Date.now() } = {}) { + if (!(buf instanceof Uint8Array)) fail("descriptor must be a Uint8Array"); + if (buf.length === 0) fail("descriptor is empty"); + if (buf.length > LIMITS.MAX_PAYLOAD_BYTES) fail("descriptor exceeds the payload limit"); + const r = new Reader(buf); + const version2 = r.u8(); + if (version2 !== SBQ2_VERSION) fail(`unsupported descriptor version 0x${version2.toString(16)}`, "version"); + const flags = r.u8(); + const type = flags & 3; + if (type !== TYPE.OFFER && type !== TYPE.ANSWER) fail("reserved descriptor type"); + const setup = flags >> 2 & 3; + if (setup > 2) fail("reserved DTLS setup role"); + const mmsIndex = flags >> 4 & 3; + const hasCommitment = (flags & 64) !== 0; + const hasExt = (flags & 128) !== 0; + const minutes = r.u24(); + const expiresAtMs = EPOCH_MS + minutes * 6e4; + if (nowMs - LIMITS.CLOCK_SKEW_MS > expiresAtMs) { + const lateMin = Math.round((nowMs - expiresAtMs) / 6e4); + fail( + `this code expired ${lateMin} minute(s) ago. If it was just created, this device's clock or time zone is probably wrong \u2014 check the date and time settings.`, + "expired" + ); + } + if (expiresAtMs - nowMs > LIMITS.MAX_LIFETIME_MINUTES * 6e4 + LIMITS.CLOCK_SKEW_MS) { + fail("descriptor lifetime is implausibly long", "lifetime"); + } + const bindingTag2 = type === TYPE.ANSWER ? r.bytes(LIMITS.BINDING_BYTES) : null; + const fingerprint = r.bytes(LIMITS.FINGERPRINT_BYTES); + const ufragLen = r.u8(); + if (ufragLen < LIMITS.MIN_UFRAG || ufragLen > LIMITS.MAX_UFRAG) fail("ice-ufrag length out of range"); + const ufrag = r.ascii(ufragLen); + if (!ICE_CHAR.test(ufrag)) fail("ice-ufrag contains characters outside the ICE alphabet"); + const pwdLen = r.u8(); + if (pwdLen < LIMITS.MIN_PWD || pwdLen > LIMITS.MAX_PWD) fail("ice-pwd length out of range"); + const pwd = r.ascii(pwdLen); + if (!ICE_CHAR.test(pwd)) fail("ice-pwd contains characters outside the ICE alphabet"); + const count = r.u8(); + if (count > LIMITS.MAX_CANDIDATES) fail("too many candidates"); + const candidates = []; + for (let i = 0; i < count; i++) { + const tagByte = r.u8(); + const kind = tagByte >> 4 & 15; + const tcptype = tagByte & 15; + const addrLen = KIND_ADDR_LEN[kind]; + if (addrLen === void 0) fail(`reserved candidate kind ${kind}`); + if (tcptype >= TCPTYPE.length) fail("reserved TCP candidate type"); + const addr = r.bytes(addrLen); + const port = r.u16(); + if (port < 1) fail("candidate port must be non-zero"); + candidates.push({ kind, tcptype, addr, port }); + } + let commitment = null; + if (hasCommitment) commitment = r.bytes(LIMITS.COMMITMENT_BYTES); + let extensions = /* @__PURE__ */ new Map(); + if (hasExt) { + const extLen = r.u8(); + if (extLen === 0) fail("extension area is flagged but empty"); + extensions = decodeExt(r.bytes(extLen)); + } + if (r.rest !== 0) fail(`${r.rest} trailing byte(s) after the descriptor`); + let maxMessageSize; + if (mmsIndex === MMS_EXPLICIT) { + if (!extensions.has(EXT.MAX_MESSAGE_SIZE)) fail("flags promise an explicit max-message-size but no extension carries it"); + maxMessageSize = extensions.get(EXT.MAX_MESSAGE_SIZE); + } else { + if (extensions.has(EXT.MAX_MESSAGE_SIZE)) fail("extension 0x01 present but the flags do not select it"); + maxMessageSize = MMS_ENUM[mmsIndex]; + } + return { + version: version2, + type, + setup, + maxMessageSize, + expiresAtMs, + bindingTag: bindingTag2, + fingerprint, + ufrag, + pwd, + candidates, + commitment, + extensions + }; +} +var hex2 = (b) => b.toString(16).padStart(2, "0"); +function renderAddr(kind, addr) { + switch (kind) { + case KIND.HOST_MDNS: { + const h = Array.from(addr, hex2).join(""); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}.local`; + } + case KIND.HOST_V4: + case KIND.SRFLX_V4: + case KIND.RELAY_V4: + return `${addr[0]}.${addr[1]}.${addr[2]}.${addr[3]}`; + default: { + const words = []; + for (let i = 0; i < 16; i += 2) words.push((addr[i] << 8 | addr[i + 1]).toString(16)); + return words.join(":"); + } + } +} +function serializeSdp(desc, { sessionId = "1" } = {}) { + const isOffer = desc.type === TYPE.OFFER; + const DEFAULT_RANK = { relay: 0, srflx: 1, host: 2 }; + const def = desc.candidates.filter((c) => c.kind !== KIND.HOST_MDNS && c.tcptype === 0).sort((x, y) => DEFAULT_RANK[KIND_TYPE[x.kind]] - DEFAULT_RANK[KIND_TYPE[y.kind]])[0]; + const defIsV6 = def && KIND_FAMILY[def.kind] === "v6"; + const mPort = def ? def.port : 9; + const cLine = def ? `c=IN IP${defIsV6 ? "6" : "4"} ${renderAddr(def.kind, def.addr)}` : "c=IN IP4 0.0.0.0"; + const lines = [ + "v=0", + `o=- ${sessionId} 2 IN IP4 127.0.0.1`, + "s=-", + "t=0 0", + "a=group:BUNDLE 0", + "a=msid-semantic: WMS", + `m=application ${mPort} UDP/DTLS/SCTP webrtc-datachannel`, + cLine, + "a=ice-ufrag:" + desc.ufrag, + "a=ice-pwd:" + desc.pwd, + // Deliberately NOT `a=ice-options:trickle`. A descriptor is a complete, + // one-shot candidate set — there is no signalling channel to trickle + // over, so advertising trickle promises candidates that can never + // arrive and leaves the peer's ICE agent waiting for them. + "a=fingerprint:sha-256 " + Array.from(desc.fingerprint, (b) => hex2(b).toUpperCase()).join(":"), + "a=setup:" + SETUP[desc.setup], + "a=mid:0", + "a=sctp-port:5000", + "a=max-message-size:" + desc.maxMessageSize + ]; + const candLines = desc.candidates.map((c, i) => { + const ctype = KIND_TYPE[c.kind]; + const transport = c.tcptype === 0 ? "udp" : "tcp"; + const localPref = Math.max(0, 65535 - i); + const priority = TYPE_PREF[ctype] * 16777216 + localPref * 256 + 255; + const foundation = String(c.kind * 4 + c.tcptype + 1); + let line = `a=candidate:${foundation} 1 ${transport} ${priority} ${renderAddr(c.kind, c.addr)} ${c.port} typ ${ctype}`; + if (ctype !== "host") { + line += KIND_FAMILY[c.kind] === "v6" ? " raddr :: rport 0" : " raddr 0.0.0.0 rport 0"; + } + if (transport === "tcp") line += ` tcptype ${TCPTYPE[c.tcptype]}`; + return line; + }); + candLines.push("a=end-of-candidates"); + const at = lines.indexOf(cLine) + 1; + lines.splice(at, 0, ...candLines); + return { type: isOffer ? "offer" : "answer", sdp: lines.join("\r\n") + "\r\n" }; +} +var enc2 = new TextEncoder(); +function concat(...parts) { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.length; + } + return out; +} +async function bindingTag(digest, offerBytes) { + const h = await digest(concat(enc2.encode("sbq2/bind\0"), offerBytes)); + return h.slice(0, LIMITS.BINDING_BYTES); +} +async function commitBlob(digest, blobBytes) { + const h = await digest(concat(enc2.encode("sbq2/blob\0"), blobBytes)); + return h.slice(0, LIMITS.COMMITMENT_BYTES); +} +function sasTranscript(offerBytes, answerBytes, offerBlob, answerBlob) { + const lp = (b) => { + const n = new Uint8Array(4); + new DataView(n.buffer).setUint32(0, b.length); + return concat(n, b); + }; + return concat( + enc2.encode("sbq2/sas/v1\0"), + lp(offerBytes), + lp(answerBytes), + lp(offerBlob), + lp(answerBlob) + ); +} +var B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; +function toBase64Url(bytes) { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2]; + out += B64URL[a >> 2]; + out += B64URL[(a & 3) << 4 | (b ?? 0) >> 4]; + if (b === void 0) break; + out += B64URL[(b & 15) << 2 | (c ?? 0) >> 6]; + if (c === void 0) break; + out += B64URL[c & 63]; + } + return out; +} +function fromBase64Url(text2) { + if (typeof text2 !== "string") fail("payload must be a string"); + const s = text2.replace(/\s+/g, ""); + if (s.length > Math.ceil(LIMITS.MAX_PAYLOAD_BYTES * 4 / 3) + 4) fail("payload is too long"); + if (!/^[A-Za-z0-9_-]*$/.test(s)) fail("payload contains characters outside base64url"); + if (s.length % 4 === 1) fail("payload has an impossible length"); + const out = new Uint8Array(Math.floor(s.length * 3 / 4)); + let o = 0, acc = 0, bits = 0; + for (const ch of s) { + acc = acc << 6 | B64URL.indexOf(ch); + bits += 6; + if (bits >= 8) { + bits -= 8; + out[o++] = acc >> bits & 255; + } + } + if (acc & (1 << bits) - 1) fail("payload has non-zero padding bits"); + return out.subarray(0, o); +} +var TEXT_PREFIX = "SB2:"; +function encodeText(bytes) { + return TEXT_PREFIX + toBase64Url(bytes); +} +function decodeText(text2) { + if (typeof text2 !== "string") fail("payload must be a string"); + const t = text2.trim(); + if (!t.startsWith(TEXT_PREFIX)) fail("not an SB2 descriptor"); + return fromBase64Url(t.slice(TEXT_PREFIX.length)); +} + +// src/network/descriptor/keyexchange.js +var KEY_BLOB_VERSION = 2; +var ROLE = Object.freeze({ OFFER: 0, ANSWER: 1 }); +var BLOB_LIMITS = Object.freeze({ + // A P-384 SPKI is 120 bytes; P-521 would be 158. The ceiling is generous + // enough for a future curve and far below anything that could be used to + // wedge the parser. + MAX_SPKI: 256, + MIN_SPKI: 40, + // ECDSA P-384 signatures are 96 bytes raw; P-521 is 132. + MAX_SIG: 160, + MIN_SIG: 48, + MAX_BLOB_BYTES: 1024 +}); +var KeyExchangeError = class extends Error { + constructor(message, code = "key_exchange") { + super(message); + this.name = "KeyExchangeError"; + this.code = code; + } +}; +var fail2 = (msg, code) => { + throw new KeyExchangeError(msg, code); +}; +function encodeKeyBlob({ role, ecdhSpki, ecdsaSpki }) { + if (role !== ROLE.OFFER && role !== ROLE.ANSWER) fail2("invalid role"); + for (const [name, v] of [["ecdh", ecdhSpki], ["ecdsa", ecdsaSpki]]) { + if (!(v instanceof Uint8Array)) fail2(`${name} SPKI must be a Uint8Array`); + if (v.length < BLOB_LIMITS.MIN_SPKI || v.length > BLOB_LIMITS.MAX_SPKI) { + fail2(`${name} SPKI length out of range`); + } + } + const out = new Uint8Array(1 + 1 + 2 + ecdhSpki.length + 2 + ecdsaSpki.length); + const dv = new DataView(out.buffer); + let o = 0; + out[o++] = KEY_BLOB_VERSION; + out[o++] = role; + dv.setUint16(o, ecdhSpki.length); + o += 2; + out.set(ecdhSpki, o); + o += ecdhSpki.length; + dv.setUint16(o, ecdsaSpki.length); + o += 2; + out.set(ecdsaSpki, o); + return out; +} +function decodeKeyBlob(buf) { + if (!(buf instanceof Uint8Array)) fail2("key blob must be a Uint8Array"); + if (buf.length === 0) fail2("key blob is empty"); + if (buf.length > BLOB_LIMITS.MAX_BLOB_BYTES) fail2("key blob exceeds the size limit"); + const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + let o = 0; + const need = (n) => { + if (o + n > buf.length) fail2("key blob is truncated"); + }; + need(1); + const version2 = buf[o++]; + if (version2 !== KEY_BLOB_VERSION) fail2(`unsupported key blob version 0x${version2.toString(16)}`, "version"); + need(1); + const role = buf[o++]; + if (role !== ROLE.OFFER && role !== ROLE.ANSWER) fail2("reserved key blob role"); + need(2); + const ecdhLen = dv.getUint16(o); + o += 2; + if (ecdhLen < BLOB_LIMITS.MIN_SPKI || ecdhLen > BLOB_LIMITS.MAX_SPKI) fail2("ECDH SPKI length out of range"); + need(ecdhLen); + const ecdhSpki = buf.slice(o, o + ecdhLen); + o += ecdhLen; + need(2); + const ecdsaLen = dv.getUint16(o); + o += 2; + if (ecdsaLen < BLOB_LIMITS.MIN_SPKI || ecdsaLen > BLOB_LIMITS.MAX_SPKI) fail2("ECDSA SPKI length out of range"); + need(ecdsaLen); + const ecdsaSpki = buf.slice(o, o + ecdsaLen); + o += ecdsaLen; + if (o !== buf.length) fail2(`${buf.length - o} trailing byte(s) after the key blob`); + return { version: version2, role, ecdhSpki, ecdsaSpki }; +} +function buildTranscript({ offerDescriptor, answerDescriptor, offerBlob, answerBlob }) { + for (const [name, v] of Object.entries({ offerDescriptor, answerDescriptor, offerBlob, answerBlob })) { + if (!(v instanceof Uint8Array) || v.length === 0) fail2(`transcript component ${name} is missing`); + } + return sasTranscript(offerDescriptor, answerDescriptor, offerBlob, answerBlob); +} +async function deriveTranscriptSalt(subtle, transcript) { + const h = await subtle.digest("SHA-512", transcript); + return Array.from(new Uint8Array(h)); +} +var enc3 = new TextEncoder(); +function proofPayload(transcript) { + const label = enc3.encode("sbq2/proof/v1\0"); + const out = new Uint8Array(label.length + transcript.length); + out.set(label, 0); + out.set(transcript, label.length); + return out; +} +async function computeTranscriptSas(subtle, { ecdhPrivateKey, peerEcdhPublicKey, transcript, digits = 7 }) { + const shared = await subtle.deriveBits({ name: "ECDH", public: peerEcdhPublicKey }, ecdhPrivateKey, 256); + let ikm = null; + try { + ikm = await subtle.importKey("raw", shared, "HKDF", false, ["deriveBits"]); + const salt = new Uint8Array(await subtle.digest("SHA-256", transcript)); + const bits = await subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt, info: enc3.encode("sbq2-sas-v1") }, + ikm, + 64 + ); + const dv = new DataView(bits); + const n = (dv.getUint32(0) ^ dv.getUint32(4)) >>> 0; + const mod = 10 ** digits; + return String(n % mod).padStart(digits, "0"); + } finally { + try { + new Uint8Array(shared).fill(0); + } catch (_) { + } + } +} +async function verifyBlobCommitment(subtle, blobBytes, expectedCommitment) { + if (!(expectedCommitment instanceof Uint8Array) || expectedCommitment.length !== LIMITS.COMMITMENT_BYTES) { + fail2("descriptor carried no usable commitment", "commitment_missing"); + } + const digest = async (b) => new Uint8Array(await subtle.digest("SHA-256", b)); + const actual = await commitBlob(digest, blobBytes); + if (actual.length !== expectedCommitment.length) fail2("commitment length mismatch", "commitment_mismatch"); + let diff = 0; + for (let i = 0; i < actual.length; i++) diff |= actual[i] ^ expectedCommitment[i]; + if (diff !== 0) { + fail2("the key material does not match the commitment in the invitation", "commitment_mismatch"); + } + return true; +} + // src/network/EnhancedSecureWebRTCManager.js var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // ============================================ @@ -7468,6 +8248,12 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // Sent by the answerer side, which must not create offers itself (glare): // it asks the offerer to drive the restart. ICE_RESTART_REQUEST: "ice_restart_request", + // SBQ2 in-band key exchange. These are the only two frames that legitimately + // arrive before any key exists, which is exactly why they are handled in one + // place and nowhere else: KEY_BLOB carries the key material the descriptor's + // commitment covers, KEY_PROOF the signature over the transcript. + KEY_BLOB: "key_blob", + KEY_PROOF: "key_proof", // Fake traffic FAKE: "fake" }; @@ -7493,6 +8279,21 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_ANSWER, _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST ]); + // ── SBQ2 ROLLBACK SWITCH ──────────────────────────────────────────────── + // Flip this ONE value to false and redeploy to put every new invitation back + // on the SB1 format. Nothing else needs touching: reception of both formats + // is unconditional, so a client built with this off still reads SBQ2 + // invitations from a client built with it on. + // + // It only governs what we EMIT. It is deliberately not consulted anywhere in + // the receive path, and never inside an established session — see + // _handshakeMode, which is latched per connection so a session cannot be + // pushed back onto the weaker format halfway through. + static SBQ2_SEND_ENABLED = true; + // How long the in-band key exchange may take from channel open to verified + // proof. Generous next to the ~1 s a handshake actually needs, tight enough + // that a peer that never sends its blob does not hang the UI indefinitely. + static SBQ2_KEY_EXCHANGE_TIMEOUT_MS = 15e3; 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. @@ -7649,6 +8450,8 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.maxSequenceGap = 100; this.replayProtectionEnabled = true; this.sessionId = null; + this._handshakeMode = null; + this._sbq2 = 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; @@ -10799,7 +11602,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { if (!keyMaterialRaw) missing.push("keyMaterialRaw"); throw new Error(`Missing required parameters for SAS computation: ${missing.join(", ")}`); } - const enc2 = new TextEncoder(); + const enc4 = new TextEncoder(); const normalizeFingerprintForSAS = (fingerprint, label) => { if (typeof fingerprint !== "string" || fingerprint.trim().length === 0) { throw new Error( @@ -10810,7 +11613,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { }; const normalizedLocalFP = normalizeFingerprintForSAS(localFP, "localFP"); const normalizedRemoteFP = normalizeFingerprintForSAS(remoteFP, "remoteFP"); - const salt = enc2.encode( + const salt = enc4.encode( "webrtc-sas|" + [normalizedLocalFP, normalizedRemoteFP].sort().join("|") ); let keyBuffer; @@ -10835,7 +11638,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { false, ["deriveBits"] ); - const info = enc2.encode("p2p-sas-v1"); + const info = enc4.encode("p2p-sas-v1"); const bits = await crypto.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt, info }, key, @@ -10863,6 +11666,360 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { throw new Error(`SAS computation failed: ${error.message}`); } } + // ======================================================================== + // SBQ2 — compact descriptor + in-band key exchange + // ======================================================================== + /** True once this connection has latched onto the SBQ2 handshake. */ + _isSbq2() { + return this._handshakeMode === "sbq2"; + } + /** + * Latch the handshake format for this connection. + * + * Called with the format of the first descriptor of the session. Calling it + * again with a different value is a bug or an attack, and is refused: this + * is the single point that makes "no downgrade inside a session" true rather + * than merely intended. + */ + _latchHandshakeMode(mode) { + if (this._handshakeMode && this._handshakeMode !== mode) { + throw new Error( + `handshake format cannot change mid-session (${this._handshakeMode} -> ${mode})` + ); + } + this._handshakeMode = mode; + } + _sbq2State() { + if (!this._sbq2) { + this._sbq2 = { + role: null, + // KX_ROLE.OFFER | KX_ROLE.ANSWER + localDescriptor: null, + // Uint8Array, our descriptor verbatim + remoteDescriptor: null, + // Uint8Array, peer's descriptor verbatim + localBlob: null, + remoteBlob: null, + remoteCommitment: null, + // from the peer's descriptor + transcript: null, + peerEcdhKey: null, + peerEcdsaKey: null, + blobSent: false, + proofSent: false, + proofVerified: false, + keysDerived: false, + pendingProof: null, + // proof that arrived before the transcript existed + completed: false, + timer: null, + startedAt: 0 + }; + } + return this._sbq2; + } + /** + * Fail closed. + * + * Every SBQ2 failure lands here: there is no path that logs a warning and + * carries on with a weaker session, and no path that retries as SB1. The + * connection is torn down and the user is told, because a handshake that + * went wrong is exactly the case where continuing is worst. + */ + _sbq2Abort(code, userMessage) { + const st = this._sbq2; + if (st?.timer) { + clearTimeout(st.timer); + st.timer = null; + } + this._secureLog("error", "SBQ2 handshake aborted", { code }); + try { + this.deliverMessageToUI(userMessage, "system"); + } catch (_) { + } + try { + this.onStatusChange?.("failed"); + } catch (_) { + } + try { + this.disconnect(); + } catch (_) { + } + } + /** SPKI bytes for a public CryptoKey. */ + async _exportSpki(key) { + return new Uint8Array(await crypto.subtle.exportKey("spki", key)); + } + /** + * Build the key blob for this side and the commitment that goes in the + * descriptor. Called while creating our descriptor, so the commitment is + * fixed before anything is shown to the user. + */ + async _sbq2BuildLocalBlob(role) { + if (!this.ecdhKeyPair?.publicKey || !this.ecdsaKeyPair?.publicKey) { + throw new Error("SBQ2: key pairs are not ready"); + } + const blob = encodeKeyBlob({ + role, + ecdhSpki: await this._exportSpki(this.ecdhKeyPair.publicKey), + ecdsaSpki: await this._exportSpki(this.ecdsaKeyPair.publicKey) + }); + const digest = async (b) => new Uint8Array(await crypto.subtle.digest("SHA-256", b)); + const commitment = await commitBlob(digest, blob); + const st = this._sbq2State(); + st.role = role; + st.localBlob = blob; + return { blob, commitment }; + } + /** + * Turn our gathered localDescription into a compact descriptor. + * @returns {{bytes: Uint8Array, text: string}} + */ + async _sbq2BuildDescriptor(type, { bindingTag: bindingTag2 = null, lifetimeMs = 10 * 60 * 1e3 } = {}) { + const sdp = this.peerConnection?.localDescription?.sdp; + if (!sdp) throw new Error("SBQ2: no local description to encode"); + const role = type === TYPE.OFFER ? ROLE.OFFER : ROLE.ANSWER; + const { commitment } = await this._sbq2BuildLocalBlob(role); + const raw = parseSdp(sdp); + const bytes = encodeDescriptor({ + type, + expiresAtMs: Date.now() + lifetimeMs, + sdpFields: { ...raw, candidates: pruneCandidates(raw.candidates) }, + commitment, + ...type === TYPE.ANSWER ? { bindingTag: bindingTag2 } : {} + }); + const st = this._sbq2State(); + st.localDescriptor = bytes; + this._secureLog("info", "SBQ2 descriptor built", { + type: type === TYPE.OFFER ? "offer" : "answer", + bytes: bytes.length, + candidates: pruneCandidates(raw.candidates).length + }); + return { bytes, text: encodeText(bytes) }; + } + /** + * Parse and adopt a peer descriptor. Throws on anything the strict decoder + * refuses — version, reserved values, unknown TLV, trailing bytes, expiry. + */ + _sbq2AdoptRemoteDescriptor(bytes, expectedType) { + const desc = decodeDescriptor(bytes); + if (desc.type !== expectedType) { + throw new Error( + `expected an ${expectedType === TYPE.OFFER ? "invitation" : "answer"}, got the other kind` + ); + } + if (!desc.commitment) { + throw new Error("the invitation carries no key commitment"); + } + const st = this._sbq2State(); + st.remoteDescriptor = bytes; + st.remoteCommitment = desc.commitment; + return desc; + } + /** + * Run the in-band key exchange. Called once, as soon as the DataChannel + * opens, before anything else is allowed to use the channel. + */ + async _runSbq2KeyExchange() { + const st = this._sbq2State(); + if (st.completed || st.blobSent) return; + st.startedAt = Date.now(); + if (!st.localBlob || !st.localDescriptor || !st.remoteDescriptor || !st.remoteCommitment) { + this._sbq2Abort( + "incomplete_state", + "The secure handshake could not start because the connection setup is incomplete. Please start a new invitation." + ); + return; + } + st.timer = setTimeout(() => { + if (!st.completed) { + this._sbq2Abort( + "timeout", + "The other side did not complete the secure handshake in time. Please try connecting again." + ); + } + }, _EnhancedSecureWebRTCManager.SBQ2_KEY_EXCHANGE_TIMEOUT_MS); + try { + this.dataChannel.send(JSON.stringify({ + type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.KEY_BLOB, + v: 2, + blob: window.EnhancedSecureCryptoUtils.arrayBufferToBase64(st.localBlob.buffer.slice( + st.localBlob.byteOffset, + st.localBlob.byteOffset + st.localBlob.byteLength + )) + })); + st.blobSent = true; + this._secureLog("info", "SBQ2 key blob sent", { bytes: st.localBlob.length }); + } catch (error) { + this._sbq2Abort( + "blob_send_failed", + "The secure handshake could not be sent. Please try connecting again." + ); + } + } + /** + * Handle the two in-band handshake frames. These are the only frames + * accepted before keys exist, so everything they touch is validated here and + * nothing is acted on before the commitment check passes. + */ + async _sbq2HandleHandshakeFrame(parsed) { + const T = _EnhancedSecureWebRTCManager.MESSAGE_TYPES; + const st = this._sbq2State(); + if (!this._isSbq2()) { + this._secureLog("error", "Rejected SBQ2 handshake frame on a non-SBQ2 session", { + messageType: parsed?.type + }); + return; + } + try { + if (parsed.type === T.KEY_BLOB) { + if (st.remoteBlob) { + this._sbq2Abort( + "duplicate_blob", + "The secure handshake was sent twice. The connection has been closed for safety." + ); + return; + } + const bytes = new Uint8Array(window.EnhancedSecureCryptoUtils.base64ToArrayBuffer(String(parsed.blob || ""))); + await verifyBlobCommitment(crypto.subtle, bytes, st.remoteCommitment); + const blob = decodeKeyBlob(bytes); + const expectedRole = st.role === ROLE.OFFER ? ROLE.ANSWER : ROLE.OFFER; + if (blob.role !== expectedRole) { + this._sbq2Abort( + "role_mismatch", + "The other side sent the wrong kind of handshake. The connection has been closed for safety." + ); + return; + } + st.remoteBlob = bytes; + st.peerEcdhKey = await crypto.subtle.importKey( + "spki", + blob.ecdhSpki, + { name: "ECDH", namedCurve: "P-384" }, + false, + [] + ); + st.peerEcdsaKey = await crypto.subtle.importKey( + "spki", + blob.ecdsaSpki, + { name: "ECDSA", namedCurve: "P-384" }, + false, + ["verify"] + ); + await this._sbq2CompleteExchange(); + return; + } + if (parsed.type === T.KEY_PROOF) { + const sig = new Uint8Array(window.EnhancedSecureCryptoUtils.base64ToArrayBuffer(String(parsed.sig || ""))); + if (!st.transcript || !st.peerEcdsaKey) { + st.pendingProof = sig; + return; + } + await this._sbq2VerifyProof(sig); + return; + } + } catch (error) { + const code = error?.code || "handshake_failed"; + const message = code === "commitment_mismatch" ? "The key material does not match the invitation you scanned. This can mean someone tampered with the connection, so it has been closed." : "The secure handshake failed. The connection has been closed for safety."; + this._sbq2Abort(code, message); + } + } + /** + * Both blobs are in hand and verified: define the transcript, derive + * everything from it, and prove possession of the identity key. + */ + async _sbq2CompleteExchange() { + const st = this._sbq2State(); + if (st.keysDerived || !st.remoteBlob || !st.localBlob) return; + this._peerSupportsRatchet = true; + const isOffer = st.role === ROLE.OFFER; + st.transcript = buildTranscript({ + offerDescriptor: isOffer ? st.localDescriptor : st.remoteDescriptor, + answerDescriptor: isOffer ? st.remoteDescriptor : st.localDescriptor, + offerBlob: isOffer ? st.localBlob : st.remoteBlob, + answerBlob: isOffer ? st.remoteBlob : st.localBlob + }); + this.sessionSalt = await deriveTranscriptSalt(crypto.subtle, st.transcript); + this.peerPublicKey = st.peerEcdhKey; + this.peerECDHPublicKey = st.peerEcdhKey; + const derivedKeys = await window.EnhancedSecureCryptoUtils.deriveSharedKeys( + this.ecdhKeyPair.privateKey, + st.peerEcdhKey, + this.sessionSalt + ); + await this._setEncryptionKeys( + derivedKeys.messageKey, + derivedKeys.macKey, + derivedKeys.metadataKey, + derivedKeys.fingerprint + ); + await this._initializeRatchet( + derivedKeys, + /* isInitiator */ + isOffer + ); + st.keysDerived = true; + const sig = new Uint8Array(await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-384" }, + this.ecdsaKeyPair.privateKey, + proofPayload(st.transcript) + )); + this.dataChannel.send(JSON.stringify({ + type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.KEY_PROOF, + sig: window.EnhancedSecureCryptoUtils.arrayBufferToBase64(sig.buffer) + })); + st.proofSent = true; + if (st.pendingProof) { + const held = st.pendingProof; + st.pendingProof = null; + await this._sbq2VerifyProof(held); + } + } + /** Verify the peer's transcript signature, then release the session. */ + async _sbq2VerifyProof(sig) { + const st = this._sbq2State(); + if (st.proofVerified) return; + const ok = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-384" }, + st.peerEcdsaKey, + sig, + proofPayload(st.transcript) + ); + if (!ok) { + this._sbq2Abort( + "bad_proof", + "The other side could not prove it owns its identity key. The connection has been closed for safety." + ); + return; + } + st.proofVerified = true; + this.verificationCode = await computeTranscriptSas(crypto.subtle, { + ecdhPrivateKey: this.ecdhKeyPair.privateKey, + peerEcdhPublicKey: st.peerEcdhKey, + transcript: st.transcript + }); + const localFP = this.expectedDTLSFingerprint; + const remoteFP = this._peerDTLSFingerprint; + if (localFP && remoteFP) this._setSASMaterialReady(localFP, remoteFP); + st.completed = true; + if (st.timer) { + clearTimeout(st.timer); + st.timer = null; + } + this.securityFeatures.hasMutualAuth = true; + this.securityFeatures.hasMetadataProtection = true; + this.securityFeatures.hasEnhancedReplayProtection = true; + this._secureLog("info", "SBQ2 in-band key exchange complete", { + elapsedMs: Date.now() - st.startedAt, + ratchetActive: this.isRatchetActive?.() === true + }); + try { + this.onKeyExchange?.(this.keyFingerprint); + } catch (_) { + } + this._notifyVerificationReadyIfPossible(); + this.initiateVerification(); + } /** * UTILITY: Decode hex keyFingerprint to Uint8Array for SAS computation * @param {string} hexString - Hex encoded keyFingerprint (e.g., "aa:bb:cc:dd") @@ -13864,6 +15021,20 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } } catch (e) { } + if (this._handshakeMode === "sbq2") { + try { + await this._runSbq2KeyExchange(); + } catch (error) { + this._secureLog("error", "SBQ2 key exchange failed to start", { + errorType: error?.constructor?.name || "Unknown" + }); + this._sbq2Abort( + "start_failed", + "The secure handshake could not be started. Please try connecting again." + ); + return; + } + } try { await this.establishConnection(); this.initializeFileTransfer(); @@ -14039,6 +15210,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } return; } + if (parsed.type === _EnhancedSecureWebRTCManager.MESSAGE_TYPES.KEY_BLOB || parsed.type === _EnhancedSecureWebRTCManager.MESSAGE_TYPES.KEY_PROOF) { + await this._sbq2HandleHandshakeFrame(parsed); + return; + } if (parsed.type && ["heartbeat", "verification", "verification_response", "verification_confirmed", "verification_both_confirmed", "sas_code", "peer_disconnect", "security_upgrade"].includes(parsed.type)) { this.handleSystemMessage(parsed); return; @@ -15706,6 +16881,12 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { operationId } })); + if (_EnhancedSecureWebRTCManager.SBQ2_SEND_ENABLED) { + this._latchHandshakeMode("sbq2"); + const { text: text2 } = await this._sbq2BuildDescriptor(TYPE.OFFER); + return { t: "offer", sbq2: text2 }; + } + this._latchHandshakeMode("sb1"); return offerPackage; } catch (error) { this._secureLog("error", "Enhanced secure offer creation failed in critical section", { @@ -15800,7 +16981,69 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { * FULLY FIXED METHOD createSecureAnswer() * With race-condition protection and enhanced security */ + /** + * SBQ2 answer path. + * + * Deliberately separate from createSecureAnswer rather than folded into it: + * that method's job is to import the peer's keys and derive the session from + * the offer, and here there are no peer keys yet — only a fingerprint and a + * commitment. Sharing the body would mean threading "do we have keys yet?" + * through fifteen phases, and the whole point of the format is that the + * answer is produced before any key material has been seen. + */ + async _createSbq2Answer(offerData) { + return this._withMutex("connectionOperation", async (operationId) => { + try { + this._resetNotificationFlags(); + if (!this._checkRateLimit()) { + throw new Error("Connection rate limit exceeded. Please wait before trying again."); + } + this._latchHandshakeMode("sbq2"); + const offerBytes = decodeText(String(offerData.sbq2)); + const desc = this._sbq2AdoptRemoteDescriptor(offerBytes, TYPE.OFFER); + const { sdp: remoteSdp } = serializeSdp(desc); + this.isInitiator = false; + this.onStatusChange("connecting"); + const keyPairs = await this._generateEncryptionKeys(); + this.ecdhKeyPair = keyPairs.ecdhKeyPair; + this.ecdsaKeyPair = keyPairs.ecdsaKeyPair; + if (!this.ecdhKeyPair?.privateKey || !this.ecdsaKeyPair?.privateKey) { + throw new Error("Failed to generate valid key pairs"); + } + this.createPeerConnection(); + this._peerDTLSFingerprint = Array.from( + desc.fingerprint, + (b) => b.toString(16).padStart(2, "0").toUpperCase() + ).join(":"); + await this.peerConnection.setRemoteDescription({ type: "offer", sdp: remoteSdp }); + await this.peerConnection.setLocalDescription(await this.peerConnection.createAnswer({ + offerToReceiveAudio: false, + offerToReceiveVideo: false + })); + this.expectedDTLSFingerprint = this._extractDTLSFingerprintFromSDP(this.peerConnection.localDescription.sdp); + await this.waitForIceGathering(); + const digest = async (b) => new Uint8Array(await crypto.subtle.digest("SHA-256", b)); + const { text: text2 } = await this._sbq2BuildDescriptor(TYPE.ANSWER, { + bindingTag: await bindingTag(digest, offerBytes) + }); + document.dispatchEvent(new CustomEvent("new-connection", { + detail: { type: "answer", timestamp: Date.now(), operationId } + })); + return { t: "answer", sbq2: text2 }; + } catch (error) { + this._secureLog("error", "SBQ2 answer creation failed", { + operationId, + errorType: error?.constructor?.name || "Unknown" + }); + this.onStatusChange("disconnected"); + throw error; + } + }, 6e4); + } async createSecureAnswer(offerData) { + if (offerData && typeof offerData.sbq2 === "string") { + return this._createSbq2Answer(offerData); + } return this._withMutex("connectionOperation", async (operationId) => { this._secureLog("info", "Creating secure answer with mutex", { operationId, @@ -16416,7 +17659,46 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } }); } + /** + * SBQ2 answer handling on the offerer side. + * + * Sets the remote description and nothing else. No key is imported and no + * secret derived here, because none has been sent yet — that happens in + * _runSbq2KeyExchange once the channel is open and the commitment has been + * checked. + */ + async _handleSbq2Answer(answerData) { + if (!this._isSbq2()) { + throw new Error("Received a new-format response to an old-format invitation. Please start a new invitation."); + } + const st = this._sbq2State(); + const answerBytes = decodeText(String(answerData.sbq2)); + const desc = decodeDescriptor(answerBytes); + if (desc.type !== TYPE.ANSWER) throw new Error("That code is an invitation, not a response to one."); + if (!desc.commitment) throw new Error("The response carries no key commitment"); + const digest = async (b) => new Uint8Array(await crypto.subtle.digest("SHA-256", b)); + const expected = await bindingTag(digest, st.localDescriptor); + let diff = 0; + for (let i = 0; i < expected.length; i++) diff |= expected[i] ^ desc.bindingTag[i]; + if (diff !== 0) { + throw new Error("This response belongs to a different invitation. Ask for a response to the code you are showing now."); + } + st.remoteDescriptor = answerBytes; + st.remoteCommitment = desc.commitment; + this._peerDTLSFingerprint = Array.from( + desc.fingerprint, + (b) => b.toString(16).padStart(2, "0").toUpperCase() + ).join(":"); + const { sdp } = serializeSdp(desc); + await this.peerConnection.setRemoteDescription({ type: "answer", sdp }); + this._secureLog("info", "SBQ2 answer accepted; awaiting in-band key exchange", { + bytes: answerBytes.length + }); + } async handleSecureAnswer(answerData) { + if (answerData && typeof answerData.sbq2 === "string") { + return this._handleSbq2Answer(answerData); + } try { if (!answerData || typeof answerData !== "object" || Array.isArray(answerData)) { this._secureLog("error", "CRITICAL: Invalid answer data structure", { @@ -19984,7 +21266,7 @@ var SecureMasterKeyManager = class { var import_NotificationIntegration = __toESM(require_NotificationIntegration()); // package.json -var version = "5.8.1"; +var version = "5.9.0"; // src/components/ui/Header.jsx var APP_VERSION = `v${version}`; diff --git a/dist/app-boot.js.map b/dist/app-boot.js.map index b2a1703..df6e82d 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/crypto/DoubleRatchet.js", "../src/network/EnhancedSecureWebRTCManager.js", "../src/scripts/app-boot.js", "../package.json", "../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, rest[0]);\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, rest[0]);\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, meta) {\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 // PRIVACY: a view-once or disappearing message must not be copied into the\n // OS notification. Notifications are shown only while the tab is in the\n // background \u2014 i.e. typically on a lock screen \u2014 and once the OS has the\n // text it lands in the notification centre, in backups and on the user's\n // other synced devices. From there the app can no longer delete it, so the\n // message the UI destroys after 30 seconds outlives itself indefinitely.\n // Show that something arrived; never what it said.\n const isEphemeral = !!meta && typeof meta === 'object' &&\n (meta.once === true || (Number.isFinite(meta.ttl) && meta.ttl > 0));\n const notificationText = isEphemeral ? 'Sent you a private message' : messageInfo.text;\n\n // Send notification\n const notificationResult = this.notificationManager.notify(\n messageInfo.senderName,\n notificationText,\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