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