feat(webrtc): recover a dropped connection without a signalling server; release v5.6.0
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

A chat no longer dies when the network moves under it. A NAT rebind, a lift, a
Wi-Fi radio parking itself, a phone that dozed: the session repairs its own network
path in place, and the messages typed meanwhile go out when it returns.

Recovery is an ICE restart, which renegotiates only the transport path — the DTLS
handshake, the session keys and the SCTP association carrying the data channel all
sit above ICE and survive it. The renegotiation SDP therefore travels over the
existing end-to-end encrypted, SAS-verified channel: no signalling service enters
the design, and an attacker who cannot already decrypt the session cannot inject a
reconnection. A restart is refused outright unless the DTLS fingerprint in the
incoming SDP matches the live session's, so recovery can never re-point a
conversation at a different peer.

When the path is gone for good the session is ended and its data wiped rather than
left half-alive: with no server there is nothing to re-signal through, and a
conversation whose transport is gone should not leave its plaintext in an open tab.
The two cases where that is already certain are recognised in seconds instead of
being retried for two minutes — a channel that has delivered nothing at all since
the drop cannot carry a renegotiation, and an ICE agent left bound to a network that
no longer exists reports zero candidate pairs on every restart.

Judging liveness was the hard part. Silence is not evidence of death: browsers freeze
backgrounded tabs outright, and a frozen peer answers nothing while being perfectly
healthy. What survives that freeze is ICE consent, which the browser runs in its
network stack rather than on the page's thread — so a connected ICE state means a
silent peer is asleep, and only a degraded one turns an unanswered probe into a
teardown. The grace window before a restart is sized to the browser's own timings:
'disconnected' arrives after ~5s of missed consent responses and is held ~25s before
'failed', and that window exists for self-healing, so restarting at the start of it
broke connections that were about to recover.

Several long-standing bugs surfaced along the way and are fixed here:

- handleHeartbeat() was dispatched to but never defined, so every inbound heartbeat
  threw a TypeError and peer liveness was never observed at all.
- Heartbeats were folded into the 5-minute maintenance cycle instead of running on
  their own timer, far too coarse to notice a dead path.
- ondatachannel can hand over a channel that is already open, so the answering side's
  'open' event had been dispatched before the handler was assigned and never fired,
  leaving that side with no heartbeat, no watchdog and no file-transfer init. The peer
  whose network was fine kept showing "connected" indefinitely because nothing was
  running to notice.
- Answering a heartbeat required the peer to have finished verifying, but the two
  sides confirm a SAS code at different moments; for that whole window one of them
  could not reply and was declared dead on a healthy connection.
- Sending on a channel that was not ready returned in silence: the text stayed in the
  box, nothing was transmitted, and nothing said why.
- The send path gated on navigator.onLine and the offline/online events, which report
  whether an interface exists rather than whether anything is reachable. A tab the OS
  froze misses the 'online' edge, and this side then queued every message forever: one
  tick on everything it sent, while incoming messages kept arriving. Sending is now
  decided by the data channel, and queues drain by polling rather than on an edge, so
  a missed event cannot strand them.
- A false offline modal appeared on a working session, because the offline event was
  taken at face value.

