diff --git a/android/app/build.gradle b/android/app/build.gradle index 7204016e..efa09414 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "olcxja.miarven" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 6 - versionName "0.2.0" + versionCode 5 + versionName "0.1.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/assets/public/blah/en-cat.json b/android/app/src/main/assets/public/blah/en-cat.json index d3ffa2ed..01a784ae 100644 --- a/android/app/src/main/assets/public/blah/en-cat.json +++ b/android/app/src/main/assets/public/blah/en-cat.json @@ -233,7 +233,6 @@ "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 fc6eaaf1..fa67cf49 100644 --- a/android/app/src/main/assets/public/blah/en-us.json +++ b/android/app/src/main/assets/public/blah/en-us.json @@ -233,7 +233,6 @@ "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 d8af39cc..68bed510 100644 --- a/android/app/src/main/assets/public/blah/pl-pl.json +++ b/android/app/src/main/assets/public/blah/pl-pl.json @@ -231,7 +231,6 @@ "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 9ef4bf68..111fc1b6 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-confirm"); + const registerPasswordConfirm = document.getElementById("reg-password"); const loginHost = document.getElementById("login-host"); const loginUsername = document.getElementById("login-username"); const loginPassword = document.getElementById("login-password"); @@ -382,13 +382,6 @@ 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({ @@ -396,7 +389,7 @@ ciphertextMlKem: sharedkey[1], idKey: createId, username: await encrypt(registerUsername.value, sharedpvkey), - password: await encrypt(regCredHash, sharedpvkey) + password: await encrypt(await hashSHA3_512(registerPassword.value), 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 fa231a06..ff092762 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -83,9 +83,7 @@ async function packetEncPass(pass, key, accountId) { async function getNonce(accountId, key) { let nonce; - let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`, { - headers: {"X-Crypto": "g1"} - })).text(); + let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`)).text(); try { nonce = await decryptString(fetchRes, key); @@ -125,8 +123,7 @@ async function calcHybridSharedKeyClient(pubX25519ServerBase64, pubMlKemServerBa function keyDataFromServerJson(jsonFromServer) { const data = JSON.parse(jsonFromServer); - //0=pubX25519, 1=pubMlKem, 2=idKey, 3=kdfSalt (wymagany do PBKDF2 przy rejestracji) - return [data.pubX25519, data.pubMlKem, data.idKey, data.kdfSalt] + return [data.pubX25519, data.pubMlKem, data.idKey] } function base64ToUint8(base64) { @@ -708,20 +705,25 @@ 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-GCM'}, false, ['encrypt'] + 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt'] ); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encrypted = new Uint8Array(await crypto.subtle.encrypt( - {name: 'AES-GCM', iv: iv}, + const iv = crypto.getRandomValues(new Uint8Array(16)); + const encrypted = await crypto.subtle.encrypt( + {name: 'AES-CBC', iv: iv}, key, - encoder.encode(plainText) - )); + data + ); - return "G1:" + uint8ToBase64(concatUint8(iv, encrypted)); + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), iv.length); + + return uint8ToBase64(combined); } async function encryptBytesWithNonce(dataUint8, key, nonce) { @@ -730,44 +732,37 @@ 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-GCM'}, false, ['encrypt'] + 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt'] ); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encrypted = new Uint8Array(await crypto.subtle.encrypt( - {name: 'AES-GCM', iv: iv}, + const iv = crypto.getRandomValues(new Uint8Array(16)); + const encrypted = await crypto.subtle.encrypt( + {name: 'AES-CBC', iv: iv}, importedKey, dataUint8 - )); + ); - 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); + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), 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 = base64ToUint8(base64Text.substring(3)); + const combined = new Uint8Array(atob(base64Text).split("").map(c => c.charCodeAt(0))); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const key = await crypto.subtle.importKey( - 'raw', pwHash, {name: 'AES-GCM'}, false, ['decrypt'] + 'raw', pwHash, {name: 'AES-CBC'}, false, ['decrypt'] ); - const iv = combined.slice(0, 12); - const data = combined.slice(12); + const iv = combined.slice(0, 16); + const data = combined.slice(16); const decrypted = await crypto.subtle.decrypt( - {name: 'AES-GCM', iv: iv}, + {name: 'AES-CBC', iv: iv}, key, data ); @@ -789,7 +784,6 @@ async function fetchPost(url, value) { method: "POST", body: value, headers: { - "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); @@ -812,7 +806,6 @@ async function fetchPostEnc(url, value) { method: "POST", body: await encryptWithNonce(value, passwordHash, nonce), headers: { - "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); @@ -842,7 +835,6 @@ async function fetchAsyncWAuth(url) { let response = await fetch(url, { method: "GET", headers: { - "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); @@ -872,30 +864,17 @@ async function Auth(loginUsername, loginPassword) { } let actualId = resolvedId.split(":")[0]; - //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 passwordHash = await hashSHA3_512(loginPassword); let response = await fetch(`${url}/auth?id=${actualId}`, { method: "GET", headers: { - "X-Crypto": "g1", - "secret": await encryptWithNonce(credHash, credHash, await getNonce(actualId, credHash)) + "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(actualId, passwordHash)) } }); 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 { @@ -923,7 +902,6 @@ async function fetchEncrypted(request, body = "") { , passwordHash, nonce), headers: { - "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); @@ -951,7 +929,6 @@ 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) } @@ -1069,35 +1046,13 @@ function clearAction(actionid) { } -//solony, wolny KDF - 600k iteracji PBKDF2-SHA256, wyjscie 512 bitow (128 hex) -async function deriveCredentialKdf(passwordPlain, saltB64) { +async function hashSHA3_512(input) { const encoder = new TextEncoder(); - 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; + 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; } //placeholder pfp @@ -1447,10 +1402,9 @@ async function mainJS() { } await initBlahs(); - //emotes.json leży w katalogu głównym - na stronie /login fetch by trafił w HTML - if (!window.location.pathname.includes("/login")) { - await loadEmotes(); - } + await loadEmotes(); + + passwordHash = await hashSHA3_512(password); if (host) { await updateProtocolAndUrl(host); @@ -1466,12 +1420,6 @@ 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(); } @@ -1925,7 +1873,6 @@ 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); @@ -1949,7 +1896,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() !== "" ? `
${escapeHtml(bio)}
` : ""; + let bioHtml = bio && bio.trim() !== "" ? `
${bio.replace(//g, ">")}
` : ""; let isOwnProfile = accountId === id; let actionBtnHtml = ""; @@ -2045,8 +1992,8 @@ async function openProfile(accountId) {
-
${escapeHtml(profileName)}
-
${escapeHtml(profileName)}:${escapeHtml(profileHost)} (${escapeHtml(accountId.split(':')[0].split(';')[0])})
+
${profileName}
+
${profileName}:${profileHost} (${accountId.split(':')[0].split(';')[0]})
${roleHtml}
${bioHtml} @@ -2182,7 +2129,7 @@ async function renderInvites(res, type) { if (invite.type === "dm") { - let id = sanitizeId(invite.id); + let id = invite.id; if (!id.includes(":")) { id += `:${host}`; @@ -2197,7 +2144,7 @@ async function renderInvites(res, type) { let actions = ""; let desc = ""; - let displayName = escapeHtml(username || onlyId); + let displayName = username || onlyId; if (type === "received") { desc = `:desc.invite.dm.received:${displayName};${onlyId}`; actions = ` @@ -2227,8 +2174,8 @@ async function renderInvites(res, type) { html += entry; } else if (invite.type === "group") { - let groupId = sanitizeId(invite.groupId || invite.id); - let targetId = sanitizeId((type === "received") ? invite.sender : invite.receiver); + let groupId = invite.groupId || invite.id; + let targetId = (type === "received") ? invite.sender : invite.receiver; if (!targetId.includes(":")) { targetId += `:${host}`; @@ -2251,9 +2198,9 @@ async function renderInvites(res, type) { let actions = ""; let desc = ""; - let displayName = escapeHtml(username || onlyId); - let val1 = `${displayName}`; - let val2 = `${escapeHtml(onlyId)}`; + let displayName = username || onlyId; + let val1 = `${displayName}`; + let val2 = `${onlyId}`; if (type === "received") { desc = `:desc.invite.group.received:${val1};${val2}`; @@ -2659,17 +2606,16 @@ async function renderDms(res) { let html = ""; let count = 0; - for (let [dmIdRaw, dmData] of dmEntries) { - let dmId = sanitizeId(dmIdRaw); + for (let [dmId, dmData] of dmEntries) { let parts = dmId.split('_'); let myId = `${id};${host}`; - let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); + let targetId = 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 = escapeHtml(targetUsername.split(':')[0]); + let displayName = targetUsername.split(':')[0]; let isRead = dmData.string3 === "true"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; @@ -2683,7 +2629,7 @@ async function renderDms(res) {
${displayName} - ${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])}) + ${targetUsername} (${targetId.split(';')[0]})
${unreadBadge} @@ -2739,23 +2685,19 @@ 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'); } } @@ -2801,18 +2743,6 @@ 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}`); @@ -2821,28 +2751,24 @@ 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 `
- -
@@ -2959,7 +2885,7 @@ async function generateGroupMembersHtml(groupId, groupName) { `; @@ -2969,16 +2895,14 @@ async function generateGroupMembersHtml(groupId, groupName) { if (!memberUsername || memberUsername.startsWith("error")) continue; let memberPfp = await getAvatarUrl(memberId, memberUsername); - let memberDisplayName = escapeHtml(memberUsername.split(':')[0]); - let safeMemberId = sanitizeId(memberId); - let safeGroupId = sanitizeId(groupId); + let memberDisplayName = memberUsername.split(':')[0]; html += ` - `; } @@ -3001,16 +2925,6 @@ 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"); @@ -3022,16 +2936,8 @@ async function openDm(dmId, username, targetId, type = 'dms') { window.currentGroupKeyIndex = info.string4 || "0"; window.currentGroupOwner = info.string7; } else { - 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; + window.currentGroupKeyIndex = "0"; + window.currentGroupOwner = ""; } } @@ -3109,9 +3015,13 @@ 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") { - //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); + 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
`); return; } showBlahNotification(res); @@ -3233,8 +3143,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { } } - let safeAttId = sanitizeId(att.id); - let attContainerId = `att-container-${sanitizeId(String(msgId))}-${safeAttId}`; + let attContainerId = `att-container-${msgId}-${att.id}`; 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); @@ -3245,7 +3154,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)}
@@ -3306,11 +3215,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); @@ -3320,7 +3229,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { let targetId = ""; let parts = content.split(':'); if (parts.length >= 2 && parts[0].includes(';')) { - targetId = sanitizeId(parts[0]); + targetId = parts[0]; targetUsername = escapeHtml(parts.slice(1).join(':')); } else { targetUsername = escapeHtml(parts[0]); @@ -3366,7 +3275,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]); } if (dmUsernameCache[msg.author]) { - authorName = escapeHtml(dmUsernameCache[msg.author].split(':')[0]); + authorName = dmUsernameCache[msg.author].split(':')[0]; pfp = dmPfpCache[msg.author]; } } @@ -3446,7 +3355,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')} ${escapeHtml(sName)}`; + el.innerHTML = `${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${sName}`; } } } @@ -3456,7 +3365,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')} ${escapeHtml(replyAuthor)}
`; + repliedHtml = `
${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${replyAuthor}
`; } let reactionsHtml = ""; @@ -3496,7 +3405,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 += `
`; } @@ -3506,11 +3415,11 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { } html += ` -
- ${showAvatar ? `` : `
`} -
+
+ ${showAvatar ? `` : `
`} +
${showAvatar ? `
- ${authorName} + ${authorName} ${timeStr}
` : ""} ${repliedHtml} @@ -3589,21 +3498,7 @@ 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')) { - //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]); - } - } + if (oldAttachments.getAttribute('data-raw') !== newAttachments.getAttribute('data-raw')) oldAttachments.innerHTML = newAttachments.innerHTML; } else { let contentNode = existingChild.querySelector('.chat-message-content'); if (oldReactions) contentNode.insertBefore(newAttachments, oldReactions); @@ -3861,7 +3756,7 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
-
${escapeHtml(content)} (${processBlah("info.sending.message")})
+
${content} (${processBlah("info.sending.message")})
`; @@ -3893,7 +3788,6 @@ 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 @@ -4097,10 +3991,6 @@ 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); @@ -4153,7 +4043,7 @@ function handleMessageContextMenu(e, msgId) { if (pinContainer) { let isPinned = msg && msg.pinned === "true"; pinContainer.innerHTML = ` - `; @@ -4569,14 +4459,7 @@ async function changePassword() { if (!newPass) return; showAction("action.account.saving", "accountsave"); - //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 newPassHash = await hashSHA3_512(newPass); let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass); let payload = JSON.stringify({ @@ -5087,91 +4970,6 @@ 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') { @@ -5428,9 +5226,6 @@ 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 f4b1f83e..3247c504 100644 --- a/android/app/src/main/assets/public/style.css +++ b/android/app/src/main/assets/public/style.css @@ -774,7 +774,7 @@ space{ border-radius: 0.8rem; transform: translateY(calc( ( var(--icon-button-height) + (var(--button-margin) * 2) ) / 2 - 0.6rem)); } - button:hover, input:not([type="range"]):hover, textarea:hover { + button:hover, input:hover, textarea:hover { background-color: rgba(255, 255, 255, 0.1); } .submit-button:hover, .profile-action-btn:hover { @@ -791,11 +791,7 @@ space{ filter: brightness(0.93); } } -/* Suwaki ([type=range]) są wyłączone z efektu klikania całego pola - - przytrzymanie skaluje tylko uchwyt (patrz ::-webkit-slider-thumb:active). */ -button:active, button.is-pressed, -input:not([type="range"]):active, input:not([type="range"]).is-pressed, -textarea:active, textarea.is-pressed { +button:active, button.is-pressed, input:active, input.is-pressed, textarea:active, textarea.is-pressed { background-color: rgba(255, 255, 255, 0.1); transform: var(--press-scale); } @@ -1025,18 +1021,15 @@ button.profile-action-btn.popup-btn-danger:hover, button.submit-button.popup-btn height: auto; } - -input[type="range"] { +/* Custom Zoom Slider Styling */ +#cropper-zoom, +.voice-setting-row input[type="range"] { -webkit-appearance: none; appearance: none; - display: block; - margin: 0; - padding: 0.4rem 0.05rem; - border: none; width: 100%; height: 0.375rem; background: rgba(255, 255, 255, 0.1); - border-radius: 0.34rem; + border-radius: 0.1875rem; outline: none; } @@ -1044,7 +1037,8 @@ input[type="range"] { margin: 0.625rem 0; } -input[type="range"]::-webkit-slider-thumb { +#cropper-zoom::-webkit-slider-thumb, +.voice-setting-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 1rem; @@ -1055,16 +1049,14 @@ input[type="range"]::-webkit-slider-thumb { transition: transform 0.2s, background 0.2s; } -input[type="range"]::-webkit-slider-thumb:hover { - transform: scale(1.08); +#cropper-zoom::-webkit-slider-thumb:hover, +.voice-setting-row input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.2); background: #fff; } -input[type="range"]::-webkit-slider-thumb:active { - transform: scale(0.87); -} - -input[type="range"]::-moz-range-thumb { +#cropper-zoom::-moz-range-thumb, +.voice-setting-row input[type="range"]::-moz-range-thumb { width: 1rem; height: 1rem; border-radius: 50%; @@ -1074,15 +1066,12 @@ input[type="range"]::-moz-range-thumb { border: none; } -input[type="range"]::-moz-range-thumb:hover { - transform: scale(1.08); +#cropper-zoom::-moz-range-thumb:hover, +.voice-setting-row input[type="range"]::-moz-range-thumb:hover { + transform: scale(1.2); background: #fff; } -input[type="range"]::-moz-range-thumb:active { - transform: scale(0.87); -} - roomcontent roomtopbar .room-action-pin, roomcontent roomtopbar .room-action-call { display: flex; @@ -1429,16 +1418,10 @@ 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-size, 9rem); - height: var(--voice-tile-size, 9rem); - flex: 0 0 auto; + width: var(--voice-tile-w, 9rem); + height: var(--voice-tile-h, 8rem); background: var(--light-border-color); background-size: cover; background-position: center; @@ -1724,6 +1707,13 @@ input:checked + .toggle-slider:before { display: flex; justify-content: space-between; } +.voice-setting-row input[type="range"] { + margin: 0; + width: 100%; + appearance: none; + -webkit-appearance: none; + outline: none; +} /* In-call Voice Device Settings Popup */ #voice-device-settings { @@ -1892,10 +1882,10 @@ input:checked + .toggle-slider:before { position: absolute; top: 0; bottom: 0; - width: 0.125rem; - margin-left: -0.0625rem; + width: 2px; + margin-left: -1px; background: rgba(255, 255, 255, 0.65); - border-radius: 0.0625rem; + border-radius: 1px; 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 98bb1f57..ca181587 100644 --- a/android/app/src/main/assets/public/version.js +++ b/android/app/src/main/assets/public/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.2.0"; +const CLIENT_VERSION = "0.1.4"; diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index 09dd8f0f..d4cf5d78 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -3,99 +3,10 @@ let currentVoiceCallId = null; let currentVoiceCallType = null; let voiceWsHeartbeat = null; let voiceActiveUsers = []; -let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ +let voiceAesKeyBytes = null; +let voiceAesCryptoKey = null; 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 = {}; @@ -128,8 +39,6 @@ let peerLastSeen = {}; let deadPeers = new Set(); let lastVoiceServerPacketAt = 0; let voicePingTimer = null; -let voiceRosterRetryTimer = null; -let lastVoiceKeyRequestAt = 0; let voiceReconnectAttempts = 0; let voiceSocketOpenedAt = 0; @@ -152,9 +61,9 @@ function cleanupPeerMedia(userId) { } function sendVoicePing() { - 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 || !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) return; const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); @@ -621,16 +530,6 @@ 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); @@ -783,8 +682,6 @@ 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; } @@ -883,21 +780,7 @@ async function startVoiceSession(serverUrl, ticket) { } catch (e) {} }; fetchUsers(); - //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); + voiceWsHeartbeat = setInterval(fetchUsers, 5000); voiceReconnectAttempts = 0; lastVoiceServerPacketAt = Date.now(); @@ -964,9 +847,9 @@ async function startVoiceSession(serverUrl, ticket) { if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)'; if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; - if (!voiceMySenderCryptoKey) return; - const iv = nextVoiceIv(); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); + if (!voiceAesCryptoKey) return; + const iv = crypto.getRandomValues(new Uint8Array(12)); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, 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); @@ -1003,10 +886,7 @@ async function startVoiceSession(serverUrl, ticket) { const iv = new Uint8Array(arrayBuffer, 10, 12); const ciphertext = new Uint8Array(arrayBuffer, 22); - if (!voiceAesKeyBytes) return; - - //podklucz nadawcy - deterministyczny z (root, clientId) - const senderKeyPromise = getVoicePeerSenderKey(clientId); + if (!voiceAesCryptoKey) return; if (type === 0 || type === 4) { const isScreenAudio = (type === 4); @@ -1019,9 +899,7 @@ async function startVoiceSession(serverUrl, ticket) { const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode; audioDecryptChains[audioId].enqueue(async () => { try { - const senderKey = await senderKeyPromise; - if (!senderKey) return; - const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); + const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext); if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); @@ -1033,9 +911,7 @@ async function startVoiceSession(serverUrl, ticket) { videoDecryptChains[decoderId].enqueue(async () => { try { - const senderKey = await senderKeyPromise; - if (!senderKey) return; - const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); + const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext); if (!remoteVideos[decoderId]) { const canvas = document.createElement('canvas'); @@ -1095,12 +971,8 @@ async function startVoiceSession(serverUrl, ticket) { } catch (err) {} }); } else if (type === 3) { - (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]; + crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext).then(decrypted => { + const stoppedTypeId = new Uint8Array(decrypted)[0]; if (stoppedTypeId === 4) { const audioId = `${clientId}_screen`; if (remoteAudio[audioId]) { @@ -1117,8 +989,7 @@ async function startVoiceSession(serverUrl, ticket) { updateVoiceUIUsers(); } } - } catch (err) {} - })(); + }).catch(() => {}); } }; @@ -1150,12 +1021,9 @@ async function startVoiceSession(serverUrl, ticket) { } } -async function handleCallUpdate(data) { +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(":")); @@ -1166,13 +1034,10 @@ async function handleCallUpdate(data) { if (currentVoiceCallId === roomId) { let myIdStr = String(window.id); if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) { - setVoiceRootKey(crypto.getRandomValues(new Uint8Array(32))); + voiceAesKeyBytes = crypto.getRandomValues(new Uint8Array(32)); + crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]) + .then(k => voiceAesCryptoKey = k); } - //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); @@ -1255,7 +1120,7 @@ async function resolveCallerInfo(roomId) { } function showIncomingCallUI(roomId) { - if (document.getElementById("incoming-call-popup") || currentVoiceWs || voiceJoinInFlight) return; + if (document.getElementById("incoming-call-popup") || currentVoiceWs) return; let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0]; @@ -1309,24 +1174,18 @@ function joinIncomingCall(roomId) { initiateVoiceCall(roomId); } -async function sendVoiceSignalToUser(targetUserId, roomId, requestData = false) { +async function sendVoiceSignalToUser(targetUserId, roomId) { try { - 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 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 payload = JSON.stringify({ string1: currentVoiceCallType, @@ -1345,22 +1204,15 @@ async function handleRoomSignal(data) { let signalStr = parts.slice(2).join(":"); let signalObj = JSON.parse(signalStr); - if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId) { + if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId && !voiceAesKeyBytes) { try { let setupData = JSON.parse(signalObj.data); - 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)); - } + 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"]); } catch(e) { console.error("Signal decryption failed", e); } @@ -2186,12 +2038,6 @@ 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; @@ -2206,34 +2052,28 @@ 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-size"); + container.style.removeProperty("--voice-tile-w"); + container.style.removeProperty("--voice-tile-h"); 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ć - //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 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)); - const size = Math.min(maxSize, Math.max(minSize, Math.floor(best))); - const sig = `${n}:${size}`; + const sig = `${n}:${cols}:${w}x${h}`; if (container.dataset.layoutSig === sig) return; container.dataset.layoutSig = sig; - container.style.setProperty("--voice-tile-size", size + "px"); + container.style.setProperty("--voice-tile-w", w + "px"); + container.style.setProperty("--voice-tile-h", h + "px"); } let voiceSettingsState = null; @@ -2339,16 +2179,10 @@ 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'}); - setVoiceRootKey(null); - voiceSendEpoch = null; - voiceSendCounter = 0n; + voiceAesKeyBytes = null; + voiceAesCryptoKey = null; lastVoiceServerPacketAt = Date.now(); } @@ -2409,11 +2243,8 @@ 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){} @@ -2444,9 +2275,8 @@ function endVoiceSession() { remoteVideos = {}; videoDecryptChains = {}; - setVoiceRootKey(null); - voiceSendEpoch = null; - voiceSendCounter = 0n; + voiceAesKeyBytes = null; + voiceAesCryptoKey = null; currentVoiceWs = null; currentVoiceCallType = null; currentVoiceCallId = null; @@ -2492,11 +2322,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 || !voiceMySenderCryptoKey) return; + if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; // Same backpressure valve as the mic sender. if (currentVoiceWs.bufferedAmount > 256 * 1024) return; - const iv = nextVoiceIv(); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, 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); @@ -2527,10 +2357,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 || !voiceMySenderCryptoKey) return; + if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; const buffer = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buffer); - const iv = nextVoiceIv(); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, 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); @@ -2582,9 +2412,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { updateVoiceUIUsers(); btnEl.classList.remove('voice-btn-active'); - 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); + 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); 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); } @@ -2593,9 +2423,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 && voiceMySenderCryptoKey) { - const bufferAudioInfo = new Uint8Array([4]); const ivA = nextVoiceIv(); - const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceMySenderCryptoKey, bufferAudioInfo); + 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); 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 5d883df0..4b890f9c 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "Miarven", - "version": "0.2.0", + "version": "0.1.4", "description": "An Amazing Capacitor App", "author": { "name": "", diff --git a/package.json b/package.json index 53c7193b..404bb4c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "olcxja.miarven", - "version": "0.2.0", + "version": "0.1.4", "description": "Larpix client", "license": "AGPL-3.0", "author": "olcxja's softworks", diff --git a/version.json b/version.json index 756f31ec..e6d29ba7 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "versionCode": 6, - "versionName": "0.2.0", - "npmVersion": "0.2.0" + "versionCode": 5, + "versionName": "0.1.4", + "npmVersion": "0.1.4" } diff --git a/webroot/blah/en-cat.json b/webroot/blah/en-cat.json index d3ffa2ed..01a784ae 100644 --- a/webroot/blah/en-cat.json +++ b/webroot/blah/en-cat.json @@ -233,7 +233,6 @@ "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 fc6eaaf1..fa67cf49 100644 --- a/webroot/blah/en-us.json +++ b/webroot/blah/en-us.json @@ -233,7 +233,6 @@ "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 d8af39cc..68bed510 100644 --- a/webroot/blah/pl-pl.json +++ b/webroot/blah/pl-pl.json @@ -231,7 +231,6 @@ "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 9ef4bf68..111fc1b6 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-confirm"); + const registerPasswordConfirm = document.getElementById("reg-password"); const loginHost = document.getElementById("login-host"); const loginUsername = document.getElementById("login-username"); const loginPassword = document.getElementById("login-password"); @@ -382,13 +382,6 @@ 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({ @@ -396,7 +389,7 @@ ciphertextMlKem: sharedkey[1], idKey: createId, username: await encrypt(registerUsername.value, sharedpvkey), - password: await encrypt(regCredHash, sharedpvkey) + password: await encrypt(await hashSHA3_512(registerPassword.value), sharedpvkey) }) }); const clone = captchaimage.clone(); diff --git a/webroot/main.js b/webroot/main.js index fa231a06..ff092762 100644 --- a/webroot/main.js +++ b/webroot/main.js @@ -83,9 +83,7 @@ async function packetEncPass(pass, key, accountId) { async function getNonce(accountId, key) { let nonce; - let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`, { - headers: {"X-Crypto": "g1"} - })).text(); + let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`)).text(); try { nonce = await decryptString(fetchRes, key); @@ -125,8 +123,7 @@ async function calcHybridSharedKeyClient(pubX25519ServerBase64, pubMlKemServerBa function keyDataFromServerJson(jsonFromServer) { const data = JSON.parse(jsonFromServer); - //0=pubX25519, 1=pubMlKem, 2=idKey, 3=kdfSalt (wymagany do PBKDF2 przy rejestracji) - return [data.pubX25519, data.pubMlKem, data.idKey, data.kdfSalt] + return [data.pubX25519, data.pubMlKem, data.idKey] } function base64ToUint8(base64) { @@ -708,20 +705,25 @@ 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-GCM'}, false, ['encrypt'] + 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt'] ); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encrypted = new Uint8Array(await crypto.subtle.encrypt( - {name: 'AES-GCM', iv: iv}, + const iv = crypto.getRandomValues(new Uint8Array(16)); + const encrypted = await crypto.subtle.encrypt( + {name: 'AES-CBC', iv: iv}, key, - encoder.encode(plainText) - )); + data + ); - return "G1:" + uint8ToBase64(concatUint8(iv, encrypted)); + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), iv.length); + + return uint8ToBase64(combined); } async function encryptBytesWithNonce(dataUint8, key, nonce) { @@ -730,44 +732,37 @@ 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-GCM'}, false, ['encrypt'] + 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt'] ); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encrypted = new Uint8Array(await crypto.subtle.encrypt( - {name: 'AES-GCM', iv: iv}, + const iv = crypto.getRandomValues(new Uint8Array(16)); + const encrypted = await crypto.subtle.encrypt( + {name: 'AES-CBC', iv: iv}, importedKey, dataUint8 - )); + ); - 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); + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), 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 = base64ToUint8(base64Text.substring(3)); + const combined = new Uint8Array(atob(base64Text).split("").map(c => c.charCodeAt(0))); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const key = await crypto.subtle.importKey( - 'raw', pwHash, {name: 'AES-GCM'}, false, ['decrypt'] + 'raw', pwHash, {name: 'AES-CBC'}, false, ['decrypt'] ); - const iv = combined.slice(0, 12); - const data = combined.slice(12); + const iv = combined.slice(0, 16); + const data = combined.slice(16); const decrypted = await crypto.subtle.decrypt( - {name: 'AES-GCM', iv: iv}, + {name: 'AES-CBC', iv: iv}, key, data ); @@ -789,7 +784,6 @@ async function fetchPost(url, value) { method: "POST", body: value, headers: { - "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); @@ -812,7 +806,6 @@ async function fetchPostEnc(url, value) { method: "POST", body: await encryptWithNonce(value, passwordHash, nonce), headers: { - "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); @@ -842,7 +835,6 @@ async function fetchAsyncWAuth(url) { let response = await fetch(url, { method: "GET", headers: { - "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); @@ -872,30 +864,17 @@ async function Auth(loginUsername, loginPassword) { } let actualId = resolvedId.split(":")[0]; - //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 passwordHash = await hashSHA3_512(loginPassword); let response = await fetch(`${url}/auth?id=${actualId}`, { method: "GET", headers: { - "X-Crypto": "g1", - "secret": await encryptWithNonce(credHash, credHash, await getNonce(actualId, credHash)) + "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(actualId, passwordHash)) } }); 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 { @@ -923,7 +902,6 @@ async function fetchEncrypted(request, body = "") { , passwordHash, nonce), headers: { - "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); @@ -951,7 +929,6 @@ 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) } @@ -1069,35 +1046,13 @@ function clearAction(actionid) { } -//solony, wolny KDF - 600k iteracji PBKDF2-SHA256, wyjscie 512 bitow (128 hex) -async function deriveCredentialKdf(passwordPlain, saltB64) { +async function hashSHA3_512(input) { const encoder = new TextEncoder(); - 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; + 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; } //placeholder pfp @@ -1447,10 +1402,9 @@ async function mainJS() { } await initBlahs(); - //emotes.json leży w katalogu głównym - na stronie /login fetch by trafił w HTML - if (!window.location.pathname.includes("/login")) { - await loadEmotes(); - } + await loadEmotes(); + + passwordHash = await hashSHA3_512(password); if (host) { await updateProtocolAndUrl(host); @@ -1466,12 +1420,6 @@ 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(); } @@ -1925,7 +1873,6 @@ 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); @@ -1949,7 +1896,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() !== "" ? `
${escapeHtml(bio)}
` : ""; + let bioHtml = bio && bio.trim() !== "" ? `
${bio.replace(//g, ">")}
` : ""; let isOwnProfile = accountId === id; let actionBtnHtml = ""; @@ -2045,8 +1992,8 @@ async function openProfile(accountId) {
-
${escapeHtml(profileName)}
-
${escapeHtml(profileName)}:${escapeHtml(profileHost)} (${escapeHtml(accountId.split(':')[0].split(';')[0])})
+
${profileName}
+
${profileName}:${profileHost} (${accountId.split(':')[0].split(';')[0]})
${roleHtml}
${bioHtml} @@ -2182,7 +2129,7 @@ async function renderInvites(res, type) { if (invite.type === "dm") { - let id = sanitizeId(invite.id); + let id = invite.id; if (!id.includes(":")) { id += `:${host}`; @@ -2197,7 +2144,7 @@ async function renderInvites(res, type) { let actions = ""; let desc = ""; - let displayName = escapeHtml(username || onlyId); + let displayName = username || onlyId; if (type === "received") { desc = `:desc.invite.dm.received:${displayName};${onlyId}`; actions = ` @@ -2227,8 +2174,8 @@ async function renderInvites(res, type) { html += entry; } else if (invite.type === "group") { - let groupId = sanitizeId(invite.groupId || invite.id); - let targetId = sanitizeId((type === "received") ? invite.sender : invite.receiver); + let groupId = invite.groupId || invite.id; + let targetId = (type === "received") ? invite.sender : invite.receiver; if (!targetId.includes(":")) { targetId += `:${host}`; @@ -2251,9 +2198,9 @@ async function renderInvites(res, type) { let actions = ""; let desc = ""; - let displayName = escapeHtml(username || onlyId); - let val1 = `${displayName}`; - let val2 = `${escapeHtml(onlyId)}`; + let displayName = username || onlyId; + let val1 = `${displayName}`; + let val2 = `${onlyId}`; if (type === "received") { desc = `:desc.invite.group.received:${val1};${val2}`; @@ -2659,17 +2606,16 @@ async function renderDms(res) { let html = ""; let count = 0; - for (let [dmIdRaw, dmData] of dmEntries) { - let dmId = sanitizeId(dmIdRaw); + for (let [dmId, dmData] of dmEntries) { let parts = dmId.split('_'); let myId = `${id};${host}`; - let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); + let targetId = 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 = escapeHtml(targetUsername.split(':')[0]); + let displayName = targetUsername.split(':')[0]; let isRead = dmData.string3 === "true"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; @@ -2683,7 +2629,7 @@ async function renderDms(res) {
${displayName} - ${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])}) + ${targetUsername} (${targetId.split(';')[0]})
${unreadBadge} @@ -2739,23 +2685,19 @@ 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'); } } @@ -2801,18 +2743,6 @@ 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}`); @@ -2821,28 +2751,24 @@ 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 `
- -
@@ -2959,7 +2885,7 @@ async function generateGroupMembersHtml(groupId, groupName) { `; @@ -2969,16 +2895,14 @@ async function generateGroupMembersHtml(groupId, groupName) { if (!memberUsername || memberUsername.startsWith("error")) continue; let memberPfp = await getAvatarUrl(memberId, memberUsername); - let memberDisplayName = escapeHtml(memberUsername.split(':')[0]); - let safeMemberId = sanitizeId(memberId); - let safeGroupId = sanitizeId(groupId); + let memberDisplayName = memberUsername.split(':')[0]; html += ` - `; } @@ -3001,16 +2925,6 @@ 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"); @@ -3022,16 +2936,8 @@ async function openDm(dmId, username, targetId, type = 'dms') { window.currentGroupKeyIndex = info.string4 || "0"; window.currentGroupOwner = info.string7; } else { - 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; + window.currentGroupKeyIndex = "0"; + window.currentGroupOwner = ""; } } @@ -3109,9 +3015,13 @@ 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") { - //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); + 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
`); return; } showBlahNotification(res); @@ -3233,8 +3143,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { } } - let safeAttId = sanitizeId(att.id); - let attContainerId = `att-container-${sanitizeId(String(msgId))}-${safeAttId}`; + let attContainerId = `att-container-${msgId}-${att.id}`; 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); @@ -3245,7 +3154,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)}
@@ -3306,11 +3215,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); @@ -3320,7 +3229,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { let targetId = ""; let parts = content.split(':'); if (parts.length >= 2 && parts[0].includes(';')) { - targetId = sanitizeId(parts[0]); + targetId = parts[0]; targetUsername = escapeHtml(parts.slice(1).join(':')); } else { targetUsername = escapeHtml(parts[0]); @@ -3366,7 +3275,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]); } if (dmUsernameCache[msg.author]) { - authorName = escapeHtml(dmUsernameCache[msg.author].split(':')[0]); + authorName = dmUsernameCache[msg.author].split(':')[0]; pfp = dmPfpCache[msg.author]; } } @@ -3446,7 +3355,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')} ${escapeHtml(sName)}`; + el.innerHTML = `${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${sName}`; } } } @@ -3456,7 +3365,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')} ${escapeHtml(replyAuthor)}
`; + repliedHtml = `
${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${replyAuthor}
`; } let reactionsHtml = ""; @@ -3496,7 +3405,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 += `
`; } @@ -3506,11 +3415,11 @@ async function generateMessagesHtml(entries, isPinnedContext = false) { } html += ` -
- ${showAvatar ? `` : `
`} -
+
+ ${showAvatar ? `` : `
`} +
${showAvatar ? `
- ${authorName} + ${authorName} ${timeStr}
` : ""} ${repliedHtml} @@ -3589,21 +3498,7 @@ 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')) { - //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]); - } - } + if (oldAttachments.getAttribute('data-raw') !== newAttachments.getAttribute('data-raw')) oldAttachments.innerHTML = newAttachments.innerHTML; } else { let contentNode = existingChild.querySelector('.chat-message-content'); if (oldReactions) contentNode.insertBefore(newAttachments, oldReactions); @@ -3861,7 +3756,7 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
-
${escapeHtml(content)} (${processBlah("info.sending.message")})
+
${content} (${processBlah("info.sending.message")})
`; @@ -3893,7 +3788,6 @@ 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 @@ -4097,10 +3991,6 @@ 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); @@ -4153,7 +4043,7 @@ function handleMessageContextMenu(e, msgId) { if (pinContainer) { let isPinned = msg && msg.pinned === "true"; pinContainer.innerHTML = ` - `; @@ -4569,14 +4459,7 @@ async function changePassword() { if (!newPass) return; showAction("action.account.saving", "accountsave"); - //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 newPassHash = await hashSHA3_512(newPass); let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass); let payload = JSON.stringify({ @@ -5087,91 +4970,6 @@ 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') { @@ -5428,9 +5226,6 @@ 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 f4b1f83e..3247c504 100644 --- a/webroot/style.css +++ b/webroot/style.css @@ -774,7 +774,7 @@ space{ border-radius: 0.8rem; transform: translateY(calc( ( var(--icon-button-height) + (var(--button-margin) * 2) ) / 2 - 0.6rem)); } - button:hover, input:not([type="range"]):hover, textarea:hover { + button:hover, input:hover, textarea:hover { background-color: rgba(255, 255, 255, 0.1); } .submit-button:hover, .profile-action-btn:hover { @@ -791,11 +791,7 @@ space{ filter: brightness(0.93); } } -/* Suwaki ([type=range]) są wyłączone z efektu klikania całego pola - - przytrzymanie skaluje tylko uchwyt (patrz ::-webkit-slider-thumb:active). */ -button:active, button.is-pressed, -input:not([type="range"]):active, input:not([type="range"]).is-pressed, -textarea:active, textarea.is-pressed { +button:active, button.is-pressed, input:active, input.is-pressed, textarea:active, textarea.is-pressed { background-color: rgba(255, 255, 255, 0.1); transform: var(--press-scale); } @@ -1025,18 +1021,15 @@ button.profile-action-btn.popup-btn-danger:hover, button.submit-button.popup-btn height: auto; } - -input[type="range"] { +/* Custom Zoom Slider Styling */ +#cropper-zoom, +.voice-setting-row input[type="range"] { -webkit-appearance: none; appearance: none; - display: block; - margin: 0; - padding: 0.4rem 0.05rem; - border: none; width: 100%; height: 0.375rem; background: rgba(255, 255, 255, 0.1); - border-radius: 0.34rem; + border-radius: 0.1875rem; outline: none; } @@ -1044,7 +1037,8 @@ input[type="range"] { margin: 0.625rem 0; } -input[type="range"]::-webkit-slider-thumb { +#cropper-zoom::-webkit-slider-thumb, +.voice-setting-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 1rem; @@ -1055,16 +1049,14 @@ input[type="range"]::-webkit-slider-thumb { transition: transform 0.2s, background 0.2s; } -input[type="range"]::-webkit-slider-thumb:hover { - transform: scale(1.08); +#cropper-zoom::-webkit-slider-thumb:hover, +.voice-setting-row input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.2); background: #fff; } -input[type="range"]::-webkit-slider-thumb:active { - transform: scale(0.87); -} - -input[type="range"]::-moz-range-thumb { +#cropper-zoom::-moz-range-thumb, +.voice-setting-row input[type="range"]::-moz-range-thumb { width: 1rem; height: 1rem; border-radius: 50%; @@ -1074,15 +1066,12 @@ input[type="range"]::-moz-range-thumb { border: none; } -input[type="range"]::-moz-range-thumb:hover { - transform: scale(1.08); +#cropper-zoom::-moz-range-thumb:hover, +.voice-setting-row input[type="range"]::-moz-range-thumb:hover { + transform: scale(1.2); background: #fff; } -input[type="range"]::-moz-range-thumb:active { - transform: scale(0.87); -} - roomcontent roomtopbar .room-action-pin, roomcontent roomtopbar .room-action-call { display: flex; @@ -1429,16 +1418,10 @@ 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-size, 9rem); - height: var(--voice-tile-size, 9rem); - flex: 0 0 auto; + width: var(--voice-tile-w, 9rem); + height: var(--voice-tile-h, 8rem); background: var(--light-border-color); background-size: cover; background-position: center; @@ -1724,6 +1707,13 @@ input:checked + .toggle-slider:before { display: flex; justify-content: space-between; } +.voice-setting-row input[type="range"] { + margin: 0; + width: 100%; + appearance: none; + -webkit-appearance: none; + outline: none; +} /* In-call Voice Device Settings Popup */ #voice-device-settings { @@ -1892,10 +1882,10 @@ input:checked + .toggle-slider:before { position: absolute; top: 0; bottom: 0; - width: 0.125rem; - margin-left: -0.0625rem; + width: 2px; + margin-left: -1px; background: rgba(255, 255, 255, 0.65); - border-radius: 0.0625rem; + border-radius: 1px; 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 98bb1f57..ca181587 100644 --- a/webroot/version.js +++ b/webroot/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.2.0"; +const CLIENT_VERSION = "0.1.4"; diff --git a/webroot/voice.js b/webroot/voice.js index 09dd8f0f..d4cf5d78 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -3,99 +3,10 @@ let currentVoiceCallId = null; let currentVoiceCallType = null; let voiceWsHeartbeat = null; let voiceActiveUsers = []; -let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ +let voiceAesKeyBytes = null; +let voiceAesCryptoKey = null; 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 = {}; @@ -128,8 +39,6 @@ let peerLastSeen = {}; let deadPeers = new Set(); let lastVoiceServerPacketAt = 0; let voicePingTimer = null; -let voiceRosterRetryTimer = null; -let lastVoiceKeyRequestAt = 0; let voiceReconnectAttempts = 0; let voiceSocketOpenedAt = 0; @@ -152,9 +61,9 @@ function cleanupPeerMedia(userId) { } function sendVoicePing() { - 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 || !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) return; const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); @@ -621,16 +530,6 @@ 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); @@ -783,8 +682,6 @@ 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; } @@ -883,21 +780,7 @@ async function startVoiceSession(serverUrl, ticket) { } catch (e) {} }; fetchUsers(); - //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); + voiceWsHeartbeat = setInterval(fetchUsers, 5000); voiceReconnectAttempts = 0; lastVoiceServerPacketAt = Date.now(); @@ -964,9 +847,9 @@ async function startVoiceSession(serverUrl, ticket) { if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)'; if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; - if (!voiceMySenderCryptoKey) return; - const iv = nextVoiceIv(); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); + if (!voiceAesCryptoKey) return; + const iv = crypto.getRandomValues(new Uint8Array(12)); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, 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); @@ -1003,10 +886,7 @@ async function startVoiceSession(serverUrl, ticket) { const iv = new Uint8Array(arrayBuffer, 10, 12); const ciphertext = new Uint8Array(arrayBuffer, 22); - if (!voiceAesKeyBytes) return; - - //podklucz nadawcy - deterministyczny z (root, clientId) - const senderKeyPromise = getVoicePeerSenderKey(clientId); + if (!voiceAesCryptoKey) return; if (type === 0 || type === 4) { const isScreenAudio = (type === 4); @@ -1019,9 +899,7 @@ async function startVoiceSession(serverUrl, ticket) { const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode; audioDecryptChains[audioId].enqueue(async () => { try { - const senderKey = await senderKeyPromise; - if (!senderKey) return; - const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); + const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext); if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); @@ -1033,9 +911,7 @@ async function startVoiceSession(serverUrl, ticket) { videoDecryptChains[decoderId].enqueue(async () => { try { - const senderKey = await senderKeyPromise; - if (!senderKey) return; - const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); + const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext); if (!remoteVideos[decoderId]) { const canvas = document.createElement('canvas'); @@ -1095,12 +971,8 @@ async function startVoiceSession(serverUrl, ticket) { } catch (err) {} }); } else if (type === 3) { - (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]; + crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext).then(decrypted => { + const stoppedTypeId = new Uint8Array(decrypted)[0]; if (stoppedTypeId === 4) { const audioId = `${clientId}_screen`; if (remoteAudio[audioId]) { @@ -1117,8 +989,7 @@ async function startVoiceSession(serverUrl, ticket) { updateVoiceUIUsers(); } } - } catch (err) {} - })(); + }).catch(() => {}); } }; @@ -1150,12 +1021,9 @@ async function startVoiceSession(serverUrl, ticket) { } } -async function handleCallUpdate(data) { +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(":")); @@ -1166,13 +1034,10 @@ async function handleCallUpdate(data) { if (currentVoiceCallId === roomId) { let myIdStr = String(window.id); if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) { - setVoiceRootKey(crypto.getRandomValues(new Uint8Array(32))); + voiceAesKeyBytes = crypto.getRandomValues(new Uint8Array(32)); + crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]) + .then(k => voiceAesCryptoKey = k); } - //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); @@ -1255,7 +1120,7 @@ async function resolveCallerInfo(roomId) { } function showIncomingCallUI(roomId) { - if (document.getElementById("incoming-call-popup") || currentVoiceWs || voiceJoinInFlight) return; + if (document.getElementById("incoming-call-popup") || currentVoiceWs) return; let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0]; @@ -1309,24 +1174,18 @@ function joinIncomingCall(roomId) { initiateVoiceCall(roomId); } -async function sendVoiceSignalToUser(targetUserId, roomId, requestData = false) { +async function sendVoiceSignalToUser(targetUserId, roomId) { try { - 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 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 payload = JSON.stringify({ string1: currentVoiceCallType, @@ -1345,22 +1204,15 @@ async function handleRoomSignal(data) { let signalStr = parts.slice(2).join(":"); let signalObj = JSON.parse(signalStr); - if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId) { + if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId && !voiceAesKeyBytes) { try { let setupData = JSON.parse(signalObj.data); - 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)); - } + 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"]); } catch(e) { console.error("Signal decryption failed", e); } @@ -2186,12 +2038,6 @@ 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; @@ -2206,34 +2052,28 @@ 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-size"); + container.style.removeProperty("--voice-tile-w"); + container.style.removeProperty("--voice-tile-h"); 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ć - //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 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)); - const size = Math.min(maxSize, Math.max(minSize, Math.floor(best))); - const sig = `${n}:${size}`; + const sig = `${n}:${cols}:${w}x${h}`; if (container.dataset.layoutSig === sig) return; container.dataset.layoutSig = sig; - container.style.setProperty("--voice-tile-size", size + "px"); + container.style.setProperty("--voice-tile-w", w + "px"); + container.style.setProperty("--voice-tile-h", h + "px"); } let voiceSettingsState = null; @@ -2339,16 +2179,10 @@ 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'}); - setVoiceRootKey(null); - voiceSendEpoch = null; - voiceSendCounter = 0n; + voiceAesKeyBytes = null; + voiceAesCryptoKey = null; lastVoiceServerPacketAt = Date.now(); } @@ -2409,11 +2243,8 @@ 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){} @@ -2444,9 +2275,8 @@ function endVoiceSession() { remoteVideos = {}; videoDecryptChains = {}; - setVoiceRootKey(null); - voiceSendEpoch = null; - voiceSendCounter = 0n; + voiceAesKeyBytes = null; + voiceAesCryptoKey = null; currentVoiceWs = null; currentVoiceCallType = null; currentVoiceCallId = null; @@ -2492,11 +2322,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 || !voiceMySenderCryptoKey) return; + if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; // Same backpressure valve as the mic sender. if (currentVoiceWs.bufferedAmount > 256 * 1024) return; - const iv = nextVoiceIv(); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, 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); @@ -2527,10 +2357,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 || !voiceMySenderCryptoKey) return; + if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; const buffer = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buffer); - const iv = nextVoiceIv(); - const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, 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); @@ -2582,9 +2412,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { updateVoiceUIUsers(); btnEl.classList.remove('voice-btn-active'); - 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); + 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); 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); } @@ -2593,9 +2423,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 && voiceMySenderCryptoKey) { - const bufferAudioInfo = new Uint8Array([4]); const ivA = nextVoiceIv(); - const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceMySenderCryptoKey, bufferAudioInfo); + 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); 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); }