massive security update
This commit is contained in:
parent
cab6314bc7
commit
1593a3a33a
20 changed files with 1121 additions and 339 deletions
302
webroot/voice.js
302
webroot/voice.js
|
|
@ -3,10 +3,99 @@ let currentVoiceCallId = null;
|
|||
let currentVoiceCallType = null;
|
||||
let voiceWsHeartbeat = null;
|
||||
let voiceActiveUsers = [];
|
||||
let voiceAesKeyBytes = null;
|
||||
let voiceAesCryptoKey = null;
|
||||
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ
|
||||
let voiceUiContainer = null;
|
||||
|
||||
// === Gwarantowana unikalność IV (AES-GCM): ===
|
||||
// 1) każdy nadawca szyfruje WŁASNYM podkluczem HKDF(root, "larpix:voice:v1:"+senderId)
|
||||
// -> kolizja między nadawcami niemożliwa z konstrukcji,
|
||||
// 2) IV(12B) = epoka(4B = SHA-256(ticketPublicId)[0..4]) || licznik(8B BE, ściśle rosnący)
|
||||
// -> kolizja w ramach jednego nadawcy niemożliwa w obrębie epoki; każde dołączenie
|
||||
// do calla ma unikalny ticketPublicId (single-use po stronie serwera), a licznik
|
||||
// może się resetować TYLKO razem ze zmianą klucza root.
|
||||
let voiceMySenderCryptoKey = null; // mój podklucz nadawcy (CryptoKey)
|
||||
const voicePeerSenderKeys = new Map(); // clientIdStr -> Promise<CryptoKey> odbiorczy
|
||||
let voiceSendEpoch = null; // Uint8Array(4)
|
||||
let voiceSendCounter = 0n; // BigInt, monotoniczny w ramach epoki
|
||||
|
||||
//Promise rozstrzygany gdy operacja na kluczu root (ustawienie/wyczyszczenie)
|
||||
//jest gotowa - pozwala innym kodom czekać zamiast wchodzić w wyścig z derivacją.
|
||||
let voiceRootKeyReady = Promise.resolve();
|
||||
|
||||
const VOICE_HKDF_INFO_PREFIX = "larpix:voice:v1:";
|
||||
|
||||
function voiceHkdfInfoBytes(senderIdStr) {
|
||||
const prefix = new TextEncoder().encode(VOICE_HKDF_INFO_PREFIX);
|
||||
const idBytes = new TextEncoder().encode(String(senderIdStr));
|
||||
const out = new Uint8Array(prefix.length + idBytes.length);
|
||||
out.set(prefix, 0);
|
||||
out.set(idBytes, prefix.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveVoiceSenderKey(rootBytes, senderIdStr) {
|
||||
const ikm = await crypto.subtle.importKey("raw", rootBytes, "HKDF", false, ["deriveBits"]);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: voiceHkdfInfoBytes(senderIdStr)},
|
||||
ikm, 256
|
||||
);
|
||||
return await crypto.subtle.importKey("raw", new Uint8Array(bits), {name: "AES-GCM"}, false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
//Jedyny punkt zmiany klucza root. Reset licznika jest poprawny wyłącznie tutaj -
|
||||
//nowy root = świeża przestrzeń IV. Null czyści cały stan.
|
||||
function setVoiceRootKey(bytesOrNull) {
|
||||
const done = (async () => {
|
||||
if (!bytesOrNull) {
|
||||
voiceAesKeyBytes = null;
|
||||
voiceMySenderCryptoKey = null;
|
||||
voicePeerSenderKeys.clear();
|
||||
return;
|
||||
}
|
||||
voicePeerSenderKeys.clear();
|
||||
const k = await deriveVoiceSenderKey(bytesOrNull, String(window.id));
|
||||
voiceAesKeyBytes = bytesOrNull;
|
||||
voiceMySenderCryptoKey = k;
|
||||
})();
|
||||
voiceRootKeyReady = done;
|
||||
return done;
|
||||
}
|
||||
|
||||
//Epoka z unikalnego per-dołączenie ticketPublicId - deterministycznie, bez dodatkowego losowania.
|
||||
function setVoiceEpochFromTicket(publicIdB64u) {
|
||||
crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(publicIdB64u))).then(d => {
|
||||
voiceSendEpoch = new Uint8Array(d).slice(0, 4);
|
||||
voiceSendCounter = 0n;
|
||||
}).catch(() => { voiceSendEpoch = null; voiceSendCounter = 0n; });
|
||||
}
|
||||
|
||||
//Jedynne źródło IV do wysyłki. Inkrementacja synchroniczna (single-thread JS = atomiczne).
|
||||
function nextVoiceIv() {
|
||||
if (!voiceSendEpoch) throw new Error("voice epoch not ready");
|
||||
voiceSendCounter += 1n;
|
||||
if (voiceSendCounter >= (1n << 63n)) throw new Error("voice iv counter exhausted"); //praktycznie nieosiągalne
|
||||
const iv = new Uint8Array(12);
|
||||
iv.set(voiceSendEpoch, 0);
|
||||
let c = voiceSendCounter;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
iv[11 - i] = Number(c & 0xffn);
|
||||
c >>= 8n;
|
||||
}
|
||||
return iv;
|
||||
}
|
||||
|
||||
//Podklucz odbiorczy danego nadawcy (cache). clientId to dziesiętny string ulonga -
|
||||
//dokładnie ta sama forma, której nadawca użył przy derivacji własnego klucza.
|
||||
function getVoicePeerSenderKey(clientIdStr) {
|
||||
let p = voicePeerSenderKeys.get(clientIdStr);
|
||||
if (!p && voiceAesKeyBytes) {
|
||||
p = deriveVoiceSenderKey(voiceAesKeyBytes, clientIdStr);
|
||||
voicePeerSenderKeys.set(clientIdStr, p);
|
||||
}
|
||||
return p || null;
|
||||
}
|
||||
|
||||
|
||||
let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
|
||||
let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null;
|
||||
let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {};
|
||||
|
|
@ -39,6 +128,8 @@ let peerLastSeen = {};
|
|||
let deadPeers = new Set();
|
||||
let lastVoiceServerPacketAt = 0;
|
||||
let voicePingTimer = null;
|
||||
let voiceRosterRetryTimer = null;
|
||||
let lastVoiceKeyRequestAt = 0;
|
||||
let voiceReconnectAttempts = 0;
|
||||
let voiceSocketOpenedAt = 0;
|
||||
|
||||
|
|
@ -61,9 +152,9 @@ function cleanupPeerMedia(userId) {
|
|||
}
|
||||
|
||||
function sendVoicePing() {
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => {
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||
const iv = nextVoiceIv();
|
||||
crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => {
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||
payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
||||
|
|
@ -530,6 +621,16 @@ setInterval(() => {
|
|||
} else if (audioContext && audioContext.state === 'suspended') {
|
||||
audioContext.resume().catch(() => {});
|
||||
}
|
||||
// Safety net for the call-key handshake: if others are in the call but
|
||||
// we never received the root key (raced/lost signal), ask for it
|
||||
// instead of staying deaf until someone refreshes the page.
|
||||
if (others > 0 && !voiceAesKeyBytes && now - lastVoiceKeyRequestAt > 4000) {
|
||||
lastVoiceKeyRequestAt = now;
|
||||
console.debug("Voice: requesting call key from peers");
|
||||
for (const u of voiceActiveUsers) {
|
||||
if (u !== String(window.id)) sendVoiceSignalToUser(u, currentVoiceCallId, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) updateVoiceUIUsers();
|
||||
}, 2000);
|
||||
|
|
@ -682,6 +783,8 @@ async function requestVoiceJoin() {
|
|||
|
||||
let serverUrl = parts[0];
|
||||
const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]];
|
||||
//epoka IV z unikalnego (single-use) publicId - każde dołączenie = rozłączna przestrzeń IV
|
||||
setVoiceEpochFromTicket(publicId);
|
||||
if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) {
|
||||
serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl;
|
||||
}
|
||||
|
|
@ -780,7 +883,21 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
} catch (e) {}
|
||||
};
|
||||
fetchUsers();
|
||||
voiceWsHeartbeat = setInterval(fetchUsers, 5000);
|
||||
//The voice-server roster is joined only AFTER ticket validation (an
|
||||
//HTTP roundtrip to the main server), so the immediate fetch can still
|
||||
//miss us - retry quickly until our own tile shows up instead of
|
||||
//staring at an empty call for the first heartbeat.
|
||||
if (voiceRosterRetryTimer) clearInterval(voiceRosterRetryTimer);
|
||||
let rosterTries = 0;
|
||||
voiceRosterRetryTimer = setInterval(() => {
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN ||
|
||||
voiceActiveUsers.includes(String(window.id)) || ++rosterTries > 12) {
|
||||
clearInterval(voiceRosterRetryTimer);
|
||||
voiceRosterRetryTimer = null;
|
||||
return;
|
||||
}
|
||||
fetchUsers();
|
||||
}, 400);
|
||||
|
||||
voiceReconnectAttempts = 0;
|
||||
lastVoiceServerPacketAt = Date.now();
|
||||
|
|
@ -847,9 +964,9 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)';
|
||||
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
|
||||
|
||||
if (!voiceAesCryptoKey) return;
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, audioData);
|
||||
if (!voiceMySenderCryptoKey) return;
|
||||
const iv = nextVoiceIv();
|
||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData);
|
||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||
payload[0] = 0; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
||||
currentVoiceWs.send(payload.buffer);
|
||||
|
|
@ -886,7 +1003,10 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
const iv = new Uint8Array(arrayBuffer, 10, 12);
|
||||
const ciphertext = new Uint8Array(arrayBuffer, 22);
|
||||
|
||||
if (!voiceAesCryptoKey) return;
|
||||
if (!voiceAesKeyBytes) return;
|
||||
|
||||
//podklucz nadawcy - deterministyczny z (root, clientId)
|
||||
const senderKeyPromise = getVoicePeerSenderKey(clientId);
|
||||
|
||||
if (type === 0 || type === 4) {
|
||||
const isScreenAudio = (type === 4);
|
||||
|
|
@ -899,7 +1019,9 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode;
|
||||
audioDecryptChains[audioId].enqueue(async () => {
|
||||
try {
|
||||
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
|
||||
const senderKey = await senderKeyPromise;
|
||||
if (!senderKey) return;
|
||||
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
||||
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
|
||||
|
||||
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
|
||||
|
|
@ -911,7 +1033,9 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
|
||||
videoDecryptChains[decoderId].enqueue(async () => {
|
||||
try {
|
||||
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
|
||||
const senderKey = await senderKeyPromise;
|
||||
if (!senderKey) return;
|
||||
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
||||
|
||||
if (!remoteVideos[decoderId]) {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
|
@ -971,8 +1095,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
} catch (err) {}
|
||||
});
|
||||
} else if (type === 3) {
|
||||
crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext).then(decrypted => {
|
||||
const stoppedTypeId = new Uint8Array(decrypted)[0];
|
||||
(async () => {
|
||||
try {
|
||||
const senderKey = await senderKeyPromise;
|
||||
if (!senderKey) return;
|
||||
const decrypted = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
||||
const stoppedTypeId = new Uint8Array(decrypted)[0];
|
||||
if (stoppedTypeId === 4) {
|
||||
const audioId = `${clientId}_screen`;
|
||||
if (remoteAudio[audioId]) {
|
||||
|
|
@ -989,7 +1117,8 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
updateVoiceUIUsers();
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
} catch (err) {}
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1021,9 +1150,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
}
|
||||
}
|
||||
|
||||
function handleCallUpdate(data) {
|
||||
async function handleCallUpdate(data) {
|
||||
let parts = data.split(":");
|
||||
let roomId = parts[1];
|
||||
//Ignore updates for rooms we have nothing to do with - they would otherwise
|
||||
//overwrite the participant list of an ongoing call and wipe its tiles.
|
||||
if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return;
|
||||
let state = [];
|
||||
if(parts.slice(2).join(":") !== "") {
|
||||
let stateRaw = JSON.parse(parts.slice(2).join(":"));
|
||||
|
|
@ -1034,10 +1166,13 @@ function handleCallUpdate(data) {
|
|||
if (currentVoiceCallId === roomId) {
|
||||
let myIdStr = String(window.id);
|
||||
if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) {
|
||||
voiceAesKeyBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"])
|
||||
.then(k => voiceAesCryptoKey = k);
|
||||
setVoiceRootKey(crypto.getRandomValues(new Uint8Array(32)));
|
||||
}
|
||||
//The root key is set asynchronously - wait for it before distributing.
|
||||
//Otherwise a fast second join produces [me,peer] while the derivation
|
||||
//is still running, no signal is sent and the peer never gets the key
|
||||
//(audio stayed broken for everyone until someone refreshed the page).
|
||||
await voiceRootKeyReady;
|
||||
if (voiceAesKeyBytes) {
|
||||
for (let u of state) {
|
||||
if (u !== myIdStr) sendVoiceSignalToUser(u, roomId);
|
||||
|
|
@ -1120,7 +1255,7 @@ async function resolveCallerInfo(roomId) {
|
|||
}
|
||||
|
||||
function showIncomingCallUI(roomId) {
|
||||
if (document.getElementById("incoming-call-popup") || currentVoiceWs) return;
|
||||
if (document.getElementById("incoming-call-popup") || currentVoiceWs || voiceJoinInFlight) return;
|
||||
|
||||
let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null;
|
||||
if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0];
|
||||
|
|
@ -1174,18 +1309,24 @@ function joinIncomingCall(roomId) {
|
|||
initiateVoiceCall(roomId);
|
||||
}
|
||||
|
||||
async function sendVoiceSignalToUser(targetUserId, roomId) {
|
||||
async function sendVoiceSignalToUser(targetUserId, roomId, requestData = false) {
|
||||
try {
|
||||
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`);
|
||||
let publicKeys = JSON.parse(publicKeysRaw);
|
||||
let hybrid = await deriveHybridKeyToUser(publicKeys);
|
||||
|
||||
let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes);
|
||||
let setupPayloadJson = JSON.stringify({
|
||||
v: 1,
|
||||
ctMlKem: hybrid.ciphertextMlKemBase64,
|
||||
enc: encForMember
|
||||
});
|
||||
let setupPayloadJson;
|
||||
if (!requestData && voiceAesKeyBytes) {
|
||||
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`);
|
||||
let publicKeys = JSON.parse(publicKeysRaw);
|
||||
let hybrid = await deriveHybridKeyToUser(publicKeys);
|
||||
|
||||
let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes);
|
||||
setupPayloadJson = JSON.stringify({
|
||||
v: 1,
|
||||
ctMlKem: hybrid.ciphertextMlKemBase64,
|
||||
enc: encForMember
|
||||
});
|
||||
} else {
|
||||
//Prośba o klucz rozmowy (bez sekretów) - adresat z kluczem odpowie pełnym sygnałem.
|
||||
setupPayloadJson = JSON.stringify({ v: 1, needKey: true });
|
||||
}
|
||||
|
||||
let payload = JSON.stringify({
|
||||
string1: currentVoiceCallType,
|
||||
|
|
@ -1204,15 +1345,22 @@ async function handleRoomSignal(data) {
|
|||
let signalStr = parts.slice(2).join(":");
|
||||
let signalObj = JSON.parse(signalStr);
|
||||
|
||||
if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId && !voiceAesKeyBytes) {
|
||||
if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId) {
|
||||
try {
|
||||
let setupData = JSON.parse(signalObj.data);
|
||||
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`);
|
||||
let otherPublicKeysObj = JSON.parse(publicKeysRaw);
|
||||
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
|
||||
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
|
||||
voiceAesKeyBytes = base64ToUint8(decRoomKeyB64);
|
||||
voiceAesCryptoKey = await crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]);
|
||||
if (setupData.needKey) {
|
||||
//Peer nie dostał klucza rozmowy (wygryziony/zagubiony sygnał) -
|
||||
//odeślij mu go, ale tylko jeśli sami już go mamy.
|
||||
if (voiceAesKeyBytes) await sendVoiceSignalToUser(signalObj.from, roomId);
|
||||
return;
|
||||
}
|
||||
if (!voiceAesKeyBytes) {
|
||||
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`);
|
||||
let otherPublicKeysObj = JSON.parse(publicKeysRaw);
|
||||
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
|
||||
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
|
||||
await setVoiceRootKey(base64ToUint8(decRoomKeyB64));
|
||||
}
|
||||
} catch(e) {
|
||||
console.error("Signal decryption failed", e);
|
||||
}
|
||||
|
|
@ -2038,6 +2186,12 @@ function ensureVoiceGridObserver() {
|
|||
voiceGridRO.observe(container);
|
||||
}
|
||||
|
||||
/*
|
||||
* Wszystkie kafelki to kwadraty o wspólnym rozmiarze --voice-tile-size.
|
||||
* Wybieramy największy rozmiar kwadratu, przy którym n kafelków mieści się
|
||||
* w oknie rozmowy - układanie i zawijanie do następnych rzędów robi sam
|
||||
* flex-wrap w CSS (justify-content: center).
|
||||
*/
|
||||
function relayoutVoiceTiles() {
|
||||
let container = document.getElementById("voice-users");
|
||||
if (!container || !container.isConnected) return;
|
||||
|
|
@ -2052,28 +2206,34 @@ function relayoutVoiceTiles() {
|
|||
const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
||||
|
||||
if (!isFinite(innerW) || !isFinite(innerH) || !isFinite(rootFs) || innerW <= 0 || innerH <= 0 || rootFs <= 0) {
|
||||
container.style.removeProperty("--voice-tile-w");
|
||||
container.style.removeProperty("--voice-tile-h");
|
||||
container.style.removeProperty("--voice-tile-size");
|
||||
delete container.dataset.layoutSig;
|
||||
return;
|
||||
}
|
||||
|
||||
const minCol = 6.25 * rootFs;
|
||||
const minRow = 7 * rootFs;
|
||||
const gap = 0.75 * rootFs;
|
||||
const maxSize = 18 * rootFs; // jedna osoba nie dostanie gigantycznego kafelka
|
||||
const minSize = 4.5 * rootFs; // poniżej tego wolimy scrollować niż spłaszczać
|
||||
|
||||
const maxCols = Math.max(1, Math.floor((innerW + gap) / (minCol + gap)));
|
||||
const cols = Math.min(n, maxCols);
|
||||
const rows = Math.ceil(n / cols);
|
||||
const w = Math.floor((innerW - gap * (cols - 1)) / cols);
|
||||
const h = Math.floor(Math.max(minRow, (innerH - gap * (rows - 1)) / rows));
|
||||
//Największy kwadrat, przy którym n kafelków mieści się w dostępnej powierzchni:
|
||||
//próbujemy liczby kolumn 1..n, dla każdej wyliczamy ograniczony rozmiar
|
||||
//(szerokość kolumny vs wysokość rzędu) i bierzemy najlepszy wariant.
|
||||
let best = 0;
|
||||
for (let cols = 1; cols <= n; cols++) {
|
||||
const rows = Math.ceil(n / cols);
|
||||
const s = Math.min(
|
||||
(innerW - gap * (cols - 1)) / cols,
|
||||
(innerH - gap * (rows - 1)) / rows
|
||||
);
|
||||
if (s > best) best = s;
|
||||
}
|
||||
|
||||
const sig = `${n}:${cols}:${w}x${h}`;
|
||||
const size = Math.min(maxSize, Math.max(minSize, Math.floor(best)));
|
||||
const sig = `${n}:${size}`;
|
||||
if (container.dataset.layoutSig === sig) return;
|
||||
container.dataset.layoutSig = sig;
|
||||
|
||||
container.style.setProperty("--voice-tile-w", w + "px");
|
||||
container.style.setProperty("--voice-tile-h", h + "px");
|
||||
container.style.setProperty("--voice-tile-size", size + "px");
|
||||
}
|
||||
|
||||
let voiceSettingsState = null;
|
||||
|
|
@ -2179,10 +2339,16 @@ function teardownVoiceTransport() {
|
|||
audioDecryptChains = {};
|
||||
peerLastSeen = {};
|
||||
deadPeers.clear();
|
||||
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
||||
lastVoiceKeyRequestAt = 0;
|
||||
//Stale participants from the previous attempt must not linger in the UI
|
||||
voiceActiveUsers = [];
|
||||
updateVoiceUIUsers();
|
||||
if (receiverNode) receiverNode.port.postMessage({action: 'clear'});
|
||||
if (screenReceiverNode) screenReceiverNode.port.postMessage({action: 'clear'});
|
||||
voiceAesKeyBytes = null;
|
||||
voiceAesCryptoKey = null;
|
||||
setVoiceRootKey(null);
|
||||
voiceSendEpoch = null;
|
||||
voiceSendCounter = 0n;
|
||||
lastVoiceServerPacketAt = Date.now();
|
||||
}
|
||||
|
||||
|
|
@ -2243,8 +2409,11 @@ function endVoiceSession() {
|
|||
|
||||
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
|
||||
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
||||
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
||||
peerLastSeen = {};
|
||||
deadPeers.clear();
|
||||
lastVoiceKeyRequestAt = 0;
|
||||
voiceActiveUsers = [];
|
||||
|
||||
for (let id in audioDecoders) {
|
||||
try { audioDecoders[id].close(); } catch(e){}
|
||||
|
|
@ -2275,8 +2444,9 @@ function endVoiceSession() {
|
|||
remoteVideos = {};
|
||||
videoDecryptChains = {};
|
||||
|
||||
voiceAesKeyBytes = null;
|
||||
voiceAesCryptoKey = null;
|
||||
setVoiceRootKey(null);
|
||||
voiceSendEpoch = null;
|
||||
voiceSendCounter = 0n;
|
||||
currentVoiceWs = null;
|
||||
currentVoiceCallType = null;
|
||||
currentVoiceCallId = null;
|
||||
|
|
@ -2322,11 +2492,11 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
screenAudioSourceNode = audioContext.createMediaStreamSource(stream);
|
||||
screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
||||
screenSenderNode.port.onmessage = async (e) => {
|
||||
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
|
||||
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||
// Same backpressure valve as the mic sender.
|
||||
if (currentVoiceWs.bufferedAmount > 256 * 1024) return;
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, e.data.audioData);
|
||||
const iv = nextVoiceIv();
|
||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData);
|
||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||
payload[0] = 4; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
||||
currentVoiceWs.send(payload.buffer);
|
||||
|
|
@ -2357,10 +2527,10 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
|
||||
const encoder = new VideoEncoder({
|
||||
output: async (chunk) => {
|
||||
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
|
||||
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||
const buffer = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buffer);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer);
|
||||
const iv = nextVoiceIv();
|
||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer);
|
||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||
payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
||||
currentVoiceWs.send(payload.buffer);
|
||||
|
|
@ -2412,9 +2582,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
updateVoiceUIUsers();
|
||||
btnEl.classList.remove('voice-btn-active');
|
||||
|
||||
if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) {
|
||||
const buffer = new Uint8Array([typeId]); const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer);
|
||||
if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceMySenderCryptoKey) {
|
||||
const buffer = new Uint8Array([typeId]); const iv = nextVoiceIv();
|
||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer);
|
||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||
payload[0] = 3; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer);
|
||||
}
|
||||
|
|
@ -2423,9 +2593,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
if (screenAudioSourceNode) { screenAudioSourceNode.disconnect(); screenAudioSourceNode = null; }
|
||||
if (screenSenderNode) { screenSenderNode.disconnect(); screenSenderNode = null; }
|
||||
if (screenAudioEncoder) { try { screenAudioEncoder.close(); } catch (e) {} screenAudioEncoder = null; }
|
||||
if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) {
|
||||
const bufferAudioInfo = new Uint8Array([4]); const ivA = crypto.getRandomValues(new Uint8Array(12));
|
||||
const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceAesCryptoKey, bufferAudioInfo);
|
||||
if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceMySenderCryptoKey) {
|
||||
const bufferAudioInfo = new Uint8Array([4]); const ivA = nextVoiceIv();
|
||||
const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceMySenderCryptoKey, bufferAudioInfo);
|
||||
const payloadA = new Uint8Array(14 + cipherA.byteLength);
|
||||
payloadA[0] = 3; payloadA[1] = 0; payloadA.set(ivA, 2); payloadA.set(new Uint8Array(cipherA), 14); currentVoiceWs.send(payloadA.buffer);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue