release: v4.8.14 secure chat tools (code blocks, view-once, disappearing, unsend, panic)
New privacy-focused messaging controls in the composer: - Code blocks: button wraps the message in a fenced block; both peers render a monospace code window with a copy button (clipboard auto-clears after ~30s). Window is built from sanitized text via React nodes — no new XSS surface. - View-once: recipient sees a blurred bubble, reveals on tap, then it is wiped. Honestly cooperative (not screenshot-proof). - Disappearing messages: optional 30s/5m/1h timer auto-deletes on both sides with a live countdown; incoming TTL clamped to [5s, 24h]. - Unsend (delete for everyone) via new MESSAGE_TYPES.message_delete control. - Panic wipe: clears chat, wipes keys and disconnects (behind a confirm). Transport: - Per-message metadata (id / view-once / timer) travels inside the encrypted envelope, not in the sanitized text, so content cannot spoof these controls. - _sanitizeMessageMeta whitelists + bounds metadata on send and receive. - AAD/replay protection, SAS gate and receive-side DOMPurify are unchanged. Adds tests/secure-chat-features.test.mjs (full suite: 17 files, all passing). Bumps version to 4.8.14 across package.json, package-lock.json, manifest.json, index.html, meta.json, README, SECURITY_DISCLAIMER, header and init banner.
This commit is contained in:
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## v4.8.14 — Secure chat tools: code blocks, view-once, disappearing, unsend, panic
|
||||
|
||||
Adds privacy-focused messaging controls. Per-message metadata (id, view-once, timer) travels **inside the encrypted message envelope**, never in the sanitized text, so message content cannot spoof or corrupt these controls. The unsend/delete signal travels over the authenticated DTLS control channel like other system messages.
|
||||
|
||||
### Added
|
||||
|
||||
- **Code blocks.** A composer button wraps the message in a fenced block; both peers render it as a monospace code window with a copy button. The marker travels as ordinary text, and the window is built from already-sanitized text via React nodes only (no `dangerouslySetInnerHTML`), so there is no new XSS surface.
|
||||
- **Clipboard auto-clear.** Copying a code block clears the clipboard after ~30s — only when it can confirm the clipboard still holds the copied value, or cannot read it back, so a later copy is never clobbered.
|
||||
- **View-once messages.** The recipient sees a blurred bubble that reveals on tap and is then wiped. Honestly cooperative (a malicious client or a screenshot can still capture it) — this is hygiene, not a guarantee.
|
||||
- **Disappearing messages.** An optional sticky timer (30s / 5m / 1h) auto-deletes a message on both sides, with a live countdown. The incoming timer value is clamped to [5s, 24h].
|
||||
- **Unsend (delete for everyone).** Removes your message locally and asks the peer to drop it via a `message_delete` control message (`MESSAGE_TYPES.MESSAGE_DELETE`).
|
||||
- **Panic wipe.** One button clears the conversation, wipes keys (`_secureWipeKeys`) and tears down the session, behind a confirm prompt.
|
||||
|
||||
### Security
|
||||
|
||||
- New per-message metadata is whitelisted and bounded by `_sanitizeMessageMeta` on both send and receive; unknown fields, wrong types and out-of-range timers are dropped.
|
||||
- AAD/replay protection, the SAS verification gate and receive-side DOMPurify sanitization are unchanged.
|
||||
|
||||
### Tests
|
||||
|
||||
- Added `tests/secure-chat-features.test.mjs` covering metadata sanitization, meta delivery to the UI, and the unsend control path. Full suite: 17 files, all passing.
|
||||
|
||||
## v4.8.13 — Message integrity & transport hardening
|
||||
|
||||
Security review follow-up. The end-to-end cryptography (ECDH, AES-GCM, PBKDF2, SAS bound to DTLS fingerprints, anti-replay) was verified sound; these changes fix availability/integrity defects on the send path and tighten transport headers and logging.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SecureBit.chat v4.8.13
|
||||
# SecureBit.chat v4.8.14
|
||||
|
||||
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,7 +15,16 @@ 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.13
|
||||
## Highlights in v4.8.14
|
||||
|
||||
- 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.
|
||||
|
||||
Earlier in v4.8.13:
|
||||
|
||||
- Security/integrity: outgoing chat messages are no longer silently rejected by an over-broad keyword blocklist (plain words like "constructor", "global", "document." or the literal text "javascript:" were being blocked). XSS is still prevented at the rendering boundary by the receive-side DOMPurify pass and by message sanitization before encryption.
|
||||
- Integrity: multi-line messages and code snippets keep their newlines and indentation instead of being collapsed onto a single line.
|
||||
|
||||
@@ -22,6 +22,6 @@ SecureBit.chat is intended for legitimate private communication, journalism, res
|
||||
|
||||
## Current release
|
||||
|
||||
- Product release: `v4.8.13`
|
||||
- Product release: `v4.8.14`
|
||||
- Protocol version: `4.1`
|
||||
- Last updated: May 17, 2026
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+60
-8
@@ -6063,6 +6063,8 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
// Regular messages
|
||||
MESSAGE: "message",
|
||||
ENHANCED_MESSAGE: "enhanced_message",
|
||||
// Per-message control (unsend / disappearing sync)
|
||||
MESSAGE_DELETE: "message_delete",
|
||||
// System messages
|
||||
HEARTBEAT: "heartbeat",
|
||||
VERIFICATION: "verification",
|
||||
@@ -9986,7 +9988,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
}
|
||||
return window.EnhancedSecureCryptoUtils.sanitizeMessage(message);
|
||||
}
|
||||
deliverMessageToUI(message, type = "received") {
|
||||
deliverMessageToUI(message, type = "received", meta = null) {
|
||||
try {
|
||||
this._secureLog("debug", "\u{1F4E4} deliverMessageToUI called", {
|
||||
message,
|
||||
@@ -10052,8 +10054,9 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
}
|
||||
const uiMessage = type === "received" ? this._sanitizeIncomingChatMessage(message) : message;
|
||||
if (this.onMessage) {
|
||||
const safeMeta = meta && typeof this._sanitizeMessageMeta === "function" ? this._sanitizeMessageMeta(meta) : null;
|
||||
this._secureLog("debug", "\u{1F4E4} Calling this.onMessage callback", { message: uiMessage, type });
|
||||
this.onMessage(uiMessage, type);
|
||||
this.onMessage(uiMessage, type, safeMeta || void 0);
|
||||
} else {
|
||||
this._secureLog("warn", "\u26A0\uFE0F this.onMessage callback is null or undefined");
|
||||
}
|
||||
@@ -11069,7 +11072,42 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
async sendMessage(data) {
|
||||
/**
|
||||
* Whitelist + bound the per-message UI metadata so a peer cannot smuggle
|
||||
* arbitrary objects, huge values, or absurd timers through it.
|
||||
* @param {object} meta
|
||||
* @returns {object|null}
|
||||
*/
|
||||
_sanitizeMessageMeta(meta) {
|
||||
if (!meta || typeof meta !== "object") return null;
|
||||
const out = {};
|
||||
if (typeof meta.mid === "string" && meta.mid.length > 0 && meta.mid.length <= 64) {
|
||||
out.mid = meta.mid.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64);
|
||||
}
|
||||
if (meta.code === true) out.code = true;
|
||||
if (meta.once === true) out.once = true;
|
||||
if (Number.isFinite(meta.ttl)) {
|
||||
const ttl = Math.floor(meta.ttl);
|
||||
if (ttl >= 5 && ttl <= 86400) out.ttl = ttl;
|
||||
}
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
/**
|
||||
* Unsend: ask the peer to remove a previously delivered message by id.
|
||||
* Sent over the authenticated DTLS control channel like other system
|
||||
* messages. Best-effort and cooperative — a peer can ignore it, exactly
|
||||
* like WhatsApp/Telegram "delete for everyone".
|
||||
* @param {string} messageId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
sendMessageDelete(messageId) {
|
||||
if (typeof messageId !== "string" || !messageId) return false;
|
||||
return this.sendSystemMessage({
|
||||
type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_DELETE,
|
||||
messageId: messageId.slice(0, 64)
|
||||
});
|
||||
}
|
||||
async sendMessage(data, meta = null) {
|
||||
const validation = this._validateInputData(data, "sendMessage");
|
||||
if (!validation.isValid) {
|
||||
const errorMessage = `Input validation failed: ${validation.errors.join(", ")}`;
|
||||
@@ -11122,13 +11160,17 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
throw new Error("_createMessageAAD method is not available. Manager may not be fully initialized.");
|
||||
}
|
||||
const aad = this._createMessageAAD("message", { content: validation.sanitizedData });
|
||||
return await this.sendSecureMessage({
|
||||
const envelope = {
|
||||
type: "message",
|
||||
data: validation.sanitizedData,
|
||||
timestamp: Date.now(),
|
||||
aad
|
||||
// Include AAD for sequence number validation
|
||||
});
|
||||
};
|
||||
if (meta && typeof meta === "object") {
|
||||
envelope.meta = this._sanitizeMessageMeta(meta);
|
||||
}
|
||||
return await this.sendSecureMessage(envelope);
|
||||
}
|
||||
this._secureLog("debug", "\u{1F510} Applying security layers to non-string data");
|
||||
const securedData = await this._applySecurityLayersWithLimitedMutex(validation.sanitizedData, false);
|
||||
@@ -11263,7 +11305,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
}
|
||||
}
|
||||
if (decryptedParsed.type === "message" && this.onMessage && decryptedParsed.data) {
|
||||
this.deliverMessageToUI(decryptedParsed.data, "received");
|
||||
this.deliverMessageToUI(decryptedParsed.data, "received", decryptedParsed.meta);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -11277,7 +11319,17 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
return;
|
||||
}
|
||||
if (this.onMessage && parsed.data) {
|
||||
this.deliverMessageToUI(parsed.data, "received");
|
||||
this.deliverMessageToUI(parsed.data, "received", parsed.meta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (parsed.type === _EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_DELETE) {
|
||||
const messageId = parsed?.data?.messageId ?? parsed?.messageId;
|
||||
if (typeof messageId === "string" && messageId) {
|
||||
try {
|
||||
this.onMessageDelete?.(messageId.slice(0, 64));
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -17435,7 +17487,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.13")
|
||||
}, "End-to-end freedom v4.8.14")
|
||||
])
|
||||
]),
|
||||
// Status and Controls - Responsive
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+429
-84
@@ -152,6 +152,178 @@ async function clearIceSettings() {
|
||||
}
|
||||
|
||||
// src/app.jsx
|
||||
var copyToClipboardSecure = async (text, autoClearMs = 0) => {
|
||||
let ok = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
} catch (e) {
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
ok = document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
} catch (_) {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (ok && autoClearMs > 0 && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
setTimeout(async () => {
|
||||
let current = null;
|
||||
let readable = true;
|
||||
try {
|
||||
current = await navigator.clipboard.readText();
|
||||
} catch (_) {
|
||||
readable = false;
|
||||
}
|
||||
if (!readable || current === text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText("");
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
}, autoClearMs);
|
||||
}
|
||||
return ok;
|
||||
};
|
||||
var parseMessageSegments = (text) => {
|
||||
if (typeof text !== "string" || text.indexOf("```") === -1) return null;
|
||||
const segments = [];
|
||||
const re = /```([a-zA-Z0-9_+#.-]*)\n?([\s\S]*?)```/g;
|
||||
let last = 0;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) segments.push({ kind: "text", content: text.slice(last, m.index) });
|
||||
segments.push({ kind: "code", lang: (m[1] || "").toLowerCase(), content: m[2].replace(/\n$/, "") });
|
||||
last = re.lastIndex;
|
||||
}
|
||||
if (last < text.length) segments.push({ kind: "text", content: text.slice(last) });
|
||||
return segments.some((s) => s.kind === "code") ? segments : null;
|
||||
};
|
||||
var CodeBlock = ({ code, lang }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const handleCopy = async () => {
|
||||
const ok = await copyToClipboardSecure(code, 3e4);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2e3);
|
||||
}
|
||||
};
|
||||
return React.createElement("div", {
|
||||
className: "my-1 rounded-lg overflow-hidden border border-gray-600/40",
|
||||
style: { backgroundColor: "#1b1c1b" }
|
||||
}, [
|
||||
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" }
|
||||
}, [
|
||||
React.createElement("span", {
|
||||
key: "lang",
|
||||
className: "text-[11px] uppercase tracking-wide text-gray-500 font-mono"
|
||||
}, lang || "code"),
|
||||
React.createElement("button", {
|
||||
key: "copy",
|
||||
onClick: handleCopy,
|
||||
title: "Copy \u2014 clipboard auto-clears in 30s",
|
||||
className: "flex items-center text-[11px] text-gray-400 hover:text-green-400 transition-colors"
|
||||
}, [
|
||||
React.createElement("i", {
|
||||
key: "ic",
|
||||
className: `${copied ? "fas fa-check text-green-400" : "far fa-copy"} mr-1`
|
||||
}),
|
||||
copied ? "Copied" : "Copy"
|
||||
])
|
||||
]),
|
||||
React.createElement("pre", {
|
||||
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))
|
||||
]);
|
||||
};
|
||||
var MessageBody = ({ text }) => {
|
||||
const segments = parseMessageSegments(text);
|
||||
if (!segments) {
|
||||
return React.createElement("div", {
|
||||
className: "text-sm break-words",
|
||||
style: { whiteSpace: "pre-wrap", wordBreak: "break-word" }
|
||||
}, text);
|
||||
}
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ className: "text-sm" },
|
||||
segments.map(
|
||||
(seg, i) => seg.kind === "code" ? React.createElement(CodeBlock, { key: i, code: seg.content, lang: seg.lang }) : seg.content.trim() ? React.createElement("div", {
|
||||
key: i,
|
||||
className: "break-words",
|
||||
style: { whiteSpace: "pre-wrap", wordBreak: "break-word" }
|
||||
}, seg.content) : null
|
||||
)
|
||||
);
|
||||
};
|
||||
var ChatToolbar = ({ codeMode: codeMode2, setCodeMode: setCodeMode2, viewOnceMode: viewOnceMode2, setViewOnceMode: setViewOnceMode2, disappearTtl: disappearTtl2, setDisappearTtl: setDisappearTtl2, onPanicWipe: onPanicWipe2 }) => {
|
||||
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(disappearTtl2);
|
||||
setDisappearTtl2(ttlCycle[(i + 1) % ttlCycle.length] || 0);
|
||||
};
|
||||
const pill = (key, { active, activeClass, 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"}`
|
||||
}, [
|
||||
React.createElement("i", { key: "i", className: icon }),
|
||||
label ? React.createElement("span", { key: "l" }, label) : null
|
||||
]);
|
||||
return React.createElement("div", {
|
||||
className: "flex items-center flex-wrap gap-2 pb-3"
|
||||
}, [
|
||||
pill("code", {
|
||||
active: codeMode2,
|
||||
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)",
|
||||
onClick: () => setCodeMode2((v) => !v)
|
||||
}),
|
||||
pill("once", {
|
||||
active: viewOnceMode2,
|
||||
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: () => setViewOnceMode2((v) => !v)
|
||||
}),
|
||||
pill("ttl", {
|
||||
active: disappearTtl2 > 0,
|
||||
activeClass: "text-blue-400 border-blue-500/40 bg-blue-500/10",
|
||||
icon: "fas fa-stopwatch",
|
||||
label: `Timer: ${ttlLabel(disappearTtl2)}`,
|
||||
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 onPanicWipe2 === "function") onPanicWipe2();
|
||||
}
|
||||
})
|
||||
]);
|
||||
};
|
||||
var EnhancedCopyButton = ({ text, className = "", children }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const handleCopy = async () => {
|
||||
@@ -364,7 +536,9 @@ var VerificationStep = ({ verificationCode, onConfirm, onReject, localConfirmed,
|
||||
])
|
||||
]);
|
||||
};
|
||||
var EnhancedChatMessage = ({ message, type, timestamp }) => {
|
||||
var EnhancedChatMessage = ({ message, type, timestamp, mid, viewOnce, expiresAt, nowTick: nowTick2, canUnsend, onUnsend, onExpire }) => {
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
const revealTimerRef = React.useRef(null);
|
||||
const formatTime = (ts) => {
|
||||
return new Date(ts).toLocaleTimeString("ru-RU", {
|
||||
hour: "2-digit",
|
||||
@@ -372,6 +546,9 @@ var EnhancedChatMessage = ({ message, type, timestamp }) => {
|
||||
second: "2-digit"
|
||||
});
|
||||
};
|
||||
React.useEffect(() => () => {
|
||||
if (revealTimerRef.current) clearTimeout(revealTimerRef.current);
|
||||
}, []);
|
||||
const getMessageStyle = () => {
|
||||
switch (type) {
|
||||
case "sent":
|
||||
@@ -401,39 +578,83 @@ var EnhancedChatMessage = ({ message, type, timestamp }) => {
|
||||
}
|
||||
};
|
||||
const style = getMessageStyle();
|
||||
const remaining = typeof expiresAt === "number" ? Math.max(0, Math.ceil((expiresAt - (nowTick2 || Date.now())) / 1e3)) : null;
|
||||
const isViewOnce = type === "received" && viewOnce === true;
|
||||
const handleReveal = () => {
|
||||
if (revealed) return;
|
||||
setRevealed(true);
|
||||
revealTimerRef.current = setTimeout(() => {
|
||||
onExpire && onExpire();
|
||||
}, 12e3);
|
||||
};
|
||||
let body;
|
||||
if (isViewOnce && !revealed) {
|
||||
body = React.createElement("button", {
|
||||
key: "vo",
|
||||
onClick: handleReveal,
|
||||
className: "w-full flex items-center space-x-2 text-left text-sm text-gray-300 hover:text-white transition-colors"
|
||||
}, [
|
||||
React.createElement("i", { key: "i", className: "fas fa-eye-slash accent-orange" }),
|
||||
React.createElement("span", { key: "t" }, "View once \u2014 tap to read"),
|
||||
React.createElement("i", { key: "b", className: "fas fa-fingerprint text-muted ml-auto opacity-60" })
|
||||
]);
|
||||
} else {
|
||||
body = React.createElement(MessageBody, { key: "body", text: message });
|
||||
}
|
||||
const metaChildren = [
|
||||
React.createElement("span", { key: "time" }, formatTime(timestamp))
|
||||
];
|
||||
if (isViewOnce && revealed) {
|
||||
metaChildren.push(React.createElement("span", {
|
||||
key: "vo-note",
|
||||
className: "flex items-center text-orange-400/80"
|
||||
}, [
|
||||
React.createElement("i", { key: "i", className: "fas fa-eye-slash mr-1" }),
|
||||
"Deletes after reading"
|
||||
]));
|
||||
} else if (remaining !== null) {
|
||||
metaChildren.push(React.createElement("span", {
|
||||
key: "ttl",
|
||||
className: "flex items-center text-gray-400",
|
||||
title: "Disappearing message"
|
||||
}, [
|
||||
React.createElement("i", { key: "i", className: "far fa-clock mr-1" }),
|
||||
remaining >= 60 ? `${Math.ceil(remaining / 60)}m` : `${remaining}s`
|
||||
]));
|
||||
} else {
|
||||
metaChildren.push(React.createElement("span", { key: "status", className: "text-xs" }, style.label));
|
||||
}
|
||||
const headerRow = [
|
||||
React.createElement("i", {
|
||||
key: "icon",
|
||||
className: `${style.icon} text-sm mt-0.5 opacity-70`
|
||||
}),
|
||||
React.createElement("div", {
|
||||
key: "text",
|
||||
className: "flex-1 min-w-0"
|
||||
}, [
|
||||
body,
|
||||
timestamp && React.createElement("div", {
|
||||
key: "meta",
|
||||
className: "flex items-center justify-between gap-2 mt-1 text-xs opacity-60"
|
||||
}, metaChildren)
|
||||
])
|
||||
];
|
||||
if (canUnsend && type === "sent" && mid) {
|
||||
headerRow.push(React.createElement("button", {
|
||||
key: "unsend",
|
||||
onClick: () => onUnsend && onUnsend(mid),
|
||||
title: "Delete for everyone",
|
||||
className: "flex-shrink-0 text-gray-500 hover:text-red-400 transition-colors text-xs mt-0.5"
|
||||
}, React.createElement("i", { className: "fas fa-trash-can" })));
|
||||
}
|
||||
return React.createElement("div", {
|
||||
className: `message-slide mb-3 p-3 rounded-lg max-w-md break-words ${style.container} border`
|
||||
}, [
|
||||
React.createElement("div", {
|
||||
key: "content",
|
||||
className: "flex items-start space-x-2"
|
||||
}, [
|
||||
React.createElement("i", {
|
||||
key: "icon",
|
||||
className: `${style.icon} text-sm mt-0.5 opacity-70`
|
||||
}),
|
||||
React.createElement("div", {
|
||||
key: "text",
|
||||
className: "flex-1 min-w-0"
|
||||
}, [
|
||||
React.createElement("div", {
|
||||
key: "message",
|
||||
className: "text-sm break-words whitespace-normal"
|
||||
}, message),
|
||||
timestamp && React.createElement("div", {
|
||||
key: "meta",
|
||||
className: "flex items-center justify-between mt-1 text-xs opacity-50"
|
||||
}, [
|
||||
React.createElement("span", {
|
||||
key: "time"
|
||||
}, formatTime(timestamp)),
|
||||
React.createElement("span", {
|
||||
key: "status",
|
||||
className: "text-xs"
|
||||
}, style.label)
|
||||
])
|
||||
])
|
||||
])
|
||||
}, headerRow)
|
||||
]);
|
||||
};
|
||||
var EnhancedConnectionSetup = ({
|
||||
@@ -479,7 +700,18 @@ var EnhancedConnectionSetup = ({
|
||||
handleCreateOffer,
|
||||
relayOnlyMode,
|
||||
setRelayOnlyMode,
|
||||
webrtcManagerRef
|
||||
webrtcManagerRef,
|
||||
// Secure chat extras
|
||||
codeMode: codeMode2,
|
||||
setCodeMode: setCodeMode2,
|
||||
viewOnceMode: viewOnceMode2,
|
||||
setViewOnceMode: setViewOnceMode2,
|
||||
disappearTtl: disappearTtl2,
|
||||
setDisappearTtl: setDisappearTtl2,
|
||||
nowTick: nowTick2,
|
||||
onUnsendMessage: onUnsendMessage2,
|
||||
onMessageExpire: onMessageExpire2,
|
||||
onPanicWipe: onPanicWipe2
|
||||
}) => {
|
||||
const [mode, setMode] = React.useState("select");
|
||||
const [notificationPermissionRequested, setNotificationPermissionRequested] = React.useState(false);
|
||||
@@ -1452,7 +1684,14 @@ var EnhancedChatInterface = ({
|
||||
key: msg.id,
|
||||
message: msg.message,
|
||||
type: msg.type,
|
||||
timestamp: msg.timestamp
|
||||
timestamp: msg.timestamp,
|
||||
mid: msg.mid,
|
||||
viewOnce: msg.viewOnce,
|
||||
expiresAt: msg.expiresAt,
|
||||
nowTick,
|
||||
canUnsend: typeof onUnsendMessage === "function",
|
||||
onUnsend: onUnsendMessage,
|
||||
onExpire: () => onMessageExpire && onMessageExpire(msg.id)
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -1534,54 +1773,66 @@ var EnhancedChatInterface = ({
|
||||
React.createElement(
|
||||
"div",
|
||||
{ className: "max-w-4xl mx-auto p-4" },
|
||||
React.createElement(
|
||||
"div",
|
||||
{ className: "flex items-stretch space-x-3" },
|
||||
[
|
||||
React.createElement(
|
||||
"div",
|
||||
{ className: "flex-1 relative" },
|
||||
[
|
||||
React.createElement("textarea", {
|
||||
value: messageInput,
|
||||
onChange: (e) => setMessageInput(e.target.value),
|
||||
onKeyDown: handleKeyPress,
|
||||
placeholder: "Enter message to encrypt...",
|
||||
rows: 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"
|
||||
}),
|
||||
React.createElement(
|
||||
"div",
|
||||
{ className: "absolute bottom-2 right-3 flex items-center space-x-2 text-xs text-gray-400" },
|
||||
[
|
||||
React.createElement("span", null, `${messageInput.length}/2000`),
|
||||
React.createElement("span", null, "\u2022 Enter to send")
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick: onSendMessage,
|
||||
disabled: !messageInput.trim(),
|
||||
className: "bg-green-400/20 text-green-400 p-3 rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-h-[72px]"
|
||||
},
|
||||
[
|
||||
React.createElement(ChatToolbar, {
|
||||
key: "toolbar",
|
||||
codeMode,
|
||||
setCodeMode,
|
||||
viewOnceMode,
|
||||
setViewOnceMode,
|
||||
disappearTtl,
|
||||
setDisappearTtl,
|
||||
onPanicWipe
|
||||
}),
|
||||
React.createElement(
|
||||
"div",
|
||||
{ key: "inputrow", className: "flex items-stretch space-x-3" },
|
||||
[
|
||||
React.createElement(
|
||||
"svg",
|
||||
{ className: "w-6 h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24" },
|
||||
React.createElement("path", {
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
strokeWidth: 2,
|
||||
d: "M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
})
|
||||
"div",
|
||||
{ className: "flex-1 relative" },
|
||||
[
|
||||
React.createElement("textarea", {
|
||||
value: messageInput,
|
||||
onChange: (e) => setMessageInput(e.target.value),
|
||||
onKeyDown: handleKeyPress,
|
||||
placeholder: "Enter message to encrypt...",
|
||||
rows: 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"
|
||||
}),
|
||||
React.createElement(
|
||||
"div",
|
||||
{ className: "absolute bottom-2 right-3 flex items-center space-x-2 text-xs text-gray-400" },
|
||||
[
|
||||
React.createElement("span", null, `${messageInput.length}/2000`),
|
||||
React.createElement("span", null, "\u2022 Enter to send")
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick: onSendMessage,
|
||||
disabled: !messageInput.trim(),
|
||||
className: "bg-green-400/20 text-green-400 p-3 rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-h-[72px]"
|
||||
},
|
||||
React.createElement(
|
||||
"svg",
|
||||
{ className: "w-6 h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24" },
|
||||
React.createElement("path", {
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
strokeWidth: 2,
|
||||
d: "M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
@@ -1589,6 +1840,10 @@ var EnhancedChatInterface = ({
|
||||
};
|
||||
var EnhancedSecureP2PChat = () => {
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
const [codeMode2, setCodeMode2] = React.useState(false);
|
||||
const [viewOnceMode2, setViewOnceMode2] = React.useState(false);
|
||||
const [disappearTtl2, setDisappearTtl2] = React.useState(0);
|
||||
const [nowTick2, setNowTick] = React.useState(() => Date.now());
|
||||
const [connectionStatus, setConnectionStatus] = React.useState("disconnected");
|
||||
const [relayOnlyMode, setRelayOnlyMode] = React.useState(() => {
|
||||
try {
|
||||
@@ -1707,12 +1962,15 @@ var EnhancedSecureP2PChat = () => {
|
||||
onClearData: handleClearData
|
||||
});
|
||||
}, []);
|
||||
const addMessageWithAutoScroll = React.useCallback((message, type) => {
|
||||
const addMessageWithAutoScroll = React.useCallback((message, type, opts = {}) => {
|
||||
const newMessage = {
|
||||
message,
|
||||
type,
|
||||
id: Date.now() + Math.random(),
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
mid: opts.mid,
|
||||
viewOnce: opts.viewOnce === true,
|
||||
expiresAt: typeof opts.expiresAt === "number" ? opts.expiresAt : void 0
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev, newMessage];
|
||||
@@ -1799,12 +2057,25 @@ var EnhancedSecureP2PChat = () => {
|
||||
setTimeout(scrollToBottom, 150);
|
||||
}
|
||||
}, [messages]);
|
||||
const hasExpiring = messages.some((m) => typeof m.expiresAt === "number");
|
||||
React.useEffect(() => {
|
||||
if (!hasExpiring) return;
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setNowTick(now);
|
||||
setMessages((prev) => {
|
||||
const kept = prev.filter((m) => !(typeof m.expiresAt === "number" && m.expiresAt <= now));
|
||||
return kept.length === prev.length ? prev : kept;
|
||||
});
|
||||
}, 1e3);
|
||||
return () => clearInterval(interval);
|
||||
}, [hasExpiring]);
|
||||
React.useEffect(() => {
|
||||
if (webrtcManagerRef.current) {
|
||||
console.log("\u26A0\uFE0F WebRTC Manager already initialized, skipping...");
|
||||
return;
|
||||
}
|
||||
const handleMessage = (message, type) => {
|
||||
const handleMessage = (message, type, meta) => {
|
||||
if (typeof message === "string" && message.trim().startsWith("{")) {
|
||||
try {
|
||||
const parsedMessage = JSON.parse(message);
|
||||
@@ -1832,7 +2103,15 @@ var EnhancedSecureP2PChat = () => {
|
||||
} catch (parseError) {
|
||||
}
|
||||
}
|
||||
addMessageWithAutoScroll(message, type);
|
||||
const opts = {};
|
||||
if (meta && typeof meta === "object") {
|
||||
if (typeof meta.mid === "string") opts.mid = meta.mid;
|
||||
if (meta.once === true) opts.viewOnce = true;
|
||||
if (Number.isFinite(meta.ttl) && meta.ttl > 0) {
|
||||
opts.expiresAt = Date.now() + meta.ttl * 1e3;
|
||||
}
|
||||
}
|
||||
addMessageWithAutoScroll(message, type, opts);
|
||||
};
|
||||
const handleStatusChange = (status) => {
|
||||
setConnectionStatus(status);
|
||||
@@ -1977,6 +2256,10 @@ var EnhancedSecureP2PChat = () => {
|
||||
}
|
||||
}
|
||||
);
|
||||
webrtcManagerRef.current.onMessageDelete = (mid) => {
|
||||
if (!mid) return;
|
||||
setMessages((prev) => prev.filter((m) => String(m.mid) !== String(mid)));
|
||||
};
|
||||
if (typeof Notification !== "undefined" && Notification && Notification.permission === "granted" && window.NotificationIntegration && !notificationIntegrationRef.current) {
|
||||
try {
|
||||
const integration = new window.NotificationIntegration(webrtcManagerRef.current);
|
||||
@@ -1987,7 +2270,7 @@ var EnhancedSecureP2PChat = () => {
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
handleMessage(" SecureBit.chat Enhanced Security Edition v4.8.13 - 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.14 - 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()) {
|
||||
@@ -3183,9 +3466,19 @@ var EnhancedSecureP2PChat = () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
addMessageWithAutoScroll(messageInput.trim(), "sent");
|
||||
await webrtcManagerRef.current.sendMessage(messageInput);
|
||||
const baseText = messageInput.trim();
|
||||
const outText = codeMode2 ? "```\n" + baseText + "\n```" : baseText;
|
||||
const mid = `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const meta = { mid };
|
||||
if (viewOnceMode2) meta.once = true;
|
||||
if (disappearTtl2 > 0) meta.ttl = disappearTtl2;
|
||||
const localOpts = { mid };
|
||||
if (disappearTtl2 > 0) localOpts.expiresAt = Date.now() + disappearTtl2 * 1e3;
|
||||
addMessageWithAutoScroll(outText, "sent", localOpts);
|
||||
await webrtcManagerRef.current.sendMessage(outText, meta);
|
||||
setMessageInput("");
|
||||
if (codeMode2) setCodeMode2(false);
|
||||
if (viewOnceMode2) setViewOnceMode2(false);
|
||||
} catch (error) {
|
||||
const msg = String(error?.message || error);
|
||||
if (!/queued for sending|Data channel not ready/i.test(msg)) {
|
||||
@@ -3193,6 +3486,47 @@ var EnhancedSecureP2PChat = () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleUnsendMessage = React.useCallback((mid) => {
|
||||
if (!mid) return;
|
||||
setMessages((prev) => prev.filter((m) => String(m.mid) !== String(mid)));
|
||||
try {
|
||||
webrtcManagerRef.current?.sendMessageDelete?.(String(mid));
|
||||
} catch (_) {
|
||||
}
|
||||
}, []);
|
||||
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("");
|
||||
@@ -3492,7 +3826,18 @@ var EnhancedSecureP2PChat = () => {
|
||||
handleCreateOffer,
|
||||
relayOnlyMode,
|
||||
setRelayOnlyMode,
|
||||
webrtcManagerRef
|
||||
webrtcManagerRef,
|
||||
// Secure chat extras
|
||||
codeMode: codeMode2,
|
||||
setCodeMode: setCodeMode2,
|
||||
viewOnceMode: viewOnceMode2,
|
||||
setViewOnceMode: setViewOnceMode2,
|
||||
disappearTtl: disappearTtl2,
|
||||
setDisappearTtl: setDisappearTtl2,
|
||||
nowTick: nowTick2,
|
||||
onUnsendMessage: handleUnsendMessage,
|
||||
onMessageExpire: handleMessageExpire,
|
||||
onPanicWipe: handlePanicWipe
|
||||
})
|
||||
),
|
||||
// QR Scanner Modal
|
||||
|
||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+5
-5
@@ -113,7 +113,7 @@
|
||||
|
||||
|
||||
<!-- GitHub Pages SEO -->
|
||||
<meta name="description" content="SecureBit.chat v4.8.13 — P2P messenger with ECDH + DTLS + SAS security and 18-layer military-grade cryptography">
|
||||
<meta name="description" content="SecureBit.chat v4.8.14 — 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=1781816839471"></script>
|
||||
<script type="module" src="src/components/QRScanner.js?v=1781816839471"></script>
|
||||
<script type="module" src="dist/qr-local.js?v=1781829306093"></script>
|
||||
<script type="module" src="src/components/QRScanner.js?v=1781829306093"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="dist/app-boot.js?v=1781816839471"></script>
|
||||
<script type="module" src="dist/app.js?v=1781816839471"></script>
|
||||
<script type="module" src="dist/app-boot.js?v=1781829306093"></script>
|
||||
<script type="module" src="dist/app.js?v=1781829306093"></script>
|
||||
|
||||
<script src="src/scripts/pwa-register.js"></script>
|
||||
<script src="./src/pwa/install-prompt.js" type="module"></script>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "SecureBit.chat v4.8.13 - ECDH + DTLS + SAS",
|
||||
"name": "SecureBit.chat v4.8.14 - ECDH + DTLS + SAS",
|
||||
"short_name": "SecureBit",
|
||||
"description": "P2P messenger with ECDH + DTLS + SAS security, military-grade cryptography and Lightning Network payments",
|
||||
"start_url": "./",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"version": "1781816839471",
|
||||
"buildVersion": "1781816839471",
|
||||
"appVersion": "4.8.13",
|
||||
"buildTime": "2026-06-18T21:07:19.513Z",
|
||||
"buildId": "1781816839471-42be55a",
|
||||
"gitHash": "42be55a",
|
||||
"version": "1781829306093",
|
||||
"buildVersion": "1781829306093",
|
||||
"appVersion": "4.8.14",
|
||||
"buildTime": "2026-06-19T00:35:06.138Z",
|
||||
"buildId": "1781829306093-cf36656",
|
||||
"gitHash": "cf36656",
|
||||
"generated": true,
|
||||
"generatedAt": "2026-06-18T21:07:19.514Z"
|
||||
"generatedAt": "2026-06-19T00:35:06.139Z"
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "securebit-chat",
|
||||
"version": "4.8.13",
|
||||
"version": "4.8.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "securebit-chat",
|
||||
"version": "4.8.13",
|
||||
"version": "4.8.14",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "1.5.1",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "securebit-chat",
|
||||
"version": "4.8.13",
|
||||
"version": "4.8.14",
|
||||
"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/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/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",
|
||||
|
||||
+425
-51
@@ -1,5 +1,195 @@
|
||||
import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
|
||||
import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/iceSettingsStore.js';
|
||||
|
||||
// ── Secure chat extras: code blocks, clipboard hygiene ──────────────
|
||||
// Copy text to the clipboard and (optionally) wipe it after a delay so
|
||||
// copied secrets (keys, commands, codes) don't linger. We only clear
|
||||
// when we can confirm the clipboard still holds exactly what we wrote,
|
||||
// or when the clipboard can't be read back at all — never clobbering
|
||||
// something the user copied afterwards that we can see is different.
|
||||
const copyToClipboardSecure = async (text, autoClearMs = 0) => {
|
||||
let ok = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
} catch (e) {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
} catch (_) { ok = false; }
|
||||
}
|
||||
if (ok && autoClearMs > 0 && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
setTimeout(async () => {
|
||||
let current = null;
|
||||
let readable = true;
|
||||
try { current = await navigator.clipboard.readText(); }
|
||||
catch (_) { readable = false; }
|
||||
if (!readable || current === text) {
|
||||
try { await navigator.clipboard.writeText(''); } catch (_) {}
|
||||
}
|
||||
}, autoClearMs);
|
||||
}
|
||||
return ok;
|
||||
};
|
||||
|
||||
// Split a message into plain-text and fenced ``` code segments.
|
||||
// Returns null when there is no fenced code so callers can fast-path.
|
||||
const parseMessageSegments = (text) => {
|
||||
if (typeof text !== 'string' || text.indexOf('```') === -1) return null;
|
||||
const segments = [];
|
||||
const re = /```([a-zA-Z0-9_+#.-]*)\n?([\s\S]*?)```/g;
|
||||
let last = 0;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) segments.push({ kind: 'text', content: text.slice(last, m.index) });
|
||||
segments.push({ kind: 'code', lang: (m[1] || '').toLowerCase(), content: m[2].replace(/\n$/, '') });
|
||||
last = re.lastIndex;
|
||||
}
|
||||
if (last < text.length) segments.push({ kind: 'text', content: text.slice(last) });
|
||||
return segments.some(s => s.kind === 'code') ? segments : null;
|
||||
};
|
||||
|
||||
// Monospace code window with a copy button (clipboard auto-clears in 30s).
|
||||
const CodeBlock = ({ code, lang }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const handleCopy = async () => {
|
||||
const ok = await copyToClipboardSecure(code, 30000);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
return React.createElement('div', {
|
||||
className: "my-1 rounded-lg overflow-hidden border border-gray-600/40",
|
||||
style: { backgroundColor: '#1b1c1b' }
|
||||
}, [
|
||||
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' }
|
||||
}, [
|
||||
React.createElement('span', {
|
||||
key: 'lang',
|
||||
className: "text-[11px] uppercase tracking-wide text-gray-500 font-mono"
|
||||
}, lang || 'code'),
|
||||
React.createElement('button', {
|
||||
key: 'copy',
|
||||
onClick: handleCopy,
|
||||
title: "Copy — clipboard auto-clears in 30s",
|
||||
className: "flex items-center text-[11px] text-gray-400 hover:text-green-400 transition-colors"
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
key: 'ic',
|
||||
className: `${copied ? 'fas fa-check text-green-400' : 'far fa-copy'} mr-1`
|
||||
}),
|
||||
copied ? 'Copied' : 'Copy'
|
||||
])
|
||||
]),
|
||||
React.createElement('pre', {
|
||||
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))
|
||||
]);
|
||||
};
|
||||
|
||||
// Render a message body: code windows for fenced blocks, line-preserving
|
||||
// text otherwise. Built from already-sanitized text via React nodes only.
|
||||
const MessageBody = ({ text }) => {
|
||||
const segments = parseMessageSegments(text);
|
||||
if (!segments) {
|
||||
return React.createElement('div', {
|
||||
className: "text-sm break-words",
|
||||
style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' }
|
||||
}, text);
|
||||
}
|
||||
return React.createElement('div', { className: "text-sm" },
|
||||
segments.map((seg, i) => seg.kind === 'code'
|
||||
? React.createElement(CodeBlock, { key: i, code: seg.content, lang: seg.lang })
|
||||
: (seg.content.trim()
|
||||
? React.createElement('div', {
|
||||
key: i,
|
||||
className: "break-words",
|
||||
style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' }
|
||||
}, seg.content)
|
||||
: null)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
const pill = (key, { active, activeClass, 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'}`
|
||||
}, [
|
||||
React.createElement('i', { key: 'i', className: icon }),
|
||||
label ? React.createElement('span', { key: 'l' }, label) : null
|
||||
]);
|
||||
|
||||
return React.createElement('div', {
|
||||
className: "flex items-center flex-wrap gap-2 pb-3"
|
||||
}, [
|
||||
pill('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)',
|
||||
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();
|
||||
}
|
||||
})
|
||||
]);
|
||||
};
|
||||
|
||||
// Enhanced Copy Button with better UX
|
||||
const EnhancedCopyButton = ({ text, className = "", children }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
@@ -223,15 +413,22 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
};
|
||||
|
||||
// Enhanced Chat Message with better security indicators
|
||||
const EnhancedChatMessage = ({ message, type, timestamp }) => {
|
||||
const EnhancedChatMessage = ({ message, type, timestamp, mid, viewOnce, expiresAt, nowTick, canUnsend, onUnsend, onExpire }) => {
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
const revealTimerRef = React.useRef(null);
|
||||
|
||||
const formatTime = (ts) => {
|
||||
return new Date(ts).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
return new Date(ts).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (revealTimerRef.current) clearTimeout(revealTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const getMessageStyle = () => {
|
||||
switch (type) {
|
||||
case 'sent':
|
||||
@@ -260,42 +457,96 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const style = getMessageStyle();
|
||||
|
||||
|
||||
// Disappearing-message countdown (seconds remaining).
|
||||
const remaining = (typeof expiresAt === 'number')
|
||||
? Math.max(0, Math.ceil((expiresAt - (nowTick || Date.now())) / 1000))
|
||||
: null;
|
||||
|
||||
const isViewOnce = type === 'received' && viewOnce === true;
|
||||
|
||||
const handleReveal = () => {
|
||||
if (revealed) return;
|
||||
setRevealed(true);
|
||||
// One-time read: wipe shortly after the recipient opens it.
|
||||
revealTimerRef.current = setTimeout(() => {
|
||||
onExpire && onExpire();
|
||||
}, 12000);
|
||||
};
|
||||
|
||||
// Body: blurred placeholder for unopened view-once, else real content.
|
||||
let body;
|
||||
if (isViewOnce && !revealed) {
|
||||
body = React.createElement('button', {
|
||||
key: 'vo',
|
||||
onClick: handleReveal,
|
||||
className: "w-full flex items-center space-x-2 text-left text-sm text-gray-300 hover:text-white transition-colors"
|
||||
}, [
|
||||
React.createElement('i', { key: 'i', className: "fas fa-eye-slash accent-orange" }),
|
||||
React.createElement('span', { key: 't' }, "View once — tap to read"),
|
||||
React.createElement('i', { key: 'b', className: "fas fa-fingerprint text-muted ml-auto opacity-60" })
|
||||
]);
|
||||
} else {
|
||||
body = React.createElement(MessageBody, { key: 'body', text: message });
|
||||
}
|
||||
|
||||
const metaChildren = [
|
||||
React.createElement('span', { key: 'time' }, formatTime(timestamp))
|
||||
];
|
||||
if (isViewOnce && revealed) {
|
||||
metaChildren.push(React.createElement('span', {
|
||||
key: 'vo-note', className: "flex items-center text-orange-400/80"
|
||||
}, [
|
||||
React.createElement('i', { key: 'i', className: "fas fa-eye-slash mr-1" }),
|
||||
"Deletes after reading"
|
||||
]));
|
||||
} else if (remaining !== null) {
|
||||
metaChildren.push(React.createElement('span', {
|
||||
key: 'ttl', className: "flex items-center text-gray-400", title: "Disappearing message"
|
||||
}, [
|
||||
React.createElement('i', { key: 'i', className: "far fa-clock mr-1" }),
|
||||
remaining >= 60 ? `${Math.ceil(remaining / 60)}m` : `${remaining}s`
|
||||
]));
|
||||
} else {
|
||||
metaChildren.push(React.createElement('span', { key: 'status', className: "text-xs" }, style.label));
|
||||
}
|
||||
|
||||
const headerRow = [
|
||||
React.createElement('i', {
|
||||
key: 'icon',
|
||||
className: `${style.icon} text-sm mt-0.5 opacity-70`
|
||||
}),
|
||||
React.createElement('div', {
|
||||
key: 'text',
|
||||
className: "flex-1 min-w-0"
|
||||
}, [
|
||||
body,
|
||||
timestamp && React.createElement('div', {
|
||||
key: 'meta',
|
||||
className: "flex items-center justify-between gap-2 mt-1 text-xs opacity-60"
|
||||
}, metaChildren)
|
||||
])
|
||||
];
|
||||
|
||||
// Unsend (delete for everyone) — only on your own sent messages.
|
||||
if (canUnsend && type === 'sent' && mid) {
|
||||
headerRow.push(React.createElement('button', {
|
||||
key: 'unsend',
|
||||
onClick: () => onUnsend && onUnsend(mid),
|
||||
title: "Delete for everyone",
|
||||
className: "flex-shrink-0 text-gray-500 hover:text-red-400 transition-colors text-xs mt-0.5"
|
||||
}, React.createElement('i', { className: "fas fa-trash-can" })));
|
||||
}
|
||||
|
||||
return React.createElement('div', {
|
||||
className: `message-slide mb-3 p-3 rounded-lg max-w-md break-words ${style.container} border`
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'content',
|
||||
className: "flex items-start space-x-2"
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
key: 'icon',
|
||||
className: `${style.icon} text-sm mt-0.5 opacity-70`
|
||||
}),
|
||||
React.createElement('div', {
|
||||
key: 'text',
|
||||
className: "flex-1 min-w-0"
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'message',
|
||||
className: "text-sm break-words whitespace-normal"
|
||||
}, message),
|
||||
timestamp && React.createElement('div', {
|
||||
key: 'meta',
|
||||
className: "flex items-center justify-between mt-1 text-xs opacity-50"
|
||||
}, [
|
||||
React.createElement('span', {
|
||||
key: 'time'
|
||||
}, formatTime(timestamp)),
|
||||
React.createElement('span', {
|
||||
key: 'status',
|
||||
className: "text-xs"
|
||||
}, style.label)
|
||||
])
|
||||
])
|
||||
])
|
||||
}, headerRow)
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -343,7 +594,18 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
handleCreateOffer,
|
||||
relayOnlyMode,
|
||||
setRelayOnlyMode,
|
||||
webrtcManagerRef
|
||||
webrtcManagerRef,
|
||||
// Secure chat extras
|
||||
codeMode,
|
||||
setCodeMode,
|
||||
viewOnceMode,
|
||||
setViewOnceMode,
|
||||
disappearTtl,
|
||||
setDisappearTtl,
|
||||
nowTick,
|
||||
onUnsendMessage,
|
||||
onMessageExpire,
|
||||
onPanicWipe
|
||||
}) => {
|
||||
const [mode, setMode] = React.useState('select');
|
||||
const [notificationPermissionRequested, setNotificationPermissionRequested] = React.useState(false);
|
||||
@@ -1405,7 +1667,14 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
key: msg.id,
|
||||
message: msg.message,
|
||||
type: msg.type,
|
||||
timestamp: msg.timestamp
|
||||
timestamp: msg.timestamp,
|
||||
mid: msg.mid,
|
||||
viewOnce: msg.viewOnce,
|
||||
expiresAt: msg.expiresAt,
|
||||
nowTick: nowTick,
|
||||
canUnsend: typeof onUnsendMessage === 'function',
|
||||
onUnsend: onUnsendMessage,
|
||||
onExpire: () => onMessageExpire && onMessageExpire(msg.id)
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -1496,9 +1765,17 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
React.createElement(
|
||||
'div',
|
||||
{ className: "max-w-4xl mx-auto p-4" },
|
||||
[
|
||||
React.createElement(ChatToolbar, {
|
||||
key: 'toolbar',
|
||||
codeMode, setCodeMode,
|
||||
viewOnceMode, setViewOnceMode,
|
||||
disappearTtl, setDisappearTtl,
|
||||
onPanicWipe
|
||||
}),
|
||||
React.createElement(
|
||||
'div',
|
||||
{ className: "flex items-stretch space-x-3" },
|
||||
{ key: 'inputrow', className: "flex items-stretch space-x-3" },
|
||||
[
|
||||
React.createElement(
|
||||
'div',
|
||||
@@ -1544,6 +1821,7 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
@@ -1555,6 +1833,11 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
const EnhancedSecureP2PChat = () => {
|
||||
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
// Secure chat extras: per-message send modes + 1s tick for countdowns.
|
||||
const [codeMode, setCodeMode] = React.useState(false);
|
||||
const [viewOnceMode, setViewOnceMode] = React.useState(false);
|
||||
const [disappearTtl, setDisappearTtl] = React.useState(0); // seconds; 0 = off (sticky)
|
||||
const [nowTick, setNowTick] = React.useState(() => Date.now());
|
||||
const [connectionStatus, setConnectionStatus] = React.useState('disconnected');
|
||||
const [relayOnlyMode, setRelayOnlyMode] = React.useState(() => {
|
||||
try { return localStorage.getItem('securebit_relay_only_mode') === 'true'; } catch { return false; }
|
||||
@@ -1713,14 +1996,17 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addMessageWithAutoScroll = React.useCallback((message, type) => {
|
||||
const addMessageWithAutoScroll = React.useCallback((message, type, opts = {}) => {
|
||||
const newMessage = {
|
||||
message,
|
||||
type,
|
||||
id: Date.now() + Math.random(),
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
mid: opts.mid,
|
||||
viewOnce: opts.viewOnce === true,
|
||||
expiresAt: (typeof opts.expiresAt === 'number') ? opts.expiresAt : undefined
|
||||
};
|
||||
|
||||
|
||||
setMessages(prev => {
|
||||
const updated = [...prev, newMessage];
|
||||
|
||||
@@ -1825,6 +2111,22 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
setTimeout(scrollToBottom, 150);
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// Disappearing-message clock: tick every second (only while some
|
||||
// message has an expiry) and prune anything past its deadline.
|
||||
const hasExpiring = messages.some(m => typeof m.expiresAt === 'number');
|
||||
React.useEffect(() => {
|
||||
if (!hasExpiring) return;
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setNowTick(now);
|
||||
setMessages(prev => {
|
||||
const kept = prev.filter(m => !(typeof m.expiresAt === 'number' && m.expiresAt <= now));
|
||||
return kept.length === prev.length ? prev : kept;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [hasExpiring]);
|
||||
|
||||
// PAKE password functions removed - using SAS verification instead
|
||||
|
||||
@@ -1835,7 +2137,7 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMessage = (message, type) => {
|
||||
const handleMessage = (message, type, meta) => {
|
||||
if (typeof message === 'string' && message.trim().startsWith('{')) {
|
||||
try {
|
||||
const parsedMessage = JSON.parse(message);
|
||||
@@ -1865,9 +2167,18 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
}
|
||||
}
|
||||
|
||||
addMessageWithAutoScroll(message, type);
|
||||
// Per-message UI metadata from the peer (view-once / disappearing / id).
|
||||
const opts = {};
|
||||
if (meta && typeof meta === 'object') {
|
||||
if (typeof meta.mid === 'string') opts.mid = meta.mid;
|
||||
if (meta.once === true) opts.viewOnce = true;
|
||||
if (Number.isFinite(meta.ttl) && meta.ttl > 0) {
|
||||
opts.expiresAt = Date.now() + meta.ttl * 1000;
|
||||
}
|
||||
}
|
||||
addMessageWithAutoScroll(message, type, opts);
|
||||
};
|
||||
|
||||
|
||||
const handleStatusChange = (status) => {
|
||||
setConnectionStatus(status);
|
||||
|
||||
@@ -2056,7 +2367,13 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// Unsend / delete-for-everyone: peer asked us to drop a message.
|
||||
webrtcManagerRef.current.onMessageDelete = (mid) => {
|
||||
if (!mid) return;
|
||||
setMessages(prev => prev.filter(m => String(m.mid) !== String(mid)));
|
||||
};
|
||||
|
||||
// Initialize notification integration if permission was already granted
|
||||
if (typeof Notification !== 'undefined' && Notification && Notification.permission === 'granted' && window.NotificationIntegration && !notificationIntegrationRef.current) {
|
||||
try {
|
||||
@@ -2071,7 +2388,7 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(' SecureBit.chat Enhanced Security Edition v4.8.13 - 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.14 - 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) {
|
||||
@@ -3473,13 +3790,29 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// Add the message to local messages immediately (sent message)
|
||||
addMessageWithAutoScroll(messageInput.trim(), 'sent');
|
||||
|
||||
// Use sendMessage for simple text messages instead of sendSecureMessage
|
||||
await webrtcManagerRef.current.sendMessage(messageInput);
|
||||
const baseText = messageInput.trim();
|
||||
// Code mode wraps the text in a fenced block so both sides render
|
||||
// a code window with a copy button (the marker travels as text).
|
||||
const outText = codeMode ? '```\n' + baseText + '\n```' : baseText;
|
||||
|
||||
// Shared id lets unsend/disappearing reference the same message
|
||||
// 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 (disappearTtl > 0) meta.ttl = disappearTtl; // applies to both sides
|
||||
|
||||
// Local echo: sender sees their own text normally (view-once is a
|
||||
// recipient-side control), but disappearing also expires our copy.
|
||||
const localOpts = { mid };
|
||||
if (disappearTtl > 0) localOpts.expiresAt = Date.now() + disappearTtl * 1000;
|
||||
addMessageWithAutoScroll(outText, 'sent', localOpts);
|
||||
|
||||
await webrtcManagerRef.current.sendMessage(outText, meta);
|
||||
setMessageInput('');
|
||||
// Per-message toggles reset; disappearing stays as a sticky setting.
|
||||
if (codeMode) setCodeMode(false);
|
||||
if (viewOnceMode) setViewOnceMode(false);
|
||||
} catch (error) {
|
||||
const msg = String(error?.message || error);
|
||||
if (!/queued for sending|Data channel not ready/i.test(msg)) {
|
||||
@@ -3487,6 +3820,36 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Unsend: remove locally and ask the peer to drop it too.
|
||||
const handleUnsendMessage = React.useCallback((mid) => {
|
||||
if (!mid) return;
|
||||
setMessages(prev => prev.filter(m => String(m.mid) !== String(mid)));
|
||||
try { webrtcManagerRef.current?.sendMessageDelete?.(String(mid)); } catch (_) {}
|
||||
}, []);
|
||||
|
||||
// View-once reveal timeout / disappearing expiry: drop a message by id.
|
||||
const handleMessageExpire = React.useCallback((id) => {
|
||||
setMessages(prev => prev.filter(m => m.id !== id));
|
||||
}, []);
|
||||
|
||||
// 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('');
|
||||
@@ -3853,7 +4216,18 @@ import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/ic
|
||||
handleCreateOffer: handleCreateOffer,
|
||||
relayOnlyMode: relayOnlyMode,
|
||||
setRelayOnlyMode: setRelayOnlyMode,
|
||||
webrtcManagerRef: webrtcManagerRef
|
||||
webrtcManagerRef: webrtcManagerRef,
|
||||
// Secure chat extras
|
||||
codeMode: codeMode,
|
||||
setCodeMode: setCodeMode,
|
||||
viewOnceMode: viewOnceMode,
|
||||
setViewOnceMode: setViewOnceMode,
|
||||
disappearTtl: disappearTtl,
|
||||
setDisappearTtl: setDisappearTtl,
|
||||
nowTick: nowTick,
|
||||
onUnsendMessage: handleUnsendMessage,
|
||||
onMessageExpire: handleMessageExpire,
|
||||
onPanicWipe: handlePanicWipe
|
||||
})
|
||||
),
|
||||
|
||||
|
||||
@@ -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.13')
|
||||
}, 'End-to-end freedom v4.8.14')
|
||||
])
|
||||
]),
|
||||
|
||||
|
||||
@@ -70,7 +70,10 @@ class EnhancedSecureWebRTCManager {
|
||||
// Regular messages
|
||||
MESSAGE: 'message',
|
||||
ENHANCED_MESSAGE: 'enhanced_message',
|
||||
|
||||
|
||||
// Per-message control (unsend / disappearing sync)
|
||||
MESSAGE_DELETE: 'message_delete',
|
||||
|
||||
// System messages
|
||||
HEARTBEAT: 'heartbeat',
|
||||
VERIFICATION: 'verification',
|
||||
@@ -5015,7 +5018,7 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
||||
return window.EnhancedSecureCryptoUtils.sanitizeMessage(message);
|
||||
}
|
||||
|
||||
deliverMessageToUI(message, type = 'received') {
|
||||
deliverMessageToUI(message, type = 'received', meta = null) {
|
||||
try {
|
||||
// Add debug logs
|
||||
this._secureLog('debug', '📤 deliverMessageToUI called', {
|
||||
@@ -5091,8 +5094,14 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
||||
: message;
|
||||
|
||||
if (this.onMessage) {
|
||||
// Whitelist/bound the optional per-message UI metadata centrally here,
|
||||
// so every delivery path stays consistent and partial test doubles that
|
||||
// override deliverMessageToUI are unaffected.
|
||||
const safeMeta = (meta && typeof this._sanitizeMessageMeta === 'function')
|
||||
? this._sanitizeMessageMeta(meta)
|
||||
: null;
|
||||
this._secureLog('debug', '📤 Calling this.onMessage callback', { message: uiMessage, type });
|
||||
this.onMessage(uiMessage, type);
|
||||
this.onMessage(uiMessage, type, safeMeta || undefined);
|
||||
} else {
|
||||
this._secureLog('warn', '⚠️ this.onMessage callback is null or undefined');
|
||||
}
|
||||
@@ -6439,7 +6448,45 @@ async processOrderedPackets() {
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(data) {
|
||||
/**
|
||||
* Whitelist + bound the per-message UI metadata so a peer cannot smuggle
|
||||
* arbitrary objects, huge values, or absurd timers through it.
|
||||
* @param {object} meta
|
||||
* @returns {object|null}
|
||||
*/
|
||||
_sanitizeMessageMeta(meta) {
|
||||
if (!meta || typeof meta !== 'object') return null;
|
||||
const out = {};
|
||||
if (typeof meta.mid === 'string' && meta.mid.length > 0 && meta.mid.length <= 64) {
|
||||
out.mid = meta.mid.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64);
|
||||
}
|
||||
if (meta.code === true) out.code = true;
|
||||
if (meta.once === true) out.once = true;
|
||||
if (Number.isFinite(meta.ttl)) {
|
||||
// Clamp disappearing timer to [5s, 24h]; ignore anything else.
|
||||
const ttl = Math.floor(meta.ttl);
|
||||
if (ttl >= 5 && ttl <= 86400) out.ttl = ttl;
|
||||
}
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsend: ask the peer to remove a previously delivered message by id.
|
||||
* Sent over the authenticated DTLS control channel like other system
|
||||
* messages. Best-effort and cooperative — a peer can ignore it, exactly
|
||||
* like WhatsApp/Telegram "delete for everyone".
|
||||
* @param {string} messageId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
sendMessageDelete(messageId) {
|
||||
if (typeof messageId !== 'string' || !messageId) return false;
|
||||
return this.sendSystemMessage({
|
||||
type: EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_DELETE,
|
||||
messageId: messageId.slice(0, 64)
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(data, meta = null) {
|
||||
// Comprehensive input validation
|
||||
const validation = this._validateInputData(data, 'sendMessage');
|
||||
if (!validation.isValid) {
|
||||
@@ -6518,13 +6565,21 @@ async processOrderedPackets() {
|
||||
|
||||
// Create AAD with sequence number for anti-replay protection
|
||||
const aad = this._createMessageAAD('message', { content: validation.sanitizedData });
|
||||
|
||||
return await this.sendSecureMessage({
|
||||
type: 'message',
|
||||
data: validation.sanitizedData,
|
||||
|
||||
const envelope = {
|
||||
type: 'message',
|
||||
data: validation.sanitizedData,
|
||||
timestamp: Date.now(),
|
||||
aad: aad // Include AAD for sequence number validation
|
||||
});
|
||||
};
|
||||
// Optional per-message UI metadata (code/view-once/disappearing/id).
|
||||
// Travels inside the encrypted envelope, NOT in the sanitized text,
|
||||
// so it cannot corrupt or be spoofed from message content.
|
||||
if (meta && typeof meta === 'object') {
|
||||
envelope.meta = this._sanitizeMessageMeta(meta);
|
||||
}
|
||||
|
||||
return await this.sendSecureMessage(envelope);
|
||||
}
|
||||
|
||||
// For binary data, apply security layers with a limited mutex
|
||||
@@ -6713,11 +6768,11 @@ async processMessage(data) {
|
||||
}
|
||||
}
|
||||
|
||||
// Process decrypted message
|
||||
// Process decrypted message (with optional UI metadata)
|
||||
if (decryptedParsed.type === 'message' && this.onMessage && decryptedParsed.data) {
|
||||
this.deliverMessageToUI(decryptedParsed.data, 'received');
|
||||
this.deliverMessageToUI(decryptedParsed.data, 'received', decryptedParsed.meta);
|
||||
}
|
||||
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
this._secureLog('error', '❌ Failed to decrypt enhanced message', { error: error.message });
|
||||
@@ -6735,7 +6790,16 @@ async processMessage(data) {
|
||||
return;
|
||||
}
|
||||
if (this.onMessage && parsed.data) {
|
||||
this.deliverMessageToUI(parsed.data, 'received');
|
||||
this.deliverMessageToUI(parsed.data, 'received', parsed.meta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-message delete (unsend / disappearing sync) 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// No DOM needed: we mock the incoming-chat sanitizer so DOMPurify/window are
|
||||
// not required, and exercise the transport/meta plumbing directly.
|
||||
globalThis.window = globalThis.window || {};
|
||||
|
||||
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
|
||||
const P = EnhancedSecureWebRTCManager.prototype;
|
||||
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 });
|
||||
|
||||
// Junk and out-of-range values are stripped; with no valid keys -> null.
|
||||
assert.equal(P._sanitizeMessageMeta.call({}, { foo: 1 }), null);
|
||||
assert.equal(P._sanitizeMessageMeta.call({}, null), null);
|
||||
assert.equal(P._sanitizeMessageMeta.call({}, { ttl: 999999 }), null); // above 24h cap
|
||||
assert.equal(P._sanitizeMessageMeta.call({}, { ttl: 1 }), null); // below 5s floor
|
||||
assert.equal(P._sanitizeMessageMeta.call({}, { once: 'yes' }), null); // must be exactly true
|
||||
assert.deepEqual(P._sanitizeMessageMeta.call({}, { mid: 'bad id!@#' }), { mid: 'badid' }); // sanitized
|
||||
}
|
||||
|
||||
// ── deliverMessageToUI forwards sanitized meta to onMessage ──────────────────
|
||||
{
|
||||
const calls = [];
|
||||
const manager = {
|
||||
_debugMode: false,
|
||||
_secureLog() {},
|
||||
_sanitizeIncomingChatMessage: (m) => m, // bypass DOMPurify in test
|
||||
_sanitizeMessageMeta: P._sanitizeMessageMeta,
|
||||
onMessage: (message, type, meta) => calls.push({ message, type, meta })
|
||||
};
|
||||
P.deliverMessageToUI.call(manager, 'hello', 'received', { once: true, ttl: 30, mid: 'm1', junk: 9 });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].message, 'hello');
|
||||
assert.equal(calls[0].type, 'received');
|
||||
assert.deepEqual(calls[0].meta, { mid: 'm1', once: true, ttl: 30 });
|
||||
|
||||
// No meta -> onMessage gets undefined (backward compatible).
|
||||
calls.length = 0;
|
||||
P.deliverMessageToUI.call(manager, 'plain', 'received');
|
||||
assert.equal(calls[0].meta, undefined);
|
||||
}
|
||||
|
||||
// ── processMessage routes message_delete to onMessageDelete ──────────────────
|
||||
{
|
||||
const deleted = [];
|
||||
const manager = {
|
||||
_secureLog() {},
|
||||
onMessageDelete: (id) => deleted.push(id)
|
||||
};
|
||||
await P.processMessage.call(
|
||||
manager,
|
||||
JSON.stringify({ type: T.MESSAGE_DELETE, data: { messageId: 'm_42' } })
|
||||
);
|
||||
assert.deepEqual(deleted, ['m_42']);
|
||||
}
|
||||
|
||||
// ── sendMessageDelete emits a well-formed control message ────────────────────
|
||||
{
|
||||
const sent = [];
|
||||
const manager = { sendSystemMessage: (m) => { sent.push(m); return true; } };
|
||||
const result = P.sendMessageDelete.call(manager, 'm_99');
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(sent, [{ type: T.MESSAGE_DELETE, messageId: 'm_99' }]);
|
||||
|
||||
// Invalid ids are rejected without emitting anything.
|
||||
sent.length = 0;
|
||||
assert.equal(P.sendMessageDelete.call(manager, ''), false);
|
||||
assert.equal(sent.length, 0);
|
||||
}
|
||||
|
||||
console.log('Secure chat features tests passed');
|
||||
Reference in New Issue
Block a user