feat: user-configurable STUN/TURN servers (advanced network settings)
- add iceServers.js: allowlist-based validation/normalization of user-supplied STUN/TURN URLs (rejects javascript:/data:/http/ws, control chars, enforces limits) - add iceSettingsStore.js: opt-in persistence encrypted at rest with a non-extractable AES-GCM device key in IndexedDB; load/save/clear - add IceServerSettings.jsx modal: public vs custom servers, JSON/line input, live validation, relay-only toggle, 'Test servers' connectivity check, save-on-device prompt, forget-saved action - wire chosen servers/privacy mode into EnhancedSecureWebRTCManager construction (priority: custom > operator override > built-in defaults) - entry point on the connection-creation screen next to the relay-only toggle - add ice-servers-validation.test.mjs to the suite
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+381
@@ -19071,6 +19071,387 @@ var FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFiles
|
|||||||
};
|
};
|
||||||
window.FileTransferComponent = FileTransferComponent;
|
window.FileTransferComponent = FileTransferComponent;
|
||||||
|
|
||||||
|
// src/network/iceServers.js
|
||||||
|
var ICE_LIMITS = Object.freeze({
|
||||||
|
MAX_SERVERS: 10,
|
||||||
|
MAX_URLS_PER_SERVER: 8,
|
||||||
|
MAX_STRING_LENGTH: 512
|
||||||
|
});
|
||||||
|
var ALLOWED_ICE_SCHEMES = Object.freeze(["stun", "stuns", "turn", "turns"]);
|
||||||
|
var SCHEME_RE = /^(stuns?|turns?):/i;
|
||||||
|
var HOST_RE = /^(\[[0-9a-f:]+\]|[a-z0-9.-]+)(:\d{1,5})?$/i;
|
||||||
|
var TRANSPORT_RE = /^transport=(udp|tcp)$/i;
|
||||||
|
function hasControlChars(value) {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const code = value.charCodeAt(i);
|
||||||
|
if (code < 32 || code === 127) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function validateIceUrl(url) {
|
||||||
|
if (typeof url !== "string") return "URL must be a string";
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return "URL is empty";
|
||||||
|
if (trimmed.length > ICE_LIMITS.MAX_STRING_LENGTH) return "URL is too long";
|
||||||
|
if (hasControlChars(trimmed)) return "URL contains invalid characters";
|
||||||
|
const scheme = trimmed.match(SCHEME_RE);
|
||||||
|
if (!scheme) {
|
||||||
|
return "URL must start with stun:, stuns:, turn: or turns:";
|
||||||
|
}
|
||||||
|
const rest = trimmed.slice(scheme[0].length);
|
||||||
|
const [hostPort, query, ...extra] = rest.split("?");
|
||||||
|
if (extra.length > 0) return "URL has an invalid query";
|
||||||
|
if (!hostPort) return "URL is missing a host";
|
||||||
|
if (!HOST_RE.test(hostPort)) return "URL has an invalid host or port";
|
||||||
|
if (query !== void 0 && !TRANSPORT_RE.test(query)) {
|
||||||
|
return "URL query must be transport=udp or transport=tcp";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function isTurnUrl(url) {
|
||||||
|
return typeof url === "string" && /^turns?:/i.test(url.trim());
|
||||||
|
}
|
||||||
|
function validateSecret(value, label) {
|
||||||
|
if (value === void 0 || value === null || value === "") return null;
|
||||||
|
if (typeof value !== "string") return `${label} must be a string`;
|
||||||
|
if (value.length > ICE_LIMITS.MAX_STRING_LENGTH) return `${label} is too long`;
|
||||||
|
if (hasControlChars(value)) return `${label} contains invalid characters`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function normalizeIceServers(entries2) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
const servers = [];
|
||||||
|
if (!Array.isArray(entries2)) {
|
||||||
|
return { servers: [], errors: ["Server list must be an array"], warnings: [] };
|
||||||
|
}
|
||||||
|
if (entries2.length === 0) {
|
||||||
|
return { servers: [], errors: [], warnings: [] };
|
||||||
|
}
|
||||||
|
if (entries2.length > ICE_LIMITS.MAX_SERVERS) {
|
||||||
|
errors.push(`Too many servers (max ${ICE_LIMITS.MAX_SERVERS})`);
|
||||||
|
return { servers: [], errors, warnings };
|
||||||
|
}
|
||||||
|
entries2.forEach((entry, index) => {
|
||||||
|
const label = `Server #${index + 1}`;
|
||||||
|
if (!entry || typeof entry !== "object") {
|
||||||
|
errors.push(`${label}: invalid entry`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawUrls = Array.isArray(entry.urls) ? entry.urls : [entry.urls];
|
||||||
|
if (rawUrls.length === 0 || rawUrls.length > ICE_LIMITS.MAX_URLS_PER_SERVER) {
|
||||||
|
errors.push(`${label}: between 1 and ${ICE_LIMITS.MAX_URLS_PER_SERVER} URLs required`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cleanUrls = [];
|
||||||
|
let entryHasTurn = false;
|
||||||
|
for (const rawUrl of rawUrls) {
|
||||||
|
const err = validateIceUrl(rawUrl);
|
||||||
|
if (err) {
|
||||||
|
errors.push(`${label}: ${err}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trimmed = rawUrl.trim();
|
||||||
|
cleanUrls.push(trimmed);
|
||||||
|
if (isTurnUrl(trimmed)) entryHasTurn = true;
|
||||||
|
}
|
||||||
|
if (cleanUrls.length === 0) return;
|
||||||
|
const userErr = validateSecret(entry.username, `${label} username`);
|
||||||
|
if (userErr) errors.push(userErr);
|
||||||
|
const credErr = validateSecret(entry.credential, `${label} credential`);
|
||||||
|
if (credErr) errors.push(credErr);
|
||||||
|
const server = { urls: cleanUrls.length === 1 ? cleanUrls[0] : cleanUrls };
|
||||||
|
if (entry.username) server.username = String(entry.username);
|
||||||
|
if (entry.credential) server.credential = String(entry.credential);
|
||||||
|
if (entryHasTurn && (!server.username || !server.credential)) {
|
||||||
|
warnings.push(`${label}: TURN servers usually require a username and credential`);
|
||||||
|
}
|
||||||
|
servers.push(server);
|
||||||
|
});
|
||||||
|
return { servers, errors, warnings };
|
||||||
|
}
|
||||||
|
function parseIceServersInput(text2) {
|
||||||
|
if (typeof text2 !== "string" || !text2.trim()) {
|
||||||
|
return { servers: [], errors: [], warnings: [] };
|
||||||
|
}
|
||||||
|
const trimmed = text2.trim();
|
||||||
|
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return { servers: [], errors: ["Invalid JSON"], warnings: [] };
|
||||||
|
}
|
||||||
|
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
return normalizeIceServers(arr);
|
||||||
|
}
|
||||||
|
const entries2 = trimmed.split("\n").map((line) => line.trim()).filter(Boolean).map((url) => ({ urls: url }));
|
||||||
|
return normalizeIceServers(entries2);
|
||||||
|
}
|
||||||
|
function listHasTurn(servers) {
|
||||||
|
if (!Array.isArray(servers)) return false;
|
||||||
|
return servers.some((server) => {
|
||||||
|
const urls = Array.isArray(server?.urls) ? server.urls : [server?.urls];
|
||||||
|
return urls.some(isTurnUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/components/ui/IceServerSettings.jsx
|
||||||
|
var React2 = window.React;
|
||||||
|
var PLACEHOLDER = [
|
||||||
|
"# One URL per line, e.g.:",
|
||||||
|
"stun:stun.example.com:3478",
|
||||||
|
"turn:turn.example.com:3478?transport=udp",
|
||||||
|
"",
|
||||||
|
"# Or paste JSON for servers with credentials:",
|
||||||
|
'[{"urls":"turns:turn.example.com:5349","username":"user","credential":"secret"}]'
|
||||||
|
].join("\n");
|
||||||
|
async function testIceServers(servers, timeoutMs = 6e3) {
|
||||||
|
const found = { host: 0, srflx: 0, relay: 0 };
|
||||||
|
if (typeof RTCPeerConnection === "undefined") {
|
||||||
|
return { ...found, error: "WebRTC is not available in this browser" };
|
||||||
|
}
|
||||||
|
let pc;
|
||||||
|
try {
|
||||||
|
pc = new RTCPeerConnection({ iceServers: servers });
|
||||||
|
} catch (error) {
|
||||||
|
return { ...found, error: error.message || "Invalid server configuration" };
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
try {
|
||||||
|
pc.close();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
resolve(found);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(finish, timeoutMs);
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (!event.candidate) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c = event.candidate.candidate || "";
|
||||||
|
if (/ typ host/.test(c)) found.host++;
|
||||||
|
else if (/ typ srflx/.test(c)) found.srflx++;
|
||||||
|
else if (/ typ relay/.test(c)) found.relay++;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
pc.createDataChannel("securebit-ice-test");
|
||||||
|
pc.createOffer().then((offer) => pc.setLocalDescription(offer)).catch(() => finish());
|
||||||
|
} catch {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForget }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
const [useCustom, setUseCustom] = React2.useState(initial?.useCustom || false);
|
||||||
|
const [serversText, setServersText] = React2.useState(initial?.serversText || "");
|
||||||
|
const [relayOnly, setRelayOnly] = React2.useState(initial?.privacyMode === "relay-only");
|
||||||
|
const [persist, setPersist] = React2.useState(initial?.persisted || false);
|
||||||
|
const [testState, setTestState] = React2.useState("idle");
|
||||||
|
const [testResult, setTestResult] = React2.useState(null);
|
||||||
|
const parsed = useCustom ? parseIceServersInput(serversText) : { servers: [], errors: [], warnings: [] };
|
||||||
|
const hasTurn = listHasTurn(parsed.servers);
|
||||||
|
const canApply = !useCustom || parsed.servers.length > 0 && parsed.errors.length === 0;
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTestState("running");
|
||||||
|
setTestResult(null);
|
||||||
|
const result = await testIceServers(parsed.servers);
|
||||||
|
setTestResult(result);
|
||||||
|
setTestState("done");
|
||||||
|
};
|
||||||
|
const handleApply = () => {
|
||||||
|
if (!canApply) return;
|
||||||
|
onApply(
|
||||||
|
{
|
||||||
|
useCustom,
|
||||||
|
servers: useCustom ? parsed.servers : [],
|
||||||
|
privacyMode: relayOnly ? "relay-only" : "standard",
|
||||||
|
serversText
|
||||||
|
},
|
||||||
|
persist
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const handleForget = async () => {
|
||||||
|
if (onForget) await onForget();
|
||||||
|
setPersist(false);
|
||||||
|
};
|
||||||
|
const labelCls = "block text-sm font-medium text-primary";
|
||||||
|
const descCls = "block text-sm text-secondary";
|
||||||
|
const children = [];
|
||||||
|
children.push(React2.createElement("div", { key: "header", className: "flex items-center mb-4" }, [
|
||||||
|
React2.createElement("div", {
|
||||||
|
key: "icon",
|
||||||
|
className: "w-10 h-10 bg-purple-500/10 border border-purple-500/20 rounded-lg flex items-center justify-center mr-3"
|
||||||
|
}, [React2.createElement("i", { className: "fas fa-network-wired accent-purple" })]),
|
||||||
|
React2.createElement("h3", { key: "title", className: "text-lg font-medium text-primary" }, "Advanced network settings")
|
||||||
|
]));
|
||||||
|
children.push(React2.createElement(
|
||||||
|
"p",
|
||||||
|
{ key: "intro", className: "text-sm text-secondary mb-4" },
|
||||||
|
"By default SecureBit uses public STUN servers. You can supply your own STUN/TURN servers \u2014 useful if you self-host a TURN relay and do not want to rely on public infrastructure. Servers are configured locally on your side only; you do not need to share them with your peer."
|
||||||
|
));
|
||||||
|
children.push(React2.createElement("div", { key: "mode", className: "space-y-2 mb-4" }, [
|
||||||
|
React2.createElement("label", { key: "public", className: "flex items-start gap-3" }, [
|
||||||
|
React2.createElement("input", {
|
||||||
|
key: "r",
|
||||||
|
type: "radio",
|
||||||
|
name: "ice-mode",
|
||||||
|
checked: !useCustom,
|
||||||
|
onChange: () => setUseCustom(false),
|
||||||
|
className: "mt-1"
|
||||||
|
}),
|
||||||
|
React2.createElement("span", { key: "s" }, [
|
||||||
|
React2.createElement("span", { key: "t", className: labelCls }, "Public servers (default)"),
|
||||||
|
React2.createElement("span", { key: "d", className: descCls }, "Zero-config. Good for most users.")
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
React2.createElement("label", { key: "custom", className: "flex items-start gap-3" }, [
|
||||||
|
React2.createElement("input", {
|
||||||
|
key: "r",
|
||||||
|
type: "radio",
|
||||||
|
name: "ice-mode",
|
||||||
|
checked: useCustom,
|
||||||
|
onChange: () => setUseCustom(true),
|
||||||
|
className: "mt-1"
|
||||||
|
}),
|
||||||
|
React2.createElement("span", { key: "s" }, [
|
||||||
|
React2.createElement("span", { key: "t", className: labelCls }, "My own STUN/TURN servers"),
|
||||||
|
React2.createElement("span", { key: "d", className: descCls }, `Up to ${ICE_LIMITS.MAX_SERVERS} servers.`)
|
||||||
|
])
|
||||||
|
])
|
||||||
|
]));
|
||||||
|
if (useCustom) {
|
||||||
|
children.push(React2.createElement("textarea", {
|
||||||
|
key: "textarea",
|
||||||
|
value: serversText,
|
||||||
|
onChange: (e) => setServersText(e.target.value),
|
||||||
|
placeholder: PLACEHOLDER,
|
||||||
|
spellCheck: false,
|
||||||
|
autoComplete: "off",
|
||||||
|
className: "w-full h-36 mb-2 p-3 rounded-lg bg-black/30 border border-purple-500/20 text-sm text-primary font-mono"
|
||||||
|
}));
|
||||||
|
if (parsed.errors.length > 0) {
|
||||||
|
children.push(React2.createElement(
|
||||||
|
"ul",
|
||||||
|
{ key: "errors", className: "mb-2 text-sm text-red-400 list-disc pl-5" },
|
||||||
|
parsed.errors.slice(0, 6).map((err, i) => React2.createElement("li", { key: i }, err))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (parsed.warnings.length > 0) {
|
||||||
|
children.push(React2.createElement(
|
||||||
|
"ul",
|
||||||
|
{ key: "warnings", className: "mb-2 text-sm text-yellow-400 list-disc pl-5" },
|
||||||
|
parsed.warnings.slice(0, 6).map((w, i) => React2.createElement("li", { key: i }, w))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (parsed.servers.length > 0 && parsed.errors.length === 0) {
|
||||||
|
children.push(React2.createElement(
|
||||||
|
"p",
|
||||||
|
{ key: "ok", className: "mb-2 text-sm text-green-400" },
|
||||||
|
`${parsed.servers.length} server(s) parsed${hasTurn ? " (TURN present)" : " (STUN only \u2014 does not hide IP)"}.`
|
||||||
|
));
|
||||||
|
}
|
||||||
|
children.push(React2.createElement(
|
||||||
|
"p",
|
||||||
|
{ key: "disclaimer", className: "mb-3 text-xs text-secondary" },
|
||||||
|
"Privacy note: a TURN relay sees the IP addresses and traffic timing of both peers (never your message contents, which stay end-to-end encrypted). Only a TURN server you trust or self-host improves privacy \u2014 pointing this at a random public relay does not. Prefer turns: (TLS)."
|
||||||
|
));
|
||||||
|
children.push(React2.createElement("div", { key: "test", className: "mb-3" }, [
|
||||||
|
React2.createElement("button", {
|
||||||
|
key: "btn",
|
||||||
|
type: "button",
|
||||||
|
disabled: !canApply || testState === "running",
|
||||||
|
onClick: handleTest,
|
||||||
|
className: "px-3 py-2 text-sm rounded-lg border border-purple-500/30 text-primary disabled:opacity-50"
|
||||||
|
}, testState === "running" ? "Testing\u2026" : "Test servers"),
|
||||||
|
testState === "done" && testResult ? React2.createElement(
|
||||||
|
"span",
|
||||||
|
{
|
||||||
|
key: "res",
|
||||||
|
className: "ml-3 text-sm " + (testResult.error ? "text-red-400" : "text-secondary")
|
||||||
|
},
|
||||||
|
testResult.error ? `Test failed: ${testResult.error}` : `STUN ${testResult.srflx > 0 ? "OK" : "none"} \xB7 TURN ${testResult.relay > 0 ? "OK" : "none"} \xB7 host ${testResult.host}`
|
||||||
|
) : null
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
children.push(React2.createElement("label", { key: "relay", className: "flex items-start gap-3 mb-3 rounded-lg border border-purple-500/20 bg-purple-500/10 p-3" }, [
|
||||||
|
React2.createElement("input", {
|
||||||
|
key: "i",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: relayOnly,
|
||||||
|
onChange: (e) => setRelayOnly(e.target.checked),
|
||||||
|
className: "mt-1"
|
||||||
|
}),
|
||||||
|
React2.createElement("span", { key: "s" }, [
|
||||||
|
React2.createElement("span", { key: "t", className: labelCls }, "Relay-only mode (maximum privacy)"),
|
||||||
|
React2.createElement("span", { key: "d", className: descCls }, "Routes all traffic through TURN so your IP is not exposed to the peer. Requires a TURN server; connections cannot start without one.")
|
||||||
|
])
|
||||||
|
]));
|
||||||
|
if (relayOnly && useCustom && !hasTurn) {
|
||||||
|
children.push(React2.createElement(
|
||||||
|
"p",
|
||||||
|
{ key: "relaywarn", className: "mb-3 text-sm text-yellow-400" },
|
||||||
|
"Relay-only is enabled but no TURN server is configured. The connection will not be able to start."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
children.push(React2.createElement("label", { key: "persist", className: "flex items-start gap-3 mb-4" }, [
|
||||||
|
React2.createElement("input", {
|
||||||
|
key: "i",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: persist,
|
||||||
|
onChange: (e) => setPersist(e.target.checked),
|
||||||
|
className: "mt-1"
|
||||||
|
}),
|
||||||
|
React2.createElement("span", { key: "s" }, [
|
||||||
|
React2.createElement("span", { key: "t", className: labelCls }, "Save on this device"),
|
||||||
|
React2.createElement("span", { key: "d", className: descCls }, "Stored encrypted in this browser. Leave off to use only for this session.")
|
||||||
|
])
|
||||||
|
]));
|
||||||
|
const actions = [
|
||||||
|
React2.createElement("button", {
|
||||||
|
key: "cancel",
|
||||||
|
type: "button",
|
||||||
|
onClick: onClose,
|
||||||
|
className: "px-4 py-2 text-sm rounded-lg border border-white/10 text-secondary"
|
||||||
|
}, "Cancel"),
|
||||||
|
React2.createElement("button", {
|
||||||
|
key: "apply",
|
||||||
|
type: "button",
|
||||||
|
onClick: handleApply,
|
||||||
|
disabled: !canApply,
|
||||||
|
className: "px-4 py-2 text-sm rounded-lg bg-purple-500/20 border border-purple-500/30 text-primary disabled:opacity-50"
|
||||||
|
}, "Apply")
|
||||||
|
];
|
||||||
|
if (hasSaved) {
|
||||||
|
actions.unshift(React2.createElement("button", {
|
||||||
|
key: "forget",
|
||||||
|
type: "button",
|
||||||
|
onClick: handleForget,
|
||||||
|
className: "px-4 py-2 text-sm rounded-lg border border-red-500/30 text-red-400 mr-auto"
|
||||||
|
}, "Forget saved"));
|
||||||
|
}
|
||||||
|
children.push(React2.createElement("div", { key: "actions", className: "flex items-center gap-2 flex-wrap" }, actions));
|
||||||
|
return React2.createElement("div", {
|
||||||
|
className: "fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4",
|
||||||
|
onClick: (e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
React2.createElement("div", {
|
||||||
|
key: "modal",
|
||||||
|
className: "card-minimal rounded-xl p-6 max-w-lg w-full border-purple-500/20 max-h-[90vh] overflow-y-auto"
|
||||||
|
}, children)
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
window.IceServerSettings = IceServerSettings;
|
||||||
|
|
||||||
// src/scripts/app-boot.js
|
// src/scripts/app-boot.js
|
||||||
window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils;
|
window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils;
|
||||||
window.EnhancedSecureWebRTCManager = EnhancedSecureWebRTCManager;
|
window.EnhancedSecureWebRTCManager = EnhancedSecureWebRTCManager;
|
||||||
|
|||||||
Vendored
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+199
-1
@@ -31,6 +31,126 @@ function installDebugWindowHooks({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/network/iceSettingsStore.js
|
||||||
|
var DB_NAME = "securebit-net";
|
||||||
|
var DB_VERSION = 1;
|
||||||
|
var STORE = "kv";
|
||||||
|
var KEY_RECORD = "ice-device-key";
|
||||||
|
var SETTINGS_RECORD = "ice-settings";
|
||||||
|
var SETTINGS_VERSION = 1;
|
||||||
|
function isSupported() {
|
||||||
|
return typeof indexedDB !== "undefined" && typeof crypto !== "undefined" && !!crypto.subtle;
|
||||||
|
}
|
||||||
|
function openDb() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let request;
|
||||||
|
try {
|
||||||
|
request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE)) {
|
||||||
|
db.createObjectStore(STORE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function idbGet(db, key) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, "readonly");
|
||||||
|
const req = tx.objectStore(STORE).get(key);
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function idbPut(db, key, value) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, "readwrite");
|
||||||
|
tx.objectStore(STORE).put(value, key);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function idbDelete(db, key) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, "readwrite");
|
||||||
|
tx.objectStore(STORE).delete(key);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function getOrCreateDeviceKey(db) {
|
||||||
|
const existing = await idbGet(db, KEY_RECORD);
|
||||||
|
if (existing instanceof CryptoKey) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const key = await crypto.subtle.generateKey(
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
// non-extractable
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
await idbPut(db, KEY_RECORD, key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
async function saveIceSettings(settings) {
|
||||||
|
if (!isSupported()) throw new Error("Persistent storage is not available in this browser");
|
||||||
|
const db = await openDb();
|
||||||
|
const key = await getOrCreateDeviceKey(db);
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
version: SETTINGS_VERSION,
|
||||||
|
servers: Array.isArray(settings?.servers) ? settings.servers : [],
|
||||||
|
privacyMode: settings?.privacyMode === "relay-only" ? "relay-only" : "standard"
|
||||||
|
});
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv },
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(payload)
|
||||||
|
);
|
||||||
|
await idbPut(db, SETTINGS_RECORD, {
|
||||||
|
iv: Array.from(iv),
|
||||||
|
data: Array.from(new Uint8Array(ciphertext))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function loadIceSettings() {
|
||||||
|
if (!isSupported()) return null;
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
const record = await idbGet(db, SETTINGS_RECORD);
|
||||||
|
if (!record || !Array.isArray(record.iv) || !Array.isArray(record.data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const key = await idbGet(db, KEY_RECORD);
|
||||||
|
if (!(key instanceof CryptoKey)) return null;
|
||||||
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: new Uint8Array(record.iv) },
|
||||||
|
key,
|
||||||
|
new Uint8Array(record.data)
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
||||||
|
return {
|
||||||
|
servers: Array.isArray(parsed.servers) ? parsed.servers : [],
|
||||||
|
privacyMode: parsed.privacyMode === "relay-only" ? "relay-only" : "standard"
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function clearIceSettings() {
|
||||||
|
if (!isSupported()) return;
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
await idbDelete(db, SETTINGS_RECORD);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// src/app.jsx
|
// src/app.jsx
|
||||||
var EnhancedCopyButton = ({ text, className = "", children }) => {
|
var EnhancedCopyButton = ({ text, className = "", children }) => {
|
||||||
const [copied, setCopied] = React.useState(false);
|
const [copied, setCopied] = React.useState(false);
|
||||||
@@ -506,6 +626,38 @@ var EnhancedConnectionSetup = ({
|
|||||||
}, "Uses TURN relay-only when configured. Without TURN, direct WebRTC may expose IP addresses and relay-only connections cannot start.")
|
}, "Uses TURN relay-only when configured. Without TURN, direct WebRTC may expose IP addresses and relay-only connections cannot start.")
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
|
React.createElement("div", {
|
||||||
|
key: "advanced-network",
|
||||||
|
className: "mb-6 mx-auto max-w-2xl flex flex-wrap items-center justify-between gap-3"
|
||||||
|
}, [
|
||||||
|
React.createElement("span", {
|
||||||
|
key: "status",
|
||||||
|
className: "text-sm text-secondary"
|
||||||
|
}, Array.isArray(customIceServers) && customIceServers.length ? `Using ${customIceServers.length} custom ICE server(s)` : "Using public ICE servers"),
|
||||||
|
React.createElement("button", {
|
||||||
|
key: "btn",
|
||||||
|
type: "button",
|
||||||
|
onClick: () => setShowIceSettings(true),
|
||||||
|
className: "px-3 py-2 text-sm rounded-lg border border-purple-500/30 text-primary"
|
||||||
|
}, [
|
||||||
|
React.createElement("i", { key: "i", className: "fas fa-network-wired mr-2" }),
|
||||||
|
"Advanced network settings"
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
typeof window !== "undefined" && window.IceServerSettings ? React.createElement(window.IceServerSettings, {
|
||||||
|
key: "ice-settings-modal",
|
||||||
|
isOpen: showIceSettings,
|
||||||
|
onClose: () => setShowIceSettings(false),
|
||||||
|
initial: {
|
||||||
|
useCustom: Array.isArray(customIceServers) && customIceServers.length > 0,
|
||||||
|
serversText: iceServersText,
|
||||||
|
privacyMode: relayOnlyMode ? "relay-only" : "standard",
|
||||||
|
persisted: iceSettingsPersisted
|
||||||
|
},
|
||||||
|
hasSaved: iceSettingsPersisted,
|
||||||
|
onApply: handleApplyIceSettings,
|
||||||
|
onForget: handleForgetIceSettings
|
||||||
|
}) : null,
|
||||||
React.createElement("div", {
|
React.createElement("div", {
|
||||||
key: "options",
|
key: "options",
|
||||||
className: "flex flex-col md:flex-row items-center justify-center gap-6 max-w-3xl mx-auto"
|
className: "flex flex-col md:flex-row items-center justify-center gap-6 max-w-3xl mx-auto"
|
||||||
@@ -1481,6 +1633,51 @@ var EnhancedSecureP2PChat = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const [customIceServers2, setCustomIceServers] = React.useState(null);
|
||||||
|
const [iceServersText2, setIceServersText] = React.useState("");
|
||||||
|
const [iceSettingsPersisted2, setIceSettingsPersisted] = React.useState(false);
|
||||||
|
const [showIceSettings2, setShowIceSettings2] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
loadIceSettings().then((saved) => {
|
||||||
|
if (cancelled || !saved) return;
|
||||||
|
if (Array.isArray(saved.servers) && saved.servers.length > 0) {
|
||||||
|
setCustomIceServers(saved.servers);
|
||||||
|
setIceServersText(JSON.stringify(saved.servers, null, 2));
|
||||||
|
}
|
||||||
|
if (saved.privacyMode === "relay-only") {
|
||||||
|
setRelayOnlyMode(true);
|
||||||
|
}
|
||||||
|
setIceSettingsPersisted(true);
|
||||||
|
}).catch(() => {
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const handleApplyIceSettings2 = React.useCallback((next, persist) => {
|
||||||
|
const servers = next.useCustom && Array.isArray(next.servers) ? next.servers : null;
|
||||||
|
setCustomIceServers(servers && servers.length ? servers : null);
|
||||||
|
setIceServersText(next.serversText || "");
|
||||||
|
setRelayOnlyMode(next.privacyMode === "relay-only");
|
||||||
|
setShowIceSettings2(false);
|
||||||
|
if (persist) {
|
||||||
|
setIceSettingsPersisted(true);
|
||||||
|
saveIceSettings({ servers: servers || [], privacyMode: next.privacyMode }).catch(() => {
|
||||||
|
});
|
||||||
|
} else if (iceSettingsPersisted2) {
|
||||||
|
setIceSettingsPersisted(false);
|
||||||
|
clearIceSettings().catch(() => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [iceSettingsPersisted2]);
|
||||||
|
const handleForgetIceSettings2 = React.useCallback(async () => {
|
||||||
|
await clearIceSettings().catch(() => {
|
||||||
|
});
|
||||||
|
setIceSettingsPersisted(false);
|
||||||
|
setCustomIceServers(null);
|
||||||
|
setIceServersText("");
|
||||||
|
}, []);
|
||||||
const [messageInput, setMessageInput] = React.useState("");
|
const [messageInput, setMessageInput] = React.useState("");
|
||||||
const [offerData, setOfferData] = React.useState("");
|
const [offerData, setOfferData] = React.useState("");
|
||||||
const [answerData, setAnswerData] = React.useState("");
|
const [answerData, setAnswerData] = React.useState("");
|
||||||
@@ -1806,7 +2003,8 @@ var EnhancedSecureP2PChat = () => {
|
|||||||
{
|
{
|
||||||
webrtc: {
|
webrtc: {
|
||||||
relayOnly: relayOnlyMode,
|
relayOnly: relayOnlyMode,
|
||||||
iceServers: Array.isArray(window.SECUREBIT_ICE_SERVERS) ? window.SECUREBIT_ICE_SERVERS : void 0
|
// Priority: user's custom servers > operator override > built-in defaults.
|
||||||
|
iceServers: Array.isArray(customIceServers2) && customIceServers2.length ? customIceServers2 : Array.isArray(window.SECUREBIT_ICE_SERVERS) ? window.SECUREBIT_ICE_SERVERS : void 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+4
-4
File diff suppressed because one or more lines are too long
+4
-4
@@ -148,13 +148,13 @@
|
|||||||
<!-- Update Manager - система принудительного обновления -->
|
<!-- Update Manager - система принудительного обновления -->
|
||||||
<script src="src/utils/updateManager.js"></script>
|
<script src="src/utils/updateManager.js"></script>
|
||||||
<script type="module" src="src/components/UpdateChecker.jsx"></script>
|
<script type="module" src="src/components/UpdateChecker.jsx"></script>
|
||||||
<script type="module" src="dist/qr-local.js?v=1781550335673"></script>
|
<script type="module" src="dist/qr-local.js?v=1781552284668"></script>
|
||||||
<script type="module" src="src/components/QRScanner.js?v=1781550335673"></script>
|
<script type="module" src="src/components/QRScanner.js?v=1781552284668"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="dist/app-boot.js?v=1781550335673"></script>
|
<script type="module" src="dist/app-boot.js?v=1781552284668"></script>
|
||||||
<script type="module" src="dist/app.js?v=1781550335673"></script>
|
<script type="module" src="dist/app.js?v=1781552284668"></script>
|
||||||
|
|
||||||
<script src="src/scripts/pwa-register.js"></script>
|
<script src="src/scripts/pwa-register.js"></script>
|
||||||
<script src="./src/pwa/install-prompt.js" type="module"></script>
|
<script src="./src/pwa/install-prompt.js" type="module"></script>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"version": "1781550335673",
|
"version": "1781552284668",
|
||||||
"buildVersion": "1781550335673",
|
"buildVersion": "1781552284668",
|
||||||
"appVersion": "4.8.9",
|
"appVersion": "4.8.9",
|
||||||
"buildTime": "2026-06-15T19:05:35.714Z",
|
"buildTime": "2026-06-15T19:38:04.713Z",
|
||||||
"buildId": "1781550335673-d11f250",
|
"buildId": "1781552284668-366f080",
|
||||||
"gitHash": "d11f250",
|
"gitHash": "366f080",
|
||||||
"generated": true,
|
"generated": true,
|
||||||
"generatedAt": "2026-06-15T19:05:35.717Z"
|
"generatedAt": "2026-06-15T19:38:04.715Z"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"dev": "npm run build && python -m http.server 8000",
|
"dev": "npm run build && python -m http.server 8000",
|
||||||
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
||||||
"serve": "npx http-server -p 8000",
|
"serve": "npx http-server -p 8000",
|
||||||
"test": "node tests/sas-verification.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/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"
|
"test": "node tests/sas-verification.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"p2p",
|
"p2p",
|
||||||
|
|||||||
+86
-2
@@ -1,4 +1,5 @@
|
|||||||
import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
|
import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
|
||||||
|
import { loadIceSettings, saveIceSettings, clearIceSettings } from './network/iceSettingsStore.js';
|
||||||
// Enhanced Copy Button with better UX
|
// Enhanced Copy Button with better UX
|
||||||
const EnhancedCopyButton = ({ text, className = "", children }) => {
|
const EnhancedCopyButton = ({ text, className = "", children }) => {
|
||||||
const [copied, setCopied] = React.useState(false);
|
const [copied, setCopied] = React.useState(false);
|
||||||
@@ -530,7 +531,42 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
|
|||||||
}, 'Uses TURN relay-only when configured. Without TURN, direct WebRTC may expose IP addresses and relay-only connections cannot start.')
|
}, 'Uses TURN relay-only when configured. Without TURN, direct WebRTC may expose IP addresses and relay-only connections cannot start.')
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
React.createElement('div', {
|
||||||
|
key: 'advanced-network',
|
||||||
|
className: "mb-6 mx-auto max-w-2xl flex flex-wrap items-center justify-between gap-3"
|
||||||
|
}, [
|
||||||
|
React.createElement('span', {
|
||||||
|
key: 'status',
|
||||||
|
className: "text-sm text-secondary"
|
||||||
|
}, (Array.isArray(customIceServers) && customIceServers.length)
|
||||||
|
? `Using ${customIceServers.length} custom ICE server(s)`
|
||||||
|
: 'Using public ICE servers'),
|
||||||
|
React.createElement('button', {
|
||||||
|
key: 'btn',
|
||||||
|
type: 'button',
|
||||||
|
onClick: () => setShowIceSettings(true),
|
||||||
|
className: "px-3 py-2 text-sm rounded-lg border border-purple-500/30 text-primary"
|
||||||
|
}, [
|
||||||
|
React.createElement('i', { key: 'i', className: 'fas fa-network-wired mr-2' }),
|
||||||
|
'Advanced network settings'
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
(typeof window !== 'undefined' && window.IceServerSettings) ? React.createElement(window.IceServerSettings, {
|
||||||
|
key: 'ice-settings-modal',
|
||||||
|
isOpen: showIceSettings,
|
||||||
|
onClose: () => setShowIceSettings(false),
|
||||||
|
initial: {
|
||||||
|
useCustom: Array.isArray(customIceServers) && customIceServers.length > 0,
|
||||||
|
serversText: iceServersText,
|
||||||
|
privacyMode: relayOnlyMode ? 'relay-only' : 'standard',
|
||||||
|
persisted: iceSettingsPersisted
|
||||||
|
},
|
||||||
|
hasSaved: iceSettingsPersisted,
|
||||||
|
onApply: handleApplyIceSettings,
|
||||||
|
onForget: handleForgetIceSettings
|
||||||
|
}) : null,
|
||||||
|
|
||||||
React.createElement('div', {
|
React.createElement('div', {
|
||||||
key: 'options',
|
key: 'options',
|
||||||
className: "flex flex-col md:flex-row items-center justify-center gap-6 max-w-3xl mx-auto"
|
className: "flex flex-col md:flex-row items-center justify-center gap-6 max-w-3xl mx-auto"
|
||||||
@@ -1561,6 +1597,51 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
|
|||||||
const [relayOnlyMode, setRelayOnlyMode] = React.useState(() => {
|
const [relayOnlyMode, setRelayOnlyMode] = React.useState(() => {
|
||||||
try { return localStorage.getItem('securebit_relay_only_mode') === 'true'; } catch { return false; }
|
try { return localStorage.getItem('securebit_relay_only_mode') === 'true'; } catch { return false; }
|
||||||
});
|
});
|
||||||
|
// Custom ICE (STUN/TURN) servers — advanced network settings.
|
||||||
|
const [customIceServers, setCustomIceServers] = React.useState(null); // null => use public defaults
|
||||||
|
const [iceServersText, setIceServersText] = React.useState('');
|
||||||
|
const [iceSettingsPersisted, setIceSettingsPersisted] = React.useState(false);
|
||||||
|
const [showIceSettings, setShowIceSettings] = React.useState(false);
|
||||||
|
|
||||||
|
// Load any previously saved (encrypted) custom ICE settings on mount.
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
loadIceSettings().then((saved) => {
|
||||||
|
if (cancelled || !saved) return;
|
||||||
|
if (Array.isArray(saved.servers) && saved.servers.length > 0) {
|
||||||
|
setCustomIceServers(saved.servers);
|
||||||
|
setIceServersText(JSON.stringify(saved.servers, null, 2));
|
||||||
|
}
|
||||||
|
if (saved.privacyMode === 'relay-only') {
|
||||||
|
setRelayOnlyMode(true);
|
||||||
|
}
|
||||||
|
setIceSettingsPersisted(true);
|
||||||
|
}).catch(() => { /* fail closed: keep defaults */ });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleApplyIceSettings = React.useCallback((next, persist) => {
|
||||||
|
const servers = next.useCustom && Array.isArray(next.servers) ? next.servers : null;
|
||||||
|
setCustomIceServers(servers && servers.length ? servers : null);
|
||||||
|
setIceServersText(next.serversText || '');
|
||||||
|
setRelayOnlyMode(next.privacyMode === 'relay-only');
|
||||||
|
setShowIceSettings(false);
|
||||||
|
if (persist) {
|
||||||
|
setIceSettingsPersisted(true);
|
||||||
|
saveIceSettings({ servers: servers || [], privacyMode: next.privacyMode }).catch(() => { /* surfaced as no-op */ });
|
||||||
|
} else if (iceSettingsPersisted) {
|
||||||
|
// User turned persistence off — remove the stored copy.
|
||||||
|
setIceSettingsPersisted(false);
|
||||||
|
clearIceSettings().catch(() => {});
|
||||||
|
}
|
||||||
|
}, [iceSettingsPersisted]);
|
||||||
|
|
||||||
|
const handleForgetIceSettings = React.useCallback(async () => {
|
||||||
|
await clearIceSettings().catch(() => {});
|
||||||
|
setIceSettingsPersisted(false);
|
||||||
|
setCustomIceServers(null);
|
||||||
|
setIceServersText('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Moved scrollToBottom logic to be available globally
|
// Moved scrollToBottom logic to be available globally
|
||||||
const [messageInput, setMessageInput] = React.useState('');
|
const [messageInput, setMessageInput] = React.useState('');
|
||||||
@@ -1999,7 +2080,10 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
|
|||||||
{
|
{
|
||||||
webrtc: {
|
webrtc: {
|
||||||
relayOnly: relayOnlyMode,
|
relayOnly: relayOnlyMode,
|
||||||
iceServers: Array.isArray(window.SECUREBIT_ICE_SERVERS) ? window.SECUREBIT_ICE_SERVERS : undefined
|
// Priority: user's custom servers > operator override > built-in defaults.
|
||||||
|
iceServers: (Array.isArray(customIceServers) && customIceServers.length)
|
||||||
|
? customIceServers
|
||||||
|
: (Array.isArray(window.SECUREBIT_ICE_SERVERS) ? window.SECUREBIT_ICE_SERVERS : undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// Advanced network settings: lets a user supply their own STUN/TURN servers
|
||||||
|
// instead of the bundled public defaults, and toggle relay-only privacy mode.
|
||||||
|
// Free / power-user feature, hidden behind an explicit "Advanced" entry point.
|
||||||
|
//
|
||||||
|
// All input is validated through src/network/iceServers.js before it can reach
|
||||||
|
// RTCPeerConnection. Persistence is opt-in and encrypted at rest (see
|
||||||
|
// src/network/iceSettingsStore.js).
|
||||||
|
import {
|
||||||
|
parseIceServersInput,
|
||||||
|
listHasTurn,
|
||||||
|
ICE_LIMITS
|
||||||
|
} from '../../network/iceServers.js';
|
||||||
|
|
||||||
|
const React = window.React;
|
||||||
|
|
||||||
|
const PLACEHOLDER = [
|
||||||
|
'# One URL per line, e.g.:',
|
||||||
|
'stun:stun.example.com:3478',
|
||||||
|
'turn:turn.example.com:3478?transport=udp',
|
||||||
|
'',
|
||||||
|
'# Or paste JSON for servers with credentials:',
|
||||||
|
'[{"urls":"turns:turn.example.com:5349","username":"user","credential":"secret"}]'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
async function testIceServers(servers, timeoutMs = 6000) {
|
||||||
|
const found = { host: 0, srflx: 0, relay: 0 };
|
||||||
|
if (typeof RTCPeerConnection === 'undefined') {
|
||||||
|
return { ...found, error: 'WebRTC is not available in this browser' };
|
||||||
|
}
|
||||||
|
let pc;
|
||||||
|
try {
|
||||||
|
pc = new RTCPeerConnection({ iceServers: servers });
|
||||||
|
} catch (error) {
|
||||||
|
return { ...found, error: error.message || 'Invalid server configuration' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
try { pc.close(); } catch { /* noop */ }
|
||||||
|
resolve(found);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(finish, timeoutMs);
|
||||||
|
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (!event.candidate) { finish(); return; }
|
||||||
|
const c = event.candidate.candidate || '';
|
||||||
|
if (/ typ host/.test(c)) found.host++;
|
||||||
|
else if (/ typ srflx/.test(c)) found.srflx++;
|
||||||
|
else if (/ typ relay/.test(c)) found.relay++;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
pc.createDataChannel('securebit-ice-test');
|
||||||
|
pc.createOffer()
|
||||||
|
.then((offer) => pc.setLocalDescription(offer))
|
||||||
|
.catch(() => finish());
|
||||||
|
} catch {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForget }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const [useCustom, setUseCustom] = React.useState(initial?.useCustom || false);
|
||||||
|
const [serversText, setServersText] = React.useState(initial?.serversText || '');
|
||||||
|
const [relayOnly, setRelayOnly] = React.useState(initial?.privacyMode === 'relay-only');
|
||||||
|
const [persist, setPersist] = React.useState(initial?.persisted || false);
|
||||||
|
const [testState, setTestState] = React.useState('idle'); // idle | running | done
|
||||||
|
const [testResult, setTestResult] = React.useState(null);
|
||||||
|
|
||||||
|
const parsed = useCustom ? parseIceServersInput(serversText) : { servers: [], errors: [], warnings: [] };
|
||||||
|
const hasTurn = listHasTurn(parsed.servers);
|
||||||
|
const canApply = !useCustom || (parsed.servers.length > 0 && parsed.errors.length === 0);
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTestState('running');
|
||||||
|
setTestResult(null);
|
||||||
|
const result = await testIceServers(parsed.servers);
|
||||||
|
setTestResult(result);
|
||||||
|
setTestState('done');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
if (!canApply) return;
|
||||||
|
onApply(
|
||||||
|
{
|
||||||
|
useCustom,
|
||||||
|
servers: useCustom ? parsed.servers : [],
|
||||||
|
privacyMode: relayOnly ? 'relay-only' : 'standard',
|
||||||
|
serversText
|
||||||
|
},
|
||||||
|
persist
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForget = async () => {
|
||||||
|
if (onForget) await onForget();
|
||||||
|
setPersist(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelCls = 'block text-sm font-medium text-primary';
|
||||||
|
const descCls = 'block text-sm text-secondary';
|
||||||
|
|
||||||
|
const children = [];
|
||||||
|
|
||||||
|
// Header
|
||||||
|
children.push(React.createElement('div', { key: 'header', className: 'flex items-center mb-4' }, [
|
||||||
|
React.createElement('div', {
|
||||||
|
key: 'icon',
|
||||||
|
className: 'w-10 h-10 bg-purple-500/10 border border-purple-500/20 rounded-lg flex items-center justify-center mr-3'
|
||||||
|
}, [React.createElement('i', { className: 'fas fa-network-wired accent-purple' })]),
|
||||||
|
React.createElement('h3', { key: 'title', className: 'text-lg font-medium text-primary' }, 'Advanced network settings')
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Explainer
|
||||||
|
children.push(React.createElement('p', { key: 'intro', className: 'text-sm text-secondary mb-4' },
|
||||||
|
'By default SecureBit uses public STUN servers. You can supply your own STUN/TURN servers — useful if you self-host a TURN relay and do not want to rely on public infrastructure. Servers are configured locally on your side only; you do not need to share them with your peer.'
|
||||||
|
));
|
||||||
|
|
||||||
|
// Mode radios
|
||||||
|
children.push(React.createElement('div', { key: 'mode', className: 'space-y-2 mb-4' }, [
|
||||||
|
React.createElement('label', { key: 'public', className: 'flex items-start gap-3' }, [
|
||||||
|
React.createElement('input', {
|
||||||
|
key: 'r', type: 'radio', name: 'ice-mode', checked: !useCustom,
|
||||||
|
onChange: () => setUseCustom(false), className: 'mt-1'
|
||||||
|
}),
|
||||||
|
React.createElement('span', { key: 's' }, [
|
||||||
|
React.createElement('span', { key: 't', className: labelCls }, 'Public servers (default)'),
|
||||||
|
React.createElement('span', { key: 'd', className: descCls }, 'Zero-config. Good for most users.')
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
React.createElement('label', { key: 'custom', className: 'flex items-start gap-3' }, [
|
||||||
|
React.createElement('input', {
|
||||||
|
key: 'r', type: 'radio', name: 'ice-mode', checked: useCustom,
|
||||||
|
onChange: () => setUseCustom(true), className: 'mt-1'
|
||||||
|
}),
|
||||||
|
React.createElement('span', { key: 's' }, [
|
||||||
|
React.createElement('span', { key: 't', className: labelCls }, 'My own STUN/TURN servers'),
|
||||||
|
React.createElement('span', { key: 'd', className: descCls }, `Up to ${ICE_LIMITS.MAX_SERVERS} servers.`)
|
||||||
|
])
|
||||||
|
])
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Textarea + validation (only in custom mode)
|
||||||
|
if (useCustom) {
|
||||||
|
children.push(React.createElement('textarea', {
|
||||||
|
key: 'textarea',
|
||||||
|
value: serversText,
|
||||||
|
onChange: (e) => setServersText(e.target.value),
|
||||||
|
placeholder: PLACEHOLDER,
|
||||||
|
spellCheck: false,
|
||||||
|
autoComplete: 'off',
|
||||||
|
className: 'w-full h-36 mb-2 p-3 rounded-lg bg-black/30 border border-purple-500/20 text-sm text-primary font-mono'
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (parsed.errors.length > 0) {
|
||||||
|
children.push(React.createElement('ul', { key: 'errors', className: 'mb-2 text-sm text-red-400 list-disc pl-5' },
|
||||||
|
parsed.errors.slice(0, 6).map((err, i) => React.createElement('li', { key: i }, err))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (parsed.warnings.length > 0) {
|
||||||
|
children.push(React.createElement('ul', { key: 'warnings', className: 'mb-2 text-sm text-yellow-400 list-disc pl-5' },
|
||||||
|
parsed.warnings.slice(0, 6).map((w, i) => React.createElement('li', { key: i }, w))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (parsed.servers.length > 0 && parsed.errors.length === 0) {
|
||||||
|
children.push(React.createElement('p', { key: 'ok', className: 'mb-2 text-sm text-green-400' },
|
||||||
|
`${parsed.servers.length} server(s) parsed${hasTurn ? ' (TURN present)' : ' (STUN only — does not hide IP)'}.`
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Privacy disclaimer about third-party relays
|
||||||
|
children.push(React.createElement('p', { key: 'disclaimer', className: 'mb-3 text-xs text-secondary' },
|
||||||
|
'Privacy note: a TURN relay sees the IP addresses and traffic timing of both peers (never your message contents, which stay end-to-end encrypted). Only a TURN server you trust or self-host improves privacy — pointing this at a random public relay does not. Prefer turns: (TLS).'
|
||||||
|
));
|
||||||
|
|
||||||
|
// Test button + result
|
||||||
|
children.push(React.createElement('div', { key: 'test', className: 'mb-3' }, [
|
||||||
|
React.createElement('button', {
|
||||||
|
key: 'btn',
|
||||||
|
type: 'button',
|
||||||
|
disabled: !canApply || testState === 'running',
|
||||||
|
onClick: handleTest,
|
||||||
|
className: 'px-3 py-2 text-sm rounded-lg border border-purple-500/30 text-primary disabled:opacity-50'
|
||||||
|
}, testState === 'running' ? 'Testing…' : 'Test servers'),
|
||||||
|
testState === 'done' && testResult ? React.createElement('span', {
|
||||||
|
key: 'res',
|
||||||
|
className: 'ml-3 text-sm ' + (testResult.error ? 'text-red-400' : 'text-secondary')
|
||||||
|
}, testResult.error
|
||||||
|
? `Test failed: ${testResult.error}`
|
||||||
|
: `STUN ${testResult.srflx > 0 ? 'OK' : 'none'} · TURN ${testResult.relay > 0 ? 'OK' : 'none'} · host ${testResult.host}`
|
||||||
|
) : null
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay-only privacy toggle
|
||||||
|
children.push(React.createElement('label', { key: 'relay', className: 'flex items-start gap-3 mb-3 rounded-lg border border-purple-500/20 bg-purple-500/10 p-3' }, [
|
||||||
|
React.createElement('input', {
|
||||||
|
key: 'i', type: 'checkbox', checked: relayOnly,
|
||||||
|
onChange: (e) => setRelayOnly(e.target.checked), className: 'mt-1'
|
||||||
|
}),
|
||||||
|
React.createElement('span', { key: 's' }, [
|
||||||
|
React.createElement('span', { key: 't', className: labelCls }, 'Relay-only mode (maximum privacy)'),
|
||||||
|
React.createElement('span', { key: 'd', className: descCls }, 'Routes all traffic through TURN so your IP is not exposed to the peer. Requires a TURN server; connections cannot start without one.')
|
||||||
|
])
|
||||||
|
]));
|
||||||
|
if (relayOnly && useCustom && !hasTurn) {
|
||||||
|
children.push(React.createElement('p', { key: 'relaywarn', className: 'mb-3 text-sm text-yellow-400' },
|
||||||
|
'Relay-only is enabled but no TURN server is configured. The connection will not be able to start.'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save on device
|
||||||
|
children.push(React.createElement('label', { key: 'persist', className: 'flex items-start gap-3 mb-4' }, [
|
||||||
|
React.createElement('input', {
|
||||||
|
key: 'i', type: 'checkbox', checked: persist,
|
||||||
|
onChange: (e) => setPersist(e.target.checked), className: 'mt-1'
|
||||||
|
}),
|
||||||
|
React.createElement('span', { key: 's' }, [
|
||||||
|
React.createElement('span', { key: 't', className: labelCls }, 'Save on this device'),
|
||||||
|
React.createElement('span', { key: 'd', className: descCls }, 'Stored encrypted in this browser. Leave off to use only for this session.')
|
||||||
|
])
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
const actions = [
|
||||||
|
React.createElement('button', {
|
||||||
|
key: 'cancel', type: 'button', onClick: onClose,
|
||||||
|
className: 'px-4 py-2 text-sm rounded-lg border border-white/10 text-secondary'
|
||||||
|
}, 'Cancel'),
|
||||||
|
React.createElement('button', {
|
||||||
|
key: 'apply', type: 'button', onClick: handleApply, disabled: !canApply,
|
||||||
|
className: 'px-4 py-2 text-sm rounded-lg bg-purple-500/20 border border-purple-500/30 text-primary disabled:opacity-50'
|
||||||
|
}, 'Apply')
|
||||||
|
];
|
||||||
|
if (hasSaved) {
|
||||||
|
actions.unshift(React.createElement('button', {
|
||||||
|
key: 'forget', type: 'button', onClick: handleForget,
|
||||||
|
className: 'px-4 py-2 text-sm rounded-lg border border-red-500/30 text-red-400 mr-auto'
|
||||||
|
}, 'Forget saved'));
|
||||||
|
}
|
||||||
|
children.push(React.createElement('div', { key: 'actions', className: 'flex items-center gap-2 flex-wrap' }, actions));
|
||||||
|
|
||||||
|
return React.createElement('div', {
|
||||||
|
className: 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4',
|
||||||
|
onClick: (e) => { if (e.target === e.currentTarget) onClose(); }
|
||||||
|
}, [
|
||||||
|
React.createElement('div', {
|
||||||
|
key: 'modal',
|
||||||
|
className: 'card-minimal rounded-xl p-6 max-w-lg w-full border-purple-500/20 max-h-[90vh] overflow-y-auto'
|
||||||
|
}, children)
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.IceServerSettings = IceServerSettings;
|
||||||
|
|
||||||
|
export { IceServerSettings, testIceServers };
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// Pure, dependency-free validation and normalization of user-supplied ICE
|
||||||
|
// (STUN/TURN) servers. No DOM, no browser APIs — safe to unit-test in Node.
|
||||||
|
//
|
||||||
|
// Security intent: a user can paste arbitrary text here. We must never hand an
|
||||||
|
// un-vetted value to RTCPeerConnection, and we must reject anything that is not
|
||||||
|
// a real STUN/TURN URL (e.g. javascript:, data:, http(s):, ws(s):) so the field
|
||||||
|
// cannot be abused as an injection surface or silently break the connection.
|
||||||
|
// Validation is allowlist-based: only known-good schemes, host shapes, and the
|
||||||
|
// standard transport query are accepted; everything else is rejected.
|
||||||
|
|
||||||
|
export const ICE_LIMITS = Object.freeze({
|
||||||
|
MAX_SERVERS: 10,
|
||||||
|
MAX_URLS_PER_SERVER: 8,
|
||||||
|
MAX_STRING_LENGTH: 512
|
||||||
|
});
|
||||||
|
|
||||||
|
// RTCIceServer only accepts these schemes. Everything else is rejected.
|
||||||
|
export const ALLOWED_ICE_SCHEMES = Object.freeze(['stun', 'stuns', 'turn', 'turns']);
|
||||||
|
|
||||||
|
const SCHEME_RE = /^(stuns?|turns?):/i;
|
||||||
|
// Positive allowlist for the host portion: hostname/IPv4, or bracketed IPv6,
|
||||||
|
// with an optional numeric port. Anything outside this shape is rejected.
|
||||||
|
const HOST_RE = /^(\[[0-9a-f:]+\]|[a-z0-9.-]+)(:\d{1,5})?$/i;
|
||||||
|
const TRANSPORT_RE = /^transport=(udp|tcp)$/i;
|
||||||
|
|
||||||
|
// True if the string contains any ASCII control character (0x00–0x1F or 0x7F).
|
||||||
|
// Implemented via char codes to keep the source free of literal control bytes.
|
||||||
|
function hasControlChars(value) {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const code = value.charCodeAt(i);
|
||||||
|
if (code < 0x20 || code === 0x7f) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a single ICE URL string.
|
||||||
|
* @returns {string|null} error message, or null if valid.
|
||||||
|
*/
|
||||||
|
export function validateIceUrl(url) {
|
||||||
|
if (typeof url !== 'string') return 'URL must be a string';
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return 'URL is empty';
|
||||||
|
if (trimmed.length > ICE_LIMITS.MAX_STRING_LENGTH) return 'URL is too long';
|
||||||
|
if (hasControlChars(trimmed)) return 'URL contains invalid characters';
|
||||||
|
|
||||||
|
const scheme = trimmed.match(SCHEME_RE);
|
||||||
|
if (!scheme) {
|
||||||
|
return 'URL must start with stun:, stuns:, turn: or turns:';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the part after "<scheme>:" — host[:port][?transport=udp|tcp].
|
||||||
|
const rest = trimmed.slice(scheme[0].length);
|
||||||
|
const [hostPort, query, ...extra] = rest.split('?');
|
||||||
|
if (extra.length > 0) return 'URL has an invalid query';
|
||||||
|
if (!hostPort) return 'URL is missing a host';
|
||||||
|
if (!HOST_RE.test(hostPort)) return 'URL has an invalid host or port';
|
||||||
|
|
||||||
|
if (query !== undefined && !TRANSPORT_RE.test(query)) {
|
||||||
|
return 'URL query must be transport=udp or transport=tcp';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTurnUrl(url) {
|
||||||
|
return typeof url === 'string' && /^turns?:/i.test(url.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSecret(value, label) {
|
||||||
|
if (value === undefined || value === null || value === '') return null;
|
||||||
|
if (typeof value !== 'string') return `${label} must be a string`;
|
||||||
|
if (value.length > ICE_LIMITS.MAX_STRING_LENGTH) return `${label} is too long`;
|
||||||
|
if (hasControlChars(value)) return `${label} contains invalid characters`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and normalize a list of ICE server entries.
|
||||||
|
* Each entry: { urls: string | string[], username?: string, credential?: string }
|
||||||
|
* @returns {{ servers: Array, errors: string[], warnings: string[] }}
|
||||||
|
*/
|
||||||
|
export function normalizeIceServers(entries) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
const servers = [];
|
||||||
|
|
||||||
|
if (!Array.isArray(entries)) {
|
||||||
|
return { servers: [], errors: ['Server list must be an array'], warnings: [] };
|
||||||
|
}
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return { servers: [], errors: [], warnings: [] };
|
||||||
|
}
|
||||||
|
if (entries.length > ICE_LIMITS.MAX_SERVERS) {
|
||||||
|
errors.push(`Too many servers (max ${ICE_LIMITS.MAX_SERVERS})`);
|
||||||
|
return { servers: [], errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const label = `Server #${index + 1}`;
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
errors.push(`${label}: invalid entry`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUrls = Array.isArray(entry.urls) ? entry.urls : [entry.urls];
|
||||||
|
if (rawUrls.length === 0 || rawUrls.length > ICE_LIMITS.MAX_URLS_PER_SERVER) {
|
||||||
|
errors.push(`${label}: between 1 and ${ICE_LIMITS.MAX_URLS_PER_SERVER} URLs required`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanUrls = [];
|
||||||
|
let entryHasTurn = false;
|
||||||
|
for (const rawUrl of rawUrls) {
|
||||||
|
const err = validateIceUrl(rawUrl);
|
||||||
|
if (err) {
|
||||||
|
errors.push(`${label}: ${err}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trimmed = rawUrl.trim();
|
||||||
|
cleanUrls.push(trimmed);
|
||||||
|
if (isTurnUrl(trimmed)) entryHasTurn = true;
|
||||||
|
}
|
||||||
|
if (cleanUrls.length === 0) return;
|
||||||
|
|
||||||
|
const userErr = validateSecret(entry.username, `${label} username`);
|
||||||
|
if (userErr) errors.push(userErr);
|
||||||
|
const credErr = validateSecret(entry.credential, `${label} credential`);
|
||||||
|
if (credErr) errors.push(credErr);
|
||||||
|
|
||||||
|
const server = { urls: cleanUrls.length === 1 ? cleanUrls[0] : cleanUrls };
|
||||||
|
if (entry.username) server.username = String(entry.username);
|
||||||
|
if (entry.credential) server.credential = String(entry.credential);
|
||||||
|
|
||||||
|
if (entryHasTurn && (!server.username || !server.credential)) {
|
||||||
|
warnings.push(`${label}: TURN servers usually require a username and credential`);
|
||||||
|
}
|
||||||
|
|
||||||
|
servers.push(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { servers, errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse free-form user input into ICE server entries.
|
||||||
|
* Accepts either a JSON array of RTCIceServer-like objects, or one URL per line
|
||||||
|
* (URL-only servers, e.g. public STUN). Returns normalized + validated output.
|
||||||
|
*/
|
||||||
|
export function parseIceServersInput(text) {
|
||||||
|
if (typeof text !== 'string' || !text.trim()) {
|
||||||
|
return { servers: [], errors: [], warnings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return { servers: [], errors: ['Invalid JSON'], warnings: [] };
|
||||||
|
}
|
||||||
|
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
return normalizeIceServers(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line-based: each non-empty line is a single URL-only server.
|
||||||
|
const entries = trimmed
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(url => ({ urls: url }));
|
||||||
|
return normalizeIceServers(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does a normalized server list contain at least one TURN/TURNS server? */
|
||||||
|
export function listHasTurn(servers) {
|
||||||
|
if (!Array.isArray(servers)) return false;
|
||||||
|
return servers.some(server => {
|
||||||
|
const urls = Array.isArray(server?.urls) ? server.urls : [server?.urls];
|
||||||
|
return urls.some(isTurnUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Persistent, at-rest-encrypted storage for the user's custom ICE (STUN/TURN)
|
||||||
|
// configuration. Persistence is OPTIONAL: the UI only calls saveIceSettings when
|
||||||
|
// the user explicitly opts in ("Save on this device"). Session-only use never
|
||||||
|
// touches this store — the settings live in React state and vanish on reload.
|
||||||
|
//
|
||||||
|
// At-rest protection model:
|
||||||
|
// - A non-extractable AES-GCM device key is generated once and kept in
|
||||||
|
// IndexedDB. It can never be exported back into JS, so a copy of the
|
||||||
|
// on-disk database is useless without executing code in this exact origin.
|
||||||
|
// - The settings blob (which may contain TURN credentials) is encrypted with
|
||||||
|
// that key before being written.
|
||||||
|
// This protects against disk/profile inspection. It does NOT protect against a
|
||||||
|
// live code-execution compromise of the page (consistent with the project's
|
||||||
|
// stated threat model — see SECURITY.md). Credentials are never persisted in
|
||||||
|
// plaintext, and the user can delete them at any time via clearIceSettings().
|
||||||
|
|
||||||
|
const DB_NAME = 'securebit-net';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE = 'kv';
|
||||||
|
const KEY_RECORD = 'ice-device-key';
|
||||||
|
const SETTINGS_RECORD = 'ice-settings';
|
||||||
|
const SETTINGS_VERSION = 1;
|
||||||
|
|
||||||
|
function isSupported() {
|
||||||
|
return typeof indexedDB !== 'undefined' &&
|
||||||
|
typeof crypto !== 'undefined' &&
|
||||||
|
!!crypto.subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDb() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let request;
|
||||||
|
try {
|
||||||
|
request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE)) {
|
||||||
|
db.createObjectStore(STORE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function idbGet(db, key) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readonly');
|
||||||
|
const req = tx.objectStore(STORE).get(key);
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function idbPut(db, key, value) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readwrite');
|
||||||
|
tx.objectStore(STORE).put(value, key);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function idbDelete(db, key) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readwrite');
|
||||||
|
tx.objectStore(STORE).delete(key);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateDeviceKey(db) {
|
||||||
|
const existing = await idbGet(db, KEY_RECORD);
|
||||||
|
if (existing instanceof CryptoKey) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const key = await crypto.subtle.generateKey(
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false, // non-extractable
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
await idbPut(db, KEY_RECORD, key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist custom ICE settings, encrypted at rest.
|
||||||
|
* @param {{ servers: Array, privacyMode: string }} settings
|
||||||
|
*/
|
||||||
|
export async function saveIceSettings(settings) {
|
||||||
|
if (!isSupported()) throw new Error('Persistent storage is not available in this browser');
|
||||||
|
|
||||||
|
const db = await openDb();
|
||||||
|
const key = await getOrCreateDeviceKey(db);
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
version: SETTINGS_VERSION,
|
||||||
|
servers: Array.isArray(settings?.servers) ? settings.servers : [],
|
||||||
|
privacyMode: settings?.privacyMode === 'relay-only' ? 'relay-only' : 'standard'
|
||||||
|
});
|
||||||
|
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(payload)
|
||||||
|
);
|
||||||
|
|
||||||
|
await idbPut(db, SETTINGS_RECORD, {
|
||||||
|
iv: Array.from(iv),
|
||||||
|
data: Array.from(new Uint8Array(ciphertext))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and decrypt previously saved ICE settings.
|
||||||
|
* Fails closed: returns null if absent, unsupported, or undecryptable.
|
||||||
|
* @returns {Promise<{ servers: Array, privacyMode: string }|null>}
|
||||||
|
*/
|
||||||
|
export async function loadIceSettings() {
|
||||||
|
if (!isSupported()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
const record = await idbGet(db, SETTINGS_RECORD);
|
||||||
|
if (!record || !Array.isArray(record.iv) || !Array.isArray(record.data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const key = await idbGet(db, KEY_RECORD);
|
||||||
|
if (!(key instanceof CryptoKey)) return null;
|
||||||
|
|
||||||
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv: new Uint8Array(record.iv) },
|
||||||
|
key,
|
||||||
|
new Uint8Array(record.data)
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
||||||
|
return {
|
||||||
|
servers: Array.isArray(parsed.servers) ? parsed.servers : [],
|
||||||
|
privacyMode: parsed.privacyMode === 'relay-only' ? 'relay-only' : 'standard'
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Corrupted or tampered record: fail closed.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete any persisted ICE settings (the device key is left in place). */
|
||||||
|
export async function clearIceSettings() {
|
||||||
|
if (!isSupported()) return;
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
await idbDelete(db, SETTINGS_RECORD);
|
||||||
|
} catch {
|
||||||
|
// Best-effort deletion; nothing to surface to the user.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasSavedIceSettings() {
|
||||||
|
if (!isSupported()) return false;
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
const record = await idbGet(db, SETTINGS_RECORD);
|
||||||
|
return !!record;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import '../components/ui/Testimonials.jsx';
|
|||||||
import '../components/ui/ComparisonTable.jsx';
|
import '../components/ui/ComparisonTable.jsx';
|
||||||
import '../components/ui/Roadmap.jsx';
|
import '../components/ui/Roadmap.jsx';
|
||||||
import '../components/ui/FileTransfer.jsx';
|
import '../components/ui/FileTransfer.jsx';
|
||||||
|
import '../components/ui/IceServerSettings.jsx';
|
||||||
|
|
||||||
// Expose to global for legacy usage inside app code
|
// Expose to global for legacy usage inside app code
|
||||||
window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils;
|
window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils;
|
||||||
|
|||||||
Vendored
+1
@@ -36,6 +36,7 @@
|
|||||||
import(`../components/ui/Testimonials.jsx?v=${timestamp}`),
|
import(`../components/ui/Testimonials.jsx?v=${timestamp}`),
|
||||||
import(`../components/ui/Roadmap.jsx?v=${timestamp}`),
|
import(`../components/ui/Roadmap.jsx?v=${timestamp}`),
|
||||||
import(`../components/ui/FileTransfer.jsx?v=${timestamp}`),
|
import(`../components/ui/FileTransfer.jsx?v=${timestamp}`),
|
||||||
|
import(`../components/ui/IceServerSettings.jsx?v=${timestamp}`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Components are automatically registered on window by their respective modules
|
// Components are automatically registered on window by their respective modules
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user