diff --git a/android/app/build.gradle b/android/app/build.gradle index efa09414..7204016e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "olcxja.miarven" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 5 - versionName "0.1.4" + versionCode 6 + versionName "0.2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/assets/public/blah/en-cat.json b/android/app/src/main/assets/public/blah/en-cat.json index 01a784ae..d3ffa2ed 100644 --- a/android/app/src/main/assets/public/blah/en-cat.json +++ b/android/app/src/main/assets/public/blah/en-cat.json @@ -233,6 +233,7 @@ "message.reaction.removed": "Purreaction remeowed", "no.invite": "No meowvite", "not.a.member": "Not a meowmber", + "removed.from.group": "You got hissed out of the clowder", "not.in.group": "Not in purroup", "reaction.not.found": "Purreaction not meownd", "rotation.not.required": "Purrotation not meowquired", diff --git a/android/app/src/main/assets/public/blah/en-us.json b/android/app/src/main/assets/public/blah/en-us.json index fa67cf49..fc6eaaf1 100644 --- a/android/app/src/main/assets/public/blah/en-us.json +++ b/android/app/src/main/assets/public/blah/en-us.json @@ -233,6 +233,7 @@ "message.reaction.removed": "Reaction removed", "no.invite": "No invite", "not.a.member": "Not a member", + "removed.from.group": "You were removed from the group", "not.in.group": "Not in group", "reaction.not.found": "Reaction not found", "rotation.not.required": "Rotation not required", diff --git a/android/app/src/main/assets/public/blah/pl-pl.json b/android/app/src/main/assets/public/blah/pl-pl.json index 68bed510..d8af39cc 100644 --- a/android/app/src/main/assets/public/blah/pl-pl.json +++ b/android/app/src/main/assets/public/blah/pl-pl.json @@ -231,6 +231,7 @@ "message.reaction.removed": "Reakcja usunięta", "no.invite": "Brak zaproszenia", "not.a.member": "Nie jesteś członkiem", + "removed.from.group": "Zostałeś usunięty z grupy", "not.in.group": "Nie jesteś w grupie", "reaction.not.found": "Nie znaleziono reakcji", "rotation.not.required": "Rotacja niewymagana", diff --git a/android/app/src/main/assets/public/login/index.html b/android/app/src/main/assets/public/login/index.html index 111fc1b6..9ef4bf68 100644 --- a/android/app/src/main/assets/public/login/index.html +++ b/android/app/src/main/assets/public/login/index.html @@ -285,7 +285,7 @@ const registerHost = document.getElementById("reg-host"); const registerUsername = document.getElementById("reg-username"); const registerPassword = document.getElementById("reg-password"); - const registerPasswordConfirm = document.getElementById("reg-password"); + const registerPasswordConfirm = document.getElementById("reg-confirm"); const loginHost = document.getElementById("login-host"); const loginUsername = document.getElementById("login-username"); const loginPassword = document.getElementById("login-password"); @@ -382,6 +382,13 @@ var sharedkey = await calcHybridSharedKeyClient(dataarray[0], dataarray[1]); sharedpvkey = sharedkey[2]; createId = dataarray[2]; + //sól z init jest wymagana - hasło zawsze przez PBKDF2 + if (!dataarray[3]) { + showBlahNotification("error:something.wrong"); + authContainer.className = 'auth-container show-register'; + return; + } + let regCredHash = await deriveCredentialKdf(registerPassword.value, dataarray[3]); const captchaimage = await fetch(`${url}/createaccount?step=register`, { method: 'POST', body: JSON.stringify({ @@ -389,7 +396,7 @@ ciphertextMlKem: sharedkey[1], idKey: createId, username: await encrypt(registerUsername.value, sharedpvkey), - password: await encrypt(await hashSHA3_512(registerPassword.value), sharedpvkey) + password: await encrypt(regCredHash, sharedpvkey) }) }); const clone = captchaimage.clone(); diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js index ff092762..fa231a06 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -83,7 +83,9 @@ async function packetEncPass(pass, key, accountId) { async function getNonce(accountId, key) { let nonce; - let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`)).text(); + let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`, { + headers: {"X-Crypto": "g1"} + })).text(); try { nonce = await decryptString(fetchRes, key); @@ -123,7 +125,8 @@ async function calcHybridSharedKeyClient(pubX25519ServerBase64, pubMlKemServerBa function keyDataFromServerJson(jsonFromServer) { const data = JSON.parse(jsonFromServer); - return [data.pubX25519, data.pubMlKem, data.idKey] + //0=pubX25519, 1=pubMlKem, 2=idKey, 3=kdfSalt (wymagany do PBKDF2 przy rejestracji) + return [data.pubX25519, data.pubMlKem, data.idKey, data.kdfSalt] } function base64ToUint8(base64) { @@ -705,25 +708,20 @@ async function decryptBytes(combinedBytes, passphrase) { async function encryptString(plainText, passphrase) { const encoder = new TextEncoder(); - const data = encoder.encode(plainText); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const key = await crypto.subtle.importKey( - 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt'] + 'raw', pwHash, {name: 'AES-GCM'}, false, ['encrypt'] ); - const iv = crypto.getRandomValues(new Uint8Array(16)); - const encrypted = await crypto.subtle.encrypt( - {name: 'AES-CBC', iv: iv}, + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = new Uint8Array(await crypto.subtle.encrypt( + {name: 'AES-GCM', iv: iv}, key, - data - ); + encoder.encode(plainText) + )); - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv); - combined.set(new Uint8Array(encrypted), iv.length); - - return uint8ToBase64(combined); + return "G1:" + uint8ToBase64(concatUint8(iv, encrypted)); } async function encryptBytesWithNonce(dataUint8, key, nonce) { @@ -732,37 +730,44 @@ async function encryptBytesWithNonce(dataUint8, key, nonce) { const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const importedKey = await crypto.subtle.importKey( - 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt'] + 'raw', pwHash, {name: 'AES-GCM'}, false, ['encrypt'] ); - const iv = crypto.getRandomValues(new Uint8Array(16)); - const encrypted = await crypto.subtle.encrypt( - {name: 'AES-CBC', iv: iv}, + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = new Uint8Array(await crypto.subtle.encrypt( + {name: 'AES-GCM', iv: iv}, importedKey, dataUint8 - ); + )); - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv); - combined.set(new Uint8Array(encrypted), iv.length); + const prefix = encoder.encode("G1:"); + const combined = new Uint8Array(prefix.length + iv.length + encrypted.byteLength); + combined.set(prefix, 0); + combined.set(iv, prefix.length); + combined.set(encrypted, prefix.length + iv.length); return combined; } async function decryptString(base64Text, passphrase) { + //tylko AES-GCM ("G1:") - pakiety bez prefiksu odrzucane + if (typeof base64Text !== "string" || !base64Text.startsWith("G1:")) { + throw new Error("error:invalid.cipher.format"); + } + const encoder = new TextEncoder(); - const combined = new Uint8Array(atob(base64Text).split("").map(c => c.charCodeAt(0))); + const combined = base64ToUint8(base64Text.substring(3)); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const key = await crypto.subtle.importKey( - 'raw', pwHash, {name: 'AES-CBC'}, false, ['decrypt'] + 'raw', pwHash, {name: 'AES-GCM'}, false, ['decrypt'] ); - const iv = combined.slice(0, 16); - const data = combined.slice(16); + const iv = combined.slice(0, 12); + const data = combined.slice(12); const decrypted = await crypto.subtle.decrypt( - {name: 'AES-CBC', iv: iv}, + {name: 'AES-GCM', iv: iv}, key, data ); @@ -784,6 +789,7 @@ async function fetchPost(url, value) { method: "POST", body: value, headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); @@ -806,6 +812,7 @@ async function fetchPostEnc(url, value) { method: "POST", body: await encryptWithNonce(value, passwordHash, nonce), headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); @@ -835,6 +842,7 @@ async function fetchAsyncWAuth(url) { let response = await fetch(url, { method: "GET", headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); @@ -864,17 +872,30 @@ async function Auth(loginUsername, loginPassword) { } let actualId = resolvedId.split(":")[0]; - let passwordHash = await hashSHA3_512(loginPassword); + //sól KDF jest wymagana - konta bez soli nie mogą się zalogować + let saltRes = await fetchAsync(`${url}/kdfsalt?id=${actualId}`); + if (!saltRes || !saltRes.startsWith("P:")) { + return "error:invalid.username.or.password"; + } + let credHash = await deriveCredentialKdf(loginPassword, saltRes.substring(2).trim()); + let response = await fetch(`${url}/auth?id=${actualId}`, { method: "GET", headers: { - "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(actualId, passwordHash)) + "X-Crypto": "g1", + "secret": await encryptWithNonce(credHash, credHash, await getNonce(actualId, credHash)) } }); let data = await response.text(); if (data.startsWith("success:")) { data += "|" + actualId; + //utrwal globale sesyjne - dalsze żądania w tej sesji mają od razu ważny hash + passwordHash = credHash; + if (!id) { + id = actualId; + try { localStorage.setItem("id", actualId); } catch (e) {} + } } return data; } finally { @@ -902,6 +923,7 @@ async function fetchEncrypted(request, body = "") { , passwordHash, nonce), headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); @@ -929,6 +951,7 @@ async function fetchEncryptedUpload(target, fileBytesUint8) { method: "POST", body: await encryptBytesWithNonce(fileBytesUint8, passwordHash, nonce), headers: { + "X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, nonce), "target": await encryptWithNonce(target, passwordHash, nonce) } @@ -1046,13 +1069,35 @@ function clearAction(actionid) { } -async function hashSHA3_512(input) { +//solony, wolny KDF - 600k iteracji PBKDF2-SHA256, wyjscie 512 bitow (128 hex) +async function deriveCredentialKdf(passwordPlain, saltB64) { const encoder = new TextEncoder(); - const data = encoder.encode(input); - const hashBuffer = await crypto.subtle.digest('SHA-512', data); //-3 kiedys xddddddddddddddddd - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - return hashHex; + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(passwordPlain), + {name: "PBKDF2"}, + false, + ["deriveBits"] + ); + const bits = await crypto.subtle.deriveBits( + {name: "PBKDF2", hash: "SHA-256", salt: base64ToUint8(saltB64), iterations: 600000}, + keyMaterial, + 512 + ); + return Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +//zwraca null gdy brak konta/soli - celowo NIE rzuca, żeby nie wywalać bootu aplikacji +//(strona logowania i świeży użytkownik nie mają soli; start() sam zrobi Auth i przekieruje) +async function resolveCredentialHash(accountId) { + if (!accountId) return null; + try { + let res = await fetchAsync(`${url}/kdfsalt?id=${accountId}`); + if (res && res.startsWith("P:") && password) { + return await deriveCredentialKdf(password, res.substring(2).trim()); + } + } catch (e) {} + return null; } //placeholder pfp @@ -1402,9 +1447,10 @@ async function mainJS() { } await initBlahs(); - await loadEmotes(); - - passwordHash = await hashSHA3_512(password); + //emotes.json leży w katalogu głównym - na stronie /login fetch by trafił w HTML + if (!window.location.pathname.includes("/login")) { + await loadEmotes(); + } if (host) { await updateProtocolAndUrl(host); @@ -1420,6 +1466,12 @@ async function mainJS() { } } + //hash tylko dla istniejącej sesji; brak soli/konta nie może blokować startu - + //start() i tak wykonuje własny Auth i przekieruje niezalogowanych na login + if (id && password) { + try { passwordHash = await resolveCredentialHash(id); } catch (e) { passwordHash = null; } + } + await start(); } @@ -1873,6 +1925,7 @@ async function openProfile(accountId) { try { if (!accountId) accountId = window.id; //var fallback + accountId = sanitizeId(accountId); if (accountId.endsWith(';' + host)) { accountId = accountId.substring(0, accountId.length - host.length - 1); @@ -1896,7 +1949,7 @@ async function openProfile(accountId) { let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`); if (bio.startsWith("error:")) bio = ""; - let bioHtml = bio && bio.trim() !== "" ? `