release: v4.8.20 secure chat tools — completed, fixed and polished
CodeQL Analysis / Analyze CodeQL (push) Waiting to run
Deploy Application / deploy (push) Waiting to run
Mirror to Codeberg / mirror (push) Waiting to run
Mirror to PrivacyGuides / mirror (push) Waiting to run

Completes the messaging controls from v4.8.14 and fixes the bug that made them
appear broken for recipients.

Fixed:
- Per-message metadata was silently dropped for recipients. NotificationIntegration
  wrapped onMessage and deliverMessageToUI with 2-arg shims that called the
  originals without the 3rd argument (meta); with notifications enabled, view-once,
  disappearing timers and unsend all failed on the receiving side. Both wrappers
  now forward all arguments. Added tests/notification-meta-forwarding.test.mjs.
- Chat would not open after SAS: composer props were threaded into the wrong
  component (EnhancedConnectionSetup vs EnhancedChatInterface) -> ReferenceError
  nowTick on the verified re-render. Props moved to the chat component.

Changed:
- Code blocks: lightweight dependency-free syntax highlighting via React nodes
  (no innerHTML/remote scripts); code mode expands the input; copy auto-clears
  the clipboard after ~30s.
- View-once: configurable visible-after-open time (5s/15s/30s/1m) via meta.onceTtl.
- Disappearing timer: duration picker (Off/30s/5m/1h) instead of click-cycling.
- Composer toolbar moved next to "Send files"; borderless buttons, brand-orange
  active state; pickers open upward and are mobile-friendly.
- Sender bubble background lightened to rgba(249,115,22,0.05).

Removed:
- Panic wipe button (disconnect already wipes keys and clears session state).

