v6.8.4: the relay password now changes every day

This commit is contained in:
lockbitchat
2026-09-23 19:45:54 -04:00
parent 99da907eb7
commit f39e9022b7
32 changed files with 722 additions and 276 deletions
+6 -1
View File
@@ -1,5 +1,6 @@
import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/iceSettingsStore.js';
import { startTurnCredentialRefresh } from './network/turnCredentials.js';
import {
sessionsReducer,
createInitialState,
@@ -3861,7 +3862,11 @@ import { GroupCallMedia, mediaErrorCode } from './group/groupCallMedia.js';
onClearData: handleClearData
});
}, []);
// Our relay's credential is minted by the site and expires daily;
// fetch one now and keep it renewed. See network/turnCredentials.js.
React.useEffect(() => { startTurnCredentialRefresh(); }, []);
const addMessageWithAutoScroll = React.useCallback((message, type, opts = {}) => {
const newId = Date.now() + Math.random();
const newMessage = {
@@ -14104,9 +14104,33 @@ async processMessage(data) {
this._trackActiveTimer(r.retryTimer);
}
/**
* Hand the peer connection the ICE server list as it is NOW.
*
* A connection keeps the servers it was built with, and our relay's credential
* expires after a day (src/network/turnCredentials.js renews it in place on the
* shared list). A restart gathers new relay candidates, so it must present the
* current credential or a long-lived session would lose its relay path exactly
* when it needs one. Best effort: an engine that refuses the update restarts
* with what it had.
*/
_applyCurrentIceServers(pc) {
try {
if (!pc || typeof pc.getConfiguration !== 'function' || typeof pc.setConfiguration !== 'function') return;
const servers = this._config?.webrtc?.iceServers;
if (!Array.isArray(servers) || servers.length === 0) return;
pc.setConfiguration({ ...pc.getConfiguration(), iceServers: servers });
} catch (error) {
this._secureLog('warn', 'Could not refresh ICE servers before restart', {
errorType: error?.constructor?.name || 'Unknown'
});
}
}
async _sendIceRestartOffer() {
const pc = this.peerConnection;
if (!pc) return;
this._applyCurrentIceServers(pc);
// Rolling back to 'stable' first: a previous restart round-trip may have
// left a local offer pending that was never answered.
@@ -14189,6 +14213,7 @@ async processMessage(data) {
}
this._reconnect.phase = 'restarting';
this._applyCurrentIceServers(pc);
await pc.setRemoteDescription({ type: 'offer', sdp: data.sdp });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
+130
View File
@@ -0,0 +1,130 @@
// Keeps the credential for SecureBit's own TURN relay fresh.
//
// The relay's credential is no longer something the client carries: it is minted
// by the site (POST /api/turn-credentials, see deploy/turn-credentials.js) and
// expires after a day. This module fetches one at startup and again before it
// runs out, and writes it into the ICE list the connection managers already hold.
//
// The update is made IN PLACE on the existing entry objects. Every manager keeps a
// reference to window.SECUREBIT_ICE_SERVERS rather than a copy, so the next peer
// connection — and the next in-band ICE restart, which re-reads the list — picks
// up the new credential without anything else having to know it changed.
//
// If the endpoint cannot be reached, nothing is changed and the entry keeps the
// credential it shipped with. Only our relay's entries are touched; a TURN server
// the user configured themselves is never modified.
const ENDPOINT = '/api/turn-credentials';
const OWN_RELAY_HOSTS = ['turn.securebit.chat', '144.172.96.126'];
const MAX_FIELD = 512;
// How long to wait before trying again after a failed fetch.
const RETRY_DELAYS_MS = [60_000, 5 * 60_000, 15 * 60_000];
function relayHost(url) {
const m = /^turns?:([^:?\s]+)/i.exec(String(url || '').trim());
return m ? m[1].toLowerCase() : null;
}
/** True when an ICE entry points at SecureBit's own relay. */
export function isOwnRelayEntry(entry) {
if (!entry || typeof entry !== 'object') return false;
const urls = Array.isArray(entry.urls) ? entry.urls : [entry.urls];
return urls.some((u) => OWN_RELAY_HOSTS.includes(relayHost(u)));
}
function isCleanField(value) {
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_FIELD) return false;
for (let i = 0; i < value.length; i++) {
const c = value.charCodeAt(i);
if (c < 0x20 || c === 0x7f) return false;
}
return true;
}
/**
* Write a fresh credential into every own-relay entry of `list`, in place.
* @returns {boolean} whether anything was updated
*/
export function applyTurnCredentials(list, cred) {
if (!Array.isArray(list) || !cred || !isCleanField(cred.username) || !isCleanField(cred.credential)) {
return false;
}
let updated = false;
for (const entry of list) {
if (!isOwnRelayEntry(entry)) continue;
entry.username = cred.username;
entry.credential = cred.credential;
updated = true;
}
return updated;
}
/** Pull the credential out of an endpoint response, or null if it is not usable. */
export function parseCredentialResponse(body, nowSeconds) {
const server = body && Array.isArray(body.iceServers) ? body.iceServers[0] : null;
if (!server || !isCleanField(server.username) || !isCleanField(server.credential)) return null;
// coturn REST-API usernames are "<expiry>:<label>"; refuse one already expired.
const expiry = Number(String(server.username).split(':')[0]);
if (!Number.isFinite(expiry) || expiry <= nowSeconds) return null;
const ttl = Number(body.ttl);
return {
username: server.username,
credential: server.credential,
expiry,
ttl: Number.isFinite(ttl) && ttl > 0 ? ttl : expiry - nowSeconds,
};
}
let started = false;
/**
* Start keeping the relay credential fresh. Safe to call more than once.
* @param {object} [opts]
* @param {() => Array} [opts.getList] the ICE list to update
*/
export function startTurnCredentialRefresh(opts = {}) {
if (started || typeof window === 'undefined' || typeof fetch !== 'function') return;
started = true;
const getList = opts.getList || (() => window.SECUREBIT_ICE_SERVERS);
let expiry = 0;
let ttl = 0;
let timer = null;
let failures = 0;
const schedule = (ms) => {
if (timer) clearTimeout(timer);
timer = setTimeout(refresh, ms);
};
async function refresh() {
timer = null;
try {
const res = await fetch(ENDPOINT, { method: 'POST', cache: 'no-store', credentials: 'omit' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const cred = parseCredentialResponse(await res.json(), Date.now() / 1000);
if (!cred) throw new Error('unusable response');
applyTurnCredentials(getList(), cred);
expiry = cred.expiry;
ttl = cred.ttl;
failures = 0;
// Renew at half-life, so a call started just before renewal still has
// hours of validity left for the relay to refresh its allocation.
schedule(Math.max(60, ttl / 2) * 1000);
} catch (_) {
const delay = RETRY_DELAYS_MS[Math.min(failures, RETRY_DELAYS_MS.length - 1)];
failures++;
schedule(delay);
}
}
// Timers are throttled in background tabs and stop while a laptop sleeps; on
// coming back, renew straight away if the credential is past its half-life.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
const now = Date.now() / 1000;
if (!expiry || expiry - now < ttl / 2) refresh();
});
refresh();
}