From cab6314bc7af670eb0bb140652a3a13571b21358 Mon Sep 17 00:00:00 2001 From: olcxja Date: Mon, 24 Aug 2026 15:01:18 +0200 Subject: [PATCH] 0.1.4 - voice chat security - improve voice chat security by implementing join validation - fix XSS in showIncomingCallUI --- android/app/build.gradle | 4 +- .../src/main/assets/public/blah/en-cat.json | 4 +- .../src/main/assets/public/blah/en-us.json | 4 +- .../src/main/assets/public/blah/pl-pl.json | 4 +- android/app/src/main/assets/public/version.js | 2 +- android/app/src/main/assets/public/voice.js | 125 ++++++++++++++---- electron/package.json | 2 +- package.json | 2 +- version.json | 6 +- webroot/blah/en-cat.json | 4 +- webroot/blah/en-us.json | 4 +- webroot/blah/pl-pl.json | 4 +- webroot/version.js | 2 +- webroot/voice.js | 125 ++++++++++++++---- 14 files changed, 229 insertions(+), 63 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index dca32d25..efa09414 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "olcxja.miarven" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 4 - versionName "0.1.3" + versionCode 5 + versionName "0.1.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/assets/public/blah/en-cat.json b/android/app/src/main/assets/public/blah/en-cat.json index 11a435fd..01a784ae 100644 --- a/android/app/src/main/assets/public/blah/en-cat.json +++ b/android/app/src/main/assets/public/blah/en-cat.json @@ -322,5 +322,7 @@ "label.voice.sensitivity": "input sensitivity", "label.voice.default": "default", "error.call.microphone": "microphone error :c", - "error.voice.reconnect.failed": "voice connection lost :c" + "error.voice.reconnect.failed": "voice connection lost :c", + "error.voice.ticket.rejected": "voice auth failed :c check VOICE_KEY/server version", + "error.voice.not.configured": "voice server not configured :c missing VOICE_KEY" } diff --git a/android/app/src/main/assets/public/blah/en-us.json b/android/app/src/main/assets/public/blah/en-us.json index 79530aeb..fa67cf49 100644 --- a/android/app/src/main/assets/public/blah/en-us.json +++ b/android/app/src/main/assets/public/blah/en-us.json @@ -331,5 +331,7 @@ "label.voice.sensitivity": "Input sensitivity", "label.voice.default": "Default", "error.call.microphone": "Microphone error", - "error.voice.reconnect.failed": "Voice connection lost" + "error.voice.reconnect.failed": "Voice connection lost", + "error.voice.ticket.rejected": "Voice authentication failed - check VOICE_KEY/server version", + "error.voice.not.configured": "Voice server is not configured (missing VOICE_KEY)" } diff --git a/android/app/src/main/assets/public/blah/pl-pl.json b/android/app/src/main/assets/public/blah/pl-pl.json index c951e60e..68bed510 100644 --- a/android/app/src/main/assets/public/blah/pl-pl.json +++ b/android/app/src/main/assets/public/blah/pl-pl.json @@ -331,5 +331,7 @@ "label.voice.sensitivity": "Czułość wejścia", "label.voice.default": "Domyślne", "error.call.microphone": "Błąd mikrofonu", - "error.voice.reconnect.failed": "Utracono połączenie głosowe" + "error.voice.reconnect.failed": "Utracono połączenie głosowe", + "error.voice.ticket.rejected": "Autoryzacja głosu nieudana - sprawdź VOICE_KEY/wersję serwera", + "error.voice.not.configured": "Serwer głosowy nieskonfigurowany (brak VOICE_KEY)" } diff --git a/android/app/src/main/assets/public/version.js b/android/app/src/main/assets/public/version.js index 5b1bb6df..ca181587 100644 --- a/android/app/src/main/assets/public/version.js +++ b/android/app/src/main/assets/public/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.1.3"; +const CLIENT_VERSION = "0.1.4"; diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index f1257296..d4cf5d78 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -40,7 +40,7 @@ let deadPeers = new Set(); let lastVoiceServerPacketAt = 0; let voicePingTimer = null; let voiceReconnectAttempts = 0; -let lastVoiceServerUrl = null; +let voiceSocketOpenedAt = 0; function cleanupPeerMedia(userId) { for (const aid of [userId, `${userId}_screen`]) { @@ -640,34 +640,90 @@ class TaskQueue { clear() { this.queue = []; } } +let voiceJoinInFlight = false; + async function initiateVoiceCall(roomId = null) { - if (currentVoiceWs) { + if (currentVoiceWs || voiceJoinInFlight) { showBlahNotification("error:already.in.call"); return; } currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms"; currentVoiceCallId = roomId || window.currentDmId; - - showAction("action.call.init", "callinit"); + voiceJoinInFlight = true; + + try { + showAction("action.call.init", "callinit"); + const joined = await requestVoiceJoin(); + clearAction("callinit"); + + if (joined) { + await startVoiceSession(joined.serverUrl, joined.ticket); + } + } finally { + voiceJoinInFlight = false; + } +} + +// Asks the main server for a voice server IP plus a fresh single-use ticket. +// The join payload carries publicId/connKey/proof - the client encrypts the +// proof with connKey (AES-GCM) so the wire never contains a usable credential. +async function requestVoiceJoin() { let res = await fetchEncrypted("room/voice/join", JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId })); - clearAction("callinit"); - - if (res.startsWith("success:")) { - let bestIp = res.split("success:")[1]; - if (!bestIp.startsWith("ws://") && !bestIp.startsWith("wss://")) { - bestIp = bestIp.includes("localhost") || bestIp.includes("127.0.0.1") ? "ws://" + bestIp : "wss://" + bestIp; - } - await startVoiceSession(bestIp); - } else { + if (!res.startsWith("success:")) { showBlahNotification(res); + return null; + } + const payload = res.substring("success:".length); + const parts = payload.split("|"); + if (parts.length !== 4) return null; + + let serverUrl = parts[0]; + const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]]; + if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) { + serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl; + } + + try { + const keyBytes = b64uToBytes(connKey); + const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, b64uToBytes(proof))); + const ticket = `${publicId}.${bytesToB64u(iv)}.${bytesToB64u(ct)}`; + return {serverUrl, ticket}; + } catch (e) { + console.warn("Voice: failed to build ticket frame", e); + return null; } } -async function startVoiceSession(serverUrl) { - lastVoiceServerUrl = serverUrl; +function b64uToBytes(s) { + const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/")); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function bytesToB64u(a) { + let bin = ""; + for (const byte of a) bin += String.fromCharCode(byte); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +async function startVoiceSession(serverUrl, ticket) { + if (!ticket) throw new Error("missing voice ticket"); + // Single-session invariant: a stale socket (e.g. a double-fired join) + // must be replaced BEFORE its onopen sends the same ticket twice - + // otherwise the duplicate validation burns the single-use ticket. + if (currentVoiceWs) { + try { currentVoiceWs.onclose = null; currentVoiceWs.close(); } catch (e) {} + currentVoiceWs = null; + } + const myTicket = ticket; // captured PER INVOCATION - the global may be + // overwritten by a racing second join, and reading it inside onopen made + // both sockets send the same (already consumed) ticket. showVoiceUI(); updateVoiceUIUsers(); setVoiceStatus("connecting"); @@ -680,12 +736,17 @@ async function startVoiceSession(serverUrl) { } wsUrl.searchParams.set("room", currentVoiceCallId); wsUrl.searchParams.set("userId", window.id); - wsUrl.searchParams.set("secret", "none"); - + currentVoiceWs = new WebSocket(wsUrl.toString()); currentVoiceWs.binaryType = 'arraybuffer'; - + currentVoiceWs.onopen = async () => { + // Ticket must be the very first frame - the voice server + // authenticates the connection before joining it to the room. + voiceSocketOpenedAt = Date.now(); + console.debug("Voice: sending ticket", String(myTicket).split(".")[0]); + currentVoiceWs.send(myTicket); + setVoiceStatus("connected"); let btn = document.getElementById("voice-btn-mic"); if (btn) { @@ -760,7 +821,7 @@ async function startVoiceSession(serverUrl) { let vadHangover = 0; senderNode.port.onmessage = async (e) => { - if (currentVoiceWs.readyState !== WebSocket.OPEN) return; + if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; // Backpressure valve: when the socket is congested, drop mic // frames instead of queueing them - queued audio would arrive // minutes late and drown the connection for every stream. @@ -933,6 +994,7 @@ async function startVoiceSession(serverUrl) { }; currentVoiceWs.onclose = (ev) => { + console.debug("Voice: socket closed", JSON.stringify({ reason: ev && ev.reason, code: ev && ev.code, aliveForMs: Date.now() - voiceSocketOpenedAt })); // Server closes with reason "Kicked" when another connection took // our userId slot (second device/tab). Reconnecting would start an // infinite kick-war - give up cleanly instead. Any other close @@ -941,6 +1003,14 @@ async function startVoiceSession(serverUrl) { endVoiceSession(); return; } + // Rejected right after opening = ticket/server-key problem. + // Retrying cannot succeed - surface it instead of looping. + if (ev && ev.reason && Date.now() - voiceSocketOpenedAt < 6000) { + console.error("Voice: connection rejected by server:", ev.reason); + showBlahNotification("error:voice.ticket.rejected"); + endVoiceSession(); + return; + } scheduleVoiceReconnect(); }; @@ -1058,10 +1128,12 @@ function showIncomingCallUI(roomId) { let incomingUi = document.createElement("div"); incomingUi.id = "incoming-call-popup"; + // Remote-controlled values (username/avatar) are NEVER interpolated into + // HTML - they are assigned via textContent/src after the skeleton. incomingUi.innerHTML = ` - +
- ${cachedName || "..."} + ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}
@@ -1073,6 +1145,8 @@ function showIncomingCallUI(roomId) {
`; + incomingUi.querySelector(".incoming-call-name").textContent = cachedName || "..."; + incomingUi.querySelector(".incoming-call-avatar").src = cachedPfp || "assets/default_avatar.png"; document.body.appendChild(incomingUi); requestAnimationFrame(() => { requestAnimationFrame(() => incomingUi.classList.add("show")); @@ -2113,7 +2187,7 @@ function teardownVoiceTransport() { } function scheduleVoiceReconnect() { - if (!currentVoiceCallId || !lastVoiceServerUrl) { + if (!currentVoiceCallId || !currentVoiceCallType) { // Real hangup already ran its cleanup. return; } @@ -2126,9 +2200,12 @@ function scheduleVoiceReconnect() { setVoiceStatus("connecting"); teardownVoiceTransport(); setTimeout(async () => { - if (!currentVoiceCallId || !lastVoiceServerUrl) return; + if (!currentVoiceCallId || !currentVoiceCallType) return; try { - await startVoiceSession(lastVoiceServerUrl); + // Tickets are single-use - every reconnect needs a fresh one. + const joined = await requestVoiceJoin(); + if (!joined) { endVoiceSession(); return; } + await startVoiceSession(joined.serverUrl, joined.ticket); } catch (err) { console.warn("Voice reconnect failed", err); scheduleVoiceReconnect(); diff --git a/electron/package.json b/electron/package.json index 54499e4e..4b890f9c 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "Miarven", - "version": "0.1.3", + "version": "0.1.4", "description": "An Amazing Capacitor App", "author": { "name": "", diff --git a/package.json b/package.json index fceed83d..404bb4c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "olcxja.miarven", - "version": "0.1.3", + "version": "0.1.4", "description": "Larpix client", "license": "AGPL-3.0", "author": "olcxja's softworks", diff --git a/version.json b/version.json index 04746cff..e6d29ba7 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "versionCode": 4, - "versionName": "0.1.3", - "npmVersion": "0.1.3" + "versionCode": 5, + "versionName": "0.1.4", + "npmVersion": "0.1.4" } diff --git a/webroot/blah/en-cat.json b/webroot/blah/en-cat.json index 11a435fd..01a784ae 100644 --- a/webroot/blah/en-cat.json +++ b/webroot/blah/en-cat.json @@ -322,5 +322,7 @@ "label.voice.sensitivity": "input sensitivity", "label.voice.default": "default", "error.call.microphone": "microphone error :c", - "error.voice.reconnect.failed": "voice connection lost :c" + "error.voice.reconnect.failed": "voice connection lost :c", + "error.voice.ticket.rejected": "voice auth failed :c check VOICE_KEY/server version", + "error.voice.not.configured": "voice server not configured :c missing VOICE_KEY" } diff --git a/webroot/blah/en-us.json b/webroot/blah/en-us.json index 79530aeb..fa67cf49 100644 --- a/webroot/blah/en-us.json +++ b/webroot/blah/en-us.json @@ -331,5 +331,7 @@ "label.voice.sensitivity": "Input sensitivity", "label.voice.default": "Default", "error.call.microphone": "Microphone error", - "error.voice.reconnect.failed": "Voice connection lost" + "error.voice.reconnect.failed": "Voice connection lost", + "error.voice.ticket.rejected": "Voice authentication failed - check VOICE_KEY/server version", + "error.voice.not.configured": "Voice server is not configured (missing VOICE_KEY)" } diff --git a/webroot/blah/pl-pl.json b/webroot/blah/pl-pl.json index c951e60e..68bed510 100644 --- a/webroot/blah/pl-pl.json +++ b/webroot/blah/pl-pl.json @@ -331,5 +331,7 @@ "label.voice.sensitivity": "Czułość wejścia", "label.voice.default": "Domyślne", "error.call.microphone": "Błąd mikrofonu", - "error.voice.reconnect.failed": "Utracono połączenie głosowe" + "error.voice.reconnect.failed": "Utracono połączenie głosowe", + "error.voice.ticket.rejected": "Autoryzacja głosu nieudana - sprawdź VOICE_KEY/wersję serwera", + "error.voice.not.configured": "Serwer głosowy nieskonfigurowany (brak VOICE_KEY)" } diff --git a/webroot/version.js b/webroot/version.js index 5b1bb6df..ca181587 100644 --- a/webroot/version.js +++ b/webroot/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.1.3"; +const CLIENT_VERSION = "0.1.4"; diff --git a/webroot/voice.js b/webroot/voice.js index f1257296..d4cf5d78 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -40,7 +40,7 @@ let deadPeers = new Set(); let lastVoiceServerPacketAt = 0; let voicePingTimer = null; let voiceReconnectAttempts = 0; -let lastVoiceServerUrl = null; +let voiceSocketOpenedAt = 0; function cleanupPeerMedia(userId) { for (const aid of [userId, `${userId}_screen`]) { @@ -640,34 +640,90 @@ class TaskQueue { clear() { this.queue = []; } } +let voiceJoinInFlight = false; + async function initiateVoiceCall(roomId = null) { - if (currentVoiceWs) { + if (currentVoiceWs || voiceJoinInFlight) { showBlahNotification("error:already.in.call"); return; } currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms"; currentVoiceCallId = roomId || window.currentDmId; - - showAction("action.call.init", "callinit"); + voiceJoinInFlight = true; + + try { + showAction("action.call.init", "callinit"); + const joined = await requestVoiceJoin(); + clearAction("callinit"); + + if (joined) { + await startVoiceSession(joined.serverUrl, joined.ticket); + } + } finally { + voiceJoinInFlight = false; + } +} + +// Asks the main server for a voice server IP plus a fresh single-use ticket. +// The join payload carries publicId/connKey/proof - the client encrypts the +// proof with connKey (AES-GCM) so the wire never contains a usable credential. +async function requestVoiceJoin() { let res = await fetchEncrypted("room/voice/join", JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId })); - clearAction("callinit"); - - if (res.startsWith("success:")) { - let bestIp = res.split("success:")[1]; - if (!bestIp.startsWith("ws://") && !bestIp.startsWith("wss://")) { - bestIp = bestIp.includes("localhost") || bestIp.includes("127.0.0.1") ? "ws://" + bestIp : "wss://" + bestIp; - } - await startVoiceSession(bestIp); - } else { + if (!res.startsWith("success:")) { showBlahNotification(res); + return null; + } + const payload = res.substring("success:".length); + const parts = payload.split("|"); + if (parts.length !== 4) return null; + + let serverUrl = parts[0]; + const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]]; + if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) { + serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl; + } + + try { + const keyBytes = b64uToBytes(connKey); + const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, b64uToBytes(proof))); + const ticket = `${publicId}.${bytesToB64u(iv)}.${bytesToB64u(ct)}`; + return {serverUrl, ticket}; + } catch (e) { + console.warn("Voice: failed to build ticket frame", e); + return null; } } -async function startVoiceSession(serverUrl) { - lastVoiceServerUrl = serverUrl; +function b64uToBytes(s) { + const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/")); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function bytesToB64u(a) { + let bin = ""; + for (const byte of a) bin += String.fromCharCode(byte); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +async function startVoiceSession(serverUrl, ticket) { + if (!ticket) throw new Error("missing voice ticket"); + // Single-session invariant: a stale socket (e.g. a double-fired join) + // must be replaced BEFORE its onopen sends the same ticket twice - + // otherwise the duplicate validation burns the single-use ticket. + if (currentVoiceWs) { + try { currentVoiceWs.onclose = null; currentVoiceWs.close(); } catch (e) {} + currentVoiceWs = null; + } + const myTicket = ticket; // captured PER INVOCATION - the global may be + // overwritten by a racing second join, and reading it inside onopen made + // both sockets send the same (already consumed) ticket. showVoiceUI(); updateVoiceUIUsers(); setVoiceStatus("connecting"); @@ -680,12 +736,17 @@ async function startVoiceSession(serverUrl) { } wsUrl.searchParams.set("room", currentVoiceCallId); wsUrl.searchParams.set("userId", window.id); - wsUrl.searchParams.set("secret", "none"); - + currentVoiceWs = new WebSocket(wsUrl.toString()); currentVoiceWs.binaryType = 'arraybuffer'; - + currentVoiceWs.onopen = async () => { + // Ticket must be the very first frame - the voice server + // authenticates the connection before joining it to the room. + voiceSocketOpenedAt = Date.now(); + console.debug("Voice: sending ticket", String(myTicket).split(".")[0]); + currentVoiceWs.send(myTicket); + setVoiceStatus("connected"); let btn = document.getElementById("voice-btn-mic"); if (btn) { @@ -760,7 +821,7 @@ async function startVoiceSession(serverUrl) { let vadHangover = 0; senderNode.port.onmessage = async (e) => { - if (currentVoiceWs.readyState !== WebSocket.OPEN) return; + if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; // Backpressure valve: when the socket is congested, drop mic // frames instead of queueing them - queued audio would arrive // minutes late and drown the connection for every stream. @@ -933,6 +994,7 @@ async function startVoiceSession(serverUrl) { }; currentVoiceWs.onclose = (ev) => { + console.debug("Voice: socket closed", JSON.stringify({ reason: ev && ev.reason, code: ev && ev.code, aliveForMs: Date.now() - voiceSocketOpenedAt })); // Server closes with reason "Kicked" when another connection took // our userId slot (second device/tab). Reconnecting would start an // infinite kick-war - give up cleanly instead. Any other close @@ -941,6 +1003,14 @@ async function startVoiceSession(serverUrl) { endVoiceSession(); return; } + // Rejected right after opening = ticket/server-key problem. + // Retrying cannot succeed - surface it instead of looping. + if (ev && ev.reason && Date.now() - voiceSocketOpenedAt < 6000) { + console.error("Voice: connection rejected by server:", ev.reason); + showBlahNotification("error:voice.ticket.rejected"); + endVoiceSession(); + return; + } scheduleVoiceReconnect(); }; @@ -1058,10 +1128,12 @@ function showIncomingCallUI(roomId) { let incomingUi = document.createElement("div"); incomingUi.id = "incoming-call-popup"; + // Remote-controlled values (username/avatar) are NEVER interpolated into + // HTML - they are assigned via textContent/src after the skeleton. incomingUi.innerHTML = ` - +
- ${cachedName || "..."} + ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}
@@ -1073,6 +1145,8 @@ function showIncomingCallUI(roomId) {
`; + incomingUi.querySelector(".incoming-call-name").textContent = cachedName || "..."; + incomingUi.querySelector(".incoming-call-avatar").src = cachedPfp || "assets/default_avatar.png"; document.body.appendChild(incomingUi); requestAnimationFrame(() => { requestAnimationFrame(() => incomingUi.classList.add("show")); @@ -2113,7 +2187,7 @@ function teardownVoiceTransport() { } function scheduleVoiceReconnect() { - if (!currentVoiceCallId || !lastVoiceServerUrl) { + if (!currentVoiceCallId || !currentVoiceCallType) { // Real hangup already ran its cleanup. return; } @@ -2126,9 +2200,12 @@ function scheduleVoiceReconnect() { setVoiceStatus("connecting"); teardownVoiceTransport(); setTimeout(async () => { - if (!currentVoiceCallId || !lastVoiceServerUrl) return; + if (!currentVoiceCallId || !currentVoiceCallType) return; try { - await startVoiceSession(lastVoiceServerUrl); + // Tickets are single-use - every reconnect needs a fresh one. + const joined = await requestVoiceJoin(); + if (!joined) { endVoiceSession(); return; } + await startVoiceSession(joined.serverUrl, joined.ticket); } catch (err) { console.warn("Voice reconnect failed", err); scheduleVoiceReconnect();