0.1.4 - voice chat security
All checks were successful
Android Build / publish (push) Successful in 51s
Linux Build / publish (push) Successful in 58s

- improve voice chat security by implementing join validation
- fix XSS in showIncomingCallUI
This commit is contained in:
olcxja 2026-08-24 15:01:18 +02:00
commit cab6314bc7
14 changed files with 229 additions and 63 deletions

View file

@ -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.

View file

@ -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"
}

View file

@ -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)"
}

View file

@ -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)"
}

View file

@ -1 +1 @@
const CLIENT_VERSION = "0.1.3";
const CLIENT_VERSION = "0.1.4";

View file

@ -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;
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 = `
<img class="incoming-call-avatar" src="${cachedPfp || "assets/default_avatar.png"}" alt="">
<img class="incoming-call-avatar" alt="">
<div class="incoming-call-info">
<span class="incoming-call-name">${cachedName || "..."}</span>
<span class="incoming-call-name"></span>
<span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}</span>
</div>
<div class="incoming-call-actions">
@ -1073,6 +1145,8 @@ function showIncomingCallUI(roomId) {
</button>
</div>
`;
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();

View file

@ -1,6 +1,6 @@
{
"name": "Miarven",
"version": "0.1.3",
"version": "0.1.4",
"description": "An Amazing Capacitor App",
"author": {
"name": "",

View file

@ -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",

View file

@ -1,5 +1,5 @@
{
"versionCode": 4,
"versionName": "0.1.3",
"npmVersion": "0.1.3"
"versionCode": 5,
"versionName": "0.1.4",
"npmVersion": "0.1.4"
}

View file

@ -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"
}

View file

@ -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)"
}

View file

@ -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)"
}

View file

@ -1 +1 @@
const CLIENT_VERSION = "0.1.3";
const CLIENT_VERSION = "0.1.4";

View file

@ -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;
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 = `
<img class="incoming-call-avatar" src="${cachedPfp || "assets/default_avatar.png"}" alt="">
<img class="incoming-call-avatar" alt="">
<div class="incoming-call-info">
<span class="incoming-call-name">${cachedName || "..."}</span>
<span class="incoming-call-name"></span>
<span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}</span>
</div>
<div class="incoming-call-actions">
@ -1073,6 +1145,8 @@ function showIncomingCallUI(roomId) {
</button>
</div>
`;
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();