Transport unchanged: per-message metadata travels inside the encrypted envelope,
whitelisted/bounded by _sanitizeMessageMeta. Full suite: 19 files, all passing.
Docs (README, CHANGELOG) updated; version bumped to 4.8.20.
This commit is contained in:
lockbitchat
2026-06-19 02:58:03 -04:00
parent cb72b9cb1c
commit b39f9ecd2c
19 changed files with 628 additions and 287 deletions
+21
View File
@@ -1,5 +1,26 @@
# Changelog
## v4.8.20 — Secure chat tools: completed, fixed and polished
Completes the messaging controls introduced in v4.8.14 and fixes the bug that made them appear broken for recipients. All per-message options travel inside the encrypted message envelope (never in the sanitized text), so message content cannot spoof or corrupt them.
### Fixed
- **Per-message metadata was silently dropped for recipients.** `NotificationIntegration` wrapped both `webrtcManager.onMessage` and `webrtcManager.deliverMessageToUI` with two-argument shims that called the originals without the third argument (`meta`). With notifications enabled, every received message lost its `meta`, so view-once, disappearing timers and unsend all failed on the recipient side. Both wrappers now forward all arguments (`...rest`). Added `tests/notification-meta-forwarding.test.mjs`.
- **Chat would not open after SAS** (regression from the initial wiring): the composer props were threaded into the wrong component (`EnhancedConnectionSetup` instead of `EnhancedChatInterface`), throwing `ReferenceError: nowTick` on the verified-state re-render. Props are now on the chat component.
### Changed
- **Code blocks** now include lightweight, dependency-free syntax highlighting (comments, strings, numbers, keywords) rendered via React nodes — no `innerHTML`, no remote scripts. Enabling code mode expands the input (monospace, 8 rows) for comfortable entry. Copying a block auto-clears the clipboard after ~30s.
- **View-once** is now configurable: the sender picks how long the message stays visible after the peer opens it (5s / 15s / 30s / 1m) via `meta.onceTtl` (clamped 1s1h).
- **Disappearing timer** uses a duration picker (Off / 30s / 5m / 1h) instead of click-cycling.
- **Composer toolbar** moved next to the "Send files" control; borderless buttons with the brand-orange (`accent-orange`) active state; time pickers open upward and are sized for mobile readability.
- Sender bubble background lightened to `rgba(249, 115, 22, 0.05)`.
### Removed
- **Panic wipe** button. Disconnecting already wipes keys and clears session state, so a separate panic control was redundant.
## v4.8.15 — Fix: chat would not open after SAS in v4.8.14
### Fixed
+7 -11
View File
@@ -1,4 +1,4 @@
# SecureBit.chat v4.8.15
# SecureBit.chat v4.8.20
SecureBit.chat is a browser-based peer-to-peer chat application built on WebRTC and Web Crypto APIs. It is designed for direct encrypted communication, explicit peer verification, and a small operational footprint without account registration or server-side message storage.
@@ -15,18 +15,14 @@ SecureBit.chat uses:
A session is not treated as verified until both peers complete the interactive SAS flow. Each user must compare the displayed code with the peer through an out-of-band channel and enter the matching code manually. Three failed SAS attempts terminate the session.
## Highlights in v4.8.15
## Highlights in v4.8.20
- Fix: the secure chat failed to open after SAS confirmation in v4.8.14 (a `nowTick` reference was scoped to the wrong component). The new messaging controls are now wired into the chat component correctly.
Secure messaging controls, available from a single composer toolbar next to **Send files**. Active controls use the brand-orange accent. Every per-message option travels **inside the encrypted message envelope** (never in the sanitized text), so message content can neither spoof nor corrupt these controls.
The v4.8.14 messaging features:
- Code blocks: a composer button wraps a message in a monospace code window with a one-click copy button (clipboard auto-clears after 30s).
- View-once messages: the recipient sees a blurred bubble that reveals on tap and is then deleted. Cooperative, like WhatsApp view-once — not screenshot-proof.
- Disappearing messages: an optional timer (30s / 5m / 1h) auto-deletes a message on both sides with a live countdown.
- Unsend: "delete for everyone" removes your message from the peer's chat too.
- Panic wipe: one button clears the conversation, wipes keys and disconnects.
- Per-message metadata travels inside the encrypted envelope (not in the sanitized text), so message content can never spoof or corrupt these controls.
- **Code blocks.** A `Code` button sends the message as a monospace code window with lightweight syntax highlighting and a one-click **Copy** button; the clipboard auto-clears ~30s after copying so keys/commands don't linger. Enabling it also expands the input box (monospace, 8 rows) for comfortable code entry. Highlighting is built from already-sanitized text via React nodes only — no `innerHTML`, no remote scripts, no new XSS surface.
- **View-once messages.** Pick how long the message stays visible after the peer opens it (5s / 15s / 30s / 1m). The recipient sees a blurred bubble; tapping reveals it, then it is wiped after the chosen window. Cooperative, like WhatsApp view-once — it reduces accidental lingering but is **not** screenshot-proof.
- **Disappearing messages.** A timer picker (30s / 5m / 1h) auto-deletes the message on **both** sides, with a live countdown. The incoming timer is clamped to a safe range.
- **Unsend (delete for everyone).** Removes your message locally and asks the peer to drop it too, over the authenticated control channel.
Earlier in v4.8.13:
+1 -1
View File
@@ -22,6 +22,6 @@ SecureBit.chat is intended for legitimate private communication, journalism, res
## Current release
- Product release: `v4.8.15`
- Product release: `v4.8.20`
- Protocol version: `4.1`
- Last updated: May 17, 2026
+1 -1
View File
File diff suppressed because one or more lines are too long
+21 -7
View File
@@ -542,10 +542,10 @@ var require_NotificationIntegration = __commonJS({
}
this.originalOnMessage = this.webrtcManager.onMessage;
this.originalOnStatusChange = this.webrtcManager.onStatusChange;
this.webrtcManager.onMessage = (message, type) => {
this.webrtcManager.onMessage = (message, type, ...rest) => {
this.handleIncomingMessage(message, type);
if (this.originalOnMessage) {
this.originalOnMessage(message, type);
this.originalOnMessage(message, type, ...rest);
}
};
this.webrtcManager.onStatusChange = (status) => {
@@ -556,9 +556,9 @@ var require_NotificationIntegration = __commonJS({
};
if (this.webrtcManager.deliverMessageToUI) {
this.originalDeliverMessageToUI = this.webrtcManager.deliverMessageToUI.bind(this.webrtcManager);
this.webrtcManager.deliverMessageToUI = (message, type) => {
this.webrtcManager.deliverMessageToUI = (message, type, ...rest) => {
this.handleIncomingMessage(message, type);
this.originalDeliverMessageToUI(message, type);
this.originalDeliverMessageToUI(message, type, ...rest);
};
}
this.isInitialized = true;
@@ -11086,6 +11086,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
if (meta.code === true) out.code = true;
if (meta.once === true) out.once = true;
if (Number.isFinite(meta.onceTtl)) {
const onceTtl = Math.floor(meta.onceTtl);
if (onceTtl >= 1 && onceTtl <= 3600) out.onceTtl = onceTtl;
}
if (Number.isFinite(meta.ttl)) {
const ttl = Math.floor(meta.ttl);
if (ttl >= 5 && ttl <= 86400) out.ttl = ttl;
@@ -12148,6 +12152,16 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this._secureLog("error", "No file transfer system available for:", { errorType: parsed.type?.constructor?.name || "Unknown" });
return;
}
if (parsed.type === _EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_DELETE) {
const messageId = parsed?.data?.messageId ?? parsed?.messageId;
if (typeof messageId === "string" && messageId) {
try {
this.onMessageDelete?.(messageId.slice(0, 64));
} catch (_) {
}
}
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;
@@ -12157,7 +12171,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
return;
}
if (this.onMessage) {
this.deliverMessageToUI(parsed.data, "received");
this.deliverMessageToUI(parsed.data, "received", parsed.meta);
}
return;
}
@@ -12252,7 +12266,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
if (decryptedContent && decryptedContent.type === "message" && typeof decryptedContent.data === "string") {
if (this.onMessage) {
this.deliverMessageToUI(decryptedContent.data, "received");
this.deliverMessageToUI(decryptedContent.data, "received", decryptedContent.meta);
}
return;
}
@@ -17487,7 +17501,7 @@ Right-click or Ctrl+click to disconnect`,
React.createElement("p", {
key: "subtitle",
className: "text-xs sm:text-sm text-muted hidden sm:block"
}, "End-to-end freedom v4.8.15")
}, "End-to-end freedom v4.8.20")
])
]),
// Status and Controls - Responsive
+2 -2
View File
File diff suppressed because one or more lines are too long
Vendored
+276 -134
View File
@@ -204,6 +204,128 @@ var parseMessageSegments = (text) => {
if (last < text.length) segments.push({ kind: "text", content: text.slice(last) });
return segments.some((s) => s.kind === "code") ? segments : null;
};
var HL_KEYWORDS = /* @__PURE__ */ new Set([
"const",
"let",
"var",
"function",
"return",
"if",
"else",
"for",
"while",
"do",
"switch",
"case",
"break",
"continue",
"class",
"extends",
"new",
"this",
"super",
"import",
"export",
"from",
"as",
"default",
"async",
"await",
"try",
"catch",
"finally",
"throw",
"typeof",
"instanceof",
"delete",
"yield",
"in",
"of",
"def",
"elif",
"lambda",
"pass",
"with",
"global",
"public",
"private",
"protected",
"static",
"final",
"void",
"int",
"long",
"float",
"double",
"char",
"bool",
"boolean",
"string",
"struct",
"enum",
"interface",
"package",
"func",
"fn",
"type",
"where",
"select",
"update",
"insert",
"delete",
"where",
"and",
"or",
"not",
"end",
"then",
"fi",
"done",
"echo",
"use",
"mut",
"impl",
"trait",
"match",
"module",
"require"
]);
var HL_LITERALS = /* @__PURE__ */ new Set(["true", "false", "null", "undefined", "None", "True", "False", "nil", "NaN", "Infinity"]);
var highlightCode = (code) => {
const re = /(\/\/[^\n]*|#[^\n]*|\/\*[\s\S]*?\*\/|--[^\n]*)|(`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(\b\d[\d_.]*(?:[eE][+-]?\d+)?\b|\b0[xX][0-9a-fA-F]+\b)|([A-Za-z_$][A-Za-z0-9_$]*)/g;
const nodes = [];
let buffer = "";
let last = 0;
let key = 0;
const flush = () => {
if (buffer) {
nodes.push(buffer);
buffer = "";
}
};
let m;
while ((m = re.exec(code)) !== null) {
if (m.index > last) buffer += code.slice(last, m.index);
last = re.lastIndex;
let cls = null;
if (m[1]) cls = "text-gray-500 italic";
else if (m[2]) cls = "text-amber-300";
else if (m[3]) cls = "text-sky-300";
else if (m[4]) {
if (HL_KEYWORDS.has(m[4])) cls = "text-purple-300";
else if (HL_LITERALS.has(m[4])) cls = "text-sky-300";
}
if (cls) {
flush();
nodes.push(React.createElement("span", { key: `h${key++}`, className: cls }, m[0]));
} else {
buffer += m[0];
}
}
if (last < code.length) buffer += code.slice(last);
flush();
return nodes;
};
var CodeBlock = ({ code, lang }) => {
const [copied, setCopied] = React.useState(false);
const handleCopy = async () => {
@@ -214,13 +336,13 @@ var CodeBlock = ({ code, lang }) => {
}
};
return React.createElement("div", {
className: "my-1 rounded-lg overflow-hidden border border-gray-600/40",
style: { backgroundColor: "#1b1c1b" }
className: "my-1 rounded-lg overflow-hidden",
style: { backgroundColor: "#1b1c1b", border: "0 solid #e5e7eb" }
}, [
React.createElement("div", {
key: "hdr",
className: "flex items-center justify-between px-3 py-1.5 border-b border-gray-600/30",
style: { backgroundColor: "#222322" }
className: "flex items-center justify-between px-3 py-1.5",
style: { backgroundColor: "#222322", border: "0 solid #e5e7eb" }
}, [
React.createElement("span", {
key: "lang",
@@ -243,7 +365,7 @@ var CodeBlock = ({ code, lang }) => {
key: "pre",
className: "px-3 py-2 overflow-x-auto text-xs leading-relaxed text-gray-200 custom-scrollbar",
style: { whiteSpace: "pre", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", margin: 0 }
}, React.createElement("code", null, code))
}, React.createElement("code", null, highlightCode(code)))
]);
};
var MessageBody = ({ text }) => {
@@ -266,62 +388,93 @@ var MessageBody = ({ text }) => {
)
);
};
var ChatToolbar = ({ codeMode, setCodeMode, viewOnceMode, setViewOnceMode, disappearTtl, setDisappearTtl, onPanicWipe }) => {
const ttlCycle = [0, 30, 300, 3600];
const ttlLabel = (s) => s === 0 ? "Off" : s >= 3600 ? `${Math.round(s / 3600)}h` : s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`;
const cycleTtl = () => {
const i = ttlCycle.indexOf(disappearTtl);
setDisappearTtl(ttlCycle[(i + 1) % ttlCycle.length] || 0);
};
const pill = (key, { active, activeClass, icon, label, title, onClick }) => React.createElement("button", {
var ChatToolbar = ({ codeMode, setCodeMode, viewOnceMode, setViewOnceMode, viewOnceTtl, setViewOnceTtl, disappearTtl, setDisappearTtl }) => {
const [openMenu, setOpenMenu] = React.useState(null);
const fmt = (s) => s >= 3600 ? `${Math.round(s / 3600)}h` : s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`;
const btnClass = (active) => `inline-flex items-center gap-2 h-9 px-3 rounded-lg text-xs font-medium transition-colors duration-150 ${active ? "accent-orange bg-orange-500/10" : "text-gray-400 hover:text-gray-200 hover:bg-gray-700/40"}`;
const pickerItem = (opt, current, onPick) => React.createElement("button", {
key: String(opt.value),
type: "button",
onClick: () => {
onPick(opt.value);
setOpenMenu(null);
},
// Comfortable tap target + readable size on mobile.
className: `w-full text-left px-4 py-3 sm:py-2.5 text-sm flex items-center gap-3 transition-colors ${current === opt.value ? "accent-orange bg-orange-500/10" : "text-gray-200 hover:bg-gray-700/50 active:bg-gray-700/60"}`
}, [
React.createElement("i", { key: "i", className: `${opt.icon || "far fa-clock"} w-4 text-center` }),
React.createElement("span", { key: "l" }, opt.label)
]);
const picker = (options, current, onPick) => React.createElement("div", {
className: "absolute right-0 z-50 min-w-[180px] max-w-[78vw] rounded-xl shadow-2xl overflow-hidden",
style: { backgroundColor: "#1f201f", border: "0 solid #e5e7eb", bottom: "100%", marginBottom: "8px" }
}, options.map((opt) => pickerItem(opt, current, onPick)));
const labelBtn = (key, { active, icon, label, title, onClick }) => React.createElement("button", {
key,
type: "button",
onClick,
title,
className: `inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs border transition-all duration-200 ${active ? activeClass : "text-gray-400 border-gray-600/50 hover:text-gray-200 hover:border-gray-500"}`
"aria-pressed": !!active,
className: btnClass(active)
}, [
React.createElement("i", { key: "i", className: icon }),
label ? React.createElement("span", { key: "l" }, label) : null
React.createElement("i", { key: "i", className: `${icon} text-[13px]` }),
React.createElement("span", { key: "l", className: "leading-none" }, label)
]);
return React.createElement("div", {
className: "flex items-center flex-wrap gap-2 pb-3"
}, [
pill("code", {
return React.createElement("div", { className: "flex items-center gap-1" }, [
// Invisible backdrop closes any open picker on outside click.
openMenu && React.createElement("div", {
key: "backdrop",
className: "fixed inset-0 z-40",
onClick: () => setOpenMenu(null)
}),
// Code — toggles code mode (expands the input box).
labelBtn("code", {
active: codeMode,
activeClass: "text-green-400 border-green-500/40 bg-green-500/10",
icon: "fas fa-code",
label: "Code",
title: "Send as a code block (with copy button)",
title: "Send as a code block (expands the input)",
onClick: () => setCodeMode((v) => !v)
}),
pill("once", {
active: viewOnceMode,
activeClass: "text-orange-400 border-orange-500/40 bg-orange-500/10",
icon: "fas fa-eye-slash",
label: "View once",
title: "Recipient can read it once, then it is deleted (cooperative \u2014 not screenshot-proof)",
onClick: () => setViewOnceMode((v) => !v)
}),
pill("ttl", {
active: disappearTtl > 0,
activeClass: "text-blue-400 border-blue-500/40 bg-blue-500/10",
icon: "fas fa-stopwatch",
label: `Timer: ${ttlLabel(disappearTtl)}`,
title: "Disappearing messages \u2014 auto-delete on both sides",
onClick: cycleTtl
}),
React.createElement("div", { key: "spacer", className: "flex-1" }),
pill("panic", {
active: true,
activeClass: "text-red-400 border-red-500/40 bg-red-500/10 hover:bg-red-500/20",
icon: "fas fa-fire-extinguisher",
label: "Panic",
title: "Wipe this conversation and keys, and disconnect",
onClick: () => {
const ok = typeof window !== "undefined" && window.confirm ? window.confirm("Panic wipe: delete all messages, wipe keys and disconnect now?") : true;
if (ok && typeof onPanicWipe === "function") onPanicWipe();
}
})
// View once — pick how long it stays after the peer opens it.
React.createElement("div", { key: "once", className: "relative" }, [
labelBtn("once-btn", {
active: viewOnceMode,
icon: "fas fa-eye-slash",
label: viewOnceMode ? `Once \xB7 ${fmt(viewOnceTtl)}` : "View once",
title: "View once \u2014 vanishes after the peer reads it",
onClick: () => setOpenMenu(openMenu === "once" ? null : "once")
}),
openMenu === "once" && picker([
{ value: 0, label: "Off", icon: "fas fa-ban" },
{ value: 5, label: "5s after reading" },
{ value: 15, label: "15s after reading" },
{ value: 30, label: "30s after reading" },
{ value: 60, label: "1m after reading" }
], viewOnceMode ? viewOnceTtl : 0, (v) => {
if (v === 0) {
setViewOnceMode(false);
} else {
setViewOnceTtl(v);
setViewOnceMode(true);
}
})
]),
// Timer — pick the disappearing duration.
React.createElement("div", { key: "timer", className: "relative" }, [
labelBtn("timer-btn", {
active: disappearTtl > 0,
icon: "fas fa-stopwatch",
label: disappearTtl > 0 ? `Timer \xB7 ${fmt(disappearTtl)}` : "Timer",
title: "Disappearing message \u2014 deletes on both sides",
onClick: () => setOpenMenu(openMenu === "timer" ? null : "timer")
}),
openMenu === "timer" && picker([
{ value: 0, label: "Off", icon: "fas fa-ban" },
{ value: 30, label: "30 seconds" },
{ value: 300, label: "5 minutes" },
{ value: 3600, label: "1 hour" }
], disappearTtl, (v) => setDisappearTtl(v))
])
]);
};
var EnhancedCopyButton = ({ text, className = "", children }) => {
@@ -536,7 +689,7 @@ var VerificationStep = ({ verificationCode, onConfirm, onReject, localConfirmed,
])
]);
};
var EnhancedChatMessage = ({ message, type, timestamp, mid, viewOnce, expiresAt, nowTick, canUnsend, onUnsend, onExpire }) => {
var EnhancedChatMessage = ({ message, type, timestamp, mid, viewOnce, viewOnceTtl, expiresAt, nowTick, canUnsend, onUnsend, onExpire }) => {
const [revealed, setRevealed] = React.useState(false);
const revealTimerRef = React.useRef(null);
const formatTime = (ts) => {
@@ -553,7 +706,7 @@ var EnhancedChatMessage = ({ message, type, timestamp, mid, viewOnce, expiresAt,
switch (type) {
case "sent":
return {
container: "ml-auto bg-orange-500/15 border-orange-500/20 text-primary",
container: "ml-auto bg-orange-500/5 border-orange-500/20 text-primary",
icon: "fas fa-lock accent-orange",
label: "Encrypted"
};
@@ -583,9 +736,10 @@ var EnhancedChatMessage = ({ message, type, timestamp, mid, viewOnce, expiresAt,
const handleReveal = () => {
if (revealed) return;
setRevealed(true);
const ms = Math.max(1, typeof viewOnceTtl === "number" ? viewOnceTtl : 15) * 1e3;
revealTimerRef.current = setTimeout(() => {
onExpire && onExpire();
}, 12e3);
}, ms);
};
let body;
if (isViewOnce && !revealed) {
@@ -1531,12 +1685,13 @@ var EnhancedChatInterface = ({
setCodeMode,
viewOnceMode,
setViewOnceMode,
viewOnceTtl,
setViewOnceTtl,
disappearTtl,
setDisappearTtl,
nowTick,
onUnsendMessage,
onMessageExpire,
onPanicWipe
onMessageExpire
}) => {
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [showFileTransfer, setShowFileTransfer] = React.useState(false);
@@ -1687,6 +1842,7 @@ var EnhancedChatInterface = ({
timestamp: msg.timestamp,
mid: msg.mid,
viewOnce: msg.viewOnce,
viewOnceTtl: msg.viewOnceTtl,
expiresAt: msg.expiresAt,
nowTick,
canUnsend: typeof onUnsendMessage === "function",
@@ -1726,36 +1882,53 @@ var EnhancedChatInterface = ({
"div",
{ className: "max-w-4xl mx-auto px-4" },
[
React.createElement(
"button",
{
onClick: () => setShowFileTransfer(!showFileTransfer),
className: `flex items-center text-sm text-gray-400 hover:text-gray-300 transition-colors py-4 ${showFileTransfer ? "mb-4" : ""}`
},
[
React.createElement(
"svg",
{
className: `w-4 h-4 mr-2 transform transition-transform ${showFileTransfer ? "rotate-180" : ""}`,
fill: "none",
stroke: "currentColor",
viewBox: "0 0 24 24"
},
showFileTransfer ? React.createElement("path", {
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: 2,
d: "M5 15l7-7 7 7"
}) : React.createElement("path", {
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: 2,
d: "M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
})
),
showFileTransfer ? "Hide file transfer" : "Send files"
]
),
React.createElement("div", {
key: "composer-bar",
className: `flex items-center justify-between flex-wrap gap-2 ${showFileTransfer ? "mb-4" : ""}`
}, [
React.createElement(
"button",
{
key: "send-files-toggle",
onClick: () => setShowFileTransfer(!showFileTransfer),
className: "flex items-center text-sm text-gray-400 hover:text-gray-300 transition-colors py-4"
},
[
React.createElement(
"svg",
{
className: `w-4 h-4 mr-2 transform transition-transform ${showFileTransfer ? "rotate-180" : ""}`,
fill: "none",
stroke: "currentColor",
viewBox: "0 0 24 24"
},
showFileTransfer ? React.createElement("path", {
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: 2,
d: "M5 15l7-7 7 7"
}) : React.createElement("path", {
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: 2,
d: "M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
})
),
showFileTransfer ? "Hide file transfer" : "Send files"
]
),
React.createElement(ChatToolbar, {
key: "toolbar",
codeMode,
setCodeMode,
viewOnceMode,
setViewOnceMode,
viewOnceTtl,
setViewOnceTtl,
disappearTtl,
setDisappearTtl
})
]),
showFileTransfer && React.createElement(window.FileTransferComponent || (() => React.createElement("div", {
className: "p-4 text-center text-red-400"
}, "FileTransferComponent not loaded")), {
@@ -1774,16 +1947,6 @@ var EnhancedChatInterface = ({
"div",
{ className: "max-w-4xl mx-auto p-4" },
[
React.createElement(ChatToolbar, {
key: "toolbar",
codeMode,
setCodeMode,
viewOnceMode,
setViewOnceMode,
disappearTtl,
setDisappearTtl,
onPanicWipe
}),
React.createElement(
"div",
{ key: "inputrow", className: "flex items-stretch space-x-3" },
@@ -1796,11 +1959,11 @@ var EnhancedChatInterface = ({
value: messageInput,
onChange: (e) => setMessageInput(e.target.value),
onKeyDown: handleKeyPress,
placeholder: "Enter message to encrypt...",
rows: 2,
placeholder: codeMode ? "Paste or type code\u2026" : "Enter message to encrypt...",
rows: codeMode ? 8 : 2,
maxLength: 2e3,
style: { backgroundColor: "#272827" },
className: "w-full p-3 border border-gray-600 rounded-lg resize-none text-gray-300 placeholder-gray-500 focus:border-green-500/40 focus:outline-none transition-all custom-scrollbar text-sm"
style: codeMode ? { backgroundColor: "#1b1c1b", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" } : { backgroundColor: "#272827" },
className: `w-full p-3 border rounded-lg resize-none text-gray-300 placeholder-gray-500 focus:outline-none transition-all custom-scrollbar text-sm ${codeMode ? "border-orange-500/40 focus:border-orange-500/60" : "border-gray-600 focus:border-green-500/40"}`
}),
React.createElement(
"div",
@@ -1842,6 +2005,7 @@ var EnhancedSecureP2PChat = () => {
const [messages, setMessages] = React.useState([]);
const [codeMode, setCodeMode] = React.useState(false);
const [viewOnceMode, setViewOnceMode] = React.useState(false);
const [viewOnceTtl, setViewOnceTtl] = React.useState(15);
const [disappearTtl, setDisappearTtl] = React.useState(0);
const [nowTick, setNowTick] = React.useState(() => Date.now());
const [connectionStatus, setConnectionStatus] = React.useState("disconnected");
@@ -1970,6 +2134,7 @@ var EnhancedSecureP2PChat = () => {
timestamp: Date.now(),
mid: opts.mid,
viewOnce: opts.viewOnce === true,
viewOnceTtl: typeof opts.viewOnceTtl === "number" ? opts.viewOnceTtl : 15,
expiresAt: typeof opts.expiresAt === "number" ? opts.expiresAt : void 0
};
setMessages((prev) => {
@@ -2106,7 +2271,10 @@ var EnhancedSecureP2PChat = () => {
const opts = {};
if (meta && typeof meta === "object") {
if (typeof meta.mid === "string") opts.mid = meta.mid;
if (meta.once === true) opts.viewOnce = true;
if (meta.once === true) {
opts.viewOnce = true;
opts.viewOnceTtl = Number.isFinite(meta.onceTtl) ? meta.onceTtl : 15;
}
if (Number.isFinite(meta.ttl) && meta.ttl > 0) {
opts.expiresAt = Date.now() + meta.ttl * 1e3;
}
@@ -2270,7 +2438,7 @@ var EnhancedSecureP2PChat = () => {
} catch (error) {
}
}
handleMessage(" SecureBit.chat Enhanced Security Edition v4.8.15 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.", "system");
handleMessage(" SecureBit.chat Enhanced Security Edition v4.8.20 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.", "system");
const handleBeforeUnload = (event) => {
if (event.type === "beforeunload" && !isTabSwitching) {
if (webrtcManagerRef.current && webrtcManagerRef.current.isConnected()) {
@@ -3470,7 +3638,10 @@ var EnhancedSecureP2PChat = () => {
const outText = codeMode ? "```\n" + baseText + "\n```" : baseText;
const mid = `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const meta = { mid };
if (viewOnceMode) meta.once = true;
if (viewOnceMode) {
meta.once = true;
meta.onceTtl = viewOnceTtl;
}
if (disappearTtl > 0) meta.ttl = disappearTtl;
const localOpts = { mid };
if (disappearTtl > 0) localOpts.expiresAt = Date.now() + disappearTtl * 1e3;
@@ -3497,36 +3668,6 @@ var EnhancedSecureP2PChat = () => {
const handleMessageExpire = React.useCallback((id) => {
setMessages((prev) => prev.filter((m) => m.id !== id));
}, []);
const handlePanicWipe = React.useCallback(() => {
setMessages([]);
try {
const mgr = webrtcManagerRef.current;
if (mgr) {
if (typeof mgr._secureWipeKeys === "function") {
try {
mgr._secureWipeKeys();
} catch (_) {
}
}
if (typeof mgr.disconnect === "function") {
try {
mgr.disconnect();
} catch (_) {
}
}
}
} catch (_) {
}
try {
document.dispatchEvent(new CustomEvent("disconnected"));
} catch (_) {
}
setConnectionStatus("disconnected");
setIsVerified(false);
setKeyFingerprint("");
setSecurityLevel(null);
setMessageInput("");
}, []);
const handleClearData = () => {
setOfferData("");
setAnswerData("");
@@ -3788,12 +3929,13 @@ var EnhancedSecureP2PChat = () => {
setCodeMode,
viewOnceMode,
setViewOnceMode,
viewOnceTtl,
setViewOnceTtl,
disappearTtl,
setDisappearTtl,
nowTick,
onUnsendMessage: handleUnsendMessage,
onMessageExpire: handleMessageExpire,
onPanicWipe: handlePanicWipe
onMessageExpire: handleMessageExpire
});
})() : React.createElement(EnhancedConnectionSetup, {
onCreateOffer: handleCreateOffer,
+2 -2
View File
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -113,7 +113,7 @@
<!-- GitHub Pages SEO -->
<meta name="description" content="SecureBit.chat v4.8.15 — P2P messenger with ECDH + DTLS + SAS security and 18-layer military-grade cryptography">
<meta name="description" content="SecureBit.chat v4.8.20 — P2P messenger with ECDH + DTLS + SAS security and 18-layer military-grade cryptography">
<meta name="keywords" content="P2P messenger, ECDH, DTLS, SAS, encryption, WebRTC, privacy, ASN.1 validation, military-grade security, 18-layer defense, MITM protection, PFS">
<meta name="author" content="Volodymyr">
<link rel="canonical" href="https://github.com/SecureBitChat/securebit-chat/">
@@ -148,13 +148,13 @@
<!-- Update Manager - система принудительного обновления -->
<script src="src/utils/updateManager.js"></script>
<script type="module" src="src/components/UpdateChecker.jsx"></script>
<script type="module" src="dist/qr-local.js?v=1781831678894"></script>
<script type="module" src="src/components/QRScanner.js?v=1781831678894"></script>
<script type="module" src="dist/qr-local.js?v=1781851206401"></script>
<script type="module" src="src/components/QRScanner.js?v=1781851206401"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1781831678894"></script>
<script type="module" src="dist/app.js?v=1781831678894"></script>
<script type="module" src="dist/app-boot.js?v=1781851206401"></script>
<script type="module" src="dist/app.js?v=1781851206401"></script>
<script src="src/scripts/pwa-register.js"></script>
<script src="./src/pwa/install-prompt.js" type="module"></script>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "SecureBit.chat v4.8.15 - ECDH + DTLS + SAS",
"name": "SecureBit.chat v4.8.20 - ECDH + DTLS + SAS",
"short_name": "SecureBit",
"description": "P2P messenger with ECDH + DTLS + SAS security, military-grade cryptography and Lightning Network payments",
"start_url": "./",
+7 -7
View File
@@ -1,10 +1,10 @@
{
"version": "1781831678894",
"buildVersion": "1781831678894",
"appVersion": "4.8.15",
"buildTime": "2026-06-19T01:14:38.934Z",
"buildId": "1781831678894-15173a9",
"gitHash": "15173a9",
"version": "1781851206401",
"buildVersion": "1781851206401",
"appVersion": "4.8.20",
"buildTime": "2026-06-19T06:40:06.446Z",
"buildId": "1781851206401-cb72b9c",
"gitHash": "cb72b9c",
"generated": true,
"generatedAt": "2026-06-19T01:14:38.935Z"
"generatedAt": "2026-06-19T06:40:06.447Z"
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "securebit-chat",
"version": "4.8.15",
"version": "4.8.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "securebit-chat",
"version": "4.8.15",
"version": "4.8.20",
"license": "MIT",
"dependencies": {
"base64-js": "1.5.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "securebit-chat",
"version": "4.8.15",
"version": "4.8.20",
"description": "Secure P2P Communication Application with End-to-End Encryption",
"main": "index.html",
"scripts": {
@@ -11,7 +11,7 @@
"dev": "npm run build && python -m http.server 8000",
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
"serve": "npx http-server -p 8000",
"test": "node tests/sas-verification.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs"
"test": "node tests/sas-verification.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs"
},
"keywords": [
"p2p",
+182 -99
View File
@@ -55,6 +55,53 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
return segments.some(s => s.kind === 'code') ? segments : null;
};
// Lightweight, dependency-free syntax highlighter. Returns React nodes
// (no innerHTML / dangerouslySetInnerHTML) so it stays CSP-safe and adds
// no XSS surface. Language-agnostic: highlights comments, strings,
// numbers, common keywords and literals good enough for snippets
// without shipping a heavy library or allowing remote scripts.
const HL_KEYWORDS = new Set([
'const','let','var','function','return','if','else','for','while','do','switch',
'case','break','continue','class','extends','new','this','super','import','export',
'from','as','default','async','await','try','catch','finally','throw','typeof',
'instanceof','delete','yield','in','of','def','elif','lambda','pass','with','global',
'public','private','protected','static','final','void','int','long','float','double',
'char','bool','boolean','string','struct','enum','interface','package','func','fn',
'type','where','select','update','insert','delete','where','and','or','not','end',
'then','fi','done','echo','use','mut','impl','trait','match','module','require'
]);
const HL_LITERALS = new Set(['true','false','null','undefined','None','True','False','nil','NaN','Infinity']);
const highlightCode = (code) => {
const re = /(\/\/[^\n]*|#[^\n]*|\/\*[\s\S]*?\*\/|--[^\n]*)|(`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(\b\d[\d_.]*(?:[eE][+-]?\d+)?\b|\b0[xX][0-9a-fA-F]+\b)|([A-Za-z_$][A-Za-z0-9_$]*)/g;
const nodes = [];
let buffer = '';
let last = 0;
let key = 0;
const flush = () => { if (buffer) { nodes.push(buffer); buffer = ''; } };
let m;
while ((m = re.exec(code)) !== null) {
if (m.index > last) buffer += code.slice(last, m.index);
last = re.lastIndex;
let cls = null;
if (m[1]) cls = 'text-gray-500 italic'; // comment
else if (m[2]) cls = 'text-amber-300'; // string
else if (m[3]) cls = 'text-sky-300'; // number
else if (m[4]) { // identifier
if (HL_KEYWORDS.has(m[4])) cls = 'text-purple-300';
else if (HL_LITERALS.has(m[4])) cls = 'text-sky-300';
}
if (cls) {
flush();
nodes.push(React.createElement('span', { key: `h${key++}`, className: cls }, m[0]));
} else {
buffer += m[0];
}
}
if (last < code.length) buffer += code.slice(last);
flush();
return nodes;
};
// Monospace code window with a copy button (clipboard auto-clears in 30s).
const CodeBlock = ({ code, lang }) => {
const [copied, setCopied] = React.useState(false);
@@ -66,13 +113,13 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
}
};
return React.createElement('div', {
className: "my-1 rounded-lg overflow-hidden border border-gray-600/40",
style: { backgroundColor: '#1b1c1b' }
className: "my-1 rounded-lg overflow-hidden",
style: { backgroundColor: '#1b1c1b', border: '0 solid #e5e7eb' }
}, [
React.createElement('div', {
key: 'hdr',
className: "flex items-center justify-between px-3 py-1.5 border-b border-gray-600/30",
style: { backgroundColor: '#222322' }
className: "flex items-center justify-between px-3 py-1.5",
style: { backgroundColor: '#222322', border: '0 solid #e5e7eb' }
}, [
React.createElement('span', {
key: 'lang',
@@ -95,7 +142,7 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
key: 'pre',
className: "px-3 py-2 overflow-x-auto text-xs leading-relaxed text-gray-200 custom-scrollbar",
style: { whiteSpace: 'pre', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', margin: 0 }
}, React.createElement('code', null, code))
}, React.createElement('code', null, highlightCode(code)))
]);
};
@@ -123,70 +170,103 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
);
};
// Composer toolbar: code / view-once / disappearing / panic.
const ChatToolbar = ({ codeMode, setCodeMode, viewOnceMode, setViewOnceMode, disappearTtl, setDisappearTtl, onPanicWipe }) => {
const ttlCycle = [0, 30, 300, 3600];
const ttlLabel = (s) => s === 0 ? 'Off' : (s >= 3600 ? `${Math.round(s / 3600)}h` : (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`));
const cycleTtl = () => {
const i = ttlCycle.indexOf(disappearTtl);
setDisappearTtl(ttlCycle[(i + 1) % ttlCycle.length] || 0);
};
// Composer toolbar: code / view-once / disappearing.
// Borderless icon+label buttons; active state uses the brand orange
// (accent-orange). View-once and Timer open a small time picker.
const ChatToolbar = ({ codeMode, setCodeMode, viewOnceMode, setViewOnceMode, viewOnceTtl, setViewOnceTtl, disappearTtl, setDisappearTtl }) => {
const [openMenu, setOpenMenu] = React.useState(null); // 'once' | 'timer' | null
const fmt = (s) => s >= 3600 ? `${Math.round(s / 3600)}h` : (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`);
const pill = (key, { active, activeClass, icon, label, title, onClick }) =>
const btnClass = (active) =>
`inline-flex items-center gap-2 h-9 px-3 rounded-lg text-xs font-medium transition-colors duration-150 ${active
? 'accent-orange bg-orange-500/10'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/40'}`;
const pickerItem = (opt, current, onPick) =>
React.createElement('button', {
key,
key: String(opt.value),
type: 'button',
onClick,
title,
className: `inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs border transition-all duration-200 ${active
? activeClass
: 'text-gray-400 border-gray-600/50 hover:text-gray-200 hover:border-gray-500'}`
onClick: () => { onPick(opt.value); setOpenMenu(null); },
// Comfortable tap target + readable size on mobile.
className: `w-full text-left px-4 py-3 sm:py-2.5 text-sm flex items-center gap-3 transition-colors ${current === opt.value
? 'accent-orange bg-orange-500/10'
: 'text-gray-200 hover:bg-gray-700/50 active:bg-gray-700/60'}`
}, [
React.createElement('i', { key: 'i', className: icon }),
label ? React.createElement('span', { key: 'l' }, label) : null
React.createElement('i', { key: 'i', className: `${opt.icon || 'far fa-clock'} w-4 text-center` }),
React.createElement('span', { key: 'l' }, opt.label)
]);
return React.createElement('div', {
className: "flex items-center flex-wrap gap-2 pb-3"
}, [
pill('code', {
// Opens UPWARD (bottom:100%) via inline style so it never depends on a
// purgeable utility class and never pushes the composer layout down.
const picker = (options, current, onPick) =>
React.createElement('div', {
className: "absolute right-0 z-50 min-w-[180px] max-w-[78vw] rounded-xl shadow-2xl overflow-hidden",
style: { backgroundColor: '#1f201f', border: '0 solid #e5e7eb', bottom: '100%', marginBottom: '8px' }
}, options.map(opt => pickerItem(opt, current, onPick)));
const labelBtn = (key, { active, icon, label, title, onClick }) =>
React.createElement('button', {
key, type: 'button', onClick, title, 'aria-pressed': !!active,
className: btnClass(active)
}, [
React.createElement('i', { key: 'i', className: `${icon} text-[13px]` }),
React.createElement('span', { key: 'l', className: 'leading-none' }, label)
]);
return React.createElement('div', { className: "flex items-center gap-1" }, [
// Invisible backdrop closes any open picker on outside click.
openMenu && React.createElement('div', {
key: 'backdrop',
className: "fixed inset-0 z-40",
onClick: () => setOpenMenu(null)
}),
// Code toggles code mode (expands the input box).
labelBtn('code', {
active: codeMode,
activeClass: 'text-green-400 border-green-500/40 bg-green-500/10',
icon: 'fas fa-code',
label: 'Code',
title: 'Send as a code block (with copy button)',
title: 'Send as a code block (expands the input)',
onClick: () => setCodeMode(v => !v)
}),
pill('once', {
active: viewOnceMode,
activeClass: 'text-orange-400 border-orange-500/40 bg-orange-500/10',
icon: 'fas fa-eye-slash',
label: 'View once',
title: 'Recipient can read it once, then it is deleted (cooperative — not screenshot-proof)',
onClick: () => setViewOnceMode(v => !v)
}),
pill('ttl', {
active: disappearTtl > 0,
activeClass: 'text-blue-400 border-blue-500/40 bg-blue-500/10',
icon: 'fas fa-stopwatch',
label: `Timer: ${ttlLabel(disappearTtl)}`,
title: 'Disappearing messages — auto-delete on both sides',
onClick: cycleTtl
}),
React.createElement('div', { key: 'spacer', className: 'flex-1' }),
pill('panic', {
active: true,
activeClass: 'text-red-400 border-red-500/40 bg-red-500/10 hover:bg-red-500/20',
icon: 'fas fa-fire-extinguisher',
label: 'Panic',
title: 'Wipe this conversation and keys, and disconnect',
onClick: () => {
const ok = (typeof window !== 'undefined' && window.confirm)
? window.confirm('Panic wipe: delete all messages, wipe keys and disconnect now?')
: true;
if (ok && typeof onPanicWipe === 'function') onPanicWipe();
}
})
// View once pick how long it stays after the peer opens it.
React.createElement('div', { key: 'once', className: 'relative' }, [
labelBtn('once-btn', {
active: viewOnceMode,
icon: 'fas fa-eye-slash',
label: viewOnceMode ? `Once · ${fmt(viewOnceTtl)}` : 'View once',
title: 'View once — vanishes after the peer reads it',
onClick: () => setOpenMenu(openMenu === 'once' ? null : 'once')
}),
openMenu === 'once' && picker([
{ value: 0, label: 'Off', icon: 'fas fa-ban' },
{ value: 5, label: '5s after reading' },
{ value: 15, label: '15s after reading' },
{ value: 30, label: '30s after reading' },
{ value: 60, label: '1m after reading' }
], viewOnceMode ? viewOnceTtl : 0, (v) => {
if (v === 0) { setViewOnceMode(false); }
else { setViewOnceTtl(v); setViewOnceMode(true); }
})
]),
// Timer pick the disappearing duration.
React.createElement('div', { key: 'timer', className: 'relative' }, [
labelBtn('timer-btn', {
active: disappearTtl > 0,
icon: 'fas fa-stopwatch',
label: disappearTtl > 0 ? `Timer · ${fmt(disappearTtl)}` : 'Timer',
title: 'Disappearing message — deletes on both sides',
onClick: () => setOpenMenu(openMenu === 'timer' ? null : 'timer')
}),
openMenu === 'timer' && picker([
{ value: 0, label: 'Off', icon: 'fas fa-ban' },
{ value: 30, label: '30 seconds' },
{ value: 300, label: '5 minutes' },
{ value: 3600, label: '1 hour' }
], disappearTtl, (v) => setDisappearTtl(v))
])
]);
};
@@ -413,7 +493,7 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
};
// Enhanced Chat Message with better security indicators
const EnhancedChatMessage = ({ message, type, timestamp, mid, viewOnce, expiresAt, nowTick, canUnsend, onUnsend, onExpire }) => {
const EnhancedChatMessage = ({ message, type, timestamp, mid, viewOnce, viewOnceTtl, expiresAt, nowTick, canUnsend, onUnsend, onExpire }) => {
const [revealed, setRevealed] = React.useState(false);
const revealTimerRef = React.useRef(null);
@@ -433,7 +513,7 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
switch (type) {
case 'sent':
return {
container: "ml-auto bg-orange-500/15 border-orange-500/20 text-primary",
container: "ml-auto bg-orange-500/5 border-orange-500/20 text-primary",
icon: "fas fa-lock accent-orange",
label: "Encrypted"
};
@@ -470,10 +550,11 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
const handleReveal = () => {
if (revealed) return;
setRevealed(true);
// One-time read: wipe shortly after the recipient opens it.
// One-time read: wipe after the sender-chosen visible window.
const ms = Math.max(1, (typeof viewOnceTtl === 'number' ? viewOnceTtl : 15)) * 1000;
revealTimerRef.current = setTimeout(() => {
onExpire && onExpire();
}, 12000);
}, ms);
};
// Body: blurred placeholder for unopened view-once, else real content.
@@ -1498,12 +1579,13 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
setCodeMode,
viewOnceMode,
setViewOnceMode,
viewOnceTtl,
setViewOnceTtl,
disappearTtl,
setDisappearTtl,
nowTick,
onUnsendMessage,
onMessageExpire,
onPanicWipe
onMessageExpire
}) => {
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [showFileTransfer, setShowFileTransfer] = React.useState(false);
@@ -1670,6 +1752,7 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
timestamp: msg.timestamp,
mid: msg.mid,
viewOnce: msg.viewOnce,
viewOnceTtl: msg.viewOnceTtl,
expiresAt: msg.expiresAt,
nowTick: nowTick,
canUnsend: typeof onUnsendMessage === 'function',
@@ -1712,11 +1795,16 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
'div',
{ className: "max-w-4xl mx-auto px-4" },
[
React.createElement('div', {
key: 'composer-bar',
className: `flex items-center justify-between flex-wrap gap-2 ${showFileTransfer ? 'mb-4' : ''}`
}, [
React.createElement(
'button',
{
key: 'send-files-toggle',
onClick: () => setShowFileTransfer(!showFileTransfer),
className: `flex items-center text-sm text-gray-400 hover:text-gray-300 transition-colors py-4 ${showFileTransfer ? 'mb-4' : ''}`
className: "flex items-center text-sm text-gray-400 hover:text-gray-300 transition-colors py-4"
},
[
React.createElement(
@@ -1744,8 +1832,16 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
showFileTransfer ? 'Hide file transfer' : 'Send files'
]
),
React.createElement(ChatToolbar, {
key: 'toolbar',
codeMode, setCodeMode,
viewOnceMode, setViewOnceMode,
viewOnceTtl, setViewOnceTtl,
disappearTtl, setDisappearTtl
})
]),
showFileTransfer &&
React.createElement(window.FileTransferComponent || (() =>
React.createElement(window.FileTransferComponent || (() =>
React.createElement('div', {
className: "p-4 text-center text-red-400"
}, 'FileTransferComponent not loaded')
@@ -1766,13 +1862,6 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
'div',
{ className: "max-w-4xl mx-auto p-4" },
[
React.createElement(ChatToolbar, {
key: 'toolbar',
codeMode, setCodeMode,
viewOnceMode, setViewOnceMode,
disappearTtl, setDisappearTtl,
onPanicWipe
}),
React.createElement(
'div',
{ key: 'inputrow', className: "flex items-stretch space-x-3" },
@@ -1785,11 +1874,13 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
value: messageInput,
onChange: (e) => setMessageInput(e.target.value),
onKeyDown: handleKeyPress,
placeholder: "Enter message to encrypt...",
rows: 2,
placeholder: codeMode ? "Paste or type code…" : "Enter message to encrypt...",
rows: codeMode ? 8 : 2,
maxLength: 2000,
style: { backgroundColor: '#272827' },
className: "w-full p-3 border border-gray-600 rounded-lg resize-none text-gray-300 placeholder-gray-500 focus:border-green-500/40 focus:outline-none transition-all custom-scrollbar text-sm"
style: codeMode
? { backgroundColor: '#1b1c1b', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' }
: { backgroundColor: '#272827' },
className: `w-full p-3 border rounded-lg resize-none text-gray-300 placeholder-gray-500 focus:outline-none transition-all custom-scrollbar text-sm ${codeMode ? 'border-orange-500/40 focus:border-orange-500/60' : 'border-gray-600 focus:border-green-500/40'}`
}),
React.createElement(
'div',
@@ -1836,6 +1927,7 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
// Secure chat extras: per-message send modes + 1s tick for countdowns.
const [codeMode, setCodeMode] = React.useState(false);
const [viewOnceMode, setViewOnceMode] = React.useState(false);
const [viewOnceTtl, setViewOnceTtl] = React.useState(15); // seconds visible after the peer opens it
const [disappearTtl, setDisappearTtl] = React.useState(0); // seconds; 0 = off (sticky)
const [nowTick, setNowTick] = React.useState(() => Date.now());
const [connectionStatus, setConnectionStatus] = React.useState('disconnected');
@@ -2004,6 +2096,7 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
timestamp: Date.now(),
mid: opts.mid,
viewOnce: opts.viewOnce === true,
viewOnceTtl: (typeof opts.viewOnceTtl === 'number') ? opts.viewOnceTtl : 15,
expiresAt: (typeof opts.expiresAt === 'number') ? opts.expiresAt : undefined
};
@@ -2171,7 +2264,10 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
const opts = {};
if (meta && typeof meta === 'object') {
if (typeof meta.mid === 'string') opts.mid = meta.mid;
if (meta.once === true) opts.viewOnce = true;
if (meta.once === true) {
opts.viewOnce = true;
opts.viewOnceTtl = Number.isFinite(meta.onceTtl) ? meta.onceTtl : 15;
}
if (Number.isFinite(meta.ttl) && meta.ttl > 0) {
opts.expiresAt = Date.now() + meta.ttl * 1000;
}
@@ -2388,7 +2484,7 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
}
}
handleMessage(' SecureBit.chat Enhanced Security Edition v4.8.15 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.', 'system');
handleMessage(' SecureBit.chat Enhanced Security Edition v4.8.20 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.', 'system');
const handleBeforeUnload = (event) => {
if (event.type === 'beforeunload' && !isTabSwitching) {
@@ -3799,7 +3895,10 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
// on both peers.
const mid = `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const meta = { mid };
if (viewOnceMode) meta.once = true; // applies to the recipient
if (viewOnceMode) { // applies to the recipient
meta.once = true;
meta.onceTtl = viewOnceTtl; // seconds visible after opening
}
if (disappearTtl > 0) meta.ttl = disappearTtl; // applies to both sides
// Local echo: sender sees their own text normally (view-once is a
@@ -3834,23 +3933,6 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
}, []);
// Panic wipe: clear the conversation, tear down the session and wipe keys.
const handlePanicWipe = React.useCallback(() => {
setMessages([]);
try {
const mgr = webrtcManagerRef.current;
if (mgr) {
if (typeof mgr._secureWipeKeys === 'function') { try { mgr._secureWipeKeys(); } catch (_) {} }
if (typeof mgr.disconnect === 'function') { try { mgr.disconnect(); } catch (_) {} }
}
} catch (_) {}
try { document.dispatchEvent(new CustomEvent('disconnected')); } catch (_) {}
setConnectionStatus('disconnected');
setIsVerified(false);
setKeyFingerprint('');
setSecurityLevel(null);
setMessageInput('');
}, []);
const handleClearData = () => {
setOfferData('');
setAnswerData('');
@@ -4177,12 +4259,13 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
setCodeMode: setCodeMode,
viewOnceMode: viewOnceMode,
setViewOnceMode: setViewOnceMode,
viewOnceTtl: viewOnceTtl,
setViewOnceTtl: setViewOnceTtl,
disappearTtl: disappearTtl,
setDisappearTtl: setDisappearTtl,
nowTick: nowTick,
onUnsendMessage: handleUnsendMessage,
onMessageExpire: handleMessageExpire,
onPanicWipe: handlePanicWipe
onMessageExpire: handleMessageExpire
});
})()
: React.createElement(EnhancedConnectionSetup, {
+1 -1
View File
@@ -539,7 +539,7 @@ const EnhancedMinimalHeader = ({
React.createElement('p', {
key: 'subtitle',
className: 'text-xs sm:text-sm text-muted hidden sm:block'
}, 'End-to-end freedom v4.8.15')
}, 'End-to-end freedom v4.8.20')
])
]),
+17 -3
View File
@@ -6462,6 +6462,11 @@ async processOrderedPackets() {
}
if (meta.code === true) out.code = true;
if (meta.once === true) out.once = true;
if (Number.isFinite(meta.onceTtl)) {
// View-once: seconds the message stays visible after the peer opens it.
const onceTtl = Math.floor(meta.onceTtl);
if (onceTtl >= 1 && onceTtl <= 3600) out.onceTtl = onceTtl;
}
if (Number.isFinite(meta.ttl)) {
// Clamp disappearing timer to [5s, 24h]; ignore anything else.
const ttl = Math.floor(meta.ttl);
@@ -7847,10 +7852,19 @@ async processMessage(data) {
return; // IMPORTANT: Do not process further
}
// Per-message delete (unsend) from the peer.
if (parsed.type === EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_DELETE) {
const messageId = parsed?.data?.messageId ?? parsed?.messageId;
if (typeof messageId === 'string' && messageId) {
try { this.onMessageDelete?.(messageId.slice(0, 64)); } catch (_) {}
}
return;
}
// ============================================
// SYSTEM MESSAGES (WITHOUT MUTEX)
// ============================================
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;
@@ -7865,7 +7879,7 @@ async processMessage(data) {
return;
}
if (this.onMessage) {
this.deliverMessageToUI(parsed.data, 'received');
this.deliverMessageToUI(parsed.data, 'received', parsed.meta);
}
return;
}
@@ -7994,7 +8008,7 @@ async processMessage(data) {
}
if (decryptedContent && decryptedContent.type === 'message' && typeof decryptedContent.data === 'string') {
if (this.onMessage) {
this.deliverMessageToUI(decryptedContent.data, 'received');
this.deliverMessageToUI(decryptedContent.data, 'received', decryptedContent.meta);
}
return;
}
+11 -7
View File
@@ -42,13 +42,15 @@ class NotificationIntegration {
this.originalOnStatusChange = this.webrtcManager.onStatusChange;
// Wrap the original onMessage callback
this.webrtcManager.onMessage = (message, type) => {
// Wrap the original onMessage callback.
// IMPORTANT: forward ALL arguments (incl. per-message `meta`) so the app
// still receives view-once / disappearing / unsend metadata.
this.webrtcManager.onMessage = (message, type, ...rest) => {
this.handleIncomingMessage(message, type);
// Call original callback if it exists
if (this.originalOnMessage) {
this.originalOnMessage(message, type);
this.originalOnMessage(message, type, ...rest);
}
};
@@ -62,12 +64,14 @@ class NotificationIntegration {
}
};
// Also hook into the deliverMessageToUI method if it exists
// Also hook into the deliverMessageToUI method if it exists.
// IMPORTANT: forward ALL arguments (incl. per-message `meta`) to the
// original, otherwise view-once / disappearing / unsend metadata is lost.
if (this.webrtcManager.deliverMessageToUI) {
this.originalDeliverMessageToUI = this.webrtcManager.deliverMessageToUI.bind(this.webrtcManager);
this.webrtcManager.deliverMessageToUI = (message, type) => {
this.webrtcManager.deliverMessageToUI = (message, type, ...rest) => {
this.handleIncomingMessage(message, type);
this.originalDeliverMessageToUI(message, type);
this.originalDeliverMessageToUI(message, type, ...rest);
};
}
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
// NotificationIntegration wraps webrtcManager.onMessage and .deliverMessageToUI.
// Regression: those wrappers must forward the 3rd argument (per-message `meta`)
// to the originals, otherwise view-once / disappearing / unsend break ONLY when
// notifications are enabled (which is exactly how it shipped broken).
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'https://localhost/' });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
// Minimal Notification stub so init() does not throw.
globalThis.Notification = dom.window.Notification = class { static permission = 'granted'; static requestPermission() { return Promise.resolve('granted'); } close() {} };
await import('../src/notifications/NotificationIntegration.js');
const NotificationIntegration = window.NotificationIntegration;
const received = [];
const delivered = [];
const manager = {
onMessage: (message, type, meta) => received.push({ message, type, meta }),
onStatusChange: () => {},
deliverMessageToUI: (message, type, meta) => delivered.push({ message, type, meta })
};
const integration = new NotificationIntegration(manager);
await integration.init();
// After init, the manager's callbacks are the wrappers. Calling them with meta
// must forward meta to the originals.
manager.onMessage('hi', 'received', { mid: 'm1', once: true });
manager.deliverMessageToUI('yo', 'received', { mid: 'm2', ttl: 30 });
assert.equal(received.length, 1, 'original onMessage called once');
assert.deepEqual(received[0].meta, { mid: 'm1', once: true }, 'meta forwarded through onMessage wrapper');
assert.equal(delivered.length, 1, 'original deliverMessageToUI called once');
assert.deepEqual(delivered[0].meta, { mid: 'm2', ttl: 30 }, 'meta forwarded through deliverMessageToUI wrapper');
console.log('Notification meta-forwarding tests passed');
+29 -2
View File
@@ -10,8 +10,12 @@ const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
// ── _sanitizeMessageMeta: whitelist + bounds ────────────────────────────────
{
const ok = P._sanitizeMessageMeta.call({}, { mid: 'm_1-a', once: true, ttl: 300, code: true });
assert.deepEqual(ok, { mid: 'm_1-a', code: true, once: true, ttl: 300 });
const ok = P._sanitizeMessageMeta.call({}, { mid: 'm_1-a', once: true, onceTtl: 15, ttl: 300, code: true });
assert.deepEqual(ok, { mid: 'm_1-a', code: true, once: true, onceTtl: 15, ttl: 300 });
// onceTtl is clamped to [1, 3600]; out-of-range is dropped.
assert.equal(P._sanitizeMessageMeta.call({}, { once: true, onceTtl: 99999 }).onceTtl, undefined);
assert.equal(P._sanitizeMessageMeta.call({}, { once: true, onceTtl: 30 }).onceTtl, 30);
// Junk and out-of-range values are stripped; with no valid keys -> null.
assert.equal(P._sanitizeMessageMeta.call({}, { foo: 1 }), null);
@@ -58,6 +62,29 @@ const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
assert.deepEqual(deleted, ['m_42']);
}
// ── live enhanced-message path delivers metadata to the UI ───────────────────
// This is the path real chat uses (dataChannel.onmessage -> _processEnhancedMessageWithoutMutex).
{
const envelope = JSON.stringify({ type: 'message', data: 'hi there', meta: { mid: 'm7', once: true, ttl: 30 } });
globalThis.window.EnhancedSecureCryptoUtils = {
decryptMessage: async () => ({ message: envelope })
};
const calls = [];
const manager = {
encryptionKey: {}, macKey: {}, metadataKey: {},
_secureLog() {},
_checkInboundRateLimit: () => true,
_sanitizeIncomingChatMessage: (m) => m,
_sanitizeMessageMeta: P._sanitizeMessageMeta,
onMessage: (message, type, meta) => calls.push({ message, type, meta }),
deliverMessageToUI: P.deliverMessageToUI
};
await P._processEnhancedMessageWithoutMutex.call(manager, { type: 'enhanced_message', data: 'enc' });
assert.equal(calls.length, 1);
assert.equal(calls[0].message, 'hi there');
assert.deepEqual(calls[0].meta, { mid: 'm7', once: true, ttl: 30 });
}
// ── sendMessageDelete emits a well-formed control message ────────────────────
{
const sent = [];