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
+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;