diff --git a/tasks/066-download-page-and-update-check.md b/tasks/066-download-page-and-update-check.md index f0a95f8..9626b07 100644 --- a/tasks/066-download-page-and-update-check.md +++ b/tasks/066-download-page-and-update-check.md @@ -44,4 +44,8 @@ to host it in a folder of the existing `choimy0204.github.io` site. ## Review findings -None yet. +- 2026-10-07, product owner: a wrong password still downloaded. In a desktop browser a wrong + password got nothing, but the page fetched the encrypted APK before checking the password, + and a phone browser can keep that fetch as a download (still encrypted, not installable). The + page now opens a small sealed check with the password first and fetches nothing when it is + wrong; tried in the browser: a wrong password made no request, the right one downloaded. diff --git a/tools/publish-download.py b/tools/publish-download.py index 6a9ff40..3585e26 100644 --- a/tools/publish-download.py +++ b/tools/publish-download.py @@ -25,6 +25,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM ITERATIONS = 310_000 ENCRYPTED_NAME = "ubgitea.apk.enc" +CHECK_TEXT = b"UBGitea" def gradle_version(root: Path): @@ -53,6 +54,10 @@ def main(): iv = os.urandom(12) key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, ITERATIONS, 32) sealed = AESGCM(key).encrypt(iv, data, None) + # A few sealed bytes the page opens first: a wrong password is refused before the APK is + # fetched at all, so nothing is downloaded with it. + check_iv = os.urandom(12) + check = AESGCM(key).encrypt(check_iv, CHECK_TEXT, None) args.out.mkdir(parents=True, exist_ok=True) (args.out / ENCRYPTED_NAME).write_bytes(sealed) @@ -62,6 +67,8 @@ def main(): "salt": base64.b64encode(salt).decode(), "iv": base64.b64encode(iv).decode(), "iterations": ITERATIONS, + "check": base64.b64encode(check).decode(), + "checkIv": base64.b64encode(check_iv).decode(), "file": ENCRYPTED_NAME, "fileName": f"UBGitea-{name}.apk", "size": len(data), @@ -138,23 +145,24 @@ document.getElementById("form").addEventListener("submit", async event => { event.preventDefault(); go.disabled = true; try { - say("파일을 받는 중…"); - const response = await fetch(META.file, { cache: "no-store" }); - if (!response.ok) throw new Error("fetch"); - const sealed = await response.arrayBuffer(); - say("여는 중…"); + say("비밀번호 확인 중…"); const password = new TextEncoder().encode(document.getElementById("password").value); const base = await crypto.subtle.importKey("raw", password, "PBKDF2", false, ["deriveKey"]); const key = await crypto.subtle.deriveKey( { name: "PBKDF2", salt: b64(META.salt), iterations: META.iterations, hash: "SHA-256" }, base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]); - let apk; try { - apk = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(META.iv) }, key, sealed); + await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(META.checkIv) }, key, b64(META.check)); } catch (e) { say("비밀번호가 맞지 않습니다.", true); return; } + say("파일을 받는 중…"); + const response = await fetch(META.file, { cache: "no-store" }); + if (!response.ok) throw new Error("fetch"); + const sealed = await response.arrayBuffer(); + say("여는 중…"); + const apk = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(META.iv) }, key, sealed); const url = URL.createObjectURL(new Blob([apk], { type: "application/vnd.android.package-archive" })); const link = document.createElement("a"); link.href = url;