TASK-066: the download page checks the password before fetching

A wrong password fetched the encrypted APK before being refused, and a
phone browser can keep that fetch as a download. A small sealed check is
opened first; nothing is fetched when it fails.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-10-07 20:57:06 +09:00
parent 69556b1e23
commit a02a987946
2 changed files with 20 additions and 8 deletions

View File

@ -44,4 +44,8 @@ to host it in a folder of the existing `choimy0204.github.io` site.
## Review findings ## 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.

View File

@ -25,6 +25,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
ITERATIONS = 310_000 ITERATIONS = 310_000
ENCRYPTED_NAME = "ubgitea.apk.enc" ENCRYPTED_NAME = "ubgitea.apk.enc"
CHECK_TEXT = b"UBGitea"
def gradle_version(root: Path): def gradle_version(root: Path):
@ -53,6 +54,10 @@ def main():
iv = os.urandom(12) iv = os.urandom(12)
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, ITERATIONS, 32) key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, ITERATIONS, 32)
sealed = AESGCM(key).encrypt(iv, data, None) 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.mkdir(parents=True, exist_ok=True)
(args.out / ENCRYPTED_NAME).write_bytes(sealed) (args.out / ENCRYPTED_NAME).write_bytes(sealed)
@ -62,6 +67,8 @@ def main():
"salt": base64.b64encode(salt).decode(), "salt": base64.b64encode(salt).decode(),
"iv": base64.b64encode(iv).decode(), "iv": base64.b64encode(iv).decode(),
"iterations": ITERATIONS, "iterations": ITERATIONS,
"check": base64.b64encode(check).decode(),
"checkIv": base64.b64encode(check_iv).decode(),
"file": ENCRYPTED_NAME, "file": ENCRYPTED_NAME,
"fileName": f"UBGitea-{name}.apk", "fileName": f"UBGitea-{name}.apk",
"size": len(data), "size": len(data),
@ -138,23 +145,24 @@ document.getElementById("form").addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
go.disabled = true; go.disabled = true;
try { try {
say("파일을 받는 중…"); say("비밀번호 확인 중…");
const response = await fetch(META.file, { cache: "no-store" });
if (!response.ok) throw new Error("fetch");
const sealed = await response.arrayBuffer();
say("여는 중…");
const password = new TextEncoder().encode(document.getElementById("password").value); const password = new TextEncoder().encode(document.getElementById("password").value);
const base = await crypto.subtle.importKey("raw", password, "PBKDF2", false, ["deriveKey"]); const base = await crypto.subtle.importKey("raw", password, "PBKDF2", false, ["deriveKey"]);
const key = await crypto.subtle.deriveKey( const key = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: b64(META.salt), iterations: META.iterations, hash: "SHA-256" }, { name: "PBKDF2", salt: b64(META.salt), iterations: META.iterations, hash: "SHA-256" },
base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]); base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
let apk;
try { 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) { } catch (e) {
say("비밀번호가 맞지 않습니다.", true); say("비밀번호가 맞지 않습니다.", true);
return; 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 url = URL.createObjectURL(new Blob([apk], { type: "application/vnd.android.package-archive" }));
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;