tests/session-recovery.test.mjs covers the state machine, the backoff and its
serialisation, the offline hold, the sleeping-peer discriminator and the identity
check.
This commit is contained in:
lockbitchat
2026-08-02 21:16:13 -04:00
parent 60bf037ef9
commit 2a7142c722
18 changed files with 2794 additions and 224 deletions
+90
View File
@@ -1,5 +1,95 @@
# Changelog
## v5.6.0 — Survive a dropped connection
A chat no longer dies when the network moves under it. Switching Wi-Fi → LTE,
a NAT rebind, a lift, a tunnel: the session now repairs its own network path and
the messages you typed meanwhile go out when it comes back.
This is done without adding any server. An ICE restart renegotiates *only* the
transport path; the DTLS handshake, the session keys and the SCTP association
carrying the data channel all sit above ICE and survive it. So the renegotiation
SDP travels over the existing end-to-end encrypted, SAS-verified channel — there
is still no signalling service anywhere in the design, and an attacker who cannot
already decrypt the session cannot inject a reconnection.
### Added
- **Automatic session recovery.** A broken path is repaired in place with an
in-band ICE restart, retried with a 1/2/4/8/15/30 s backoff for up to two
minutes. Keys, SAS verification and message history are all preserved — no
re-handshake, no comparing codes again.
- **Liveness detection that understands sleeping devices.** A data channel keeps
reporting `readyState: 'open'` long after the path underneath it has died — the
classic Wi-Fi → LTE switch, where nothing closes and nothing errors, packets
just stop. Silence alone is deliberately not treated as death, because a
browser freezes a backgrounded tab outright and a healthy peer then answers
nothing at all. What survives that freeze is ICE consent, which the browser
runs in its network stack rather than on the page's thread — so a connected ICE
state means a silent peer is asleep, not gone, and the session is left alone.
Only when ICE itself is degraded does an unanswered probe end the session.
- **Recovery is given up promptly when it cannot possibly work.** Every route out
of a broken path runs over the data channel, so if nothing at all has reached
us since the drop, no further attempt can succeed. Likewise an ICE agent left
bound to a network that is gone — every candidate times out, every restart ends
with zero candidate pairs — cannot be repaired by restarting it. Both are now
recognised in seconds instead of being retried for two minutes.
- **A session that cannot be recovered is closed, not left half-alive.** When the
path is gone for good, the chat is ended and its data wiped — keys, queued
messages and transcript together. There is deliberately no manual fallback: a
conversation whose transport is gone should not leave its plaintext sitting in
a tab, and starting a fresh one is a single, honest step.
- **Store-and-forward while reconnecting.** Messages typed during a repair are
queued and delivered when the path returns, in order. A send that races a
still-settling path is re-queued rather than marked failed.
- **The conversation stays on screen** during a repair, with a "Restoring
connection…" state, instead of dropping you back to the connect screen.
- **A device with no network holds the session open.** Five minutes underground
no longer costs a session: the give-up deadline does not run while this device
has no connectivity, and recovery retries the moment the radio returns or the
tab comes back to the foreground.
- `tests/session-recovery.test.mjs` covers the state machine, the backoff, the
offline hold and the identity check below.
### Security
- **A reconnection cannot re-point a session at a different peer.** The DTLS
fingerprint in an incoming restart offer or answer is checked against the
fingerprint of the live, already-verified session *before* anything is applied
to the peer connection. A mismatch aborts recovery. If there is no live
fingerprint to compare against, the restart is refused rather than trusted.
- Only the side that created the original offer may drive a restart; the other
side asks. With no signalling server there is no referee to resolve glare.
- Calls cannot be placed onto a path that is mid-repair, where the media
renegotiation would race the ICE restart on the same connection.
### Fixed
- **Every inbound heartbeat threw a `TypeError`.** `handleHeartbeat()` was
dispatched to but never defined, so peer liveness was never actually observed.
- **Heartbeats were sent every 5 minutes, not the intended interval** — the send
was folded into the general maintenance cycle, far too coarse to notice a dead
path. It now runs on its own timer, and answering one no longer requires the
peer to have finished verifying: the two sides confirm a SAS code at different
moments, and for that whole window one of them could not reply and was being
declared dead on a healthy connection.
- **The answering side never started its watchdog.** `ondatachannel` can hand over
a channel that is already open, so the `open` event had been dispatched before
the handler was assigned and never fired — leaving that side with no heartbeat,
no liveness watchdog and no file-transfer init. The peer whose network was fine
kept showing "connected" indefinitely because nothing was running to notice.
- **A failed send no longer fails silently.** Sending on a channel that was not
ready simply returned: the text stayed in the box, nothing was transmitted and
nothing said why.
- **A transient `disconnected` no longer tears down the session.** ICE reports it
routinely and the browser usually recovers unaided; it is now given a grace
window before a restart is spent, and it never clears verification on its own.
- A reconnected session no longer re-announces "secure connection established" —
it is the same session resuming, and no handshake took place.
- Liveness bookkeeping can no longer throw ahead of message routing, where the
surrounding catch would have swallowed it and silently dropped every inbound
message.
## v5.5.4 — Fix the desktop download buttons
### Fixed
+1 -1
View File
@@ -9,7 +9,7 @@
No accounts. No servers storing your messages. No installation required.
[![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-5.5.4-3ecf8e.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-5.6.0-3ecf8e.svg)](CHANGELOG.md)
[![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app)
[![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model)
+701 -36
View File
@@ -6694,8 +6694,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
// 5 minutes
CONNECTION_TIMEOUT: 1e4,
// 10 seconds
HEARTBEAT_INTERVAL: 3e4,
// 30 seconds
// Kept below LIVENESS_PROBE_AFTER so a healthy peer's own heartbeats keep
// the liveness clock fresh and probing never happens on a working link.
HEARTBEAT_INTERVAL: 1e4,
// 10 seconds
SECURITY_CALC_DELAY: 1e3,
// 1 second
SECURITY_CALC_RETRY_DELAY: 3e3,
@@ -6730,11 +6732,71 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
// 25 seconds
REORDER_TIMEOUT: 3e3,
// 3 seconds
RETRY_CONNECTION_DELAY: 2e3
RETRY_CONNECTION_DELAY: 2e3,
// 2 seconds
// --- Session recovery ---
// How long to let a 'disconnected' path heal itself before renegotiating.
//
// The browser enters 'disconnected' after only ~5 s without a consent
// binding response, which ordinary packet loss produces, and then holds
// that state for roughly 25 s before declaring 'failed'. That whole
// window exists precisely so the connection can come back on its own —
// and it very often does, especially against a phone whose screen went
// off, which generates these episodes constantly.
//
// So restart LATE in the window, not at the start: early enough to still
// beat 'failed', late enough that self-healing has had its chance. An
// earlier 3 s value meant every backgrounded phone was answered with a
// renegotiation — tearing down a connection that was about to recover.
// See https://blog.mozilla.org/webrtc/ice-disconnected-not/
ICE_DISCONNECT_GRACE: 8e3,
// 8 seconds
// How long one restart round-trip (offer → gather → answer) may take. No
// new attempt is launched while one is in flight: the round-trip is far
// longer than the head of the backoff, so retrying blindly cancels the
// attempt already running and recovery never converges.
ICE_RESTART_TIMEOUT: 2e4,
// 20 seconds
// Gathering budget inside a restart. Deliberately far below the initial
// handshake's 10 s: host and server-reflexive candidates arrive in well
// under a second, and waiting out the full budget for a relay candidate
// that may never come would blow the round-trip deadline above.
ICE_RESTART_GATHERING: 4e3,
// 4 seconds
// Give up on automatic recovery after this long. There is no manual
// fallback: the session is ended and its data wiped.
RECONNECT_MAX_DURATION: 12e4,
// 2 minutes
// In-band recovery needs the data channel to carry the renegotiation. If
// nothing at all arrives from the peer for this long once recovery has
// started, it cannot — and no number of further attempts will change
// that, so the session is ended promptly instead of after a two-minute
// wait that was never going to succeed.
RECOVERY_SILENCE_LIMIT: 15e3,
// 15 seconds
// Liveness is established by an explicit probe/ack, not by silence alone.
// Silence on its own is not proof of death: a browser throttles timers in a
// backgrounded tab (Chrome down to roughly one per minute, iOS Safari
// freezes them outright), so a perfectly healthy peer can stop sending for
// a long time. Inbound message handling is NOT throttled that way, so a
// live peer — even a backgrounded one — answers a probe within milliseconds
// while a peer whose network is gone cannot answer at all.
LIVENESS_PROBE_AFTER: 12e3,
// silence before probing the peer
LIVENESS_PROBE_TIMEOUT: 5e3,
// how long the ack may take
LIVENESS_CHECK_INTERVAL: 2e3
// 2 seconds
};
// Backoff between automatic ICE-restart attempts (ms). Deliberately short at
// the head: most real drops recover on the first or second try.
static RECONNECT_BACKOFF = Object.freeze([1e3, 2e3, 4e3, 8e3, 15e3, 3e4]);
static LIMITS = {
MAX_CONNECTION_ATTEMPTS: 3,
// Consecutive ICE failures that produced zero candidate pairs before
// concluding the PeerConnection itself is unusable, rather than the path
// merely being flaky.
MAX_BARREN_ICE_FAILURES: 2,
MAX_OLD_KEYS: 3,
MAX_PROCESSED_MESSAGE_IDS: 1e3,
MAX_OUT_OF_ORDER_PACKETS: 5,
@@ -6797,6 +6859,18 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
CALL_ICE: "call_ice",
CALL_DECLINE: "call_decline",
CALL_END: "call_end",
// Session recovery. An ICE restart renegotiates ONLY the transport path
// (new candidates after a NAT rebind / IP change); the DTLS handshake and
// the SCTP association that carries this data channel survive it, so the
// session keys, the SAS verification and the message history all stay
// valid. The renegotiation SDP therefore rides the existing E2E channel —
// still no signalling server, and an attacker cannot inject a restart
// without already holding the session keys.
ICE_RESTART_OFFER: "ice_restart_offer",
ICE_RESTART_ANSWER: "ice_restart_answer",
// Sent by the answerer side, which must not create offers itself (glare):
// it asks the offerer to drive the restart.
ICE_RESTART_REQUEST: "ice_restart_request",
// Fake traffic
FAKE: "fake"
};
@@ -6910,6 +6984,25 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this._secureLog("info", "\u{1F512} Enhanced Mutex system fully initialized and validated");
this.heartbeatInterval = null;
this.messageQueue = [];
this._reconnect = {
phase: "idle",
// idle | grace | restarting | waiting | exhausted
attempts: 0,
startedAt: 0,
graceTimer: null,
retryTimer: null,
restartTimer: null,
inFlightAt: 0,
// when the current restart round-trip was launched
barrenFailures: 0,
// consecutive failures that produced no candidate pairs
pendingRole: null
// 'offerer' | 'answerer' during a restart round-trip
};
this._lastInboundAt = 0;
this._livenessProbeAt = 0;
this._livenessTimer = null;
this._heartbeatTimer = null;
this.ecdhKeyPair = null;
this.ecdsaKeyPair = null;
if (this.fileTransferSystem) {
@@ -7715,9 +7808,6 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
if (this._debugMode) {
this._monitorGlobalExposure();
}
if (this._heartbeatConfig && this._heartbeatConfig.enabled && this.isConnected()) {
this._sendHeartbeat();
}
this._secureLog("info", "\u{1F527} Maintenance cycle completed successfully");
} catch (error) {
this._secureLog("error", "\u274C Maintenance cycle failed", {
@@ -7884,21 +7974,29 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
/**
* Send heartbeat message (called by unified scheduler)
*/
_sendHeartbeat() {
/**
* @param {boolean} ack - true when replying to a peer's probe. An ack is never
* itself acked, otherwise the two sides would ping-pong forever.
*/
_sendHeartbeat(ack = false) {
try {
if (this.isConnected() && this.dataChannel && this.dataChannel.readyState === "open") {
if (this.dataChannel && this.dataChannel.readyState === "open") {
this.dataChannel.send(JSON.stringify({
type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.HEARTBEAT,
ack,
timestamp: Date.now()
}));
this._heartbeatConfig.lastHeartbeat = Date.now();
this._secureLog("debug", "\u{1F493} Heartbeat sent");
this._secureLog("debug", ack ? "\u{1F493} Heartbeat ack sent" : "\u{1F493} Heartbeat sent");
return true;
}
return false;
} catch (error) {
this._secureLog("error", "\u274C Heartbeat failed:", {
errorType: error?.constructor?.name || "Unknown",
message: error?.message || "Unknown error"
});
return false;
}
}
/**
@@ -11806,7 +11904,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
return "FAKE_MESSAGE_FILTERED";
}
if (jsonData.type && ["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "security_upgrade"].includes(jsonData.type)) {
if (jsonData.type && ["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "security_upgrade", "ice_restart_offer", "ice_restart_answer", "ice_restart_request"].includes(jsonData.type)) {
return "SYSTEM_MESSAGE_FILTERED";
}
if (jsonData.type && ["file_transfer_start", "file_transfer_response", "file_chunk", "chunk_confirmation", "file_transfer_complete", "file_transfer_error"].includes(jsonData.type)) {
@@ -11875,7 +11973,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
return data;
}
if (!jsonData.type || jsonData.type !== "fake" && !["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "enhanced_message", "security_upgrade", "file_transfer_start", "file_transfer_response", "file_chunk", "chunk_confirmation", "file_transfer_complete", "file_transfer_error"].includes(jsonData.type)) {
if (!jsonData.type || jsonData.type !== "fake" && !["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "enhanced_message", "security_upgrade", "ice_restart_offer", "ice_restart_answer", "ice_restart_request", "file_transfer_start", "file_transfer_response", "file_chunk", "chunk_confirmation", "file_transfer_complete", "file_transfer_error"].includes(jsonData.type)) {
if (this._debugMode) {
this._secureLog("debug", "\u{1F4DD} Regular message detected, returning for display");
}
@@ -12215,6 +12313,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
// FIX 1: Simplified mutex system for message processing
async processMessage(data) {
try {
this._noteInboundActivity?.();
this._secureLog("debug", "\uFFFD\uFFFD Processing message", {
dataType: typeof data,
isArrayBuffer: data instanceof ArrayBuffer,
@@ -12329,6 +12428,18 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
return;
}
if (parsed.type && [
_EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER,
_EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_ANSWER,
_EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST
].includes(parsed.type)) {
try {
await this._handleIceRestartSignal(parsed.type, parsed.data || {});
} catch (e) {
this._secureLog("error", "\u274C ICE restart signal handling failed", { errorType: e?.constructor?.name });
}
return;
}
if (parsed.type && ["heartbeat", "verification", "verification_response", "verification_confirmed", "verification_both_confirmed", "peer_disconnect", "security_upgrade"].includes(parsed.type)) {
this.handleSystemMessage(parsed);
return;
@@ -12410,7 +12521,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
"peer_disconnect",
"key_rotation_signal",
"key_rotation_ready",
"security_upgrade"
"security_upgrade",
"ice_restart_offer",
"ice_restart_answer",
"ice_restart_request"
];
if (finalCheck.type && blockedTypes.includes(finalCheck.type)) {
this._secureLog("warn", `\u{1F4C1} Final system/file message check blocked: ${finalCheck.type}`);
@@ -12486,7 +12600,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this._secureLog("debug", "\u{1F527} Handling system message:", { type: message.type });
switch (message.type) {
case "heartbeat":
this.handleHeartbeat();
this.handleHeartbeat(message);
break;
case "verification":
this.handleVerificationRequest(message.data);
@@ -12983,28 +13097,32 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
if (state === "connected" && !this.isVerified) {
this._notifyVerificationReadyIfPossible();
} else if (state === "connected" && this.isVerified) {
this.onStatusChange("connected");
} else if (state === "disconnected" || state === "closed") {
if (!this._onPathRecovered()) this.onStatusChange("connected");
} else if (state === "disconnected") {
if (this.intentionalDisconnect) {
this.onStatusChange("disconnected");
setTimeout(() => this.disconnect(), 100);
} else if (this.isVerified) {
this._onPathDegraded("ice_disconnected");
} else {
if (this.isVerified || state === "closed") {
this.onStatusChange("disconnected");
this._clearVerificationStates();
} else {
console.warn(`[SecureBit ICE] State is ${state} but not verified yet. Keeping session open for manual exchange.`);
}
console.warn(`[SecureBit ICE] State is ${state} but not verified yet. Keeping session open for manual exchange.`);
}
} else if (state === "closed") {
this._resetReconnectState();
this.onStatusChange("disconnected");
this._clearVerificationStates();
if (this.intentionalDisconnect) setTimeout(() => this.disconnect(), 100);
} else if (state === "failed") {
this._collectIceFailureDiagnostics().then((diagnostics) => {
console.warn("[SecureBit ICE] failure diagnostics", diagnostics);
this._noteIceFailureDiagnostics(diagnostics);
});
if (this.isVerified) {
this.onStatusChange("disconnected");
this._onPathLost("ice_failed");
} else {
console.warn("[SecureBit ICE] State is failed but not verified yet. Keeping session open for manual exchange.");
}
} else if (this.isReconnecting() && (state === "connecting" || state === "new")) {
} else {
this.onStatusChange(state);
}
@@ -13043,7 +13161,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
setupDataChannel(channel) {
this.dataChannel = channel;
this.dataChannel.onopen = async () => {
let openHandled = false;
const handleChannelOpen = async () => {
if (openHandled) return;
openHandled = true;
try {
if (this.dataChannel && typeof this.dataChannel.bufferedAmountLowThreshold === "number") {
this.dataChannel.bufferedAmountLowThreshold = 1024 * 1024;
@@ -13087,7 +13208,17 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
this.startHeartbeat();
};
this.dataChannel.onopen = handleChannelOpen;
if (this.dataChannel.readyState === "open") {
Promise.resolve().then(() => handleChannelOpen()).catch((error) => {
this._secureLog("error", "Deferred data channel open handling failed", {
errorType: error?.constructor?.name || "Unknown"
});
});
}
this.dataChannel.onclose = () => {
this._resetReconnectState?.();
this._teardownRecoveryLifecycleListeners?.();
if (!this.intentionalDisconnect) {
this.onStatusChange("disconnected");
this._clearVerificationStates();
@@ -13109,6 +13240,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
};
this.dataChannel.onmessage = async (event) => {
try {
this._noteInboundActivity?.();
if (typeof event.data === "string") {
try {
const parsed = JSON.parse(event.data);
@@ -13192,6 +13324,18 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
return;
}
if (parsed.type && [
_EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER,
_EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_ANSWER,
_EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST
].includes(parsed.type)) {
try {
await this._handleIceRestartSignal(parsed.type, parsed.data || {});
} catch (e) {
this._secureLog("error", "\u274C ICE restart signal handling failed", { errorType: e?.constructor?.name });
}
return;
}
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;
@@ -16256,18 +16400,520 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this.sendSecureMessage(message).catch(console.error);
}
}
// Heartbeat runs on its own HEARTBEAT_INTERVAL timer. It used to be folded
// into the unified maintenance cycle, which ticks every 5 minutes — far too
// coarse to notice a dead path, and long enough that a drop looked like
// silence. The maintenance cycle no longer sends heartbeats.
startHeartbeat() {
this._secureLog("info", "Heartbeat moved to unified scheduler");
this._heartbeatConfig = {
enabled: true,
interval: _EnhancedSecureWebRTCManager.TIMEOUTS.HEARTBEAT_INTERVAL,
lastHeartbeat: 0
};
this.stopHeartbeat(
/* keepConfig */
true
);
this._heartbeatTimer = setInterval(() => {
if (!this._heartbeatConfig?.enabled) return;
if (this.dataChannel?.readyState === "open") {
this._sendHeartbeat();
}
}, _EnhancedSecureWebRTCManager.TIMEOUTS.HEARTBEAT_INTERVAL);
this._trackActiveTimer(this._heartbeatTimer);
this._lastInboundAt = Date.now();
this._livenessProbeAt = 0;
this._livenessArmed = false;
this._startLivenessWatchdog();
this._setupRecoveryLifecycleListeners();
this._secureLog("info", "\u{1F504} Liveness watchdog started", {
heartbeatMs: _EnhancedSecureWebRTCManager.TIMEOUTS.HEARTBEAT_INTERVAL,
probeAfterMs: _EnhancedSecureWebRTCManager.TIMEOUTS.LIVENESS_PROBE_AFTER,
probeTimeoutMs: _EnhancedSecureWebRTCManager.TIMEOUTS.LIVENESS_PROBE_TIMEOUT
});
}
stopHeartbeat() {
if (this._heartbeatConfig) {
stopHeartbeat(keepConfig = false) {
if (!keepConfig && this._heartbeatConfig) {
this._heartbeatConfig.enabled = false;
}
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._activeTimers?.delete(this._heartbeatTimer);
this._heartbeatTimer = null;
}
if (!keepConfig) this._stopLivenessWatchdog();
}
/**
* Inbound heartbeat from the peer. This method used to be missing entirely
* while handleSystemMessage still dispatched to it, so every heartbeat threw
* a TypeError and liveness was never actually observed.
*
* A non-ack heartbeat is a probe and must be answered immediately: that reply
* is what proves this side is alive even when its tab is backgrounded and its
* own timers have been throttled to a standstill.
*/
handleHeartbeat(message) {
this._lastInboundAt = Date.now();
this._livenessProbeAt = 0;
const isAck = message?.ack === true || message?.data?.ack === true;
if (!isAck) this._sendHeartbeat(true);
this._secureLog("debug", isAck ? "\u{1F493} Heartbeat ack received" : "\u{1F493} Heartbeat probe received");
}
/**
* Any authenticated inbound frame proves the path is alive, not just
* heartbeats a busy conversation must never trip the watchdog.
*/
_noteInboundActivity() {
this._lastInboundAt = Date.now();
this._livenessProbeAt = 0;
this._livenessArmed = true;
}
_startLivenessWatchdog() {
this._stopLivenessWatchdog();
this._livenessTimer = setInterval(() => {
try {
this._checkLiveness();
} catch (error) {
this._secureLog("error", "\u274C Liveness check failed", {
errorType: error?.constructor?.name || "Unknown"
});
}
}, _EnhancedSecureWebRTCManager.TIMEOUTS.LIVENESS_CHECK_INTERVAL);
this._trackActiveTimer(this._livenessTimer);
}
_stopLivenessWatchdog() {
if (this._livenessTimer) {
clearInterval(this._livenessTimer);
this._activeTimers?.delete(this._livenessTimer);
this._livenessTimer = null;
}
}
/**
* A data channel keeps reporting readyState === 'open' long after the
* underlying path has died (the classic Wi-Fi LTE switch: nothing closes,
* nothing errors, packets simply stop). Nothing tells us so we ask.
*
* Two steps, because silence alone is not evidence of death. A backgrounded
* tab has its timers throttled to roughly one tick per minute (frozen
* outright on iOS), so a healthy peer routinely goes quiet. What a healthy
* peer cannot do is fail to ANSWER: inbound message handling is not throttled
* the way timers are. So after a period of silence we send a probe, and only
* an unanswered probe is treated as a dead path.
*/
_checkLiveness() {
if (!this.isVerified) return;
if (this._reconnect.phase !== "idle") return;
if (this.dataChannel?.readyState !== "open") return;
if (!this._lastInboundAt) return;
if (!this._livenessArmed) return;
const T = _EnhancedSecureWebRTCManager.TIMEOUTS;
const now = Date.now();
const iceHealthy = this.peerConnection?.connectionState === "connected";
if (iceHealthy) {
this._livenessProbeAt = 0;
return;
}
if (this._livenessProbeAt) {
if (now - this._livenessProbeAt < T.LIVENESS_PROBE_TIMEOUT) return;
this._livenessProbeAt = 0;
this._secureLog("warn", "\u26A0\uFE0F liveness probe unanswered and ICE is not connected \u2014 path presumed dead");
this._onPathLost("liveness_probe_timeout");
return;
}
if (now - this._lastInboundAt < T.LIVENESS_PROBE_AFTER) return;
this._livenessProbeAt = now;
const delivered = this._sendHeartbeat(false);
this._secureLog("info", "\u{1F504} peer silent and ICE degraded, probing", {
silentForMs: now - this._lastInboundAt,
connectionState: this.peerConnection?.connectionState,
probeSent: delivered
});
}
// ============================================
// SESSION RECOVERY (serverless, in-band)
// ============================================
//
// What survives an ICE restart and what does not:
//
// ICE restart replaces the candidate pair — i.e. the network path. The
// DTLS handshake, the negotiated keys and the SCTP association that the
// data channel rides on are all layered ABOVE ICE and survive untouched.
// That is why a restart can recover a Wi-Fi → LTE switch without a new
// handshake, without a new SAS, and without losing message history.
//
// The restart SDP travels over that same still-established data channel,
// so it inherits the channel's authentication: an attacker who cannot
// already decrypt the session cannot inject one. No signalling server is
// involved at any point.
//
// The one thing a restart must never do is change peer identity, so the
// DTLS fingerprint in the incoming SDP is checked against the fingerprint
// of the live session before anything is applied. A mismatch is treated
// as an attack and aborts recovery rather than re-keying to a stranger.
//
// What it cannot recover: a closed data channel (SCTP gone) or a path so
// dead that the restart offer itself cannot be delivered. Those fall
// through to _giveUpAutoReconnect and require a fresh, manually exchanged
// handshake — the existing offer/answer flow.
isReconnecting() {
return this._reconnect.phase !== "idle" && this._reconnect.phase !== "exhausted";
}
/**
* Device-level signals that a path is worth re-checking right now, instead of
* waiting out a backoff: this device regained network, or a mobile browser
* brought the tab back to the foreground (where it may have frozen the
* connection while backgrounded).
*/
_setupRecoveryLifecycleListeners() {
if (typeof window === "undefined" || this._recoveryLifecycleBound) return;
this._recoveryLifecycleBound = true;
this._onDeviceOnline = () => {
if (!this.isVerified) return;
if (this.isReconnecting()) {
this._secureLog("info", "\u{1F504} Device back online \u2014 retrying immediately");
this._attemptIceRestart();
} else {
this._checkLiveness();
}
};
this._onVisibilityRestored = () => {
if (typeof document === "undefined" || document.visibilityState !== "visible") return;
if (!this.isVerified) return;
this._lastInboundAt = Date.now();
this._livenessProbeAt = 0;
if (this._reconnect.phase === "idle" && this.dataChannel?.readyState === "open") {
this._livenessProbeAt = Date.now();
this._sendHeartbeat(false);
this._secureLog("info", "\u{1F504} returned to foreground, probing peer");
}
};
window.addEventListener("online", this._onDeviceOnline);
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", this._onVisibilityRestored);
}
}
_teardownRecoveryLifecycleListeners() {
if (!this._recoveryLifecycleBound || typeof window === "undefined") return;
this._recoveryLifecycleBound = false;
if (this._onDeviceOnline) window.removeEventListener("online", this._onDeviceOnline);
if (this._onVisibilityRestored && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", this._onVisibilityRestored);
}
this._onDeviceOnline = null;
this._onVisibilityRestored = null;
}
_resetReconnectState() {
const r = this._reconnect;
if (!r) return;
if (r.graceTimer) {
clearTimeout(r.graceTimer);
this._activeTimers?.delete(r.graceTimer);
}
if (r.retryTimer) {
clearTimeout(r.retryTimer);
this._activeTimers?.delete(r.retryTimer);
}
if (r.restartTimer) {
clearTimeout(r.restartTimer);
this._activeTimers?.delete(r.restartTimer);
}
r.graceTimer = null;
r.retryTimer = null;
r.restartTimer = null;
r.phase = "idle";
r.attempts = 0;
r.startedAt = 0;
r.inFlightAt = 0;
r.barrenFailures = 0;
r.pendingRole = null;
}
/**
* ICE reported 'disconnected'. This is usually transient the browser's own
* consent freshness checks recover it within a couple of seconds so hold a
* grace window before spending a restart, but tell the UI right away so the
* user sees "reconnecting" rather than a silently stalled chat.
*/
_onPathDegraded(reason = "ice_disconnected") {
if (!this.isVerified) return;
if (this._reconnect.phase !== "idle") return;
this._reconnect.phase = "grace";
this._reconnect.startedAt = Date.now();
this._secureLog("info", "\u{1F504} path degraded, holding grace window", { reason });
this.onStatusChange("reconnecting");
this._reconnect.graceTimer = setTimeout(() => {
this._reconnect.graceTimer = null;
if (this.peerConnection?.connectionState === "connected") {
this._onPathRecovered();
return;
}
this._attemptIceRestart();
}, _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_DISCONNECT_GRACE);
this._trackActiveTimer(this._reconnect.graceTimer);
}
/** ICE failed outright, or the peer went silent — restart without waiting. */
_onPathLost(reason = "ice_failed") {
if (!this.isVerified) return;
if (this._reconnect.phase === "restarting" || this._reconnect.phase === "exhausted") return;
if (this._reconnect.phase === "idle") {
this._reconnect.startedAt = Date.now();
this.onStatusChange("reconnecting");
}
if (this._reconnect.graceTimer) {
clearTimeout(this._reconnect.graceTimer);
this._activeTimers?.delete(this._reconnect.graceTimer);
this._reconnect.graceTimer = null;
}
this._secureLog("info", "\u{1F504} path lost, restarting ICE", { reason });
this._attemptIceRestart();
}
/**
* A restart is only worth trying while the ICE agent can still produce
* candidates. After the device changes network, a PeerConnection is often
* left bound to interfaces that no longer exist: every STUN binding and TURN
* allocation times out, gathering yields nothing, and each restart fails with
* zero candidate pairs. restartIce() does not rebind it only a brand-new
* PeerConnection will, and building one needs a whole new handshake.
*
* Recognising that early matters: retrying it for the full two-minute
* deadline is two minutes of the user watching nothing happen, when the way
* out was available immediately.
*/
_noteIceFailureDiagnostics(diagnostics) {
if (!this.isReconnecting()) return;
if (!diagnostics) return;
if (diagnostics.pairCount > 0) {
this._reconnect.barrenFailures = 0;
return;
}
this._reconnect.barrenFailures = (this._reconnect.barrenFailures || 0) + 1;
if (this._reconnect.barrenFailures < _EnhancedSecureWebRTCManager.LIMITS.MAX_BARREN_ICE_FAILURES) return;
this._secureLog("warn", "\u26A0\uFE0F ICE cannot gather any usable candidate \u2014 this connection is bound to a network that is gone", {
consecutiveBarrenFailures: this._reconnect.barrenFailures
});
this._giveUpAutoReconnect("ice_agent_unusable");
}
/**
* Path is back. Same keys, same verification, same history carry on.
* Returns true if it actually handled a recovery (and therefore already
* emitted 'connected'), so the caller does not emit it twice.
*/
_onPathRecovered() {
const wasRecovering = this.isReconnecting();
this._resetReconnectState();
this._lastInboundAt = Date.now();
this._livenessProbeAt = 0;
if (!wasRecovering) return false;
this._secureLog("info", "\u{1F504} connection recovered, session preserved");
this.onStatusChange("connected");
this.processMessageQueue();
try {
document.dispatchEvent(new CustomEvent("connection-recovered", {
detail: { timestamp: Date.now() }
}));
} catch (_) {
}
return true;
}
/**
* Only the side that created the original offer drives restarts. Both sides
* offering at once produces glare, and with no signalling server there is no
* referee to break the tie so the answerer asks instead of acting.
*/
async _attemptIceRestart() {
if (!this.isVerified || !this.peerConnection) return;
if (this.peerConnection.connectionState === "connected") {
this._onPathRecovered();
return;
}
const r = this._reconnect;
if (typeof navigator !== "undefined" && navigator.onLine === false) {
r.phase = "waiting";
r.startedAt = Date.now();
this._secureLog("debug", "\u{1F504} Device offline \u2014 holding recovery open");
this._scheduleReconnectRetry();
return;
}
const elapsed = Date.now() - (r.startedAt || Date.now());
if (elapsed > _EnhancedSecureWebRTCManager.TIMEOUTS.RECONNECT_MAX_DURATION) {
this._giveUpAutoReconnect("timeout");
return;
}
if (r.inFlightAt && Date.now() - r.inFlightAt < _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_TIMEOUT) {
this._scheduleReconnectRetry();
return;
}
if (this.dataChannel?.readyState !== "open") {
this._giveUpAutoReconnect("data_channel_closed");
return;
}
const silentFor = Date.now() - Math.max(this._lastInboundAt || 0, r.startedAt);
if (r.attempts >= 2 && silentFor > _EnhancedSecureWebRTCManager.TIMEOUTS.RECOVERY_SILENCE_LIMIT) {
this._secureLog("warn", "\u26A0\uFE0F nothing has reached us since the drop \u2014 the channel cannot carry a renegotiation", {
silentForMs: silentFor,
attempts: r.attempts
});
this._giveUpAutoReconnect("no_signalling_path");
return;
}
r.phase = "restarting";
r.attempts += 1;
r.inFlightAt = Date.now();
this._secureLog("info", "\u{1F504} ICE restart attempt", {
attempt: r.attempts,
role: this.isInitiator ? "offerer" : "answerer"
});
try {
if (this.isInitiator) {
await this._sendIceRestartOffer();
} else {
await this.sendSystemMessage({
type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST,
timestamp: Date.now()
});
}
} catch (error) {
this._secureLog("warn", "\u26A0\uFE0F ICE restart attempt failed to send", {
errorType: error?.constructor?.name || "Unknown"
});
}
this._scheduleReconnectRetry();
}
_scheduleReconnectRetry() {
const r = this._reconnect;
if (r.retryTimer) {
clearTimeout(r.retryTimer);
this._activeTimers?.delete(r.retryTimer);
}
const backoff = _EnhancedSecureWebRTCManager.RECONNECT_BACKOFF;
const delay = backoff[Math.min(Math.max(r.attempts - 1, 0), backoff.length - 1)];
r.retryTimer = setTimeout(() => {
r.retryTimer = null;
if (this.peerConnection?.connectionState === "connected") {
this._onPathRecovered();
return;
}
this._attemptIceRestart();
}, delay);
this._trackActiveTimer(r.retryTimer);
}
async _sendIceRestartOffer() {
const pc = this.peerConnection;
if (!pc) return;
if (pc.signalingState === "have-local-offer") {
try {
await pc.setLocalDescription({ type: "rollback" });
} catch (_) {
}
}
const offer = await pc.createOffer({ iceRestart: true });
await pc.setLocalDescription(offer);
await this.waitForIceGathering(_EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING);
await this.sendSystemMessage({
type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER,
sdp: pc.localDescription.sdp,
timestamp: Date.now()
});
this._secureLog("debug", "\u{1F504} ICE restart offer sent");
}
/**
* The fingerprint of the live, already-SAS-verified session. Recovery must
* re-point the path at the SAME peer, never re-key to a new one.
*/
_currentRemoteDtlsFingerprint() {
const sdp = this.peerConnection?.currentRemoteDescription?.sdp || this.peerConnection?.remoteDescription?.sdp;
if (!sdp) return null;
try {
return this._extractDTLSFingerprintFromSDP(sdp);
} catch (_) {
return null;
}
}
async _assertSameRemoteIdentity(sdp, context) {
const expected = this._currentRemoteDtlsFingerprint();
if (!expected) {
throw new Error(`Cannot verify peer identity for ${context}`);
}
const received = this._extractDTLSFingerprintFromSDP(sdp);
await this._validateDTLSFingerprint(received, expected, context);
}
/** Inbound recovery signalling, routed from processMessage. */
async _handleIceRestartSignal(type, data) {
const T = _EnhancedSecureWebRTCManager.MESSAGE_TYPES;
const pc = this.peerConnection;
if (!pc) return;
this._noteInboundActivity();
switch (type) {
case T.ICE_RESTART_REQUEST: {
if (!this.isInitiator) return;
if (this._reconnect.phase === "idle") {
this._reconnect.startedAt = Date.now();
this._reconnect.phase = "restarting";
this.onStatusChange("reconnecting");
}
await this._sendIceRestartOffer();
return;
}
case T.ICE_RESTART_OFFER: {
if (!data.sdp) return;
await this._assertSameRemoteIdentity(data.sdp, "ice_restart_offer");
if (this._reconnect.phase === "idle") {
this._reconnect.startedAt = Date.now();
this.onStatusChange("reconnecting");
}
this._reconnect.phase = "restarting";
await pc.setRemoteDescription({ type: "offer", sdp: data.sdp });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await this.waitForIceGathering(_EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING);
await this.sendSystemMessage({
type: T.ICE_RESTART_ANSWER,
sdp: pc.localDescription.sdp,
timestamp: Date.now()
});
this._secureLog("debug", "\u{1F504} ICE restart answer sent");
return;
}
case T.ICE_RESTART_ANSWER: {
if (!data.sdp) return;
if (pc.signalingState !== "have-local-offer") {
this._secureLog("warn", "\u26A0\uFE0F Ignoring restart answer in unexpected state", {
signalingState: pc.signalingState
});
return;
}
await this._assertSameRemoteIdentity(data.sdp, "ice_restart_answer");
await pc.setRemoteDescription({ type: "answer", sdp: data.sdp });
this._reconnect.inFlightAt = 0;
this._secureLog("debug", "\u{1F504} ICE restart answer applied");
return;
}
default:
}
}
/**
* Automatic recovery is out of road, and there is no fallback: with no
* signalling server, a path that cannot carry a renegotiation cannot be
* rebuilt without a fresh, manually exchanged handshake.
*
* So the session ends here rather than lingering half-alive. Everything goes
* with it keys, queued messages, transcript which is also the safer
* default: a conversation whose transport is gone should not leave its
* plaintext sitting in a tab the user has stopped watching.
*/
_giveUpAutoReconnect(reason) {
this._resetReconnectState();
this._reconnect.phase = "exhausted";
this._teardownRecoveryLifecycleListeners?.();
this._secureLog("warn", "\u26A0\uFE0F automatic reconnection exhausted \u2014 ending session", { reason });
if (!this.reconnectionFailedNotificationSent) {
this.reconnectionFailedNotificationSent = true;
this.deliverMessageToUI(
"Could not restore the connection. This chat is being closed and its data wiped \u2014 start a new one to continue.",
"system"
);
}
this.onStatusChange("recovery_failed");
this._clearVerificationStates();
}
/**
* Stop all active timers and cleanup scheduler
@@ -16278,9 +16924,8 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
clearInterval(this._maintenanceScheduler);
this._maintenanceScheduler = null;
}
if (this._heartbeatConfig) {
this._heartbeatConfig.enabled = false;
}
this.stopHeartbeat?.();
this._resetReconnectState?.();
if (this._activeTimers) {
this._activeTimers.forEach((timer) => {
if (timer) {
@@ -16296,7 +16941,12 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this._logCleanupInterval = null;
this._secureLog("info", "All timers stopped successfully");
}
waitForIceGathering() {
/**
* @param {number} [timeoutMs] - gathering budget. Recovery uses a much shorter
* one than the initial handshake: a restart round-trip must finish well
* inside the retry backoff, or the next attempt cancels the one in flight.
*/
waitForIceGathering(timeoutMs = _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT) {
return new Promise((resolve) => {
if (this.peerConnection.iceGatheringState === "complete") {
resolve(true);
@@ -16314,7 +16964,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this.peerConnection.removeEventListener("icegatheringstatechange", checkState);
}
resolve(this.peerConnection?.iceGatheringState === "complete");
}, _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT);
}, timeoutMs);
});
}
retryConnection() {
@@ -16391,11 +17041,25 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
});
}
}
/**
* Manual "try again" from the UI. Restarts the automatic recovery cycle from
* scratch (fresh attempt counter and deadline) as long as the data channel is
* still there to carry the renegotiation.
*/
attemptReconnection() {
if (!this.reconnectionFailedNotificationSent) {
this.reconnectionFailedNotificationSent = true;
this.deliverMessageToUI("Unable to reconnect. A new connection is required.", "system");
if (!this.isVerified || this.dataChannel?.readyState !== "open") {
if (!this.reconnectionFailedNotificationSent) {
this.reconnectionFailedNotificationSent = true;
this.deliverMessageToUI("Unable to reconnect. A new connection is required.", "system");
}
return false;
}
this._resetReconnectState();
this.reconnectionFailedNotificationSent = false;
this._reconnect.startedAt = Date.now();
this.onStatusChange("reconnecting");
this._attemptIceRestart();
return true;
}
handlePeerDisconnectNotification(data) {
const reason = data.reason || "unknown";
@@ -16448,6 +17112,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
this.intentionalDisconnect = true;
window.EnhancedSecureCryptoUtils.secureLog.log("info", "Starting intentional disconnect");
this.sendDisconnectNotification();
this._teardownRecoveryLifecycleListeners?.();
this._stopAllTimers();
this._peerDisconnectCleanupTimer = null;
this.stopHeartbeat();
@@ -17024,7 +17689,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
_callCanStart() {
const connected = typeof this.isConnected === "function" ? this.isConnected() : false;
const channelOpen = this.dataChannel && this.dataChannel.readyState === "open";
const ok = !!(connected && channelOpen && this.isVerified);
const ok = !!(connected && channelOpen && this.isVerified && !this.isReconnecting());
return ok;
}
async _sendCallSignal(type, data) {
@@ -18910,7 +19575,7 @@ Right-click or Ctrl+click to disconnect`,
React.createElement("div", { key: "txt", style: { lineHeight: 1.2, minWidth: 0 } }, [
React.createElement("div", { key: "r1", style: { display: "flex", alignItems: "baseline", gap: "7px" } }, [
React.createElement("span", { key: "n", style: { fontSize: "16px", fontWeight: 800, letterSpacing: "-0.3px", color: "#e8e8eb" } }, "SecureBit"),
React.createElement("span", { key: "v", style: { fontFamily: MONO, fontSize: "10px", fontWeight: 500, color: "#56565e" } }, "v5.5.4")
React.createElement("span", { key: "v", style: { fontFamily: MONO, fontSize: "10px", fontWeight: 500, color: "#56565e" } }, "v5.6.0")
]),
React.createElement("div", { key: "r2", className: "hidden sm:block", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500 } }, "End-to-end encrypted")
])
+2 -2
View File
File diff suppressed because one or more lines are too long
Vendored
+97 -38
View File
@@ -201,6 +201,8 @@ function statusSub(status) {
case "connecting":
case "new":
return "Connecting\u2026";
case "reconnecting":
return "Reconnecting\u2026";
case "peer_disconnected":
return "Peer disconnected";
default:
@@ -290,7 +292,7 @@ function sessionsReducer(state, action) {
case A.SET_STATUS: {
const session = state.sessions[action.id];
if (!session || session.status === action.status) return state;
const connected = action.status === "connected" || action.status === "verified";
const connected = action.status === "connected" || action.status === "verified" || action.status === "reconnecting";
const patch = !connected && session.peerPresence !== null ? { status: action.status, peerPresence: null } : { status: action.status };
return patchSession(state, action.id, patch);
}
@@ -403,7 +405,7 @@ function decorateSession(session, activeSessionId) {
const lastMessage = [...session.messages].reverse().find((m) => !m.expired && (typeof m.message === "string" && m.message.trim() || m.voice));
const s = session.status;
const isUp = s === "connected" || s === "verified";
const isPending = s === "connecting" || s === "verifying" || s === "new";
const isPending = s === "connecting" || s === "verifying" || s === "new" || s === "reconnecting";
let dot, headerSub;
if (isPending) {
dot = "#e3b341";
@@ -2198,7 +2200,7 @@ var SecureBitChatHeader = ({ status, onDisconnect, webrtcManager, title, isOffli
React.createElement("span", { key: "n", style: { fontSize: "15px", fontWeight: 800, letterSpacing: "-0.3px", color: "#f4f4f6", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, title || "Secure chat"),
React.createElement("button", { key: "edit", className: "sb-rename-btn", onClick: startRename, title: "Rename chat (local only)", style: { flex: "none", width: "24px", height: "24px", borderRadius: "7px", display: "grid", placeItems: "center", border: "none", background: "transparent", color: "#56565e", cursor: "pointer" } }, React.createElement("i", { className: "fas fa-pen", style: { fontSize: "11px" } }))
]),
React.createElement("div", { key: "r2", className: "sb-hdr-sub", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, isOffline ? "No network \xB7 reconnecting" : peerPresenceWord || (onlineConnected ? "P2P \xB7 end-to-end encrypted" : status === "peer_disconnected" ? "Peer disconnected" : status === "disconnected" ? "Disconnected" : "Connecting\u2026"))
React.createElement("div", { key: "r2", className: "sb-hdr-sub", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, isOffline ? "No network \xB7 reconnecting" : status === "reconnecting" ? "Restoring connection\u2026" : peerPresenceWord || (onlineConnected ? "P2P \xB7 end-to-end encrypted" : status === "peer_disconnected" ? "Peer disconnected" : status === "disconnected" ? "Disconnected" : "Connecting\u2026"))
])
]),
secBtn,
@@ -2915,6 +2917,7 @@ var EnhancedSecureP2PChat = () => {
const managersRef = React.useRef(/* @__PURE__ */ new Map());
const integrationsRef = React.useRef(/* @__PURE__ */ new Map());
const queuesRef = React.useRef(/* @__PURE__ */ new Map());
const statusRef = React.useRef(/* @__PURE__ */ new Map());
const dispatchActive = React.useCallback((build) => {
const id = activeIdRef.current;
if (!id) return;
@@ -3034,11 +3037,17 @@ var EnhancedSecureP2PChat = () => {
React.useEffect(() => {
const goOffline = () => setIsOffline(true);
const goOnline = () => setIsOffline(false);
const resync = () => {
if (document.visibilityState !== "visible") return;
setIsOffline(navigator.onLine === false);
};
window.addEventListener("offline", goOffline);
window.addEventListener("online", goOnline);
document.addEventListener("visibilitychange", resync);
return () => {
window.removeEventListener("offline", goOffline);
window.removeEventListener("online", goOnline);
document.removeEventListener("visibilitychange", resync);
};
}, []);
React.useEffect(() => {
@@ -3217,43 +3226,68 @@ var EnhancedSecureP2PChat = () => {
if (!fileId) return;
setMessages((prev) => prev.map((m) => m.fileId && String(m.fileId) === String(fileId) ? { ...m, ...typeof patch === "function" ? patch(m) : patch } : m));
}, []);
const flushOfflineQueues = React.useCallback(() => {
for (const [id, q] of queuesRef.current.entries()) {
const mgr = managersRef.current.get(id);
const out = q.outgoing;
q.outgoing = [];
for (const item of out) {
const send = mgr?.sendMessage?.(item.outText, item.meta);
const flushSessionQueue = React.useCallback((id) => {
const q = queuesRef.current.get(id);
if (!q) return;
const mgr = managersRef.current.get(id);
const out = q.outgoing;
q.outgoing = [];
const deferred = [];
for (const item of out) {
if (!mgr || mgr.isConnected?.() !== true) {
deferred.push(item);
continue;
}
try {
const send = mgr.sendMessage?.(item.outText, item.meta);
if (send && typeof send.then === "function") {
send.then(() => dispatch({ type: SESSION_ACTIONS.UPDATE_MESSAGE_STATUS, id, mid: item.mid, status: "delivered" })).catch(() => dispatch({ type: SESSION_ACTIONS.UPDATE_MESSAGE_STATUS, id, mid: item.mid, status: "failed" }));
}
} catch (_) {
deferred.push(item);
}
const inc = q.incoming;
q.incoming = [];
if (inc.length > 0) {
dispatch({ type: SESSION_ACTIONS.ADD_MESSAGE, id, message: buildSessionMessage(
`Connection restored \u2014 ${inc.length} message${inc.length === 1 ? "" : "s"} received while you were offline.`,
"notice"
) });
}
const viewing = id === activeIdRef.current && (typeof document === "undefined" || document.visibilityState === "visible");
for (const item of inc) {
dispatch({ type: SESSION_ACTIONS.ADD_MESSAGE, id, message: buildSessionMessage(item.message, item.type, item.opts) });
if (item.opts && item.opts.mid && item.type === "received") {
if (viewing) {
try {
mgr?.sendDeliveryReceipt?.(item.opts.mid);
} catch (_) {
}
} else if (q.pendingReadAcks) q.pendingReadAcks.push(item.opts.mid);
}
}
if (deferred.length) q.outgoing = deferred.concat(q.outgoing);
const inc = q.incoming;
q.incoming = [];
if (inc.length > 0) {
dispatch({ type: SESSION_ACTIONS.ADD_MESSAGE, id, message: buildSessionMessage(
`Connection restored \u2014 ${inc.length} message${inc.length === 1 ? "" : "s"} received while you were offline.`,
"notice"
) });
}
const viewing = id === activeIdRef.current && (typeof document === "undefined" || document.visibilityState === "visible");
for (const item of inc) {
dispatch({ type: SESSION_ACTIONS.ADD_MESSAGE, id, message: buildSessionMessage(item.message, item.type, item.opts) });
if (item.opts && item.opts.mid && item.type === "received") {
if (viewing) {
try {
mgr?.sendDeliveryReceipt?.(item.opts.mid);
} catch (_) {
}
} else if (q.pendingReadAcks) q.pendingReadAcks.push(item.opts.mid);
}
}
}, []);
const flushOfflineQueues = React.useCallback(() => {
for (const id of queuesRef.current.keys()) flushSessionQueue(id);
}, [flushSessionQueue]);
React.useEffect(() => {
if (isOffline) return;
flushOfflineQueues();
}, [isOffline, flushOfflineQueues]);
React.useEffect(() => {
const timer = setInterval(() => {
for (const [id, q] of queuesRef.current.entries()) {
if (!q.outgoing.length && !q.incoming.length) continue;
const mgr = managersRef.current.get(id);
if (mgr?.isConnected?.() !== true) continue;
if (mgr?.isReconnecting?.() === true) continue;
flushSessionQueue(id);
}
}, 2e3);
return () => clearInterval(timer);
}, [flushSessionQueue]);
const updateSecurityLevel = React.useCallback(async () => {
if (window.isUpdatingSecurity) {
return;
@@ -3458,7 +3492,22 @@ var EnhancedSecureP2PChat = () => {
}
};
const handleStatusChange = (status) => {
const prevStatus = statusRef.current.get(id);
statusRef.current.set(id, status);
setConnectionStatus2(status);
if (status === "reconnecting") return;
if (status === "connected" && prevStatus === "reconnecting") {
flushSessionQueue(id);
}
if (status === "recovery_failed") {
setConnectionStatus2("disconnected");
if (id === activeIdRef.current) {
document.dispatchEvent(new CustomEvent("peer-disconnect"));
document.dispatchEvent(new CustomEvent("disconnected"));
}
setTimeout(() => destroySession(id), 2500);
return;
}
if (status === "connected") {
document.dispatchEvent(new CustomEvent("new-connection"));
if (!window.isUpdatingSecurity) {
@@ -3591,7 +3640,7 @@ var EnhancedSecureP2PChat = () => {
} catch (error) {
}
}
handleMessage(" SecureBit.chat Enhanced Security Edition v5.5.4 - 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 v5.6.0 - 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");
manager.setFileTransferCallbacks(
// Progress callback — drives the voice-note upload/download ring.
(progress) => {
@@ -3705,6 +3754,7 @@ var EnhancedSecureP2PChat = () => {
integrationsRef.current.delete(id);
}
queuesRef.current.delete(id);
statusRef.current.delete(id);
dispatch({ type: SESSION_ACTIONS.REMOVE_SESSION, id });
} finally {
destroyingRef.current.delete(id);
@@ -3830,6 +3880,7 @@ var EnhancedSecureP2PChat = () => {
}
integrationsRef.current.clear();
queuesRef.current.clear();
statusRef.current.clear();
};
}, []);
const compressOfferData = (offerData2) => {
@@ -4923,8 +4974,9 @@ var EnhancedSecureP2PChat = () => {
}
const baseTextEarly = messageInput.trim();
const midEarly = `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const offlineNow = isOffline || typeof navigator !== "undefined" && navigator.onLine === false || window.pwaOfflineManager && window.pwaOfflineManager.isOnline === false;
if (offlineNow) {
const mgr = webrtcManagerRef.current;
const channelUsable = mgr?.isConnected?.() === true && mgr?.isReconnecting?.() !== true;
if (!channelUsable && mgr?.isConnected) {
const outTextOff = codeMode ? "```\n" + baseTextEarly + "\n```" : baseTextEarly;
const tsOff = Date.now();
const metaOff = { mid: midEarly, ts: tsOff };
@@ -4943,7 +4995,8 @@ var EnhancedSecureP2PChat = () => {
if (viewOnceMode) setViewOnceMode(false);
return;
}
if (!webrtcManagerRef.current.isConnected()) {
if (!channelUsable) {
addMessageWithAutoScroll("Not sent \u2014 the secure channel is not ready. Reconnect to continue.", "system");
return;
}
try {
@@ -4990,8 +5043,8 @@ var EnhancedSecureP2PChat = () => {
localUrl = URL.createObjectURL(blob);
} catch (_) {
}
const offlineNow = isOffline || typeof navigator !== "undefined" && navigator.onLine === false || window.pwaOfflineManager && window.pwaOfflineManager.isOnline === false;
const notReady = offlineNow || !webrtcManagerRef.current.isConnected || !webrtcManagerRef.current.isConnected();
const reconnecting = webrtcManagerRef.current?.isReconnecting?.() === true;
const notReady = reconnecting || webrtcManagerRef.current?.isConnected?.() !== true;
if (notReady) {
if (localUrl) {
try {
@@ -4999,7 +5052,10 @@ var EnhancedSecureP2PChat = () => {
} catch (_) {
}
}
addMessageWithAutoScroll("Voice message needs an active secure connection. Reconnect and try again.", "system");
addMessageWithAutoScroll(
reconnecting ? "Restoring the connection \u2014 try sending the voice message again in a moment." : "Voice message needs an active secure connection. Reconnect and try again.",
"system"
);
return;
}
const voiceMeta = {
@@ -5101,12 +5157,15 @@ var EnhancedSecureP2PChat = () => {
}
addMessageWithAutoScroll(message, "system");
};
const prevConnStatusRef = React.useRef(connectionStatus);
React.useEffect(() => {
if (connectionStatus === "connected" && isVerified) {
const resumed = prevConnStatusRef.current === "reconnecting";
prevConnStatusRef.current = connectionStatus;
if (connectionStatus === "connected" && isVerified && !resumed) {
addMessageWithAutoScroll(" Secure connection successfully established and verified! You can now communicate safely with full protection against MITM attacks and Perfect Forward Secrecy..", "system");
}
}, [connectionStatus, isVerified]);
const isConnectedAndVerified = (connectionStatus === "connected" || connectionStatus === "verified") && isVerified;
const isConnectedAndVerified = (connectionStatus === "connected" || connectionStatus === "verified" || connectionStatus === "reconnecting") && isVerified;
React.useEffect(() => {
document.body.classList.toggle("sb-in-chat", isConnectedAndVerified);
return () => document.body.classList.remove("sb-in-chat");
+2 -2
View File
File diff suppressed because one or more lines are too long
+22 -22
View File
@@ -24,7 +24,7 @@
<!-- PWA Manifest -->
<link rel="manifest" href="./manifest.json">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1785142410213">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1785719685129">
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
@@ -90,7 +90,7 @@
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1785142410213">
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1785719685129">
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
@@ -99,7 +99,7 @@
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1785142410213">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1785719685129">
<!-- Microsoft Tiles -->
<meta name="msapplication-TileColor" content="#ff6b35">
@@ -183,7 +183,7 @@
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
both execute in document order after parsing, so React still runs before the
app modules below, but the parser / first paint is no longer blocked. -->
<script defer src="config/ice-servers.js?v=1785142410213"></script>
<script defer src="config/ice-servers.js?v=1785719685129"></script>
<script defer src="libs/react/react.production.min.js"></script>
<script defer src="libs/react-dom/react-dom.production.min.js"></script>
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
@@ -191,8 +191,8 @@
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
<script defer src="libs/prism/prism.js"></script>
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
<link rel="stylesheet" href="assets/tailwind.css?v=1785142410213">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1785142410213">
<link rel="stylesheet" href="assets/tailwind.css?v=1785719685129">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1785719685129">
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
@@ -200,31 +200,31 @@
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
<link rel="stylesheet" href="src/styles/main.css?v=1785142410213">
<link rel="stylesheet" href="src/styles/animations.css?v=1785142410213">
<link rel="stylesheet" href="src/styles/components.css?v=1785142410213">
<link rel="stylesheet" href="src/styles/main.css?v=1785719685129">
<link rel="stylesheet" href="src/styles/animations.css?v=1785719685129">
<link rel="stylesheet" href="src/styles/components.css?v=1785719685129">
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
<script defer src="src/scripts/load-async-css.js?v=1785142410213"></script>
<script defer src="src/scripts/load-async-css.js?v=1785719685129"></script>
<noscript>
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
<link rel="stylesheet" href="libs/prism/prism.css">
</noscript>
<script defer src="src/scripts/fa-check.js?v=1785142410213"></script>
<script defer src="src/scripts/fa-check.js?v=1785719685129"></script>
<!-- Update Manager - система принудительного обновления -->
<script defer src="src/utils/updateManager.js?v=1785142410213"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1785142410213"></script>
<script type="module" src="dist/qr-local.js?v=1785142410213"></script>
<script type="module" src="src/components/QRScanner.js?v=1785142410213"></script>
<script defer src="src/utils/updateManager.js?v=1785719685129"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1785719685129"></script>
<script type="module" src="dist/qr-local.js?v=1785719685129"></script>
<script type="module" src="src/components/QRScanner.js?v=1785719685129"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1785142410213"></script>
<script type="module" src="dist/app.js?v=1785142410213"></script>
<script type="module" src="dist/app-boot.js?v=1785719685129"></script>
<script type="module" src="dist/app.js?v=1785719685129"></script>
<script defer src="src/scripts/pwa-register.js?v=1785142410213"></script>
<script src="./src/pwa/install-prompt.js?v=1785142410213" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1785142410213" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1785142410213"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1785142410213">
<script defer src="src/scripts/pwa-register.js?v=1785719685129"></script>
<script src="./src/pwa/install-prompt.js?v=1785719685129" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1785719685129" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1785719685129"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1785719685129">
</body>
</html>
+7 -7
View File
@@ -1,10 +1,10 @@
{
"version": "1785142410213",
"buildVersion": "1785142410213",
"appVersion": "5.5.4",
"buildTime": "2026-07-27T08:53:30.254Z",
"buildId": "1785142410213-fcee421",
"gitHash": "fcee421",
"version": "1785719685129",
"buildVersion": "1785719685129",
"appVersion": "5.6.0",
"buildTime": "2026-08-03T01:14:45.169Z",
"buildId": "1785719685129-60bf037",
"gitHash": "60bf037",
"generated": true,
"generatedAt": "2026-07-27T08:53:30.255Z"
"generatedAt": "2026-08-03T01:14:45.171Z"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "securebit-chat",
"version": "5.5.4",
"version": "5.6.0",
"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/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.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 && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs"
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.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 && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs && node tests/session-recovery.test.mjs"
},
"keywords": [
"p2p",
+178 -54
View File
@@ -2010,7 +2010,7 @@ import {
React.createElement('span', { key: 'n', style: { fontSize: '15px', fontWeight: 800, letterSpacing: '-0.3px', color: '#f4f4f6', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, title || 'Secure chat'),
React.createElement('button', { key: 'edit', className: 'sb-rename-btn', onClick: startRename, title: 'Rename chat (local only)', style: { flex: 'none', width: '24px', height: '24px', borderRadius: '7px', display: 'grid', placeItems: 'center', border: 'none', background: 'transparent', color: '#56565e', cursor: 'pointer' } }, React.createElement('i', { className: 'fas fa-pen', style: { fontSize: '11px' } }))
]),
React.createElement('div', { key: 'r2', className: 'sb-hdr-sub', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, isOffline ? 'No network · reconnecting' : (peerPresenceWord || (onlineConnected ? 'P2P · end-to-end encrypted' : (status === 'peer_disconnected' ? 'Peer disconnected' : (status === 'disconnected' ? 'Disconnected' : 'Connecting…')))))
React.createElement('div', { key: 'r2', className: 'sb-hdr-sub', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, isOffline ? 'No network · reconnecting' : (status === 'reconnecting' ? 'Restoring connection…' : (peerPresenceWord || (onlineConnected ? 'P2P · end-to-end encrypted' : (status === 'peer_disconnected' ? 'Peer disconnected' : (status === 'disconnected' ? 'Disconnected' : 'Connecting…'))))))
])
]),
secBtn,
@@ -2716,6 +2716,10 @@ import {
const managersRef = React.useRef(new Map()); // id -> EnhancedSecureWebRTCManager
const integrationsRef = React.useRef(new Map()); // id -> NotificationIntegration
const queuesRef = React.useRef(new Map()); // id -> { incoming:[], outgoing:[] }
// id -> last status seen from the manager. Used to spot the
// reconnecting connected edge (a repaired P2P path) without
// going through React state, which manager callbacks can't read.
const statusRef = React.useRef(new Map());
// Active-session VIEW. The rest of the component (and the child setup/chat
// components) read these names unchanged; the setters dispatch to the active
@@ -2851,9 +2855,22 @@ import {
React.useEffect(() => {
const goOffline = () => setIsOffline(true);
const goOnline = () => setIsOffline(false);
// A frozen tab can miss the 'online' edge entirely and would then
// read as offline for the rest of its life. Re-read the real value
// whenever the tab comes back, so the header stops claiming there
// is no network long after there is.
const resync = () => {
if (document.visibilityState !== 'visible') return;
setIsOffline(navigator.onLine === false);
};
window.addEventListener('offline', goOffline);
window.addEventListener('online', goOnline);
return () => { window.removeEventListener('offline', goOffline); window.removeEventListener('online', goOnline); };
document.addEventListener('visibilitychange', resync);
return () => {
window.removeEventListener('offline', goOffline);
window.removeEventListener('online', goOnline);
document.removeEventListener('visibilitychange', resync);
};
}, []);
// Keyboard-aware viewport height. iOS Safari does not shrink the layout
@@ -3087,43 +3104,87 @@ import {
: m));
}, []);
// When WE come back online: for EVERY session, transmit anything queued while
// offline and surface (and acknowledge) anything that arrived meanwhile. Each
// session flushes against its own manager and into its own slice.
const flushOfflineQueues = React.useCallback(() => {
for (const [id, q] of queuesRef.current.entries()) {
const mgr = managersRef.current.get(id);
const out = q.outgoing; q.outgoing = [];
for (const item of out) {
const send = mgr?.sendMessage?.(item.outText, item.meta);
// Drain ONE session's store-and-forward queues: transmit anything queued
// while the link was down, and surface (and acknowledge) anything that
// arrived meanwhile. Called both when the
// browser regains connectivity and when a single session's P2P path is
// repaired by an ICE restart (the two are independent: one chat can be
// reconnecting while the others are fine).
//
// A send is only attempted while the manager reports a usable channel;
// anything that cannot go out right now is put BACK on the queue in its
// original order rather than being marked failed, so a flush that races
// a still-settling path costs nothing but a retry.
const flushSessionQueue = React.useCallback((id) => {
const q = queuesRef.current.get(id);
if (!q) return;
const mgr = managersRef.current.get(id);
const out = q.outgoing; q.outgoing = [];
const deferred = [];
for (const item of out) {
if (!mgr || mgr.isConnected?.() !== true) { deferred.push(item); continue; }
try {
const send = mgr.sendMessage?.(item.outText, item.meta);
if (send && typeof send.then === 'function') {
send.then(() => dispatch({ type: SA.UPDATE_MESSAGE_STATUS, id, mid: item.mid, status: 'delivered' }))
.catch(() => dispatch({ type: SA.UPDATE_MESSAGE_STATUS, id, mid: item.mid, status: 'failed' }));
}
} catch (_) {
deferred.push(item);
}
const inc = q.incoming; q.incoming = [];
if (inc.length > 0) {
dispatch({ type: SA.ADD_MESSAGE, id, message: buildSessionMessage(
`Connection restored — ${inc.length} message${inc.length === 1 ? '' : 's'} received while you were offline.`,
'notice'
) });
}
const viewing = id === activeIdRef.current && (typeof document === 'undefined' || document.visibilityState === 'visible');
for (const item of inc) {
dispatch({ type: SA.ADD_MESSAGE, id, message: buildSessionMessage(item.message, item.type, item.opts) });
if (item.opts && item.opts.mid && item.type === 'received') {
if (viewing) { try { mgr?.sendDeliveryReceipt?.(item.opts.mid); } catch (_) {} }
else if (q.pendingReadAcks) q.pendingReadAcks.push(item.opts.mid);
}
}
// Preserve ordering: deferred items go back ahead of anything queued
// while this flush was running.
if (deferred.length) q.outgoing = deferred.concat(q.outgoing);
const inc = q.incoming; q.incoming = [];
if (inc.length > 0) {
dispatch({ type: SA.ADD_MESSAGE, id, message: buildSessionMessage(
`Connection restored — ${inc.length} message${inc.length === 1 ? '' : 's'} received while you were offline.`,
'notice'
) });
}
const viewing = id === activeIdRef.current && (typeof document === 'undefined' || document.visibilityState === 'visible');
for (const item of inc) {
dispatch({ type: SA.ADD_MESSAGE, id, message: buildSessionMessage(item.message, item.type, item.opts) });
if (item.opts && item.opts.mid && item.type === 'received') {
if (viewing) { try { mgr?.sendDeliveryReceipt?.(item.opts.mid); } catch (_) {} }
else if (q.pendingReadAcks) q.pendingReadAcks.push(item.opts.mid);
}
}
}, []);
// When WE come back online: flush every session.
const flushOfflineQueues = React.useCallback(() => {
for (const id of queuesRef.current.keys()) flushSessionQueue(id);
}, [flushSessionQueue]);
React.useEffect(() => {
if (isOffline) return; // only act on the offline online edge
if (isOffline) return; // the offline online edge, when it arrives
flushOfflineQueues();
}, [isOffline, flushOfflineQueues]);
// but never RELY on that edge. The browser's online event is not
// guaranteed: a tab the OS froze can miss it entirely, and then a
// queue drained only on edges stays full forever the bug this
// replaces, where a phone showed one tick on every message it sent
// while happily receiving. Poll instead: whenever a session's channel
// is usable and it has something waiting, drain it. Sessions with
// nothing queued cost a map lookup.
React.useEffect(() => {
const timer = setInterval(() => {
for (const [id, q] of queuesRef.current.entries()) {
if (!q.outgoing.length && !q.incoming.length) continue;
const mgr = managersRef.current.get(id);
if (mgr?.isConnected?.() !== true) continue;
if (mgr?.isReconnecting?.() === true) continue;
flushSessionQueue(id);
}
}, 2000);
return () => clearInterval(timer);
}, [flushSessionQueue]);
// Update security level based on real verification
const updateSecurityLevel = React.useCallback(async () => {
if (window.isUpdatingSecurity) {
@@ -3374,11 +3435,41 @@ import {
};
const handleStatusChange = (status) => {
const prevStatus = statusRef.current.get(id);
statusRef.current.set(id, status);
setConnectionStatus(status);
// Path repair in progress (ICE restart). The session, its keys and
// its SAS verification all survive, so nothing is reset here the
// send path just starts queueing (see `offlineNow` in sendMessage).
if (status === 'reconnecting') return;
// Coming back from a repaired path: transmit whatever the user
// sent into the dead channel and surface what was held back.
if (status === 'connected' && prevStatus === 'reconnecting') {
flushSessionQueue(id);
}
// Recovery is out of road and there is no manual fallback, so the
// conversation ends here rather than lingering half-alive on screen.
// destroySession wipes the keys with the manager and removes the
// chat and its transcript a session whose transport is gone must
// not leave its plaintext sitting in a tab.
if (status === 'recovery_failed') {
setConnectionStatus('disconnected');
if (id === activeIdRef.current) {
document.dispatchEvent(new CustomEvent('peer-disconnect'));
document.dispatchEvent(new CustomEvent('disconnected'));
}
// Deferred so the closing notice this session just delivered
// renders before its slice is torn out from under it.
setTimeout(() => destroySession(id), 2500);
return;
}
if (status === 'connected') {
document.dispatchEvent(new CustomEvent('new-connection'));
// Не скрываем верификацию при 'connected' - только при 'verified'
// setIsVerified(true);
// setShowVerification(false);
@@ -3546,7 +3637,7 @@ import {
}
}
handleMessage(' SecureBit.chat Enhanced Security Edition v5.5.4 - 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 v5.6.0 - 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');
// Setup file transfer callbacks (id-bound to THIS session's manager).
manager.setFileTransferCallbacks(
@@ -3669,6 +3760,7 @@ import {
const integ = integrationsRef.current.get(id);
if (integ) { try { integ.cleanup?.(); } catch (_) {} integrationsRef.current.delete(id); }
queuesRef.current.delete(id);
statusRef.current.delete(id);
dispatch({ type: SA.REMOVE_SESSION, id });
} finally {
destroyingRef.current.delete(id);
@@ -3769,6 +3861,7 @@ import {
for (const integ of integrationsRef.current.values()) { try { integ.cleanup?.(); } catch (_) {} }
integrationsRef.current.clear();
queuesRef.current.clear();
statusRef.current.clear();
};
}, []); // run once
@@ -4849,7 +4942,7 @@ import {
if (!answerType || (answerType !== 'answer' && answerType !== 'enhanced_secure_answer')) {
throw new Error('Invalid response type. Expected answer or enhanced_secure_answer');
}
await webrtcManagerRef.current.handleSecureAnswer(answer);
// All security features are enabled by default - no session activation needed
@@ -5043,14 +5136,23 @@ import {
const baseTextEarly = messageInput.trim();
const midEarly = `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
// Offline guard: a P2P data channel can stay "open" after the
// browser loses connectivity, so isConnected() isn't enough show
// the bubble as "not sent" () instead of silently transmitting.
// Uses the live offline state (catches console-simulated offline too).
const offlineNow = isOffline
|| (typeof navigator !== 'undefined' && navigator.onLine === false)
|| (window.pwaOfflineManager && window.pwaOfflineManager.isOnline === false);
if (offlineNow) {
// Store-and-forward guard.
//
// The decision is made from the CHANNEL, never from the browser's
// idea of connectivity. `navigator.onLine` and the offline/online
// events report whether a network interface exists, not whether
// anything can be reached and a phone that was frozen misses the
// 'online' edge outright. When that happened this side went on
// queueing forever: every message showed one tick and none was ever
// transmitted, while incoming messages kept arriving normally,
// because receiving does not pass through here.
//
// The data channel is the only authority on whether a P2P message
// can go out: open, verified, and not mid-repair.
const mgr = webrtcManagerRef.current;
const channelUsable = mgr?.isConnected?.() === true
&& mgr?.isReconnecting?.() !== true;
if (!channelUsable && mgr?.isConnected) {
// Store-and-forward: show one check (sent), keep it in the
// conversation at its original time, and transmit on reconnect.
const outTextOff = codeMode ? '```\n' + baseTextEarly + '\n```' : baseTextEarly;
@@ -5071,9 +5173,11 @@ import {
return;
}
// Online but the channel isn't ready (e.g. dropped/not yet established)
// can't transmit. The setup screen is shown for re-establishment in that case.
if (!webrtcManagerRef.current.isConnected()) {
// No manager at all there is no session to queue against. This
// used to `return` in silence: the typed text stayed in the box,
// nothing was sent, and nothing said why.
if (!channelUsable) {
addMessageWithAutoScroll('Not sent — the secure channel is not ready. Reconnect to continue.', 'system');
return;
}
@@ -5145,13 +5249,21 @@ import {
let localUrl = null;
try { localUrl = URL.createObjectURL(blob); } catch (_) {}
const offlineNow = isOffline
|| (typeof navigator !== 'undefined' && navigator.onLine === false)
|| (window.pwaOfflineManager && window.pwaOfflineManager.isOnline === false);
const notReady = offlineNow || !webrtcManagerRef.current.isConnected || !webrtcManagerRef.current.isConnected();
const reconnecting = webrtcManagerRef.current?.isReconnecting?.() === true;
// Voice notes ride the chunked file-transfer path, which has no
// store-and-forward queue a partial transfer cannot be resumed
// across a path repair. So they are refused rather than queued,
// but the reason is stated accurately. Judged from the channel, not
// from navigator.onLine, for the same reason as the text path.
const notReady = reconnecting || webrtcManagerRef.current?.isConnected?.() !== true;
if (notReady) {
if (localUrl) { try { URL.revokeObjectURL(localUrl); } catch (_) {} }
addMessageWithAutoScroll('Voice message needs an active secure connection. Reconnect and try again.', 'system');
addMessageWithAutoScroll(
reconnecting
? 'Restoring the connection — try sending the voice message again in a moment.'
: 'Voice message needs an active secure connection. Reconnect and try again.',
'system'
);
return;
}
@@ -5288,19 +5400,31 @@ import {
};
// Announce a NEWLY established session only. Coming back from
// 'reconnecting' is the same session resuming re-announcing it would
// claim a handshake that never happened and spam the transcript on every
// flaky-network blip.
const prevConnStatusRef = React.useRef(connectionStatus);
React.useEffect(() => {
if (connectionStatus === 'connected' && isVerified) {
const resumed = prevConnStatusRef.current === 'reconnecting';
prevConnStatusRef.current = connectionStatus;
if (connectionStatus === 'connected' && isVerified && !resumed) {
addMessageWithAutoScroll(' Secure connection successfully established and verified! You can now communicate safely with full protection against MITM attacks and Perfect Forward Secrecy..', 'system');
}
}, [connectionStatus, isVerified]);
// Chat view requires an ACTIVE verified connection. On a drop the manager
// clears its verification state (it must be re-established there is no
// "keep chatting while disconnected" in this P2P design), so we fall back to
// the setup screen, which is the re-establish path. Note: this means a dropped
// chat shows the connect screen; the conversation history stays in the session.
const isConnectedAndVerified = (connectionStatus === 'connected' || connectionStatus === 'verified') && isVerified;
// Chat view requires an ACTIVE verified connection. On an UNRECOVERABLE drop
// the manager clears its verification state (it must be re-established
// there is no "keep chatting while disconnected" in this P2P design), so we
// fall back to the setup screen, which is the re-establish path. The
// conversation history stays in the session either way.
//
// 'reconnecting' is deliberately included: an ICE restart repairs only the
// network path, leaving the keys and the SAS verification intact, so the
// conversation must stay on screen. Throwing the user back to the connect
// screen for a two-second NAT rebind would defeat the recovery entirely
// the composer keeps working and queues (see the send path's offlineNow).
const isConnectedAndVerified = (connectionStatus === 'connected' || connectionStatus === 'verified' || connectionStatus === 'reconnecting') && isVerified;
// The PWA "Install app" pill is a landing-page affordance hide it once
// we're inside the chat (CSS: body.sb-in-chat #pwa-install-button).
+1 -1
View File
@@ -559,7 +559,7 @@ const EnhancedMinimalHeader = ({
React.createElement('div', { key: 'txt', style: { lineHeight: 1.2, minWidth: 0 } }, [
React.createElement('div', { key: 'r1', style: { display: 'flex', alignItems: 'baseline', gap: '7px' } }, [
React.createElement('span', { key: 'n', style: { fontSize: '16px', fontWeight: 800, letterSpacing: '-0.3px', color: '#e8e8eb' } }, 'SecureBit'),
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '10px', fontWeight: 500, color: '#56565e' } }, 'v5.5.4')
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '10px', fontWeight: 500, color: '#56565e' } }, 'v5.6.0')
]),
React.createElement('div', { key: 'r2', className: 'hidden sm:block', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500 } }, 'End-to-end encrypted')
])
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -177,7 +177,19 @@ class PWAOfflineManager {
window.addEventListener('offline', () => {
this.isOnline = false;
this.updateConnectionStatus(false);
this.handleConnectionLost();
// The `offline` event is fired liberally — an idle laptop parking its
// Wi-Fi radio, or a phone dozing, both produce one while the machine
// is perfectly reachable. Confirm before putting a modal in front of
// the user: if the check says otherwise, treat it as a false alarm.
// Without this, the offline guidance popped up on a working session
// where sending messages carried on fine.
this.checkOnlineStatus().then(() => {
if (this.isOnline) return; // the probe found we are actually fine
this.handleConnectionLost();
}).catch(() => {
this.handleConnectionLost();
});
});
// App visibility changes
@@ -461,6 +473,7 @@ class PWAOfflineManager {
if (response.ok && !this.isOnline) {
this.isOnline = true;
this.reconnectAttempts = 0;
this.handleConnectionRestored();
}
} catch (error) {
+11 -2
View File
@@ -84,6 +84,8 @@ export function statusSub(status) {
case 'connecting':
case 'new':
return 'Connecting…';
case 'reconnecting':
return 'Reconnecting…';
case 'peer_disconnected':
return 'Peer disconnected';
default:
@@ -184,7 +186,12 @@ export function sessionsReducer(state, action) {
// Peer presence is only meaningful while connected. Clear it whenever the
// session leaves the connected state, so a later reconnect doesn't briefly
// re-show the peer's stale status before they re-broadcast their presence.
const connected = action.status === 'connected' || action.status === 'verified';
// 'reconnecting' keeps the peer's advertised presence: the session is
// still alive, only its network path is being repaired, and blanking
// the presence would make a 2-second glitch look like a disconnect.
const connected = action.status === 'connected'
|| action.status === 'verified'
|| action.status === 'reconnecting';
const patch = (!connected && session.peerPresence !== null)
? { status: action.status, peerPresence: null }
: { status: action.status };
@@ -327,7 +334,9 @@ export function decorateSession(session, activeSessionId) {
const lastMessage = [...session.messages].reverse().find((m) => !m.expired && ((typeof m.message === 'string' && m.message.trim()) || m.voice));
const s = session.status;
const isUp = s === 'connected' || s === 'verified';
const isPending = s === 'connecting' || s === 'verifying' || s === 'new';
// 'reconnecting' is a live session whose path is being repaired — amber, not
// red: the keys, the SAS verification and the history are all still valid.
const isPending = s === 'connecting' || s === 'verifying' || s === 'new' || s === 'reconnecting';
// Avatar dot + sub-text: while a session is up, reflect the PEER's advertised presence;
// otherwise reflect the connection state (amber = connecting, red = dropped).
let dot, headerSub;
+1 -1
View File
@@ -11,7 +11,7 @@ let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
const SW_BUILD_VERSION = '1785142410213';
const SW_BUILD_VERSION = '1785719685129';
// Load version from meta.json on install
async function getAppVersion() {
@@ -28,6 +28,11 @@ function createReceiver({ isVerified = true } = {}) {
establishConnection: async () => {},
initializeFileTransfer() {},
startHeartbeat() {},
onStatusChange() {},
processMessageQueue() {},
calculateAndReportSecurityLevel: async () => {},
autoEnableSecurityFeatures() {},
notifySecurityUpdate() {},
_notifyVerificationReadyIfPossible() {},
initiateVerification() {},
setupDataChannel: P.setupDataChannel,
@@ -143,11 +148,17 @@ function createReceiver({ isVerified = true } = {}) {
_secureLog() {},
_checkInboundRateLimit: () => true,
_enforceVerificationGate: P._enforceVerificationGate,
_noteInboundActivity: P._noteInboundActivity,
onMessage() {},
deliverMessageToUI() {},
establishConnection: async () => {},
initializeFileTransfer() {},
startHeartbeat() {},
onStatusChange() {},
processMessageQueue() {},
calculateAndReportSecurityLevel: async () => {},
autoEnableSecurityFeatures() {},
notifySecurityUpdate() {},
_notifyVerificationReadyIfPossible() {},
initiateVerification() {},
setupDataChannel: P.setupDataChannel
+745
View File
@@ -0,0 +1,745 @@
import assert from 'node:assert/strict';
globalThis.window = {
EnhancedSecureCryptoUtils: { secureLog: { log() {} } }
};
globalThis.CustomEvent = class CustomEvent {
constructor(type, init) { this.type = type; this.detail = init?.detail; }
};
const dispatched = [];
globalThis.document = { dispatchEvent(e) { dispatched.push(e.type); } };
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
const P = EnhancedSecureWebRTCManager.prototype;
const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
const TIMEOUTS = EnhancedSecureWebRTCManager.TIMEOUTS;
// Fake timers: the retry cycle is self-rescheduling by design (bounded only by
// RECONNECT_MAX_DURATION), so real timers would keep the test process alive.
// Driving them by hand also lets the backoff schedule itself be asserted.
const realTimers = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval
};
let scheduled = [];
globalThis.setTimeout = (callback, delay) => {
const timer = { kind: 'timeout', callback, delay, cleared: false };
scheduled.push(timer);
return timer;
};
globalThis.setInterval = (callback, delay) => {
const timer = { kind: 'interval', callback, delay, cleared: false };
scheduled.push(timer);
return timer;
};
globalThis.clearTimeout = (timer) => { if (timer) timer.cleared = true; };
globalThis.clearInterval = (timer) => { if (timer) timer.cleared = true; };
const pending = () => scheduled.filter((t) => !t.cleared);
// A real timeout stops pending once it fires; mirror that so the next lookup
// finds the newly scheduled retry rather than the spent one.
const fire = (timer) => { timer.cleared = true; return timer.callback(); };
// Let queued microtasks settle: several recovery entry points fire an async
// restart without awaiting it.
const settle = async () => { for (let i = 0; i < 8; i += 1) await Promise.resolve(); };
const FP_A = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99';
const FP_B = '99:88:77:66:55:44:33:22:11:00:FF:EE:DD:CC:BB:AA';
const sdpWith = (fp) => `v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\ns=-\r\na=fingerprint:sha-256 ${fp}\r\na=setup:actpass\r\n`;
// Minimal manager stub carrying only the recovery surface under test.
function makeManager(overrides = {}) {
const statuses = [];
const sent = [];
const ui = [];
const mgr = {
isVerified: true,
isInitiator: true,
intentionalDisconnect: false,
reconnectionFailedNotificationSent: false,
// Raw frames written straight to the channel (heartbeats/probes), as
// opposed to `sent`, which collects sendSystemMessage payloads.
_sentFrames: [],
_heartbeatConfig: { enabled: true, lastHeartbeat: 0 },
_livenessProbeAt: 0,
_livenessArmed: true,
isConnected: () => true,
dataChannel: { readyState: 'open', send: (raw) => { mgr._sentFrames.push(JSON.parse(raw)); } },
peerConnection: {
connectionState: 'disconnected',
signalingState: 'stable',
currentRemoteDescription: { sdp: sdpWith(FP_A) },
localDescription: { sdp: sdpWith(FP_B) },
createOffer: async () => ({ type: 'offer', sdp: sdpWith(FP_B) }),
createAnswer: async () => ({ type: 'answer', sdp: sdpWith(FP_B) }),
setLocalDescription: async () => {},
setRemoteDescription: async (d) => { mgr._applied.push(d.type); }
},
_applied: [],
_reconnect: {
phase: 'idle', attempts: 0, startedAt: 0,
graceTimer: null, retryTimer: null, restartTimer: null, pendingRole: null
},
_activeTimers: new Set(),
_lastInboundAt: 0,
_livenessTimer: null,
_heartbeatTimer: null,
_secureLog() {},
_clearVerificationStates() { mgr._verificationCleared = true; },
_verificationCleared: false,
onStatusChange: (s) => statuses.push(s),
deliverMessageToUI: (m) => ui.push(m),
processMessageQueue() { mgr._queueFlushed = true; },
_queueFlushed: false,
waitForIceGathering: async () => true,
sendSystemMessage: async (m) => { sent.push(m); return true; },
// Real implementations under test
_trackActiveTimer: P._trackActiveTimer,
_noteInboundActivity: P._noteInboundActivity,
handleHeartbeat: P.handleHeartbeat,
_sendHeartbeat: P._sendHeartbeat,
_checkLiveness: P._checkLiveness,
isReconnecting: P.isReconnecting,
_resetReconnectState: P._resetReconnectState,
_onPathDegraded: P._onPathDegraded,
_onPathLost: P._onPathLost,
_onPathRecovered: P._onPathRecovered,
_attemptIceRestart: P._attemptIceRestart,
_scheduleReconnectRetry: P._scheduleReconnectRetry,
_sendIceRestartOffer: P._sendIceRestartOffer,
_currentRemoteDtlsFingerprint: P._currentRemoteDtlsFingerprint,
_assertSameRemoteIdentity: P._assertSameRemoteIdentity,
_handleIceRestartSignal: P._handleIceRestartSignal,
_giveUpAutoReconnect: P._giveUpAutoReconnect,
_extractDTLSFingerprintFromSDP: P._extractDTLSFingerprintFromSDP,
_validateDTLSFingerprint: P._validateDTLSFingerprint,
...overrides
};
scheduled = [];
return { mgr, statuses, sent, ui };
}
try {
// ── the heartbeat handler exists and answers probes ──────────────────────
// It used to be dispatched to but never defined, so every inbound heartbeat
// threw a TypeError and liveness was never actually observed.
{
assert.equal(typeof P.handleHeartbeat, 'function', 'handleHeartbeat must exist');
// A probe must be answered: that reply is the only thing that proves this
// side is alive when its own timers are throttled by a backgrounded tab.
const { mgr } = makeManager();
mgr._lastInboundAt = 0;
mgr.handleHeartbeat({ type: 'heartbeat', ack: false });
assert.ok(mgr._lastInboundAt > 0, 'heartbeat must refresh the liveness clock');
assert.equal(mgr._sentFrames.length, 1);
assert.equal(mgr._sentFrames[0].ack, true, 'a probe must be acked');
// An ack must NOT be acked, or the two sides ping-pong forever.
const { mgr: acked } = makeManager();
acked.handleHeartbeat({ type: 'heartbeat', ack: true });
assert.deepEqual(acked._sentFrames, [], 'an ack must not be answered');
}
// ── the answerer starts its watchdog even on an already-open channel ─────
// `ondatachannel` can hand over a channel that is ALREADY 'open', so the
// 'open' event has been dispatched before onopen is assigned. That left the
// answering side with no heartbeats and no liveness watchdog at all — the
// peer whose network was fine kept showing "connected" indefinitely because
// nothing on that side was running to notice the other one had vanished.
{
const started = [];
const base = {
isVerified: false,
pendingSASCode: null,
_secureLog() {},
establishConnection: async () => {},
initializeFileTransfer() {},
_notifyVerificationReadyIfPossible() {},
initiateVerification() {},
processMessageQueue() {},
onStatusChange() {},
_resetReconnectState() {},
_teardownRecoveryLifecycleListeners() {},
_noteInboundActivity() {},
startHeartbeat() { started.push(Date.now()); },
setupDataChannel: P.setupDataChannel
};
// Channel already open when handed over: the handler must still run.
const already = { ...base };
already.setupDataChannel({ readyState: 'open', send() {} });
await settle();
assert.equal(started.length, 1, 'an already-open channel must still start the watchdog');
// Normal case: the event fires, and the handler must not run twice.
started.length = 0;
const later = { ...base };
const channel = { readyState: 'connecting', send() {} };
later.setupDataChannel(channel);
channel.readyState = 'open';
await channel.onopen();
await settle();
assert.equal(started.length, 1, 'the open handler must run exactly once');
}
// ── a peer never heard from is not a dead peer ───────────────────────────
// Right after connecting, the two sides finish SAS verification at different
// moments, so one can be probing while the other still cannot answer.
// Without a baseline, that silence used to read as death and tore down a
// healthy, freshly established session.
{
const { mgr, statuses } = makeManager({ _livenessArmed: false });
mgr._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 10_000);
mgr._checkLiveness();
await settle();
assert.deepEqual(statuses, [], 'silence before the first frame must not start recovery');
assert.deepEqual(mgr._sentFrames, [], 'and must not even probe yet');
// One frame from the peer establishes the baseline; from then on it counts.
mgr._noteInboundActivity();
assert.equal(mgr._livenessArmed, true);
mgr._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 10_000);
mgr._checkLiveness();
assert.equal(mgr._sentFrames.length, 1, 'a known peer that goes quiet is probed');
mgr._resetReconnectState();
}
// ── a peer answers probes before it has finished verifying ───────────────
// The heartbeat path is gated on the channel, not on isVerified: a human has
// to compare the SAS code, and for that whole window the other side would
// otherwise be unable to answer and would be declared dead.
{
const { mgr } = makeManager({ isVerified: false, isConnected: () => false });
mgr.handleHeartbeat({ type: 'heartbeat', ack: false });
assert.equal(mgr._sentFrames.length, 1, 'an unverified peer must still answer a probe');
assert.equal(mgr._sentFrames[0].ack, true);
}
// ── liveness: silence prompts a probe, only an unanswered probe kills ────
{
// Busy conversation: recent inbound activity must NOT trip the watchdog.
const { mgr, statuses } = makeManager();
mgr._lastInboundAt = Date.now();
mgr._checkLiveness();
assert.deepEqual(statuses, [], 'recent traffic must not trigger recovery');
assert.deepEqual(mgr._sentFrames, [], 'no probe while the peer is chatting');
// Silence alone must NOT declare the path dead — a backgrounded tab has
// its timers throttled and legitimately goes quiet. It only earns a probe.
const { mgr: quiet, statuses: quietStatuses } = makeManager();
quiet._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000);
quiet._checkLiveness();
assert.deepEqual(quietStatuses, [], 'silence alone must not start recovery');
assert.equal(quiet._sentFrames.length, 1, 'silence must trigger a probe');
assert.equal(quiet._sentFrames[0].ack, false);
assert.ok(quiet._livenessProbeAt > 0);
// The peer answers → still alive, no recovery, probe cleared.
quiet.handleHeartbeat({ type: 'heartbeat', ack: true });
quiet._checkLiveness();
assert.deepEqual(quietStatuses, [], 'an answered probe proves the path is alive');
assert.equal(quiet._livenessProbeAt, 0);
// A peer whose tab the OS froze cannot answer anything — no JavaScript
// runs in it at all. But ICE consent checks live in the browser's network
// stack, not the page's thread, so a 'connected' ICE state proves the peer
// is still reachable and the silence is a sleeping tab. Tearing the
// session down here is what broke a healthy chat every time a phone
// locked its screen.
const { mgr: asleep, statuses: asleepStatuses } = makeManager();
asleep.peerConnection.connectionState = 'connected';
asleep._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000);
asleep._checkLiveness();
asleep._livenessProbeAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_TIMEOUT + 1000);
asleep._checkLiveness();
await settle();
assert.deepEqual(asleepStatuses, [], 'a silent peer on a healthy ICE path must be left alone');
assert.equal(asleep.isReconnecting(), false);
// And since the answer would change nothing, it must not even be asked:
// probing here was pure traffic for as long as the peer's phone slept.
assert.deepEqual(asleep._sentFrames, [], 'a healthy ICE path must not be probed at all');
assert.equal(asleep._livenessProbeAt, 0);
asleep._resetReconnectState();
// Probe goes unanswered past its deadline → the path really is dead.
// This is the Wi-Fi → LTE case: nothing closes, nothing errors, packets
// just stop and readyState still reads 'open'.
const { mgr: dead, statuses: deadStatuses } = makeManager();
dead._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000);
dead._checkLiveness(); // sends the probe
dead._livenessProbeAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_TIMEOUT + 1000);
dead._checkLiveness(); // deadline passed
await settle();
assert.ok(deadStatuses.includes('reconnecting'), 'an unanswered probe must start recovery');
dead._resetReconnectState();
}
// ── an unverified session is never dragged into recovery ────────────────
{
const { mgr, statuses } = makeManager({ isVerified: false });
mgr._lastInboundAt = Date.now() - 10 * 60 * 1000;
mgr._checkLiveness();
mgr._onPathDegraded('ice_disconnected');
mgr._onPathLost('ice_failed');
await settle();
assert.deepEqual(statuses, [], 'recovery must not run before verification');
}
// ── degraded path: UI says reconnecting, session is NOT torn down ────────
{
const { mgr, statuses } = makeManager();
mgr._onPathDegraded('ice_disconnected');
assert.deepEqual(statuses, ['reconnecting']);
assert.equal(mgr._verificationCleared, false, 'a transient drop must not clear verification');
assert.equal(mgr.isVerified, true, 'keys and SAS verification survive a path glitch');
assert.equal(mgr.isReconnecting(), true);
// The restart is held back for the grace window, because 'disconnected'
// usually clears on its own.
const grace = pending().find((t) => t.delay === TIMEOUTS.ICE_DISCONNECT_GRACE);
assert.ok(grace, 'a grace window must be scheduled before spending a restart');
// A second event while already recovering must not restart the cycle.
mgr._onPathDegraded('ice_disconnected');
assert.deepEqual(statuses, ['reconnecting'], 'recovery must not be re-entered');
mgr._resetReconnectState();
assert.equal(grace.cleared, true, 'reset must clear the grace timer');
}
// ── the grace window is sized to the browser's own ICE timings ───────────
// 'disconnected' arrives after ~5 s of missed consent responses — ordinary
// packet loss reaches that — and the browser then holds it ~25 s before
// 'failed'. That window is where self-healing happens, and a phone with its
// screen off produces these episodes constantly. Restarting at the start of
// it answered every one with a renegotiation that broke a connection about
// to recover. https://blog.mozilla.org/webrtc/ice-disconnected-not/
{
assert.ok(TIMEOUTS.ICE_DISCONNECT_GRACE >= 5000,
'must outlast the ~5 s of loss that produces "disconnected" in the first place');
assert.ok(TIMEOUTS.ICE_DISCONNECT_GRACE + TIMEOUTS.ICE_RESTART_TIMEOUT < 30000,
'but a restart must still complete before the browser gives up at ~30 s');
}
// ── the path recovering during the grace window costs no restart ─────────
{
const { mgr, statuses, sent } = makeManager();
mgr._onPathDegraded('ice_disconnected');
const grace = pending().find((t) => t.delay === TIMEOUTS.ICE_DISCONNECT_GRACE);
mgr.peerConnection.connectionState = 'connected';
fire(grace);
await settle();
assert.deepEqual(sent, [], 'a self-healing glitch must not renegotiate');
assert.deepEqual(statuses, ['reconnecting', 'connected']);
mgr._resetReconnectState();
}
// ── role split: the offerer restarts, the answerer asks ─────────────────
{
const { mgr, sent } = makeManager({ isInitiator: true });
mgr._reconnect.startedAt = Date.now();
await mgr._attemptIceRestart();
assert.equal(sent.length, 1);
assert.equal(sent[0].type, T.ICE_RESTART_OFFER, 'offerer drives the restart');
assert.ok(sent[0].sdp, 'restart offer carries SDP');
mgr._resetReconnectState();
// Both sides offering at once is glare, and with no signalling server
// there is no referee — so the answerer asks instead of acting.
const { mgr: answerer, sent: answererSent } = makeManager({ isInitiator: false });
answerer._reconnect.startedAt = Date.now();
await answerer._attemptIceRestart();
assert.equal(answererSent[0].type, T.ICE_RESTART_REQUEST, 'answerer must not create a competing offer');
answerer._resetReconnectState();
}
// ── retries back off, and stop the moment the path is back ──────────────
{
const { mgr, sent } = makeManager({ isInitiator: true });
mgr._reconnect.startedAt = Date.now();
const backoff = EnhancedSecureWebRTCManager.RECONNECT_BACKOFF;
await mgr._attemptIceRestart();
let retry = pending().find((t) => t.kind === 'timeout');
assert.equal(retry.delay, backoff[0], 'first retry uses the head of the backoff');
// The round-trip completes but the pair still fails to connect, so the
// next retry is a genuine second attempt and backs off further.
mgr.peerConnection.signalingState = 'have-local-offer';
await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) });
fire(retry);
await settle();
retry = pending().find((t) => t.kind === 'timeout');
assert.equal(retry.delay, backoff[1], 'each failed attempt backs off further');
assert.equal(sent.length, 2, 'each attempt re-sends the restart offer');
mgr.peerConnection.signalingState = 'have-local-offer';
await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) });
const sentAfterTwoAttempts = sent.length;
// Path comes back: the pending retry must not fire another restart.
mgr.peerConnection.connectionState = 'connected';
fire(retry);
await settle();
assert.equal(sent.length, sentAfterTwoAttempts, 'recovery stops as soon as the path is up');
assert.equal(mgr.isReconnecting(), false);
mgr._resetReconnectState();
}
// ── a healthy connection is never renegotiated ───────────────────────────
// Recovery can be entered on a stale reading — a mobile tab thawing out of
// the background is the common one, since its timers were frozen and
// connectionState is still catching up. Restarting from there broke a
// working session and stranded it in "reconnecting", where sending is
// blocked while messages keep arriving.
{
const { mgr, sent, statuses } = makeManager();
mgr._reconnect.phase = 'grace';
mgr._reconnect.startedAt = Date.now();
mgr.peerConnection.connectionState = 'connected';
await mgr._attemptIceRestart();
assert.deepEqual(sent, [], 'a connected path must not be renegotiated');
assert.equal(mgr.isReconnecting(), false, 'recovery must stand down, not linger');
assert.equal(statuses.at(-1), 'connected');
assert.equal(mgr._queueFlushed, true, 'anything queued meanwhile must go out');
}
// ── returning to the foreground asks the peer, it does not accuse it ─────
{
const { mgr, statuses } = makeManager();
mgr._setupRecoveryLifecycleListeners = P._setupRecoveryLifecycleListeners;
const listeners = {};
globalThis.window = {
...globalThis.window,
addEventListener: (name, fn) => { listeners[name] = fn; },
removeEventListener: () => {}
};
const realDocument = globalThis.document;
globalThis.document = {
visibilityState: 'visible',
addEventListener: (name, fn) => { listeners[name] = fn; },
removeEventListener: () => {},
dispatchEvent: () => {}
};
try {
mgr._setupRecoveryLifecycleListeners();
// Long silence, but only because the tab's timers were frozen.
mgr._lastInboundAt = Date.now() - 10 * 60 * 1000;
listeners.visibilitychange();
await settle();
assert.deepEqual(statuses, [], 'coming back must not declare the session broken');
assert.equal(mgr._sentFrames.length, 1, 'it must probe the peer instead');
assert.equal(mgr._sentFrames[0].ack, false);
assert.ok(mgr._livenessProbeAt > 0, 'the probe deadline must be armed');
} finally {
globalThis.document = realDocument;
}
mgr._resetReconnectState();
}
// ── one round-trip at a time ─────────────────────────────────────────────
// A restart takes seconds end to end, far longer than the head of the
// backoff. Without this guard each retry replaced the local description out
// from under the attempt already in flight, so every try cancelled the last
// and recovery never converged — observed as a long run of attempts that
// ended in a plain timeout.
{
const { mgr, sent } = makeManager({ isInitiator: true });
mgr._reconnect.startedAt = Date.now();
await mgr._attemptIceRestart();
assert.equal(sent.length, 1);
assert.ok(mgr._reconnect.inFlightAt > 0, 'the round-trip must be marked in flight');
// The retry fires while the round-trip is still running: it must wait,
// not launch a competing restart.
let retry = pending().find((t) => t.kind === 'timeout');
fire(retry);
await settle();
assert.equal(sent.length, 1, 'no competing restart while one is in flight');
assert.ok(pending().some((t) => t.kind === 'timeout'), 'it must keep waiting, not give up');
// Round-trip completes (answer applied) → a further attempt is allowed.
mgr.peerConnection.signalingState = 'have-local-offer';
await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) });
assert.equal(mgr._reconnect.inFlightAt, 0, 'a completed round-trip clears the in-flight mark');
retry = pending().find((t) => t.kind === 'timeout');
fire(retry);
await settle();
assert.equal(sent.length, 2, 'the next attempt runs once the round-trip is done');
mgr._resetReconnectState();
}
// ── a restart gathers candidates on a budget that fits the round-trip ─────
{
const budget = TIMEOUTS.ICE_RESTART_GATHERING;
assert.ok(budget < TIMEOUTS.ICE_GATHERING_TIMEOUT, 'recovery must gather faster than a fresh handshake');
assert.ok(budget * 2 < TIMEOUTS.ICE_RESTART_TIMEOUT, 'both gathering legs must fit inside the round-trip budget');
const { mgr } = makeManager({ isInitiator: true });
const budgets = [];
mgr.waitForIceGathering = async (ms) => { budgets.push(ms); return true; };
mgr._reconnect.startedAt = Date.now();
await mgr._attemptIceRestart();
assert.deepEqual(budgets, [budget], 'the restart must pass the short budget');
mgr._resetReconnectState();
}
// ── MITM guard: a restart must never re-point the session at a new identity
{
// Same fingerprint as the live session → accepted, answer goes back.
const { mgr, sent } = makeManager({ isInitiator: false });
await mgr._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_A) });
assert.deepEqual(mgr._applied, ['offer']);
assert.equal(sent.at(-1).type, T.ICE_RESTART_ANSWER);
mgr._resetReconnectState();
// Different fingerprint → refused before the peer connection is touched.
const { mgr: attacked, sent: attackedSent } = makeManager({ isInitiator: false });
await assert.rejects(
() => attacked._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_B) }),
/mismatch/i,
'a restart offer with a different DTLS fingerprint must be refused'
);
assert.deepEqual(attacked._applied, [], 'nothing may be applied to the peer connection');
assert.deepEqual(attackedSent, [], 'no answer may be sent to an unverified identity');
attacked._resetReconnectState();
// Same guard on the answer leg.
const { mgr: offerer } = makeManager({ isInitiator: true });
offerer.peerConnection.signalingState = 'have-local-offer';
await assert.rejects(
() => offerer._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_B) }),
/mismatch/i,
'a restart answer with a different DTLS fingerprint must be refused'
);
assert.deepEqual(offerer._applied, []);
offerer._resetReconnectState();
// With no live session to compare against, recovery refuses rather than
// accepting an unverifiable identity.
const { mgr: blind } = makeManager({ isInitiator: false });
blind.peerConnection.currentRemoteDescription = null;
blind.peerConnection.remoteDescription = null;
await assert.rejects(
() => blind._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_A) }),
/identity/i,
'no baseline fingerprint must mean refusal, not blind trust'
);
blind._resetReconnectState();
}
// ── a restart request is only honoured by the offerer ───────────────────
{
const { mgr, sent } = makeManager({ isInitiator: false });
await mgr._handleIceRestartSignal(T.ICE_RESTART_REQUEST, {});
assert.deepEqual(sent, [], 'the answerer must ignore a restart request');
mgr._resetReconnectState();
const { mgr: offerer, sent: offererSent } = makeManager({ isInitiator: true });
await offerer._handleIceRestartSignal(T.ICE_RESTART_REQUEST, {});
assert.equal(offererSent[0].type, T.ICE_RESTART_OFFER);
offerer._resetReconnectState();
}
// ── recovery completes: the same session resumes, the queue drains ───────
{
dispatched.length = 0;
const { mgr, statuses } = makeManager();
mgr._onPathDegraded('ice_disconnected');
statuses.length = 0;
mgr.peerConnection.connectionState = 'connected';
mgr._onPathRecovered();
assert.deepEqual(statuses, ['connected']);
assert.equal(mgr._queueFlushed, true, 'messages sent into the dead path must go out');
assert.equal(mgr.isReconnecting(), false);
assert.equal(mgr._reconnect.attempts, 0, 'attempt counter resets for the next drop');
assert.ok(dispatched.includes('connection-recovered'));
// Recovering from an idle state is a no-op — no spurious 'connected'.
const { mgr: idle, statuses: idleStatuses } = makeManager();
idle._onPathRecovered();
assert.deepEqual(idleStatuses, [], 'no status churn when nothing was broken');
}
// ── recovery stops when the channel cannot carry the renegotiation ───────
// Every route out of a broken path runs over the data channel. If the peer
// has been completely silent since recovery began, it carries nothing in
// either direction and no further attempt can succeed. This matters most for
// the answerer, which cannot renegotiate on its own and would otherwise sit
// out the whole deadline sending requests nobody can receive — observed as a
// long run of attempts ending in a plain timeout.
{
for (const role of [true, false]) {
const { mgr, statuses, ui } = makeManager({ isInitiator: role });
mgr._reconnect.startedAt = Date.now() - 60_000;
mgr._lastInboundAt = Date.now() - 60_000;
mgr._reconnect.attempts = 3;
await mgr._attemptIceRestart();
assert.equal(mgr._reconnect.phase, 'exhausted',
`a silent channel must end recovery (${role ? 'offerer' : 'answerer'})`);
assert.equal(statuses.at(-1), 'recovery_failed',
'the UI must be told to tear the conversation down, not merely show a drop');
assert.match(ui[0], /wiped/i, 'and the user must be told the chat is being closed');
}
// A peer that is still answering keeps recovery alive.
const { mgr: alive, sent } = makeManager();
alive._reconnect.startedAt = Date.now() - 60_000;
alive._lastInboundAt = Date.now(); // heard from just now
alive._reconnect.attempts = 3;
await alive._attemptIceRestart();
assert.notEqual(alive._reconnect.phase, 'exhausted', 'a responsive peer must not be abandoned');
assert.equal(sent.length, 1, 'and the attempt goes out');
alive._resetReconnectState();
// Early attempts are never cut short on silence alone — a restart
// round-trip has to be given a chance to produce its first reply.
const { mgr: early } = makeManager();
early._reconnect.startedAt = Date.now() - 60_000;
early._lastInboundAt = Date.now() - 60_000;
early._reconnect.attempts = 0;
await early._attemptIceRestart();
assert.notEqual(early._reconnect.phase, 'exhausted', 'the first attempt must still be tried');
early._resetReconnectState();
}
// ── an ICE agent that cannot gather at all is not worth retrying ─────────
// After a network change a PeerConnection is often left bound to interfaces
// that no longer exist: every STUN and TURN request times out and each
// restart fails with zero candidate pairs. restartIce() cannot rebind it, so
// spending the whole two-minute deadline on it is two minutes of the user
// watching nothing happen when the outcome was already decided.
{
const { mgr, statuses, ui } = makeManager();
mgr._noteIceFailureDiagnostics = P._noteIceFailureDiagnostics;
mgr._reconnect.phase = 'restarting';
mgr._reconnect.startedAt = Date.now();
// A failure that still produced pairs is an ordinary flaky path.
mgr._noteIceFailureDiagnostics({ pairCount: 3 });
assert.equal(mgr._reconnect.barrenFailures, 0);
assert.notEqual(statuses.at(-1), 'disconnected');
// Barren failures accumulate; one alone is not enough to conclude.
mgr._noteIceFailureDiagnostics({ pairCount: 0 });
assert.equal(mgr.isReconnecting(), true, 'one barren failure must not end recovery');
// A pair appearing resets the count — the agent is evidently alive.
mgr._noteIceFailureDiagnostics({ pairCount: 1 });
assert.equal(mgr._reconnect.barrenFailures, 0, 'evidence of life resets the count');
mgr._noteIceFailureDiagnostics({ pairCount: 0 });
mgr._noteIceFailureDiagnostics({ pairCount: 0 });
assert.equal(mgr._reconnect.phase, 'exhausted', 'a barren agent ends recovery promptly');
assert.equal(statuses.at(-1), 'recovery_failed');
assert.equal(ui.length, 1, 'and the user is told once, with the way out');
}
// ── a closed data channel cannot be repaired by an ICE restart ───────────
// SCTP goes with it, so there is nothing left to carry the renegotiation.
{
const { mgr, statuses, ui } = makeManager();
mgr.dataChannel = { readyState: 'closed' };
mgr._reconnect.startedAt = Date.now();
await mgr._attemptIceRestart();
assert.equal(mgr._reconnect.phase, 'exhausted');
assert.equal(statuses.at(-1), 'recovery_failed');
assert.equal(mgr._verificationCleared, true, 'an unrecoverable session clears verification');
assert.equal(ui.length, 1);
assert.match(ui[0], /wiped/i, 'the user is told the chat is being closed and wiped');
assert.deepEqual(pending().filter((t) => t.kind === 'timeout'), [], 'giving up leaves no timers behind');
}
// ── a device with no network holds the session instead of losing it ──────
// A five-minute tunnel must not cost the user their session: no restart can
// succeed with the radio off, so the give-up deadline is held open.
{
// Node exposes navigator as a getter-only global, so swap the descriptor.
const realNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
const fakeNavigator = { onLine: false };
Object.defineProperty(globalThis, 'navigator', { value: fakeNavigator, configurable: true });
try {
const { mgr, sent, statuses } = makeManager();
mgr._reconnect.startedAt = Date.now() - (TIMEOUTS.RECONNECT_MAX_DURATION + 60_000);
await mgr._attemptIceRestart();
assert.equal(mgr._reconnect.phase, 'waiting', 'recovery waits rather than giving up');
assert.notEqual(statuses.at(-1), 'disconnected', 'an offline device must not end the session');
assert.deepEqual(sent, [], 'nothing is transmitted with no network');
// The hold must schedule a real retry, not a 0 ms spin.
const retry = pending().find((t) => t.kind === 'timeout');
assert.ok(retry && retry.delay > 0, 'the offline hold must schedule a sane retry delay');
// Network comes back → retry immediately, deadline starts fresh.
fakeNavigator.onLine = true;
await mgr._attemptIceRestart();
assert.equal(sent.at(-1).type, T.ICE_RESTART_OFFER, 'recovery resumes once the radio is back');
mgr._resetReconnectState();
} finally {
if (realNavigator) Object.defineProperty(globalThis, 'navigator', realNavigator);
else delete globalThis.navigator;
}
}
// ── giving up is bounded by the overall deadline ─────────────────────────
{
const { mgr, statuses } = makeManager();
mgr._reconnect.startedAt = Date.now() - (TIMEOUTS.RECONNECT_MAX_DURATION + 1000);
await mgr._attemptIceRestart();
assert.equal(mgr._reconnect.phase, 'exhausted');
assert.equal(statuses.at(-1), 'recovery_failed');
}
// ── manual retry restarts the cycle with a fresh deadline ───────────────
{
const { mgr, sent } = makeManager();
mgr.reconnectionFailedNotificationSent = true;
mgr._reconnect.phase = 'exhausted';
const ok = P.attemptReconnection.call(mgr);
await settle();
assert.equal(ok, true);
assert.equal(mgr.reconnectionFailedNotificationSent, false, 'the user can be told again if it fails again');
assert.equal(sent.at(-1).type, T.ICE_RESTART_OFFER);
mgr._resetReconnectState();
// Nothing to restart over: report failure instead of pretending.
const { mgr: gone, ui } = makeManager();
gone.dataChannel = { readyState: 'closed' };
assert.equal(P.attemptReconnection.call(gone), false);
assert.match(ui[0], /new connection is required/i);
}
// ── backoff is bounded and monotonic ─────────────────────────────────────
{
const backoff = EnhancedSecureWebRTCManager.RECONNECT_BACKOFF;
assert.ok(backoff.length > 0);
for (let i = 1; i < backoff.length; i += 1) {
assert.ok(backoff[i] >= backoff[i - 1], 'backoff must not shrink');
}
assert.ok(Object.isFrozen(backoff));
}
// ── recovery frames never surface as chat messages ───────────────────────
{
const manager = {
_debugMode: false,
_secureLog() {},
getSecurityStatus: () => ({ activeFeaturesCount: 0 }),
_checkInboundRateLimit: () => true
};
for (const type of [T.ICE_RESTART_OFFER, T.ICE_RESTART_ANSWER, T.ICE_RESTART_REQUEST]) {
const result = await P.removeSecurityLayers.call(manager, JSON.stringify({ type, sdp: 'v=0' }));
assert.equal(result, 'SYSTEM_MESSAGE_FILTERED', `${type} must not reach the chat log`);
}
}
console.log('session-recovery.test.mjs: all assertions passed');
} finally {
Object.assign(globalThis, realTimers);
}
+17
View File
@@ -75,6 +75,23 @@ function withTwoSessions() {
assert.equal(state.sessions.a.peerPresence, null, 'no stale presence after reconnect');
state = sessionsReducer(state, { type: A.SET_PEER_PRESENCE, id: 'a', presence: 'available' });
assert.equal(state.sessions.a.peerPresence, 'available', 'fresh presence applies after reconnect');
// A session repairing its network path is still a live session: blanking the
// peer's presence would make a two-second glitch look like a disconnect.
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'reconnecting' });
assert.equal(state.sessions.a.peerPresence, 'available', 'presence survives a path repair');
}
// A reconnecting session reads as in-progress (amber), not as dropped (red).
{
const entry = createSessionEntry({ id: 'a', peerLabel: 'phone' });
entry.status = 'reconnecting';
const d = decorateSession(entry, 'a');
assert.equal(d.headerSub, 'Reconnecting…');
const dropped = createSessionEntry({ id: 'b', peerLabel: 'phone' });
dropped.status = 'disconnected';
assert.notEqual(d.dot, decorateSession(dropped, 'b').dot, 'reconnecting must not look dropped');
}
// UPDATE_MESSAGE_STATUS and DELETE_MESSAGE only touch the named session/message.