diff --git a/android/app/build.gradle b/android/app/build.gradle index efa09414..7204016e 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 5 - versionName "0.1.4" + versionCode 6 + versionName "0.2.0" 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 01a784ae..d3ffa2ed 100644 --- a/android/app/src/main/assets/public/blah/en-cat.json +++ b/android/app/src/main/assets/public/blah/en-cat.json @@ -233,6 +233,7 @@ "message.reaction.removed": "Purreaction remeowed", "no.invite": "No meowvite", "not.a.member": "Not a meowmber", + "removed.from.group": "You got hissed out of the clowder", "not.in.group": "Not in purroup", "reaction.not.found": "Purreaction not meownd", "rotation.not.required": "Purrotation not meowquired", 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 fa67cf49..fc6eaaf1 100644 --- a/android/app/src/main/assets/public/blah/en-us.json +++ b/android/app/src/main/assets/public/blah/en-us.json @@ -233,6 +233,7 @@ "message.reaction.removed": "Reaction removed", "no.invite": "No invite", "not.a.member": "Not a member", + "removed.from.group": "You were removed from the group", "not.in.group": "Not in group", "reaction.not.found": "Reaction not found", "rotation.not.required": "Rotation not required", 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 68bed510..d8af39cc 100644 --- a/android/app/src/main/assets/public/blah/pl-pl.json +++ b/android/app/src/main/assets/public/blah/pl-pl.json @@ -231,6 +231,7 @@ "message.reaction.removed": "Reakcja usunięta", "no.invite": "Brak zaproszenia", "not.a.member": "Nie jesteś członkiem", + "removed.from.group": "Zostałeś usunięty z grupy", "not.in.group": "Nie jesteś w grupie", "reaction.not.found": "Nie znaleziono reakcji", "rotation.not.required": "Rotacja niewymagana", diff --git a/android/app/src/main/assets/public/login/index.html b/android/app/src/main/assets/public/login/index.html index 111fc1b6..9ef4bf68 100644 --- a/android/app/src/main/assets/public/login/index.html +++ b/android/app/src/main/assets/public/login/index.html @@ -285,7 +285,7 @@ const registerHost = document.getElementById("reg-host"); const registerUsername = document.getElementById("reg-username"); const registerPassword = document.getElementById("reg-password"); - const registerPasswordConfirm = document.getElementById("reg-password"); + const registerPasswordConfirm = document.getElementById("reg-confirm"); const loginHost = document.getElementById("login-host"); const loginUsername = document.getElementById("login-username"); const loginPassword = document.getElementById("login-password"); @@ -382,6 +382,13 @@ var sharedkey = await calcHybridSharedKeyClient(dataarray[0], dataarray[1]); sharedpvkey = sharedkey[2]; createId = dataarray[2]; + //sól z init jest wymagana - hasło zawsze przez PBKDF2 + if (!dataarray[3]) { + showBlahNotification("error:something.wrong"); + authContainer.className = 'auth-container show-register'; + return; + } + let regCredHash = await deriveCredentialKdf(registerPassword.value, dataarray[3]); const captchaimage = await fetch(`${url}/createaccount?step=register`, { method: 'POST', body: JSON.stringify({ @@ -389,7 +396,7 @@ ciphertextMlKem: sharedkey[1], idKey: createId, username: await encrypt(registerUsername.value, sharedpvkey), - password: await encrypt(await hashSHA3_512(registerPassword.value), sharedpvkey) + password: await encrypt(regCredHash, sharedpvkey) }) }); const clone = captchaimage.clone(); diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js index ff092762..fa231a06 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -83,7 +83,9 @@ async function packetEncPass(pass, key, accountId) { async function getNonce(accountId, key) { let nonce; - let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`)).text(); + let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`, { + headers: {"X-Crypto": "g1"} + })).text(); try { nonce = await decryptString(fetchRes, key); @@ -123,7 +125,8 @@ async function calcHybridSharedKeyClient(pubX25519ServerBase64, pubMlKemServerBa function keyDataFromServerJson(jsonFromServer) { const data = JSON.parse(jsonFromServer); - return [data.pubX25519, data.pubMlKem, data.idKey] + //0=pubX25519, 1=pubMlKem, 2=idKey, 3=kdfSalt (wymagany do PBKDF2 przy rejestracji) + return [data.pubX25519, data.pubMlKem, data.idKey, data.kdfSalt] } function base64ToUint8(base64) { @@ -705,25 +708,20 @@ async function decryptBytes(combinedBytes, passphrase) { async function encryptString(plainText, passphrase) { const encoder = new TextEncoder(); - const data = encoder.encode(plainText); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const key = await crypto.subtle.importKey( - 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt'] + 'raw', pwHash, {name: 'AES-GCM'}, false, ['encrypt'] ); - const iv = crypto.getRandomValues(new Uint8Array(16)); - const encrypted = await crypto.subtle.encrypt( - {name: 'AES-CBC', iv: iv}, + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = new Uint8Array(await crypto.subtle.encrypt( + {name: 'AES-GCM', iv: iv}, key, - data - ); + encoder.encode(plainText) + )); - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv); - combined.set(new Uint8Array(encrypted), iv.length); - - return uint8ToBase64(combined); + return "G1:" + uint8ToBase64(concatUint8(iv, encrypted)); } async function encryptBytesWithNonce(dataUint8, key, nonce) { @@ -732,37 +730,44 @@ async function encryptBytesWithNonce(dataUint8, key, nonce) { const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const importedKey = await crypto.subtle.importKey( - 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt'] + 'raw', pwHash, {name: 'AES-GCM'}, false, ['encrypt'] ); - const iv = crypto.getRandomValues(new Uint8Array(16)); - const encrypted = await crypto.subtle.encrypt( - {name: 'AES-CBC', iv: iv}, + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = new Uint8Array(await crypto.subtle.encrypt( + {name: 'AES-GCM', iv: iv}, importedKey, dataUint8 - ); + )); - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv); - combined.set(new Uint8Array(encrypted), iv.length); + const prefix = encoder.encode("G1:"); + const combined = new Uint8Array(prefix.length + iv.length + encrypted.byteLength); + combined.set(prefix, 0); + combined.set(iv, prefix.length); + combined.set(encrypted, prefix.length + iv.length); return combined; } async function decryptString(base64Text, passphrase) { + //tylko AES-GCM ("G1:") - pakiety bez prefiksu odrzucane + if (typeof base64Text !== "string" || !base64Text.startsWith("G1:")) { + throw new Error("error:invalid.cipher.format"); + } + const encoder = new TextEncoder(); - const combined = new Uint8Array(atob(base64Text).split("").map(c => c.charCodeAt(0))); + const combined = base64ToUint8(base64Text.substring(3)); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const key = await crypto.subtle.importKey( - 'raw', pwHash, {name: 'AES-CBC'}, false, ['decrypt'] + 'raw', pwHash, {name: 'AES-GCM'}, false, ['decrypt'] ); - const iv = combined.slice(0, 16); - const data = combined.slice(16); + const iv = combined.slice(0, 12); + const data = combined.slice(12); const decrypted = await crypto.subtle.decrypt( - {name: 'AES-CBC', iv: iv}, + {name: 'AES-GCM', iv: iv}, key, data ); @@ -784,6 +789,7 @@ async function fetchPost(url, value) { method: "POST", body: value, headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); @@ -806,6 +812,7 @@ async function fetchPostEnc(url, value) { method: "POST", body: await encryptWithNonce(value, passwordHash, nonce), headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); @@ -835,6 +842,7 @@ async function fetchAsyncWAuth(url) { let response = await fetch(url, { method: "GET", headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); @@ -864,17 +872,30 @@ async function Auth(loginUsername, loginPassword) { } let actualId = resolvedId.split(":")[0]; - let passwordHash = await hashSHA3_512(loginPassword); + //sól KDF jest wymagana - konta bez soli nie mogą się zalogować + let saltRes = await fetchAsync(`${url}/kdfsalt?id=${actualId}`); + if (!saltRes || !saltRes.startsWith("P:")) { + return "error:invalid.username.or.password"; + } + let credHash = await deriveCredentialKdf(loginPassword, saltRes.substring(2).trim()); + let response = await fetch(`${url}/auth?id=${actualId}`, { method: "GET", headers: { - "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(actualId, passwordHash)) + "X-Crypto": "g1", + "secret": await encryptWithNonce(credHash, credHash, await getNonce(actualId, credHash)) } }); let data = await response.text(); if (data.startsWith("success:")) { data += "|" + actualId; + //utrwal globale sesyjne - dalsze żądania w tej sesji mają od razu ważny hash + passwordHash = credHash; + if (!id) { + id = actualId; + try { localStorage.setItem("id", actualId); } catch (e) {} + } } return data; } finally { @@ -902,6 +923,7 @@ async function fetchEncrypted(request, body = "") { , passwordHash, nonce), headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); @@ -929,6 +951,7 @@ async function fetchEncryptedUpload(target, fileBytesUint8) { method: "POST", body: await encryptBytesWithNonce(fileBytesUint8, passwordHash, nonce), headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce), "target": await encryptWithNonce(target, passwordHash, nonce) } @@ -1046,13 +1069,35 @@ function clearAction(actionid) { } -async function hashSHA3_512(input) { +//solony, wolny KDF - 600k iteracji PBKDF2-SHA256, wyjscie 512 bitow (128 hex) +async function deriveCredentialKdf(passwordPlain, saltB64) { const encoder = new TextEncoder(); - const data = encoder.encode(input); - const hashBuffer = await crypto.subtle.digest('SHA-512', data); //-3 kiedys xddddddddddddddddd - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - return hashHex; + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(passwordPlain), + {name: "PBKDF2"}, + false, + ["deriveBits"] + ); + const bits = await crypto.subtle.deriveBits( + {name: "PBKDF2", hash: "SHA-256", salt: base64ToUint8(saltB64), iterations: 600000}, + keyMaterial, + 512 + ); + return Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +//zwraca null gdy brak konta/soli - celowo NIE rzuca, żeby nie wywalać bootu aplikacji +//(strona logowania i świeży użytkownik nie mają soli; start() sam zrobi Auth i przekieruje) +async function resolveCredentialHash(accountId) { + if (!accountId) return null; + try { + let res = await fetchAsync(`${url}/kdfsalt?id=${accountId}`); + if (res && res.startsWith("P:") && password) { + return await deriveCredentialKdf(password, res.substring(2).trim()); + } + } catch (e) {} + return null; } //placeholder pfp @@ -1402,9 +1447,10 @@ async function mainJS() { } await initBlahs(); - await loadEmotes(); - - passwordHash = await hashSHA3_512(password); + //emotes.json leży w katalogu głównym - na stronie /login fetch by trafił w HTML + if (!window.location.pathname.includes("/login")) { + await loadEmotes(); + } if (host) { await updateProtocolAndUrl(host); @@ -1420,6 +1466,12 @@ async function mainJS() { } } + //hash tylko dla istniejącej sesji; brak soli/konta nie może blokować startu - + //start() i tak wykonuje własny Auth i przekieruje niezalogowanych na login + if (id && password) { + try { passwordHash = await resolveCredentialHash(id); } catch (e) { passwordHash = null; } + } + await start(); } @@ -1873,6 +1925,7 @@ async function openProfile(accountId) { try { if (!accountId) accountId = window.id; //var fallback + accountId = sanitizeId(accountId); if (accountId.endsWith(';' + host)) { accountId = accountId.substring(0, accountId.length - host.length - 1); @@ -1896,7 +1949,7 @@ async function openProfile(accountId) { let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`); if (bio.startsWith("error:")) bio = ""; - let bioHtml = bio && bio.trim() !== "" ? `
${bio.replace(//g, ">")}
` : ""; + let bioHtml = bio && bio.trim() !== "" ? `
${escapeHtml(bio)}
` : ""; let isOwnProfile = accountId === id; let actionBtnHtml = ""; @@ -1992,8 +2045,8 @@ async function openProfile(accountId) {
-
${profileName}
-
${profileName}:${profileHost} (${accountId.split(':')[0].split(';')[0]})
+
${escapeHtml(profileName)}
+
${escapeHtml(profileName)}:${escapeHtml(profileHost)} (${escapeHtml(accountId.split(':')[0].split(';')[0])})
${roleHtml}
${bioHtml} @@ -2129,7 +2182,7 @@ async function renderInvites(res, type) { if (invite.type === "dm") { - let id = invite.id; + let id = sanitizeId(invite.id); if (!id.includes(":")) { id += `:${host}`; @@ -2144,7 +2197,7 @@ async function renderInvites(res, type) { let actions = ""; let desc = ""; - let displayName = username || onlyId; + let displayName = escapeHtml(username || onlyId); if (type === "received") { desc = `:desc.invite.dm.received:${displayName};${onlyId}`; actions = ` @@ -2174,8 +2227,8 @@ async function renderInvites(res, type) { html += entry; } else if (invite.type === "group") { - let groupId = invite.groupId || invite.id; - let targetId = (type === "received") ? invite.sender : invite.receiver; + let groupId = sanitizeId(invite.groupId || invite.id); + let targetId = sanitizeId((type === "received") ? invite.sender : invite.receiver); if (!targetId.includes(":")) { targetId += `:${host}`; @@ -2198,9 +2251,9 @@ async function renderInvites(res, type) { let actions = ""; let desc = ""; - let displayName = username || onlyId; - let val1 = `${displayName}`; - let val2 = `${onlyId}`; + let displayName = escapeHtml(username || onlyId); + let val1 = `${displayName}`; + let val2 = `${escapeHtml(onlyId)}`; if (type === "received") { desc = `:desc.invite.group.received:${val1};${val2}`; @@ -2606,16 +2659,17 @@ async function renderDms(res) { let html = ""; let count = 0; - for (let [dmId, dmData] of dmEntries) { + for (let [dmIdRaw, dmData] of dmEntries) { + let dmId = sanitizeId(dmIdRaw); let parts = dmId.split('_'); let myId = `${id};${host}`; - let targetId = parts[0] === myId ? parts[1] : parts[0]; + let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; let pfp = await getAvatarUrl(targetId, targetUsername); - let displayName = targetUsername.split(':')[0]; + let displayName = escapeHtml(targetUsername.split(':')[0]); let isRead = dmData.string3 === "true"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; @@ -2629,7 +2683,7 @@ async function renderDms(res) {
${displayName} - ${targetUsername} (${targetId.split(';')[0]}) + ${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})
${unreadBadge} @@ -2685,19 +2739,23 @@ async function renderGroups(res) { let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27"); let pfp = await getGroupAvatarUrl(groupId, groupName); + let safeGroupId = sanitizeId(groupId); let unreadBadge = `
`; html += ` - `; count++; + } else if (infoRes === "error:group.not.found") { + //grupa została usunięta - wyczyść nieaktualny wpis z listy + removeRoomFromCachedList(groupId, 'groups'); } } @@ -2743,6 +2801,18 @@ function escapeHtml(unsafe) { .replaceAll(/'/g, "'"); } +//ID-y/identyfikatory wstawiane do atrybutów HTML i inline JS (onclick). +//Usuwa wszystko poza bezpiecznym zestawem znakow - nie da się wyjść z stringa JS ani atrybutu. +//Dozwolone: litery, cyfry oraz _ . ; : @ - +function sanitizeId(value) { + return String(value ?? "").replace(/[^A-Za-z0-9_.;:@-]/g, ""); +} + +//nazwy (grup itp.) wstawiane do treści HTML +function escapeHtmlText(value) { + return escapeHtml(String(value ?? "")); +} + async function generateDmMembersHtml(targetId, targetUsername) { let myIdStr = id + ";" + host; let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`); @@ -2751,24 +2821,28 @@ async function generateDmMembersHtml(targetId, targetUsername) { let targetPfp = await getAvatarUrl(targetId, targetUsername); let targetDisplayName = targetUsername.split(':')[0]; let myDisplayName = myUsername.split(':')[0]; + let safeTargetId = sanitizeId(targetId); + let safeMyId = sanitizeId(myIdStr); + targetDisplayName = escapeHtmlText(targetDisplayName); + let myDisplaySafe = escapeHtmlText(myDisplayName); return `
- -
@@ -2885,7 +2959,7 @@ async function generateGroupMembersHtml(groupId, groupName) { `; @@ -2895,14 +2969,16 @@ async function generateGroupMembersHtml(groupId, groupName) { if (!memberUsername || memberUsername.startsWith("error")) continue; let memberPfp = await getAvatarUrl(memberId, memberUsername); - let memberDisplayName = memberUsername.split(':')[0]; + let memberDisplayName = escapeHtml(memberUsername.split(':')[0]); + let safeMemberId = sanitizeId(memberId); + let safeGroupId = sanitizeId(groupId); html += ` - `; } @@ -2925,6 +3001,16 @@ async function openDm(dmId, username, targetId, type = 'dms') { } if (type === 'dms') { + let keyCheck = await fetchEncrypted("dm/key/get", dmId); + if (keyCheck === "error:forbidden") { + //wpis w liscie do ktorego nie mamy dostepu - usun go i nie otwieraj rozmowy + showBlahNotification(keyCheck); + removeRoomFromCachedList(dmId, 'dms'); + removeRoomButton(sanitizeId(dmId), 'dms'); + refreshDms(0, false); + clearAction("dmopen"); + return; + } currentDmKey = await ensureDmRoomKey(dmId); if (!currentDmKey) { throw new Error("Missing DM room key"); @@ -2936,8 +3022,16 @@ async function openDm(dmId, username, targetId, type = 'dms') { window.currentGroupKeyIndex = info.string4 || "0"; window.currentGroupOwner = info.string7; } else { - window.currentGroupKeyIndex = "0"; - window.currentGroupOwner = ""; + showBlahNotification(infoPayload && infoPayload.startsWith("error:") ? infoPayload : "error:group.not.found"); + //usuń wpis z listy tylko przy deterministycznym błędzie + //(network.error itp. nie może czyścić poprawnych wpisów) + if (infoPayload === "error:group.not.found") { + removeRoomFromCachedList(dmId, 'groups'); + removeRoomButton(sanitizeId(dmId), 'groups'); + refreshGroups(0, false); + } + clearAction("dmopen"); + return; } } @@ -3015,13 +3109,9 @@ async function loadDmMessages(dmId, startOffset = "", isPrepend = false) { if (res && res.startsWith("error:")) { if (res === "error:forbidden" || res === "error:not.a.member" || res === "error:group.not.found") { - if (dmMessagePollInterval) clearInterval(dmMessagePollInterval); - currentDmId = null; - showBlahNotification(res); - if (mainScreen.classList.contains('mobile-details')) { - mainScreen.classList.remove('mobile-details'); - } - switchDetailsContent("title.details", `
desc.no.chat.selected
`); + //brak dostępu do pokoju - serwer usunął też nieaktualny wpis z listy, + //my dodatkowo czyścimy cache/UI i wychodzimy z ekranu rozmowy + await handleLostRoomAccess(dmId, null, res); return; } showBlahNotification(res); @@ -3143,7 +3233,8 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { } } - let attContainerId = `att-container-${msgId}-${att.id}`; + let safeAttId = sanitizeId(att.id); + let attContainerId = `att-container-${sanitizeId(String(msgId))}-${safeAttId}`; let isMedia = att.type && (att.type.startsWith("image/") || att.type.startsWith("video/")); let sizeStr = att.size ? formatBytes(att.size) : ""; let displayName = truncateFilename(att.name, 25); @@ -3154,7 +3245,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { let safeType = encodeURIComponent(att.type).replace(/'/g, "%27"); let safeName = encodeURIComponent(att.name).replace(/'/g, "%27"); - attachmentsHtml += `
+ attachmentsHtml += `
${iconSvg}
${escapeHtml(displayName)}
@@ -3215,11 +3306,11 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { else if (msg.type === "larp.info.pfp") actionStr = "group.pfp.changed.by"; else if (msg.type === "larp.info.pfp_removed") actionStr = "group.pfp.removed.by"; - let clickableAuthor = `${authorName}`; + let clickableAuthor = `${authorName}`; let textStr = processBlah(actionStr); if (msg.type.endsWith(".pin") || msg.type.endsWith(".unpin")) { - let clickableMsg = `${processBlah('title.message.lowercase')}`; + let clickableMsg = `${processBlah('title.message.lowercase')}`; content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableMsg); } else if (msg.type === "larp.info.history" || msg.type === "larp.info.name") { let escapedContent = escapeHtml(content); @@ -3229,7 +3320,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { let targetId = ""; let parts = content.split(':'); if (parts.length >= 2 && parts[0].includes(';')) { - targetId = parts[0]; + targetId = sanitizeId(parts[0]); targetUsername = escapeHtml(parts.slice(1).join(':')); } else { targetUsername = escapeHtml(parts[0]); @@ -3275,7 +3366,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]); } if (dmUsernameCache[msg.author]) { - authorName = dmUsernameCache[msg.author].split(':')[0]; + authorName = escapeHtml(dmUsernameCache[msg.author].split(':')[0]); pfp = dmPfpCache[msg.author]; } } @@ -3355,7 +3446,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { window.replyAuthorCache[respondedId] = sName; let el = document.getElementById(randId); if (el) { - el.innerHTML = `${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${sName}`; + el.innerHTML = `${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${escapeHtml(sName)}`; } } } @@ -3365,7 +3456,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { } let divIdStr = randId !== "" ? `id="${randId}" ` : ""; - repliedHtml = `
${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${replyAuthor}
`; + repliedHtml = `
${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${escapeHtml(replyAuthor)}
`; } let reactionsHtml = ""; @@ -3405,7 +3496,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27"); let escapedRxVal = escapeHtml(rxVal); let pillStyle = `display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.1rem 0.45rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}`; - reactionsHtml += `${processBlah(escapedRxVal)}${rxCounts[rxVal].count > 1 ? `${rxCounts[rxVal].count}` : ""}`; + reactionsHtml += `${processBlah(escapedRxVal)}${rxCounts[rxVal].count > 1 ? `${rxCounts[rxVal].count}` : ""}`; } reactionsHtml += `
`; } @@ -3415,11 +3506,11 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { } html += ` -
- ${showAvatar ? `` : `
`} -
+
+ ${showAvatar ? `` : `
`} +
${showAvatar ? `
- ${authorName} + ${authorName} ${timeStr}
` : ""} ${repliedHtml} @@ -3498,7 +3589,21 @@ async function renderMessages(messages, isPrepend = false) { let newAttachments = newChild.querySelector('.chat-message-attachments'); if (newAttachments) { if (oldAttachments) { - if (oldAttachments.getAttribute('data-raw') !== newAttachments.getAttribute('data-raw')) oldAttachments.innerHTML = newAttachments.innerHTML; + if (oldAttachments.getAttribute('data-raw') !== newAttachments.getAttribute('data-raw')) { + //keep already downloaded/rendered attachment boxes alive across the update, + //and always sync data-raw - otherwise every poll would see a stale attribute + //and re-render (unloading) the attachments forever + let loadedBoxes = {}; + oldAttachments.querySelectorAll('.attachment-box[data-loaded="true"]').forEach(box => { + loadedBoxes[box.id] = box; + }); + oldAttachments.innerHTML = newAttachments.innerHTML; + oldAttachments.setAttribute('data-raw', newAttachments.getAttribute('data-raw')); + for (let boxId in loadedBoxes) { + let freshBox = oldAttachments.querySelector(`#${boxId}`); + if (freshBox) freshBox.replaceWith(loadedBoxes[boxId]); + } + } } else { let contentNode = existingChild.querySelector('.chat-message-content'); if (oldReactions) contentNode.insertBefore(newAttachments, oldReactions); @@ -3756,7 +3861,7 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
-
${content} (${processBlah("info.sending.message")})
+
${escapeHtml(content)} (${processBlah("info.sending.message")})
`; @@ -3788,6 +3893,7 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag res = await fetch(`${url}/dm/attachment/staging?id=${id}`, { method: "POST", headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) }, body: encryptedFileBytes @@ -3991,6 +4097,10 @@ function setupWebSocket() { }, 300); } } + else if (data.startsWith("group_removed:")) { + let removedGroupId = data.substring("group_removed:".length); + handleGroupRemoved(removedGroupId); + } else if (data.startsWith("call_update:")) { if (typeof handleCallUpdate === "function") { handleCallUpdate(data); @@ -4043,7 +4153,7 @@ function handleMessageContextMenu(e, msgId) { if (pinContainer) { let isPinned = msg && msg.pinned === "true"; pinContainer.innerHTML = ` - `; @@ -4459,7 +4569,14 @@ async function changePassword() { if (!newPass) return; showAction("action.account.saving", "accountsave"); - let newPassHash = await hashSHA3_512(newPass); + //sól KDF konta (wymagana; nie zmienia się przy zmianie hasła) + let saltRes = await fetchAsync(`${url}/kdfsalt?id=${window.id}`); + if (!saltRes || !saltRes.startsWith("P:")) { + clearAction("accountsave"); + showBlahNotification("error:kdf.salt.missing"); + return; + } + let newPassHash = await deriveCredentialKdf(newPass, saltRes.substring(2).trim()); let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass); let payload = JSON.stringify({ @@ -4970,6 +5087,91 @@ if (cropperContainer) { }).observe(cropperContainer); } +//Usuwa pokój z cache listy (dms/groups). Zwraca true jeśli coś usunięto. +function removeRoomFromCachedList(roomId, type) { + try { + roomId = String(roomId); + if (type === 'groups') { + if (!window.cachedGroups) return false; + let parsed = JSON.parse(window.cachedGroups); + let removed = false; + if (parsed.groups && parsed.groups[roomId]) { + delete parsed.groups[roomId]; + removed = true; + } + //fallback: wpis może być zapisany z domena albo bez + if (!removed && parsed.groups && !roomId.includes(';')) { + for (let key in parsed.groups) { + if (key.split(';')[0] === roomId) { + delete parsed.groups[key]; + removed = true; + break; + } + } + } + if (removed) window.cachedGroups = JSON.stringify(parsed); + return removed; + } else { + if (!window.cachedDms) return false; + let parsed = JSON.parse(window.cachedDms); + if (parsed.dms && parsed.dms[roomId]) { + delete parsed.dms[roomId]; + window.cachedDms = JSON.stringify(parsed); + return true; + } + } + } catch (e) { + console.error(e); + } + return false; +} + +function removeRoomButton(roomId, type) { + let btn = document.getElementById(`${type === 'groups' ? 'group' : 'dm'}-btn-${sanitizeId(roomId)}`); + if (btn) btn.remove(); +} + +//Serwer informuje, że użytkownik został usunięty z grupy (kick) - +//usuń grupę z listy, a jeśli jest otwarta, wyjdź z ekranu rozmowy. +async function handleGroupRemoved(groupId) { + try { + removeRoomFromCachedList(groupId, 'groups'); + removeRoomButton(sanitizeId(groupId), 'groups'); + if (currentDmId === groupId || (currentDmId && currentDmId.split(';')[0] === groupId.split(';')[0] && window.currentRoomType === 'groups')) { + await handleLostRoomAccess(groupId, 'groups', "info:removed.from.group"); + } + if (typeof refreshGroups === 'function') refreshGroups(0, false); + } catch (e) { + console.error(e); + } +} + +//Pokój do którego straciliśmy dostęp: usuń go z listy i wyjdź z ekranu rozmowy. +async function handleLostRoomAccess(roomId, type = null, notifyKey = null) { + if (!type) type = window.currentRoomType === 'groups' ? 'groups' : 'dms'; + let wasOpen = currentDmId === roomId; + + removeRoomFromCachedList(roomId, type); + removeRoomButton(sanitizeId(roomId), type); + + if (typeof refreshDms === 'function') refreshDms(0, false); + if (typeof refreshGroups === 'function') refreshGroups(0, false); + + if (wasOpen) { + showBlahNotification(notifyKey || (type === 'groups' ? "error:not.a.member" : "error:forbidden")); + if (dmMessagePollInterval) { + clearInterval(dmMessagePollInterval); + dmMessagePollInterval = null; + } + currentDmId = null; + window.currentRoomType = null; + if (mainScreen.classList.contains('mobile-details')) { + mainScreen.classList.remove('mobile-details'); + } + gotoHome(); + } +} + function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) { try { if (type === 'groups') { @@ -5226,6 +5428,9 @@ function handleMemberContextMenu(e, groupId, memberId) { e.preventDefault(); e.stopPropagation(); + groupId = sanitizeId(groupId); + memberId = sanitizeId(memberId); + if (e && e.type === "contextmenu") { touchContextMenuFired = true; setTimeout(() => { touchContextMenuFired = false; }, 500); diff --git a/android/app/src/main/assets/public/style.css b/android/app/src/main/assets/public/style.css index 3247c504..6506b747 100644 --- a/android/app/src/main/assets/public/style.css +++ b/android/app/src/main/assets/public/style.css @@ -1418,10 +1418,16 @@ input:checked + .toggle-slider:before { .voice-resize-se { bottom: 0; right: 0; cursor: nwse-resize; } .voice-resize-sw { bottom: 0; left: 0; cursor: nesw-resize; } +/* + * Kafelki rozmowy - wszystkie mają TEN SAM rozmiar (kwadrat, --voice-tile-size + * ustawiany przez relayoutVoiceTiles() w voice.js). Kafelki płyną obok siebie, + * a gdy się nie mieszczą w rzędzie, flex-wrap przenosi je do następnego. + */ .voice-tile { position: relative; - width: var(--voice-tile-w, 9rem); - height: var(--voice-tile-h, 8rem); + width: var(--voice-tile-size, 9rem); + height: var(--voice-tile-size, 9rem); + flex: 0 0 auto; background: var(--light-border-color); background-size: cover; background-position: center; @@ -1882,10 +1888,10 @@ input:checked + .toggle-slider:before { position: absolute; top: 0; bottom: 0; - width: 2px; - margin-left: -1px; + width: 0.125rem; + margin-left: -0.0625rem; background: rgba(255, 255, 255, 0.65); - border-radius: 1px; + border-radius: 0.0625rem; pointer-events: none; } /* Settings button keeps its hover look while the settings popup is open */ diff --git a/android/app/src/main/assets/public/version.js b/android/app/src/main/assets/public/version.js index ca181587..98bb1f57 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.4"; +const CLIENT_VERSION = "0.2.0"; diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index d4cf5d78..09dd8f0f 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -3,10 +3,99 @@ let currentVoiceCallId = null; let currentVoiceCallType = null; let voiceWsHeartbeat = null; let voiceActiveUsers = []; -let voiceAesKeyBytes = null; -let voiceAesCryptoKey = null; +let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ let voiceUiContainer = null; +// === Gwarantowana unikalność IV (AES-GCM): === +// 1) każdy nadawca szyfruje WŁASNYM podkluczem HKDF(root, "larpix:voice:v1:"+senderId) +// -> kolizja między nadawcami niemożliwa z konstrukcji, +// 2) IV(12B) = epoka(4B = SHA-256(ticketPublicId)[0..4]) || licznik(8B BE, ściśle rosnący) +// -> kolizja w ramach jednego nadawcy niemożliwa w obrębie epoki; każde dołączenie +// do calla ma unikalny ticketPublicId (single-use po stronie serwera), a licznik +// może się resetować TYLKO razem ze zmianą klucza root. +let voiceMySenderCryptoKey = null; // mój podklucz nadawcy (CryptoKey) +const voicePeerSenderKeys = new Map(); // clientIdStr -> Promise odbiorczy +let voiceSendEpoch = null; // Uint8Array(4) +let voiceSendCounter = 0n; // BigInt, monotoniczny w ramach epoki + +//Promise rozstrzygany gdy operacja na kluczu root (ustawienie/wyczyszczenie) +//jest gotowa - pozwala innym kodom czekać zamiast wchodzić w wyścig z derivacją. +let voiceRootKeyReady = Promise.resolve(); + +const VOICE_HKDF_INFO_PREFIX = "larpix:voice:v1:"; + +function voiceHkdfInfoBytes(senderIdStr) { + const prefix = new TextEncoder().encode(VOICE_HKDF_INFO_PREFIX); + const idBytes = new TextEncoder().encode(String(senderIdStr)); + const out = new Uint8Array(prefix.length + idBytes.length); + out.set(prefix, 0); + out.set(idBytes, prefix.length); + return out; +} + +async function deriveVoiceSenderKey(rootBytes, senderIdStr) { + const ikm = await crypto.subtle.importKey("raw", rootBytes, "HKDF", false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits( + {name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: voiceHkdfInfoBytes(senderIdStr)}, + ikm, 256 + ); + return await crypto.subtle.importKey("raw", new Uint8Array(bits), {name: "AES-GCM"}, false, ["encrypt", "decrypt"]); +} + +//Jedyny punkt zmiany klucza root. Reset licznika jest poprawny wyłącznie tutaj - +//nowy root = świeża przestrzeń IV. Null czyści cały stan. +function setVoiceRootKey(bytesOrNull) { + const done = (async () => { + if (!bytesOrNull) { + voiceAesKeyBytes = null; + voiceMySenderCryptoKey = null; + voicePeerSenderKeys.clear(); + return; + } + voicePeerSenderKeys.clear(); + const k = await deriveVoiceSenderKey(bytesOrNull, String(window.id)); + voiceAesKeyBytes = bytesOrNull; + voiceMySenderCryptoKey = k; + })(); + voiceRootKeyReady = done; + return done; +} + +//Epoka z unikalnego per-dołączenie ticketPublicId - deterministycznie, bez dodatkowego losowania. +function setVoiceEpochFromTicket(publicIdB64u) { + crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(publicIdB64u))).then(d => { + voiceSendEpoch = new Uint8Array(d).slice(0, 4); + voiceSendCounter = 0n; + }).catch(() => { voiceSendEpoch = null; voiceSendCounter = 0n; }); +} + +//Jedynne źródło IV do wysyłki. Inkrementacja synchroniczna (single-thread JS = atomiczne). +function nextVoiceIv() { + if (!voiceSendEpoch) throw new Error("voice epoch not ready"); + voiceSendCounter += 1n; + if (voiceSendCounter >= (1n << 63n)) throw new Error("voice iv counter exhausted"); //praktycznie nieosiągalne + const iv = new Uint8Array(12); + iv.set(voiceSendEpoch, 0); + let c = voiceSendCounter; + for (let i = 0; i < 8; i++) { + iv[11 - i] = Number(c & 0xffn); + c >>= 8n; + } + return iv; +} + +//Podklucz odbiorczy danego nadawcy (cache). clientId to dziesiętny string ulonga - +//dokładnie ta sama forma, której nadawca użył przy derivacji własnego klucza. +function getVoicePeerSenderKey(clientIdStr) { + let p = voicePeerSenderKeys.get(clientIdStr); + if (!p && voiceAesKeyBytes) { + p = deriveVoiceSenderKey(voiceAesKeyBytes, clientIdStr); + voicePeerSenderKeys.set(clientIdStr, p); + } + return p || null; +} + + let micStream = null, audioContext = null, isAudioWorkletLoaded = false; let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null; let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {}; @@ -39,6 +128,8 @@ let peerLastSeen = {}; let deadPeers = new Set(); let lastVoiceServerPacketAt = 0; let voicePingTimer = null; +let voiceRosterRetryTimer = null; +let lastVoiceKeyRequestAt = 0; let voiceReconnectAttempts = 0; let voiceSocketOpenedAt = 0; @@ -61,9 +152,9 @@ function cleanupPeerMedia(userId) { } function sendVoicePing() { - if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; - const iv = crypto.getRandomValues(new Uint8Array(12)); - crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => { + if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; + const iv = nextVoiceIv(); + crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => { if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); @@ -530,6 +621,16 @@ setInterval(() => { } else if (audioContext && audioContext.state === 'suspended') { audioContext.resume().catch(() => {}); } + // Safety net for the call-key handshake: if others are in the call but + // we never received the root key (raced/lost signal), ask for it + // instead of staying deaf until someone refreshes the page. + if (others > 0 && !voiceAesKeyBytes && now - lastVoiceKeyRequestAt > 4000) { + lastVoiceKeyRequestAt = now; + console.debug("Voice: requesting call key from peers"); + for (const u of voiceActiveUsers) { + if (u !== String(window.id)) sendVoiceSignalToUser(u, currentVoiceCallId, true); + } + } } if (changed) updateVoiceUIUsers(); }, 2000); @@ -682,6 +783,8 @@ async function requestVoiceJoin() { let serverUrl = parts[0]; const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]]; + //epoka IV z unikalnego (single-use) publicId - każde dołączenie = rozłączna przestrzeń IV + setVoiceEpochFromTicket(publicId); if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) { serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl; } @@ -780,7 +883,21 @@ async function startVoiceSession(serverUrl, ticket) { } catch (e) {} }; fetchUsers(); - voiceWsHeartbeat = setInterval(fetchUsers, 5000); + //The voice-server roster is joined only AFTER ticket validation (an + //HTTP roundtrip to the main server), so the immediate fetch can still + //miss us - retry quickly until our own tile shows up instead of + //staring at an empty call for the first heartbeat. + if (voiceRosterRetryTimer) clearInterval(voiceRosterRetryTimer); + let rosterTries = 0; + voiceRosterRetryTimer = setInterval(() => { + if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || + voiceActiveUsers.includes(String(window.id)) || ++rosterTries > 12) { + clearInterval(voiceRosterRetryTimer); + voiceRosterRetryTimer = null; + return; + } + fetchUsers(); + }, 400); voiceReconnectAttempts = 0; lastVoiceServerPacketAt = Date.now(); @@ -847,9 +964,9 @@ async function startVoiceSession(serverUrl, ticket) { if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)'; if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; - if (!voiceAesCryptoKey) return; - const iv = crypto.getRandomValues(new Uint8Array(12)); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, audioData); + if (!voiceMySenderCryptoKey) return; + const iv = nextVoiceIv(); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 0; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); @@ -886,7 +1003,10 @@ async function startVoiceSession(serverUrl, ticket) { const iv = new Uint8Array(arrayBuffer, 10, 12); const ciphertext = new Uint8Array(arrayBuffer, 22); - if (!voiceAesCryptoKey) return; + if (!voiceAesKeyBytes) return; + + //podklucz nadawcy - deterministyczny z (root, clientId) + const senderKeyPromise = getVoicePeerSenderKey(clientId); if (type === 0 || type === 4) { const isScreenAudio = (type === 4); @@ -899,7 +1019,9 @@ async function startVoiceSession(serverUrl, ticket) { const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode; audioDecryptChains[audioId].enqueue(async () => { try { - const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext); + const senderKey = await senderKeyPromise; + if (!senderKey) return; + const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); @@ -911,7 +1033,9 @@ async function startVoiceSession(serverUrl, ticket) { videoDecryptChains[decoderId].enqueue(async () => { try { - const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext); + const senderKey = await senderKeyPromise; + if (!senderKey) return; + const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); if (!remoteVideos[decoderId]) { const canvas = document.createElement('canvas'); @@ -971,8 +1095,12 @@ async function startVoiceSession(serverUrl, ticket) { } catch (err) {} }); } else if (type === 3) { - crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext).then(decrypted => { - const stoppedTypeId = new Uint8Array(decrypted)[0]; + (async () => { + try { + const senderKey = await senderKeyPromise; + if (!senderKey) return; + const decrypted = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); + const stoppedTypeId = new Uint8Array(decrypted)[0]; if (stoppedTypeId === 4) { const audioId = `${clientId}_screen`; if (remoteAudio[audioId]) { @@ -989,7 +1117,8 @@ async function startVoiceSession(serverUrl, ticket) { updateVoiceUIUsers(); } } - }).catch(() => {}); + } catch (err) {} + })(); } }; @@ -1021,9 +1150,12 @@ async function startVoiceSession(serverUrl, ticket) { } } -function handleCallUpdate(data) { +async function handleCallUpdate(data) { let parts = data.split(":"); let roomId = parts[1]; + //Ignore updates for rooms we have nothing to do with - they would otherwise + //overwrite the participant list of an ongoing call and wipe its tiles. + if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return; let state = []; if(parts.slice(2).join(":") !== "") { let stateRaw = JSON.parse(parts.slice(2).join(":")); @@ -1034,10 +1166,13 @@ function handleCallUpdate(data) { if (currentVoiceCallId === roomId) { let myIdStr = String(window.id); if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) { - voiceAesKeyBytes = crypto.getRandomValues(new Uint8Array(32)); - crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]) - .then(k => voiceAesCryptoKey = k); + setVoiceRootKey(crypto.getRandomValues(new Uint8Array(32))); } + //The root key is set asynchronously - wait for it before distributing. + //Otherwise a fast second join produces [me,peer] while the derivation + //is still running, no signal is sent and the peer never gets the key + //(audio stayed broken for everyone until someone refreshed the page). + await voiceRootKeyReady; if (voiceAesKeyBytes) { for (let u of state) { if (u !== myIdStr) sendVoiceSignalToUser(u, roomId); @@ -1120,7 +1255,7 @@ async function resolveCallerInfo(roomId) { } function showIncomingCallUI(roomId) { - if (document.getElementById("incoming-call-popup") || currentVoiceWs) return; + if (document.getElementById("incoming-call-popup") || currentVoiceWs || voiceJoinInFlight) return; let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0]; @@ -1174,18 +1309,24 @@ function joinIncomingCall(roomId) { initiateVoiceCall(roomId); } -async function sendVoiceSignalToUser(targetUserId, roomId) { +async function sendVoiceSignalToUser(targetUserId, roomId, requestData = false) { try { - let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`); - let publicKeys = JSON.parse(publicKeysRaw); - let hybrid = await deriveHybridKeyToUser(publicKeys); - - let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes); - let setupPayloadJson = JSON.stringify({ - v: 1, - ctMlKem: hybrid.ciphertextMlKemBase64, - enc: encForMember - }); + let setupPayloadJson; + if (!requestData && voiceAesKeyBytes) { + let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`); + let publicKeys = JSON.parse(publicKeysRaw); + let hybrid = await deriveHybridKeyToUser(publicKeys); + + let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes); + setupPayloadJson = JSON.stringify({ + v: 1, + ctMlKem: hybrid.ciphertextMlKemBase64, + enc: encForMember + }); + } else { + //Prośba o klucz rozmowy (bez sekretów) - adresat z kluczem odpowie pełnym sygnałem. + setupPayloadJson = JSON.stringify({ v: 1, needKey: true }); + } let payload = JSON.stringify({ string1: currentVoiceCallType, @@ -1204,15 +1345,22 @@ async function handleRoomSignal(data) { let signalStr = parts.slice(2).join(":"); let signalObj = JSON.parse(signalStr); - if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId && !voiceAesKeyBytes) { + if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId) { try { let setupData = JSON.parse(signalObj.data); - let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`); - let otherPublicKeysObj = JSON.parse(publicKeysRaw); - let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem); - let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes); - voiceAesKeyBytes = base64ToUint8(decRoomKeyB64); - voiceAesCryptoKey = await crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]); + if (setupData.needKey) { + //Peer nie dostał klucza rozmowy (wygryziony/zagubiony sygnał) - + //odeślij mu go, ale tylko jeśli sami już go mamy. + if (voiceAesKeyBytes) await sendVoiceSignalToUser(signalObj.from, roomId); + return; + } + if (!voiceAesKeyBytes) { + let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`); + let otherPublicKeysObj = JSON.parse(publicKeysRaw); + let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem); + let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes); + await setVoiceRootKey(base64ToUint8(decRoomKeyB64)); + } } catch(e) { console.error("Signal decryption failed", e); } @@ -2038,6 +2186,12 @@ function ensureVoiceGridObserver() { voiceGridRO.observe(container); } +/* + * Wszystkie kafelki to kwadraty o wspólnym rozmiarze --voice-tile-size. + * Wybieramy największy rozmiar kwadratu, przy którym n kafelków mieści się + * w oknie rozmowy - układanie i zawijanie do następnych rzędów robi sam + * flex-wrap w CSS (justify-content: center). + */ function relayoutVoiceTiles() { let container = document.getElementById("voice-users"); if (!container || !container.isConnected) return; @@ -2052,28 +2206,34 @@ function relayoutVoiceTiles() { const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); if (!isFinite(innerW) || !isFinite(innerH) || !isFinite(rootFs) || innerW <= 0 || innerH <= 0 || rootFs <= 0) { - container.style.removeProperty("--voice-tile-w"); - container.style.removeProperty("--voice-tile-h"); + container.style.removeProperty("--voice-tile-size"); delete container.dataset.layoutSig; return; } - const minCol = 6.25 * rootFs; - const minRow = 7 * rootFs; const gap = 0.75 * rootFs; + const maxSize = 18 * rootFs; // jedna osoba nie dostanie gigantycznego kafelka + const minSize = 4.5 * rootFs; // poniżej tego wolimy scrollować niż spłaszczać - const maxCols = Math.max(1, Math.floor((innerW + gap) / (minCol + gap))); - const cols = Math.min(n, maxCols); - const rows = Math.ceil(n / cols); - const w = Math.floor((innerW - gap * (cols - 1)) / cols); - const h = Math.floor(Math.max(minRow, (innerH - gap * (rows - 1)) / rows)); + //Największy kwadrat, przy którym n kafelków mieści się w dostępnej powierzchni: + //próbujemy liczby kolumn 1..n, dla każdej wyliczamy ograniczony rozmiar + //(szerokość kolumny vs wysokość rzędu) i bierzemy najlepszy wariant. + let best = 0; + for (let cols = 1; cols <= n; cols++) { + const rows = Math.ceil(n / cols); + const s = Math.min( + (innerW - gap * (cols - 1)) / cols, + (innerH - gap * (rows - 1)) / rows + ); + if (s > best) best = s; + } - const sig = `${n}:${cols}:${w}x${h}`; + const size = Math.min(maxSize, Math.max(minSize, Math.floor(best))); + const sig = `${n}:${size}`; if (container.dataset.layoutSig === sig) return; container.dataset.layoutSig = sig; - container.style.setProperty("--voice-tile-w", w + "px"); - container.style.setProperty("--voice-tile-h", h + "px"); + container.style.setProperty("--voice-tile-size", size + "px"); } let voiceSettingsState = null; @@ -2179,10 +2339,16 @@ function teardownVoiceTransport() { audioDecryptChains = {}; peerLastSeen = {}; deadPeers.clear(); + if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } + lastVoiceKeyRequestAt = 0; + //Stale participants from the previous attempt must not linger in the UI + voiceActiveUsers = []; + updateVoiceUIUsers(); if (receiverNode) receiverNode.port.postMessage({action: 'clear'}); if (screenReceiverNode) screenReceiverNode.port.postMessage({action: 'clear'}); - voiceAesKeyBytes = null; - voiceAesCryptoKey = null; + setVoiceRootKey(null); + voiceSendEpoch = null; + voiceSendCounter = 0n; lastVoiceServerPacketAt = Date.now(); } @@ -2243,8 +2409,11 @@ function endVoiceSession() { if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } + if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } peerLastSeen = {}; deadPeers.clear(); + lastVoiceKeyRequestAt = 0; + voiceActiveUsers = []; for (let id in audioDecoders) { try { audioDecoders[id].close(); } catch(e){} @@ -2275,8 +2444,9 @@ function endVoiceSession() { remoteVideos = {}; videoDecryptChains = {}; - voiceAesKeyBytes = null; - voiceAesCryptoKey = null; + setVoiceRootKey(null); + voiceSendEpoch = null; + voiceSendCounter = 0n; currentVoiceWs = null; currentVoiceCallType = null; currentVoiceCallId = null; @@ -2322,11 +2492,11 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { screenAudioSourceNode = audioContext.createMediaStreamSource(stream); screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender'); screenSenderNode.port.onmessage = async (e) => { - if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; + if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; // Same backpressure valve as the mic sender. if (currentVoiceWs.bufferedAmount > 256 * 1024) return; - const iv = crypto.getRandomValues(new Uint8Array(12)); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, e.data.audioData); + const iv = nextVoiceIv(); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData); const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 4; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); @@ -2357,10 +2527,10 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0; const encoder = new VideoEncoder({ output: async (chunk) => { - if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; + if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; const buffer = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buffer); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer); + const iv = nextVoiceIv(); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer); const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); @@ -2412,9 +2582,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { updateVoiceUIUsers(); btnEl.classList.remove('voice-btn-active'); - if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) { - const buffer = new Uint8Array([typeId]); const iv = crypto.getRandomValues(new Uint8Array(12)); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer); + if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceMySenderCryptoKey) { + const buffer = new Uint8Array([typeId]); const iv = nextVoiceIv(); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer); const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 3; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); } @@ -2423,9 +2593,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { if (screenAudioSourceNode) { screenAudioSourceNode.disconnect(); screenAudioSourceNode = null; } if (screenSenderNode) { screenSenderNode.disconnect(); screenSenderNode = null; } if (screenAudioEncoder) { try { screenAudioEncoder.close(); } catch (e) {} screenAudioEncoder = null; } - if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) { - const bufferAudioInfo = new Uint8Array([4]); const ivA = crypto.getRandomValues(new Uint8Array(12)); - const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceAesCryptoKey, bufferAudioInfo); + if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceMySenderCryptoKey) { + const bufferAudioInfo = new Uint8Array([4]); const ivA = nextVoiceIv(); + const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceMySenderCryptoKey, bufferAudioInfo); const payloadA = new Uint8Array(14 + cipherA.byteLength); payloadA[0] = 3; payloadA[1] = 0; payloadA.set(ivA, 2); payloadA.set(new Uint8Array(cipherA), 14); currentVoiceWs.send(payloadA.buffer); } diff --git a/electron/package.json b/electron/package.json index 4b890f9c..5d883df0 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "Miarven", - "version": "0.1.4", + "version": "0.2.0", "description": "An Amazing Capacitor App", "author": { "name": "", diff --git a/package.json b/package.json index 404bb4c8..53c7193b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "olcxja.miarven", - "version": "0.1.4", + "version": "0.2.0", "description": "Larpix client", "license": "AGPL-3.0", "author": "olcxja's softworks", diff --git a/version.json b/version.json index e6d29ba7..756f31ec 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "versionCode": 5, - "versionName": "0.1.4", - "npmVersion": "0.1.4" + "versionCode": 6, + "versionName": "0.2.0", + "npmVersion": "0.2.0" } diff --git a/webroot/blah/en-cat.json b/webroot/blah/en-cat.json index 01a784ae..d3ffa2ed 100644 --- a/webroot/blah/en-cat.json +++ b/webroot/blah/en-cat.json @@ -233,6 +233,7 @@ "message.reaction.removed": "Purreaction remeowed", "no.invite": "No meowvite", "not.a.member": "Not a meowmber", + "removed.from.group": "You got hissed out of the clowder", "not.in.group": "Not in purroup", "reaction.not.found": "Purreaction not meownd", "rotation.not.required": "Purrotation not meowquired", diff --git a/webroot/blah/en-us.json b/webroot/blah/en-us.json index fa67cf49..fc6eaaf1 100644 --- a/webroot/blah/en-us.json +++ b/webroot/blah/en-us.json @@ -233,6 +233,7 @@ "message.reaction.removed": "Reaction removed", "no.invite": "No invite", "not.a.member": "Not a member", + "removed.from.group": "You were removed from the group", "not.in.group": "Not in group", "reaction.not.found": "Reaction not found", "rotation.not.required": "Rotation not required", diff --git a/webroot/blah/pl-pl.json b/webroot/blah/pl-pl.json index 68bed510..d8af39cc 100644 --- a/webroot/blah/pl-pl.json +++ b/webroot/blah/pl-pl.json @@ -231,6 +231,7 @@ "message.reaction.removed": "Reakcja usunięta", "no.invite": "Brak zaproszenia", "not.a.member": "Nie jesteś członkiem", + "removed.from.group": "Zostałeś usunięty z grupy", "not.in.group": "Nie jesteś w grupie", "reaction.not.found": "Nie znaleziono reakcji", "rotation.not.required": "Rotacja niewymagana", diff --git a/webroot/login/index.html b/webroot/login/index.html index 111fc1b6..9ef4bf68 100644 --- a/webroot/login/index.html +++ b/webroot/login/index.html @@ -285,7 +285,7 @@ const registerHost = document.getElementById("reg-host"); const registerUsername = document.getElementById("reg-username"); const registerPassword = document.getElementById("reg-password"); - const registerPasswordConfirm = document.getElementById("reg-password"); + const registerPasswordConfirm = document.getElementById("reg-confirm"); const loginHost = document.getElementById("login-host"); const loginUsername = document.getElementById("login-username"); const loginPassword = document.getElementById("login-password"); @@ -382,6 +382,13 @@ var sharedkey = await calcHybridSharedKeyClient(dataarray[0], dataarray[1]); sharedpvkey = sharedkey[2]; createId = dataarray[2]; + //sól z init jest wymagana - hasło zawsze przez PBKDF2 + if (!dataarray[3]) { + showBlahNotification("error:something.wrong"); + authContainer.className = 'auth-container show-register'; + return; + } + let regCredHash = await deriveCredentialKdf(registerPassword.value, dataarray[3]); const captchaimage = await fetch(`${url}/createaccount?step=register`, { method: 'POST', body: JSON.stringify({ @@ -389,7 +396,7 @@ ciphertextMlKem: sharedkey[1], idKey: createId, username: await encrypt(registerUsername.value, sharedpvkey), - password: await encrypt(await hashSHA3_512(registerPassword.value), sharedpvkey) + password: await encrypt(regCredHash, sharedpvkey) }) }); const clone = captchaimage.clone(); diff --git a/webroot/main.js b/webroot/main.js index ff092762..fa231a06 100644 --- a/webroot/main.js +++ b/webroot/main.js @@ -83,7 +83,9 @@ async function packetEncPass(pass, key, accountId) { async function getNonce(accountId, key) { let nonce; - let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`)).text(); + let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`, { + headers: {"X-Crypto": "g1"} + })).text(); try { nonce = await decryptString(fetchRes, key); @@ -123,7 +125,8 @@ async function calcHybridSharedKeyClient(pubX25519ServerBase64, pubMlKemServerBa function keyDataFromServerJson(jsonFromServer) { const data = JSON.parse(jsonFromServer); - return [data.pubX25519, data.pubMlKem, data.idKey] + //0=pubX25519, 1=pubMlKem, 2=idKey, 3=kdfSalt (wymagany do PBKDF2 przy rejestracji) + return [data.pubX25519, data.pubMlKem, data.idKey, data.kdfSalt] } function base64ToUint8(base64) { @@ -705,25 +708,20 @@ async function decryptBytes(combinedBytes, passphrase) { async function encryptString(plainText, passphrase) { const encoder = new TextEncoder(); - const data = encoder.encode(plainText); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const key = await crypto.subtle.importKey( - 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt'] + 'raw', pwHash, {name: 'AES-GCM'}, false, ['encrypt'] ); - const iv = crypto.getRandomValues(new Uint8Array(16)); - const encrypted = await crypto.subtle.encrypt( - {name: 'AES-CBC', iv: iv}, + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = new Uint8Array(await crypto.subtle.encrypt( + {name: 'AES-GCM', iv: iv}, key, - data - ); + encoder.encode(plainText) + )); - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv); - combined.set(new Uint8Array(encrypted), iv.length); - - return uint8ToBase64(combined); + return "G1:" + uint8ToBase64(concatUint8(iv, encrypted)); } async function encryptBytesWithNonce(dataUint8, key, nonce) { @@ -732,37 +730,44 @@ async function encryptBytesWithNonce(dataUint8, key, nonce) { const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const importedKey = await crypto.subtle.importKey( - 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt'] + 'raw', pwHash, {name: 'AES-GCM'}, false, ['encrypt'] ); - const iv = crypto.getRandomValues(new Uint8Array(16)); - const encrypted = await crypto.subtle.encrypt( - {name: 'AES-CBC', iv: iv}, + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = new Uint8Array(await crypto.subtle.encrypt( + {name: 'AES-GCM', iv: iv}, importedKey, dataUint8 - ); + )); - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv); - combined.set(new Uint8Array(encrypted), iv.length); + const prefix = encoder.encode("G1:"); + const combined = new Uint8Array(prefix.length + iv.length + encrypted.byteLength); + combined.set(prefix, 0); + combined.set(iv, prefix.length); + combined.set(encrypted, prefix.length + iv.length); return combined; } async function decryptString(base64Text, passphrase) { + //tylko AES-GCM ("G1:") - pakiety bez prefiksu odrzucane + if (typeof base64Text !== "string" || !base64Text.startsWith("G1:")) { + throw new Error("error:invalid.cipher.format"); + } + const encoder = new TextEncoder(); - const combined = new Uint8Array(atob(base64Text).split("").map(c => c.charCodeAt(0))); + const combined = base64ToUint8(base64Text.substring(3)); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const key = await crypto.subtle.importKey( - 'raw', pwHash, {name: 'AES-CBC'}, false, ['decrypt'] + 'raw', pwHash, {name: 'AES-GCM'}, false, ['decrypt'] ); - const iv = combined.slice(0, 16); - const data = combined.slice(16); + const iv = combined.slice(0, 12); + const data = combined.slice(12); const decrypted = await crypto.subtle.decrypt( - {name: 'AES-CBC', iv: iv}, + {name: 'AES-GCM', iv: iv}, key, data ); @@ -784,6 +789,7 @@ async function fetchPost(url, value) { method: "POST", body: value, headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); @@ -806,6 +812,7 @@ async function fetchPostEnc(url, value) { method: "POST", body: await encryptWithNonce(value, passwordHash, nonce), headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); @@ -835,6 +842,7 @@ async function fetchAsyncWAuth(url) { let response = await fetch(url, { method: "GET", headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); @@ -864,17 +872,30 @@ async function Auth(loginUsername, loginPassword) { } let actualId = resolvedId.split(":")[0]; - let passwordHash = await hashSHA3_512(loginPassword); + //sól KDF jest wymagana - konta bez soli nie mogą się zalogować + let saltRes = await fetchAsync(`${url}/kdfsalt?id=${actualId}`); + if (!saltRes || !saltRes.startsWith("P:")) { + return "error:invalid.username.or.password"; + } + let credHash = await deriveCredentialKdf(loginPassword, saltRes.substring(2).trim()); + let response = await fetch(`${url}/auth?id=${actualId}`, { method: "GET", headers: { - "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(actualId, passwordHash)) + "X-Crypto": "g1", + "secret": await encryptWithNonce(credHash, credHash, await getNonce(actualId, credHash)) } }); let data = await response.text(); if (data.startsWith("success:")) { data += "|" + actualId; + //utrwal globale sesyjne - dalsze żądania w tej sesji mają od razu ważny hash + passwordHash = credHash; + if (!id) { + id = actualId; + try { localStorage.setItem("id", actualId); } catch (e) {} + } } return data; } finally { @@ -902,6 +923,7 @@ async function fetchEncrypted(request, body = "") { , passwordHash, nonce), headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); @@ -929,6 +951,7 @@ async function fetchEncryptedUpload(target, fileBytesUint8) { method: "POST", body: await encryptBytesWithNonce(fileBytesUint8, passwordHash, nonce), headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce), "target": await encryptWithNonce(target, passwordHash, nonce) } @@ -1046,13 +1069,35 @@ function clearAction(actionid) { } -async function hashSHA3_512(input) { +//solony, wolny KDF - 600k iteracji PBKDF2-SHA256, wyjscie 512 bitow (128 hex) +async function deriveCredentialKdf(passwordPlain, saltB64) { const encoder = new TextEncoder(); - const data = encoder.encode(input); - const hashBuffer = await crypto.subtle.digest('SHA-512', data); //-3 kiedys xddddddddddddddddd - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - return hashHex; + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(passwordPlain), + {name: "PBKDF2"}, + false, + ["deriveBits"] + ); + const bits = await crypto.subtle.deriveBits( + {name: "PBKDF2", hash: "SHA-256", salt: base64ToUint8(saltB64), iterations: 600000}, + keyMaterial, + 512 + ); + return Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +//zwraca null gdy brak konta/soli - celowo NIE rzuca, żeby nie wywalać bootu aplikacji +//(strona logowania i świeży użytkownik nie mają soli; start() sam zrobi Auth i przekieruje) +async function resolveCredentialHash(accountId) { + if (!accountId) return null; + try { + let res = await fetchAsync(`${url}/kdfsalt?id=${accountId}`); + if (res && res.startsWith("P:") && password) { + return await deriveCredentialKdf(password, res.substring(2).trim()); + } + } catch (e) {} + return null; } //placeholder pfp @@ -1402,9 +1447,10 @@ async function mainJS() { } await initBlahs(); - await loadEmotes(); - - passwordHash = await hashSHA3_512(password); + //emotes.json leży w katalogu głównym - na stronie /login fetch by trafił w HTML + if (!window.location.pathname.includes("/login")) { + await loadEmotes(); + } if (host) { await updateProtocolAndUrl(host); @@ -1420,6 +1466,12 @@ async function mainJS() { } } + //hash tylko dla istniejącej sesji; brak soli/konta nie może blokować startu - + //start() i tak wykonuje własny Auth i przekieruje niezalogowanych na login + if (id && password) { + try { passwordHash = await resolveCredentialHash(id); } catch (e) { passwordHash = null; } + } + await start(); } @@ -1873,6 +1925,7 @@ async function openProfile(accountId) { try { if (!accountId) accountId = window.id; //var fallback + accountId = sanitizeId(accountId); if (accountId.endsWith(';' + host)) { accountId = accountId.substring(0, accountId.length - host.length - 1); @@ -1896,7 +1949,7 @@ async function openProfile(accountId) { let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`); if (bio.startsWith("error:")) bio = ""; - let bioHtml = bio && bio.trim() !== "" ? `
${bio.replace(//g, ">")}
` : ""; + let bioHtml = bio && bio.trim() !== "" ? `
${escapeHtml(bio)}
` : ""; let isOwnProfile = accountId === id; let actionBtnHtml = ""; @@ -1992,8 +2045,8 @@ async function openProfile(accountId) {
-
${profileName}
-
${profileName}:${profileHost} (${accountId.split(':')[0].split(';')[0]})
+
${escapeHtml(profileName)}
+
${escapeHtml(profileName)}:${escapeHtml(profileHost)} (${escapeHtml(accountId.split(':')[0].split(';')[0])})
${roleHtml}
${bioHtml} @@ -2129,7 +2182,7 @@ async function renderInvites(res, type) { if (invite.type === "dm") { - let id = invite.id; + let id = sanitizeId(invite.id); if (!id.includes(":")) { id += `:${host}`; @@ -2144,7 +2197,7 @@ async function renderInvites(res, type) { let actions = ""; let desc = ""; - let displayName = username || onlyId; + let displayName = escapeHtml(username || onlyId); if (type === "received") { desc = `:desc.invite.dm.received:${displayName};${onlyId}`; actions = ` @@ -2174,8 +2227,8 @@ async function renderInvites(res, type) { html += entry; } else if (invite.type === "group") { - let groupId = invite.groupId || invite.id; - let targetId = (type === "received") ? invite.sender : invite.receiver; + let groupId = sanitizeId(invite.groupId || invite.id); + let targetId = sanitizeId((type === "received") ? invite.sender : invite.receiver); if (!targetId.includes(":")) { targetId += `:${host}`; @@ -2198,9 +2251,9 @@ async function renderInvites(res, type) { let actions = ""; let desc = ""; - let displayName = username || onlyId; - let val1 = `${displayName}`; - let val2 = `${onlyId}`; + let displayName = escapeHtml(username || onlyId); + let val1 = `${displayName}`; + let val2 = `${escapeHtml(onlyId)}`; if (type === "received") { desc = `:desc.invite.group.received:${val1};${val2}`; @@ -2606,16 +2659,17 @@ async function renderDms(res) { let html = ""; let count = 0; - for (let [dmId, dmData] of dmEntries) { + for (let [dmIdRaw, dmData] of dmEntries) { + let dmId = sanitizeId(dmIdRaw); let parts = dmId.split('_'); let myId = `${id};${host}`; - let targetId = parts[0] === myId ? parts[1] : parts[0]; + let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; let pfp = await getAvatarUrl(targetId, targetUsername); - let displayName = targetUsername.split(':')[0]; + let displayName = escapeHtml(targetUsername.split(':')[0]); let isRead = dmData.string3 === "true"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; @@ -2629,7 +2683,7 @@ async function renderDms(res) {
${displayName} - ${targetUsername} (${targetId.split(';')[0]}) + ${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})
${unreadBadge} @@ -2685,19 +2739,23 @@ async function renderGroups(res) { let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27"); let pfp = await getGroupAvatarUrl(groupId, groupName); + let safeGroupId = sanitizeId(groupId); let unreadBadge = `
`; html += ` - `; count++; + } else if (infoRes === "error:group.not.found") { + //grupa została usunięta - wyczyść nieaktualny wpis z listy + removeRoomFromCachedList(groupId, 'groups'); } } @@ -2743,6 +2801,18 @@ function escapeHtml(unsafe) { .replaceAll(/'/g, "'"); } +//ID-y/identyfikatory wstawiane do atrybutów HTML i inline JS (onclick). +//Usuwa wszystko poza bezpiecznym zestawem znakow - nie da się wyjść z stringa JS ani atrybutu. +//Dozwolone: litery, cyfry oraz _ . ; : @ - +function sanitizeId(value) { + return String(value ?? "").replace(/[^A-Za-z0-9_.;:@-]/g, ""); +} + +//nazwy (grup itp.) wstawiane do treści HTML +function escapeHtmlText(value) { + return escapeHtml(String(value ?? "")); +} + async function generateDmMembersHtml(targetId, targetUsername) { let myIdStr = id + ";" + host; let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`); @@ -2751,24 +2821,28 @@ async function generateDmMembersHtml(targetId, targetUsername) { let targetPfp = await getAvatarUrl(targetId, targetUsername); let targetDisplayName = targetUsername.split(':')[0]; let myDisplayName = myUsername.split(':')[0]; + let safeTargetId = sanitizeId(targetId); + let safeMyId = sanitizeId(myIdStr); + targetDisplayName = escapeHtmlText(targetDisplayName); + let myDisplaySafe = escapeHtmlText(myDisplayName); return `
- -
@@ -2885,7 +2959,7 @@ async function generateGroupMembersHtml(groupId, groupName) { `; @@ -2895,14 +2969,16 @@ async function generateGroupMembersHtml(groupId, groupName) { if (!memberUsername || memberUsername.startsWith("error")) continue; let memberPfp = await getAvatarUrl(memberId, memberUsername); - let memberDisplayName = memberUsername.split(':')[0]; + let memberDisplayName = escapeHtml(memberUsername.split(':')[0]); + let safeMemberId = sanitizeId(memberId); + let safeGroupId = sanitizeId(groupId); html += ` - `; } @@ -2925,6 +3001,16 @@ async function openDm(dmId, username, targetId, type = 'dms') { } if (type === 'dms') { + let keyCheck = await fetchEncrypted("dm/key/get", dmId); + if (keyCheck === "error:forbidden") { + //wpis w liscie do ktorego nie mamy dostepu - usun go i nie otwieraj rozmowy + showBlahNotification(keyCheck); + removeRoomFromCachedList(dmId, 'dms'); + removeRoomButton(sanitizeId(dmId), 'dms'); + refreshDms(0, false); + clearAction("dmopen"); + return; + } currentDmKey = await ensureDmRoomKey(dmId); if (!currentDmKey) { throw new Error("Missing DM room key"); @@ -2936,8 +3022,16 @@ async function openDm(dmId, username, targetId, type = 'dms') { window.currentGroupKeyIndex = info.string4 || "0"; window.currentGroupOwner = info.string7; } else { - window.currentGroupKeyIndex = "0"; - window.currentGroupOwner = ""; + showBlahNotification(infoPayload && infoPayload.startsWith("error:") ? infoPayload : "error:group.not.found"); + //usuń wpis z listy tylko przy deterministycznym błędzie + //(network.error itp. nie może czyścić poprawnych wpisów) + if (infoPayload === "error:group.not.found") { + removeRoomFromCachedList(dmId, 'groups'); + removeRoomButton(sanitizeId(dmId), 'groups'); + refreshGroups(0, false); + } + clearAction("dmopen"); + return; } } @@ -3015,13 +3109,9 @@ async function loadDmMessages(dmId, startOffset = "", isPrepend = false) { if (res && res.startsWith("error:")) { if (res === "error:forbidden" || res === "error:not.a.member" || res === "error:group.not.found") { - if (dmMessagePollInterval) clearInterval(dmMessagePollInterval); - currentDmId = null; - showBlahNotification(res); - if (mainScreen.classList.contains('mobile-details')) { - mainScreen.classList.remove('mobile-details'); - } - switchDetailsContent("title.details", `
desc.no.chat.selected
`); + //brak dostępu do pokoju - serwer usunął też nieaktualny wpis z listy, + //my dodatkowo czyścimy cache/UI i wychodzimy z ekranu rozmowy + await handleLostRoomAccess(dmId, null, res); return; } showBlahNotification(res); @@ -3143,7 +3233,8 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { } } - let attContainerId = `att-container-${msgId}-${att.id}`; + let safeAttId = sanitizeId(att.id); + let attContainerId = `att-container-${sanitizeId(String(msgId))}-${safeAttId}`; let isMedia = att.type && (att.type.startsWith("image/") || att.type.startsWith("video/")); let sizeStr = att.size ? formatBytes(att.size) : ""; let displayName = truncateFilename(att.name, 25); @@ -3154,7 +3245,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { let safeType = encodeURIComponent(att.type).replace(/'/g, "%27"); let safeName = encodeURIComponent(att.name).replace(/'/g, "%27"); - attachmentsHtml += `
+ attachmentsHtml += `
${iconSvg}
${escapeHtml(displayName)}
@@ -3215,11 +3306,11 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { else if (msg.type === "larp.info.pfp") actionStr = "group.pfp.changed.by"; else if (msg.type === "larp.info.pfp_removed") actionStr = "group.pfp.removed.by"; - let clickableAuthor = `${authorName}`; + let clickableAuthor = `${authorName}`; let textStr = processBlah(actionStr); if (msg.type.endsWith(".pin") || msg.type.endsWith(".unpin")) { - let clickableMsg = `${processBlah('title.message.lowercase')}`; + let clickableMsg = `${processBlah('title.message.lowercase')}`; content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableMsg); } else if (msg.type === "larp.info.history" || msg.type === "larp.info.name") { let escapedContent = escapeHtml(content); @@ -3229,7 +3320,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { let targetId = ""; let parts = content.split(':'); if (parts.length >= 2 && parts[0].includes(';')) { - targetId = parts[0]; + targetId = sanitizeId(parts[0]); targetUsername = escapeHtml(parts.slice(1).join(':')); } else { targetUsername = escapeHtml(parts[0]); @@ -3275,7 +3366,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]); } if (dmUsernameCache[msg.author]) { - authorName = dmUsernameCache[msg.author].split(':')[0]; + authorName = escapeHtml(dmUsernameCache[msg.author].split(':')[0]); pfp = dmPfpCache[msg.author]; } } @@ -3355,7 +3446,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { window.replyAuthorCache[respondedId] = sName; let el = document.getElementById(randId); if (el) { - el.innerHTML = `${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${sName}`; + el.innerHTML = `${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${escapeHtml(sName)}`; } } } @@ -3365,7 +3456,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { } let divIdStr = randId !== "" ? `id="${randId}" ` : ""; - repliedHtml = `
${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${replyAuthor}
`; + repliedHtml = `
${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${escapeHtml(replyAuthor)}
`; } let reactionsHtml = ""; @@ -3405,7 +3496,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27"); let escapedRxVal = escapeHtml(rxVal); let pillStyle = `display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.1rem 0.45rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}`; - reactionsHtml += `${processBlah(escapedRxVal)}${rxCounts[rxVal].count > 1 ? `${rxCounts[rxVal].count}` : ""}`; + reactionsHtml += `${processBlah(escapedRxVal)}${rxCounts[rxVal].count > 1 ? `${rxCounts[rxVal].count}` : ""}`; } reactionsHtml += `
`; } @@ -3415,11 +3506,11 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { } html += ` -
- ${showAvatar ? `` : `
`} -
+
+ ${showAvatar ? `` : `
`} +
${showAvatar ? `
- ${authorName} + ${authorName} ${timeStr}
` : ""} ${repliedHtml} @@ -3498,7 +3589,21 @@ async function renderMessages(messages, isPrepend = false) { let newAttachments = newChild.querySelector('.chat-message-attachments'); if (newAttachments) { if (oldAttachments) { - if (oldAttachments.getAttribute('data-raw') !== newAttachments.getAttribute('data-raw')) oldAttachments.innerHTML = newAttachments.innerHTML; + if (oldAttachments.getAttribute('data-raw') !== newAttachments.getAttribute('data-raw')) { + //keep already downloaded/rendered attachment boxes alive across the update, + //and always sync data-raw - otherwise every poll would see a stale attribute + //and re-render (unloading) the attachments forever + let loadedBoxes = {}; + oldAttachments.querySelectorAll('.attachment-box[data-loaded="true"]').forEach(box => { + loadedBoxes[box.id] = box; + }); + oldAttachments.innerHTML = newAttachments.innerHTML; + oldAttachments.setAttribute('data-raw', newAttachments.getAttribute('data-raw')); + for (let boxId in loadedBoxes) { + let freshBox = oldAttachments.querySelector(`#${boxId}`); + if (freshBox) freshBox.replaceWith(loadedBoxes[boxId]); + } + } } else { let contentNode = existingChild.querySelector('.chat-message-content'); if (oldReactions) contentNode.insertBefore(newAttachments, oldReactions); @@ -3756,7 +3861,7 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
-
${content} (${processBlah("info.sending.message")})
+
${escapeHtml(content)} (${processBlah("info.sending.message")})
`; @@ -3788,6 +3893,7 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag res = await fetch(`${url}/dm/attachment/staging?id=${id}`, { method: "POST", headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) }, body: encryptedFileBytes @@ -3991,6 +4097,10 @@ function setupWebSocket() { }, 300); } } + else if (data.startsWith("group_removed:")) { + let removedGroupId = data.substring("group_removed:".length); + handleGroupRemoved(removedGroupId); + } else if (data.startsWith("call_update:")) { if (typeof handleCallUpdate === "function") { handleCallUpdate(data); @@ -4043,7 +4153,7 @@ function handleMessageContextMenu(e, msgId) { if (pinContainer) { let isPinned = msg && msg.pinned === "true"; pinContainer.innerHTML = ` - `; @@ -4459,7 +4569,14 @@ async function changePassword() { if (!newPass) return; showAction("action.account.saving", "accountsave"); - let newPassHash = await hashSHA3_512(newPass); + //sól KDF konta (wymagana; nie zmienia się przy zmianie hasła) + let saltRes = await fetchAsync(`${url}/kdfsalt?id=${window.id}`); + if (!saltRes || !saltRes.startsWith("P:")) { + clearAction("accountsave"); + showBlahNotification("error:kdf.salt.missing"); + return; + } + let newPassHash = await deriveCredentialKdf(newPass, saltRes.substring(2).trim()); let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass); let payload = JSON.stringify({ @@ -4970,6 +5087,91 @@ if (cropperContainer) { }).observe(cropperContainer); } +//Usuwa pokój z cache listy (dms/groups). Zwraca true jeśli coś usunięto. +function removeRoomFromCachedList(roomId, type) { + try { + roomId = String(roomId); + if (type === 'groups') { + if (!window.cachedGroups) return false; + let parsed = JSON.parse(window.cachedGroups); + let removed = false; + if (parsed.groups && parsed.groups[roomId]) { + delete parsed.groups[roomId]; + removed = true; + } + //fallback: wpis może być zapisany z domena albo bez + if (!removed && parsed.groups && !roomId.includes(';')) { + for (let key in parsed.groups) { + if (key.split(';')[0] === roomId) { + delete parsed.groups[key]; + removed = true; + break; + } + } + } + if (removed) window.cachedGroups = JSON.stringify(parsed); + return removed; + } else { + if (!window.cachedDms) return false; + let parsed = JSON.parse(window.cachedDms); + if (parsed.dms && parsed.dms[roomId]) { + delete parsed.dms[roomId]; + window.cachedDms = JSON.stringify(parsed); + return true; + } + } + } catch (e) { + console.error(e); + } + return false; +} + +function removeRoomButton(roomId, type) { + let btn = document.getElementById(`${type === 'groups' ? 'group' : 'dm'}-btn-${sanitizeId(roomId)}`); + if (btn) btn.remove(); +} + +//Serwer informuje, że użytkownik został usunięty z grupy (kick) - +//usuń grupę z listy, a jeśli jest otwarta, wyjdź z ekranu rozmowy. +async function handleGroupRemoved(groupId) { + try { + removeRoomFromCachedList(groupId, 'groups'); + removeRoomButton(sanitizeId(groupId), 'groups'); + if (currentDmId === groupId || (currentDmId && currentDmId.split(';')[0] === groupId.split(';')[0] && window.currentRoomType === 'groups')) { + await handleLostRoomAccess(groupId, 'groups', "info:removed.from.group"); + } + if (typeof refreshGroups === 'function') refreshGroups(0, false); + } catch (e) { + console.error(e); + } +} + +//Pokój do którego straciliśmy dostęp: usuń go z listy i wyjdź z ekranu rozmowy. +async function handleLostRoomAccess(roomId, type = null, notifyKey = null) { + if (!type) type = window.currentRoomType === 'groups' ? 'groups' : 'dms'; + let wasOpen = currentDmId === roomId; + + removeRoomFromCachedList(roomId, type); + removeRoomButton(sanitizeId(roomId), type); + + if (typeof refreshDms === 'function') refreshDms(0, false); + if (typeof refreshGroups === 'function') refreshGroups(0, false); + + if (wasOpen) { + showBlahNotification(notifyKey || (type === 'groups' ? "error:not.a.member" : "error:forbidden")); + if (dmMessagePollInterval) { + clearInterval(dmMessagePollInterval); + dmMessagePollInterval = null; + } + currentDmId = null; + window.currentRoomType = null; + if (mainScreen.classList.contains('mobile-details')) { + mainScreen.classList.remove('mobile-details'); + } + gotoHome(); + } +} + function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) { try { if (type === 'groups') { @@ -5226,6 +5428,9 @@ function handleMemberContextMenu(e, groupId, memberId) { e.preventDefault(); e.stopPropagation(); + groupId = sanitizeId(groupId); + memberId = sanitizeId(memberId); + if (e && e.type === "contextmenu") { touchContextMenuFired = true; setTimeout(() => { touchContextMenuFired = false; }, 500); diff --git a/webroot/style.css b/webroot/style.css index 3247c504..6506b747 100644 --- a/webroot/style.css +++ b/webroot/style.css @@ -1418,10 +1418,16 @@ input:checked + .toggle-slider:before { .voice-resize-se { bottom: 0; right: 0; cursor: nwse-resize; } .voice-resize-sw { bottom: 0; left: 0; cursor: nesw-resize; } +/* + * Kafelki rozmowy - wszystkie mają TEN SAM rozmiar (kwadrat, --voice-tile-size + * ustawiany przez relayoutVoiceTiles() w voice.js). Kafelki płyną obok siebie, + * a gdy się nie mieszczą w rzędzie, flex-wrap przenosi je do następnego. + */ .voice-tile { position: relative; - width: var(--voice-tile-w, 9rem); - height: var(--voice-tile-h, 8rem); + width: var(--voice-tile-size, 9rem); + height: var(--voice-tile-size, 9rem); + flex: 0 0 auto; background: var(--light-border-color); background-size: cover; background-position: center; @@ -1882,10 +1888,10 @@ input:checked + .toggle-slider:before { position: absolute; top: 0; bottom: 0; - width: 2px; - margin-left: -1px; + width: 0.125rem; + margin-left: -0.0625rem; background: rgba(255, 255, 255, 0.65); - border-radius: 1px; + border-radius: 0.0625rem; pointer-events: none; } /* Settings button keeps its hover look while the settings popup is open */ diff --git a/webroot/version.js b/webroot/version.js index ca181587..98bb1f57 100644 --- a/webroot/version.js +++ b/webroot/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.1.4"; +const CLIENT_VERSION = "0.2.0"; diff --git a/webroot/voice.js b/webroot/voice.js index d4cf5d78..09dd8f0f 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -3,10 +3,99 @@ let currentVoiceCallId = null; let currentVoiceCallType = null; let voiceWsHeartbeat = null; let voiceActiveUsers = []; -let voiceAesKeyBytes = null; -let voiceAesCryptoKey = null; +let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ let voiceUiContainer = null; +// === Gwarantowana unikalność IV (AES-GCM): === +// 1) każdy nadawca szyfruje WŁASNYM podkluczem HKDF(root, "larpix:voice:v1:"+senderId) +// -> kolizja między nadawcami niemożliwa z konstrukcji, +// 2) IV(12B) = epoka(4B = SHA-256(ticketPublicId)[0..4]) || licznik(8B BE, ściśle rosnący) +// -> kolizja w ramach jednego nadawcy niemożliwa w obrębie epoki; każde dołączenie +// do calla ma unikalny ticketPublicId (single-use po stronie serwera), a licznik +// może się resetować TYLKO razem ze zmianą klucza root. +let voiceMySenderCryptoKey = null; // mój podklucz nadawcy (CryptoKey) +const voicePeerSenderKeys = new Map(); // clientIdStr -> Promise odbiorczy +let voiceSendEpoch = null; // Uint8Array(4) +let voiceSendCounter = 0n; // BigInt, monotoniczny w ramach epoki + +//Promise rozstrzygany gdy operacja na kluczu root (ustawienie/wyczyszczenie) +//jest gotowa - pozwala innym kodom czekać zamiast wchodzić w wyścig z derivacją. +let voiceRootKeyReady = Promise.resolve(); + +const VOICE_HKDF_INFO_PREFIX = "larpix:voice:v1:"; + +function voiceHkdfInfoBytes(senderIdStr) { + const prefix = new TextEncoder().encode(VOICE_HKDF_INFO_PREFIX); + const idBytes = new TextEncoder().encode(String(senderIdStr)); + const out = new Uint8Array(prefix.length + idBytes.length); + out.set(prefix, 0); + out.set(idBytes, prefix.length); + return out; +} + +async function deriveVoiceSenderKey(rootBytes, senderIdStr) { + const ikm = await crypto.subtle.importKey("raw", rootBytes, "HKDF", false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits( + {name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: voiceHkdfInfoBytes(senderIdStr)}, + ikm, 256 + ); + return await crypto.subtle.importKey("raw", new Uint8Array(bits), {name: "AES-GCM"}, false, ["encrypt", "decrypt"]); +} + +//Jedyny punkt zmiany klucza root. Reset licznika jest poprawny wyłącznie tutaj - +//nowy root = świeża przestrzeń IV. Null czyści cały stan. +function setVoiceRootKey(bytesOrNull) { + const done = (async () => { + if (!bytesOrNull) { + voiceAesKeyBytes = null; + voiceMySenderCryptoKey = null; + voicePeerSenderKeys.clear(); + return; + } + voicePeerSenderKeys.clear(); + const k = await deriveVoiceSenderKey(bytesOrNull, String(window.id)); + voiceAesKeyBytes = bytesOrNull; + voiceMySenderCryptoKey = k; + })(); + voiceRootKeyReady = done; + return done; +} + +//Epoka z unikalnego per-dołączenie ticketPublicId - deterministycznie, bez dodatkowego losowania. +function setVoiceEpochFromTicket(publicIdB64u) { + crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(publicIdB64u))).then(d => { + voiceSendEpoch = new Uint8Array(d).slice(0, 4); + voiceSendCounter = 0n; + }).catch(() => { voiceSendEpoch = null; voiceSendCounter = 0n; }); +} + +//Jedynne źródło IV do wysyłki. Inkrementacja synchroniczna (single-thread JS = atomiczne). +function nextVoiceIv() { + if (!voiceSendEpoch) throw new Error("voice epoch not ready"); + voiceSendCounter += 1n; + if (voiceSendCounter >= (1n << 63n)) throw new Error("voice iv counter exhausted"); //praktycznie nieosiągalne + const iv = new Uint8Array(12); + iv.set(voiceSendEpoch, 0); + let c = voiceSendCounter; + for (let i = 0; i < 8; i++) { + iv[11 - i] = Number(c & 0xffn); + c >>= 8n; + } + return iv; +} + +//Podklucz odbiorczy danego nadawcy (cache). clientId to dziesiętny string ulonga - +//dokładnie ta sama forma, której nadawca użył przy derivacji własnego klucza. +function getVoicePeerSenderKey(clientIdStr) { + let p = voicePeerSenderKeys.get(clientIdStr); + if (!p && voiceAesKeyBytes) { + p = deriveVoiceSenderKey(voiceAesKeyBytes, clientIdStr); + voicePeerSenderKeys.set(clientIdStr, p); + } + return p || null; +} + + let micStream = null, audioContext = null, isAudioWorkletLoaded = false; let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null; let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {}; @@ -39,6 +128,8 @@ let peerLastSeen = {}; let deadPeers = new Set(); let lastVoiceServerPacketAt = 0; let voicePingTimer = null; +let voiceRosterRetryTimer = null; +let lastVoiceKeyRequestAt = 0; let voiceReconnectAttempts = 0; let voiceSocketOpenedAt = 0; @@ -61,9 +152,9 @@ function cleanupPeerMedia(userId) { } function sendVoicePing() { - if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; - const iv = crypto.getRandomValues(new Uint8Array(12)); - crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => { + if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; + const iv = nextVoiceIv(); + crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => { if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); @@ -530,6 +621,16 @@ setInterval(() => { } else if (audioContext && audioContext.state === 'suspended') { audioContext.resume().catch(() => {}); } + // Safety net for the call-key handshake: if others are in the call but + // we never received the root key (raced/lost signal), ask for it + // instead of staying deaf until someone refreshes the page. + if (others > 0 && !voiceAesKeyBytes && now - lastVoiceKeyRequestAt > 4000) { + lastVoiceKeyRequestAt = now; + console.debug("Voice: requesting call key from peers"); + for (const u of voiceActiveUsers) { + if (u !== String(window.id)) sendVoiceSignalToUser(u, currentVoiceCallId, true); + } + } } if (changed) updateVoiceUIUsers(); }, 2000); @@ -682,6 +783,8 @@ async function requestVoiceJoin() { let serverUrl = parts[0]; const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]]; + //epoka IV z unikalnego (single-use) publicId - każde dołączenie = rozłączna przestrzeń IV + setVoiceEpochFromTicket(publicId); if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) { serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl; } @@ -780,7 +883,21 @@ async function startVoiceSession(serverUrl, ticket) { } catch (e) {} }; fetchUsers(); - voiceWsHeartbeat = setInterval(fetchUsers, 5000); + //The voice-server roster is joined only AFTER ticket validation (an + //HTTP roundtrip to the main server), so the immediate fetch can still + //miss us - retry quickly until our own tile shows up instead of + //staring at an empty call for the first heartbeat. + if (voiceRosterRetryTimer) clearInterval(voiceRosterRetryTimer); + let rosterTries = 0; + voiceRosterRetryTimer = setInterval(() => { + if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || + voiceActiveUsers.includes(String(window.id)) || ++rosterTries > 12) { + clearInterval(voiceRosterRetryTimer); + voiceRosterRetryTimer = null; + return; + } + fetchUsers(); + }, 400); voiceReconnectAttempts = 0; lastVoiceServerPacketAt = Date.now(); @@ -847,9 +964,9 @@ async function startVoiceSession(serverUrl, ticket) { if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)'; if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; - if (!voiceAesCryptoKey) return; - const iv = crypto.getRandomValues(new Uint8Array(12)); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, audioData); + if (!voiceMySenderCryptoKey) return; + const iv = nextVoiceIv(); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 0; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); @@ -886,7 +1003,10 @@ async function startVoiceSession(serverUrl, ticket) { const iv = new Uint8Array(arrayBuffer, 10, 12); const ciphertext = new Uint8Array(arrayBuffer, 22); - if (!voiceAesCryptoKey) return; + if (!voiceAesKeyBytes) return; + + //podklucz nadawcy - deterministyczny z (root, clientId) + const senderKeyPromise = getVoicePeerSenderKey(clientId); if (type === 0 || type === 4) { const isScreenAudio = (type === 4); @@ -899,7 +1019,9 @@ async function startVoiceSession(serverUrl, ticket) { const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode; audioDecryptChains[audioId].enqueue(async () => { try { - const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext); + const senderKey = await senderKeyPromise; + if (!senderKey) return; + const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); @@ -911,7 +1033,9 @@ async function startVoiceSession(serverUrl, ticket) { videoDecryptChains[decoderId].enqueue(async () => { try { - const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext); + const senderKey = await senderKeyPromise; + if (!senderKey) return; + const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); if (!remoteVideos[decoderId]) { const canvas = document.createElement('canvas'); @@ -971,8 +1095,12 @@ async function startVoiceSession(serverUrl, ticket) { } catch (err) {} }); } else if (type === 3) { - crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext).then(decrypted => { - const stoppedTypeId = new Uint8Array(decrypted)[0]; + (async () => { + try { + const senderKey = await senderKeyPromise; + if (!senderKey) return; + const decrypted = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); + const stoppedTypeId = new Uint8Array(decrypted)[0]; if (stoppedTypeId === 4) { const audioId = `${clientId}_screen`; if (remoteAudio[audioId]) { @@ -989,7 +1117,8 @@ async function startVoiceSession(serverUrl, ticket) { updateVoiceUIUsers(); } } - }).catch(() => {}); + } catch (err) {} + })(); } }; @@ -1021,9 +1150,12 @@ async function startVoiceSession(serverUrl, ticket) { } } -function handleCallUpdate(data) { +async function handleCallUpdate(data) { let parts = data.split(":"); let roomId = parts[1]; + //Ignore updates for rooms we have nothing to do with - they would otherwise + //overwrite the participant list of an ongoing call and wipe its tiles. + if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return; let state = []; if(parts.slice(2).join(":") !== "") { let stateRaw = JSON.parse(parts.slice(2).join(":")); @@ -1034,10 +1166,13 @@ function handleCallUpdate(data) { if (currentVoiceCallId === roomId) { let myIdStr = String(window.id); if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) { - voiceAesKeyBytes = crypto.getRandomValues(new Uint8Array(32)); - crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]) - .then(k => voiceAesCryptoKey = k); + setVoiceRootKey(crypto.getRandomValues(new Uint8Array(32))); } + //The root key is set asynchronously - wait for it before distributing. + //Otherwise a fast second join produces [me,peer] while the derivation + //is still running, no signal is sent and the peer never gets the key + //(audio stayed broken for everyone until someone refreshed the page). + await voiceRootKeyReady; if (voiceAesKeyBytes) { for (let u of state) { if (u !== myIdStr) sendVoiceSignalToUser(u, roomId); @@ -1120,7 +1255,7 @@ async function resolveCallerInfo(roomId) { } function showIncomingCallUI(roomId) { - if (document.getElementById("incoming-call-popup") || currentVoiceWs) return; + if (document.getElementById("incoming-call-popup") || currentVoiceWs || voiceJoinInFlight) return; let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0]; @@ -1174,18 +1309,24 @@ function joinIncomingCall(roomId) { initiateVoiceCall(roomId); } -async function sendVoiceSignalToUser(targetUserId, roomId) { +async function sendVoiceSignalToUser(targetUserId, roomId, requestData = false) { try { - let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`); - let publicKeys = JSON.parse(publicKeysRaw); - let hybrid = await deriveHybridKeyToUser(publicKeys); - - let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes); - let setupPayloadJson = JSON.stringify({ - v: 1, - ctMlKem: hybrid.ciphertextMlKemBase64, - enc: encForMember - }); + let setupPayloadJson; + if (!requestData && voiceAesKeyBytes) { + let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`); + let publicKeys = JSON.parse(publicKeysRaw); + let hybrid = await deriveHybridKeyToUser(publicKeys); + + let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes); + setupPayloadJson = JSON.stringify({ + v: 1, + ctMlKem: hybrid.ciphertextMlKemBase64, + enc: encForMember + }); + } else { + //Prośba o klucz rozmowy (bez sekretów) - adresat z kluczem odpowie pełnym sygnałem. + setupPayloadJson = JSON.stringify({ v: 1, needKey: true }); + } let payload = JSON.stringify({ string1: currentVoiceCallType, @@ -1204,15 +1345,22 @@ async function handleRoomSignal(data) { let signalStr = parts.slice(2).join(":"); let signalObj = JSON.parse(signalStr); - if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId && !voiceAesKeyBytes) { + if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId) { try { let setupData = JSON.parse(signalObj.data); - let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`); - let otherPublicKeysObj = JSON.parse(publicKeysRaw); - let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem); - let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes); - voiceAesKeyBytes = base64ToUint8(decRoomKeyB64); - voiceAesCryptoKey = await crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]); + if (setupData.needKey) { + //Peer nie dostał klucza rozmowy (wygryziony/zagubiony sygnał) - + //odeślij mu go, ale tylko jeśli sami już go mamy. + if (voiceAesKeyBytes) await sendVoiceSignalToUser(signalObj.from, roomId); + return; + } + if (!voiceAesKeyBytes) { + let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`); + let otherPublicKeysObj = JSON.parse(publicKeysRaw); + let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem); + let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes); + await setVoiceRootKey(base64ToUint8(decRoomKeyB64)); + } } catch(e) { console.error("Signal decryption failed", e); } @@ -2038,6 +2186,12 @@ function ensureVoiceGridObserver() { voiceGridRO.observe(container); } +/* + * Wszystkie kafelki to kwadraty o wspólnym rozmiarze --voice-tile-size. + * Wybieramy największy rozmiar kwadratu, przy którym n kafelków mieści się + * w oknie rozmowy - układanie i zawijanie do następnych rzędów robi sam + * flex-wrap w CSS (justify-content: center). + */ function relayoutVoiceTiles() { let container = document.getElementById("voice-users"); if (!container || !container.isConnected) return; @@ -2052,28 +2206,34 @@ function relayoutVoiceTiles() { const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); if (!isFinite(innerW) || !isFinite(innerH) || !isFinite(rootFs) || innerW <= 0 || innerH <= 0 || rootFs <= 0) { - container.style.removeProperty("--voice-tile-w"); - container.style.removeProperty("--voice-tile-h"); + container.style.removeProperty("--voice-tile-size"); delete container.dataset.layoutSig; return; } - const minCol = 6.25 * rootFs; - const minRow = 7 * rootFs; const gap = 0.75 * rootFs; + const maxSize = 18 * rootFs; // jedna osoba nie dostanie gigantycznego kafelka + const minSize = 4.5 * rootFs; // poniżej tego wolimy scrollować niż spłaszczać - const maxCols = Math.max(1, Math.floor((innerW + gap) / (minCol + gap))); - const cols = Math.min(n, maxCols); - const rows = Math.ceil(n / cols); - const w = Math.floor((innerW - gap * (cols - 1)) / cols); - const h = Math.floor(Math.max(minRow, (innerH - gap * (rows - 1)) / rows)); + //Największy kwadrat, przy którym n kafelków mieści się w dostępnej powierzchni: + //próbujemy liczby kolumn 1..n, dla każdej wyliczamy ograniczony rozmiar + //(szerokość kolumny vs wysokość rzędu) i bierzemy najlepszy wariant. + let best = 0; + for (let cols = 1; cols <= n; cols++) { + const rows = Math.ceil(n / cols); + const s = Math.min( + (innerW - gap * (cols - 1)) / cols, + (innerH - gap * (rows - 1)) / rows + ); + if (s > best) best = s; + } - const sig = `${n}:${cols}:${w}x${h}`; + const size = Math.min(maxSize, Math.max(minSize, Math.floor(best))); + const sig = `${n}:${size}`; if (container.dataset.layoutSig === sig) return; container.dataset.layoutSig = sig; - container.style.setProperty("--voice-tile-w", w + "px"); - container.style.setProperty("--voice-tile-h", h + "px"); + container.style.setProperty("--voice-tile-size", size + "px"); } let voiceSettingsState = null; @@ -2179,10 +2339,16 @@ function teardownVoiceTransport() { audioDecryptChains = {}; peerLastSeen = {}; deadPeers.clear(); + if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } + lastVoiceKeyRequestAt = 0; + //Stale participants from the previous attempt must not linger in the UI + voiceActiveUsers = []; + updateVoiceUIUsers(); if (receiverNode) receiverNode.port.postMessage({action: 'clear'}); if (screenReceiverNode) screenReceiverNode.port.postMessage({action: 'clear'}); - voiceAesKeyBytes = null; - voiceAesCryptoKey = null; + setVoiceRootKey(null); + voiceSendEpoch = null; + voiceSendCounter = 0n; lastVoiceServerPacketAt = Date.now(); } @@ -2243,8 +2409,11 @@ function endVoiceSession() { if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } + if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } peerLastSeen = {}; deadPeers.clear(); + lastVoiceKeyRequestAt = 0; + voiceActiveUsers = []; for (let id in audioDecoders) { try { audioDecoders[id].close(); } catch(e){} @@ -2275,8 +2444,9 @@ function endVoiceSession() { remoteVideos = {}; videoDecryptChains = {}; - voiceAesKeyBytes = null; - voiceAesCryptoKey = null; + setVoiceRootKey(null); + voiceSendEpoch = null; + voiceSendCounter = 0n; currentVoiceWs = null; currentVoiceCallType = null; currentVoiceCallId = null; @@ -2322,11 +2492,11 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { screenAudioSourceNode = audioContext.createMediaStreamSource(stream); screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender'); screenSenderNode.port.onmessage = async (e) => { - if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; + if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; // Same backpressure valve as the mic sender. if (currentVoiceWs.bufferedAmount > 256 * 1024) return; - const iv = crypto.getRandomValues(new Uint8Array(12)); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, e.data.audioData); + const iv = nextVoiceIv(); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData); const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 4; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); @@ -2357,10 +2527,10 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0; const encoder = new VideoEncoder({ output: async (chunk) => { - if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; + if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; const buffer = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buffer); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer); + const iv = nextVoiceIv(); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer); const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); @@ -2412,9 +2582,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { updateVoiceUIUsers(); btnEl.classList.remove('voice-btn-active'); - if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) { - const buffer = new Uint8Array([typeId]); const iv = crypto.getRandomValues(new Uint8Array(12)); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer); + if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceMySenderCryptoKey) { + const buffer = new Uint8Array([typeId]); const iv = nextVoiceIv(); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer); const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 3; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); } @@ -2423,9 +2593,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { if (screenAudioSourceNode) { screenAudioSourceNode.disconnect(); screenAudioSourceNode = null; } if (screenSenderNode) { screenSenderNode.disconnect(); screenSenderNode = null; } if (screenAudioEncoder) { try { screenAudioEncoder.close(); } catch (e) {} screenAudioEncoder = null; } - if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) { - const bufferAudioInfo = new Uint8Array([4]); const ivA = crypto.getRandomValues(new Uint8Array(12)); - const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceAesCryptoKey, bufferAudioInfo); + if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceMySenderCryptoKey) { + const bufferAudioInfo = new Uint8Array([4]); const ivA = nextVoiceIv(); + const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceMySenderCryptoKey, bufferAudioInfo); const payloadA = new Uint8Array(14 + cipherA.byteLength); payloadA[0] = 3; payloadA[1] = 0; payloadA.set(ivA, 2); payloadA.set(new Uint8Array(cipherA), 14); currentVoiceWs.send(payloadA.buffer); }