diff --git a/Cargo.lock b/Cargo.lock index ac2e980..414d66a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,6 +337,7 @@ dependencies = [ "agent-desktop-macos", "agent-desktop-windows", "arboard", + "argon2", "ashpd", "async-io", "base64 0.22.1", @@ -408,6 +409,18 @@ dependencies = [ "x11rb 0.13.2", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -651,6 +664,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.10.0" @@ -678,6 +697,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -924,7 +952,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1376,6 +1404,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -2082,7 +2111,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -3670,6 +3699,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pdf-extract" version = "0.12.1" @@ -4080,9 +4120,15 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index 50eeed7..59e0fae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ pulldown-cmark = { version = "0.13", default-features = false } # UI Automation on Windows): numbered UI elements a bot can act on without screenshots. # vercel-labs/agent-desktop, Apache-2.0, pinned to a release. agent-desktop-core = { git = "https://github.com/vercel-labs/agent-desktop", tag = "v1.1.0" } +argon2 = "0.5" [profile.release] opt-level = 2 diff --git a/docs/protocol.md b/docs/protocol.md index c6964b0..2c6f07a 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -18,9 +18,15 @@ | 方法 | 路徑 | 說明 | |---|---|---| | POST | `/relay/signup` | `{user, password, invite}`。第一個帳號不用邀請碼,之後要 `ANYBOT_RELAY_INVITE` | -| POST | `/relay/login` | `{user, password, device}` → `{token}`。錯 8 次停 5 分鐘 | +| POST | `/relay/login` | `{user, password, device}` → `{token}`;開了兩步驗證時回 `{need_2fa: true, challenge}`(5 分鐘內有效)。IP+帳號 8 次/5 分锁定 | +| POST | `/relay/2fa/verify` | 兩步驗證的第二步。登入時:`{challenge, code}` → `{token}`(TOTP 六位碼,或一次性恢復碼 → 多附 `used_recovery: true`);設定時(帶 session):`{code}` 啟用新-secret | +| POST | `/relay/2fa/enroll` | (帶 session)開始設定 → `{secret, uri, recovery_codes}`(恢復碼只顯示這一次;2FA 要到 verify 成功才生效) | +| POST | `/relay/2fa/disable` | (帶 session)`{code}` 關掉兩步驗證 | +| POST | `/relay/sessions/logout` | (帶 session)一頁登出:踢掉這帳號的**所有**裝置(含自己) | | POST | `/relay/logout` | 登出這個 session | -| GET | `/relay/me` | 帳號、可用的 AnyBot(含 `online`、`uses`、`hands`)、登入過的裝置、有沒有備份 | +| GET | `/relay/me` | 帳號、`two_factor`、可用的 AnyBot(含 `online`、`uses`、`hands`)、登入過的裝置、有沒有備份 | + +密碼雜湊為 argon2id(memory-hard);舊 PBKDF2 雜湊仍可登入,下次改密碼時自動升級。 ### 主機 diff --git a/mobile/index.html b/mobile/index.html index 69ede9f..934efaf 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -723,6 +723,13 @@ $("b-login").onclick = async () => { try { if (S.relay && signingUp) await api("relay/signup", {json: {user: $("f-user").value.trim(), password: $("f-pass").value, invite: $("f-invite").value.trim()}}); const v = await api(S.relay ? "relay/login" : "api/login", {json: {user: $("f-user").value.trim(), password: $("f-pass").value, device}}); + if (v.need_2fa) { + // 開了两步验证:密码只是第一步,验证器 App 上的六位数(或一张恢复码)才换来通行证。 + const code = (prompt("Two-step 验证:输入 App 上的六位验证码(没有 App 可用一张恢复码)") || "").trim(); + if (!code) throw new Error("已取消"); + const v2 = await api("relay/2fa/verify", {json: {challenge: v.challenge, code}}); + v.token = v2.token; + } S.token = v.token; localStorage.setItem("anybot.token", S.token); $("f-pass").value = ""; if (S.relay) await pickHost(); else start(); } catch (e) { $("login-err").textContent = e.message; } diff --git a/relay/Cargo.lock b/relay/Cargo.lock index 056ab2d..a553f2d 100644 --- a/relay/Cargo.lock +++ b/relay/Cargo.lock @@ -21,6 +21,7 @@ dependencies = [ name = "anybot-relay" version = "0.1.0" dependencies = [ + "argon2", "base64 0.22.1", "chrono", "ring", @@ -30,6 +31,18 @@ dependencies = [ "ureq", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -48,6 +61,30 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -92,8 +129,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -121,6 +158,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -139,6 +185,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -154,15 +210,26 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", ] [[package]] @@ -206,6 +273,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -226,7 +303,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -338,6 +415,17 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -382,9 +470,15 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.10.1" @@ -496,8 +590,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -635,6 +729,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/relay/Cargo.toml b/relay/Cargo.toml index 0a8681f..80bec51 100644 --- a/relay/Cargo.toml +++ b/relay/Cargo.toml @@ -10,6 +10,7 @@ edition = "2021" serde = { version = "1", features = ["derive"] } serde_json = "1" ring = "0.17" +argon2 = "0.5" base64 = "0.22" chrono = "0.4" ureq = "3" diff --git a/relay/src/main.rs b/relay/src/main.rs index d838799..1cb2b64 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -64,6 +64,14 @@ struct User { name: String, pass: String, created: i64, + /// 2FA: the TOTP shared secret once enabled, and the one waiting for its first code. + #[serde(default)] + totp: String, + #[serde(default)] + totp_pending: String, + /// sha256 of each unused recovery code (8 are handed out once, at enabling). + #[serde(default)] + recover: Vec, } #[derive(Clone, Serialize, Deserialize, Default)] @@ -215,6 +223,8 @@ struct Relay { /// When each host last asked (in memory; saved now and then). seen: Mutex>, fails: Mutex>, + /// 2FA: a right password opens a five-minute challenge (user, device) awaiting the code. + challenges: Mutex>, invite: String, vapid: push::Vapid, contact: String, @@ -247,6 +257,7 @@ impl Relay { pairs: Mutex::new(HashMap::new()), seen: Mutex::new(HashMap::new()), fails: Mutex::new(HashMap::new()), + challenges: Mutex::new(HashMap::new()), invite: std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(), vapid, // Push services want a way to reach whoever runs the server. @@ -680,6 +691,10 @@ impl Relay { } ("POST", ["relay", "signup"]) => self.signup(&req), ("POST", ["relay", "login"]) => self.login(&req), + ("POST", ["relay", "2fa", "enroll"]) => self.enroll_2fa(&req), + ("POST", ["relay", "2fa", "verify"]) => self.verify_2fa(&req), + ("POST", ["relay", "2fa", "disable"]) => self.disable_2fa(&req), + ("POST", ["relay", "sessions", "logout"]) => self.logout_everywhere(&req), ("POST", ["relay", "logout"]) => { let h = httpd::sha256_hex(&req.bearer()); let mut d = self.data.lock().unwrap(); @@ -704,7 +719,9 @@ impl Relay { }) .collect(); let sessions: Vec = d.sessions.iter().filter(|s| s.user == user).map(|s| json!({"device": s.device, "created": s.created, "last": s.last})).collect(); - Resp::json(&json!({"user": user, "hosts": hosts, "sessions": sessions, "backup": self.dir.join("backups").join(format!("{}.bin", httpd::sha256_hex(&user))).exists()})) + // (`d` above is this handler's lock: read the users through it, never a second lock.) + let totp_on = d.users.iter().find(|u| u.name == user).map(|u| !u.totp.is_empty()).unwrap_or(false); + Resp::json(&json!({"user": user, "two_factor": totp_on, "hosts": hosts, "sessions": sessions, "backup": self.dir.join("backups").join(format!("{}.bin", httpd::sha256_hex(&user))).exists()})) } ("POST", ["relay", "hosts"]) => { let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; @@ -985,7 +1002,7 @@ impl Relay { if d.users.iter().any(|u| u.name == user) { return Resp::error(409, "這個帳號已經有人用了"); } - d.users.push(User { name: user, pass: httpd::hash_password(pw), created: now() }); + d.users.push(User { name: user, pass: httpd::hash_password(pw), created: now(), ..Default::default() }); self.save(&d); Resp::json(&json!({"ok": true})) } @@ -1011,13 +1028,137 @@ impl Relay { std::thread::sleep(Duration::from_millis(500)); return Resp::error(401, "帳號或密碼不對"); } + // The password is the first factor. With 2FA on, it buys only a five-minute + // challenge: the session comes after the code (or one of the recovery codes). + let totp_on = self.data.lock().unwrap().users.iter().find(|u| u.name == user).map(|u| !u.totp.is_empty()).unwrap_or(false); + if totp_on { + let ch = httpd::random_hex(16); + let device = v["device"].as_str().unwrap_or("").chars().take(40).collect(); + let mut c = self.challenges.lock().unwrap(); + c.retain(|_, (_, _, made)| made.elapsed() < Duration::from_secs(600)); + c.insert(ch.clone(), (user, device, Instant::now())); + return Resp::json(&json!({"need_2fa": true, "challenge": ch})); + } + Resp::json(&json!({"token": self.issue_session(&user, &v["device"].as_str().unwrap_or("").chars().take(40).collect::())})) + } + + /// A signed-in session (the last act of a good login, with the code or without 2FA). + fn issue_session(&self, user: &str, device: &str) -> String { let token = httpd::random_hex(32); let mut d = self.data.lock().unwrap(); - d.sessions.push(Session { hash: httpd::sha256_hex(&token), user, device: v["device"].as_str().unwrap_or("").chars().take(40).collect(), created: now(), last: now() }); + d.sessions.push(Session { hash: httpd::sha256_hex(&token), user: user.to_string(), device: device.to_string(), created: now(), last: now() }); let cutoff = now() - SESSION_DAYS * 86_400; d.sessions.retain(|s| s.last > cutoff); self.save(&d); - Resp::json(&json!({"token": token})) + token + } + + fn peer_ip(req: &Req) -> String { + match (std::env::var("RELAY_TRUST_PROXY").is_ok(), req.header("x-forwarded-for")) { + (true, xff) if !xff.is_empty() => xff.rsplit(',').next().unwrap_or(xff).trim().to_string(), + _ => req.peer.clone(), + } + } + + /// 2FA, one endpoint, two moments. With a session and a code: finish enrolling (the + /// secret in the authenticator app proves the person has it). With a login challenge: + /// the second step of signing in — a TOTP code, or one of the eight recovery codes, + /// each usable once. + fn verify_2fa(&self, req: &Req) -> Resp { + let v = req.json(); + let code = v["code"].as_str().unwrap_or("").trim().to_string(); + let ch = v["challenge"].as_str().filter(|s| !s.is_empty()).map(|s| s.to_string()); + if let Some(ch) = ch { + let Some((user, device, made)) = self.challenges.lock().unwrap().remove(&ch) else { + return Resp::error(400, "這次登入已過期,請重新輸入密碼"); + }; + if made.elapsed() > Duration::from_secs(300) { + return Resp::error(400, "這次登入已過期,請重新輸入密碼"); + } + let key = format!("2fa|{}|{user}", Self::peer_ip(req)); + if self.too_many_fails(&key) { + return Resp::error(429, "驗證碼錯太多次,請 5 分鐘後再試"); + } + let mut d = self.data.lock().unwrap(); + let Some(u) = d.users.iter_mut().find(|u| u.name == user) else { return Resp::error(404, "帳號不存在") }; + if httpd::totp_check(&u.totp, &code) { + drop(d); + return Resp::json(&json!({"token": self.issue_session(&user, &device)})); + } + // A recovery code works once, then it's gone (the hash slot is emptied). + let h = httpd::sha256_hex(&code); + match u.recover.iter_mut().find(|rh| *rh == &h) { + Some(slot) => { + *slot = String::new(); + self.save(&d); + drop(d); + return Resp::json(&json!({"token": self.issue_session(&user, &device), "used_recovery": true})) + } + None => { + drop(d); + self.failed(&key); + std::thread::sleep(Duration::from_millis(300)); + return Resp::error(401, "驗證碼不對"); + } + } + } + // Finishing enrollment (needs the session the app is signed in with). + let Some(user) = self.session(req) else { return Resp::error(401, "請先登入") }; + let mut d = self.data.lock().unwrap(); + let secret = d.users.iter().find(|u| u.name == user).map(|u| u.totp_pending.clone()).unwrap_or_default(); + if secret.is_empty() { + return Resp::error(400, "沒有待確認的設定:先點「開始設定」取得QR Code"); + } + if !httpd::totp_check(&secret, &code) { + return Resp::error(401, "驗證碼不對:輸入 App 上現在的那個六位數"); + } + if let Some(u) = d.users.iter_mut().find(|u| u.name == user) { + u.totp = std::mem::take(&mut u.totp_pending); + } + self.save(&d); + Resp::json(&json!({"ok": true})) + } + + /// Start 2FA: a new secret (the app scans the QR) and eight recovery codes, shown once, + /// stored hashed. 2FA is not on until verify_2fa has seen a real code. + fn enroll_2fa(&self, req: &Req) -> Resp { + let Some(user) = self.session(req) else { return Resp::error(401, "請先登入") }; + let secret = httpd::totp_new_secret(); + let codes: Vec = (0..8).map(|_| httpd::random_hex(5)).collect(); + let mut d = self.data.lock().unwrap(); + let Some(u) = d.users.iter_mut().find(|u| u.name == user) else { return Resp::error(404, "帳號不存在") }; + u.totp_pending = secret.clone(); + u.recover = codes.iter().map(|c| httpd::sha256_hex(c)).collect(); + self.save(&d); + Resp::json(&json!({"secret": secret, "uri": httpd::totp_uri(&secret, &user), "recovery_codes": codes})) + } + + /// Turn 2FA off (proves the code with the second factor itself). + fn disable_2fa(&self, req: &Req) -> Resp { + let Some(user) = self.session(req) else { return Resp::error(401, "請先登入") }; + let code = req.json()["code"].as_str().unwrap_or("").to_string(); + let mut d = self.data.lock().unwrap(); + let Some(u) = d.users.iter_mut().find(|u| u.name == user) else { return Resp::error(404, "帳號不存在") }; + if u.totp.is_empty() { + return Resp::json(&json!({"ok": true})); + } + if !httpd::totp_check(&u.totp, &code) { + return Resp::error(401, "驗證碼不對"); + } + u.totp.clear(); + u.totp_pending.clear(); + u.recover.clear(); + self.save(&d); + Resp::json(&json!({"ok": true})) + } + + /// One-page logout: every session of this account is gone, including this one. + fn logout_everywhere(&self, req: &Req) -> Resp { + let Some(user) = self.session(req) else { return Resp::error(401, "請先登入") }; + let mut d = self.data.lock().unwrap(); + d.sessions.retain(|s| s.user != user); + self.save(&d); + Resp::json(&json!({"ok": true})) } /// Remember when hosts were last seen (for "離線(上次…)"). @@ -1131,6 +1272,61 @@ mod tests { (r.status().as_u16(), r.body_mut().read_to_vec().unwrap_or_default()) } + fn step_now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() / 30 + } + + #[test] + fn two_factor_codes_recovery_once_and_logout_everywhere() { + let dir = std::env::temp_dir().join(format!("anybot-relay-2fa-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let relay = Arc::new(Relay::load(dir.clone())); + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let r = relay.clone(); + std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true))); + + post(port, "/relay/signup", "", json!({"user": "d2", "password": "12345678"})); + let s = post(port, "/relay/login", "", json!({"user": "d2", "password": "12345678", "device": "phone"})).1["token"].as_str().unwrap().to_string(); + // Enroll: the app gets a secret (QR) and eight codes shown once; 2FA is on only + // after a real code proves the app has it. + let (st, v) = post(port, "/relay/2fa/enroll", &s, json!({})); + assert_eq!(st, 200); + let secret = v["secret"].as_str().unwrap().to_string(); + let recovery = v["recovery_codes"][0].as_str().unwrap().to_string(); + assert!(v["uri"].as_str().unwrap().starts_with("otpauth://totp/AnyBot:d2?secret=")); + assert_eq!(post(port, "/relay/2fa/verify", &s, json!({"code": "000000"})).0, 401); + assert_eq!(post(port, "/relay/2fa/verify", &s, json!({"code": httpd::totp_code(&secret, step_now())})).0, 200); + // Password alone no longer signs in: it buys a challenge, the code signs in. + post(port, "/relay/logout", &s, json!({})); + let (_, v) = post(port, "/relay/login", "", json!({"user": "d2", "password": "12345678", "device": "phone"})); + assert_eq!(v["need_2fa"], true); + let ch = v["challenge"].as_str().unwrap().to_string(); + let (st, v) = post(port, "/relay/2fa/verify", "", json!({"challenge": ch, "code": httpd::totp_code(&secret, step_now())})); + assert_eq!(st, 200); + let t1 = v["token"].as_str().unwrap().to_string(); + // Recovery code: in, once. + let ch = post(port, "/relay/login", "", json!({"user": "d2", "password": "12345678"})).1["challenge"].as_str().unwrap().to_string(); + let (st, v) = post(port, "/relay/2fa/verify", "", json!({"challenge": ch, "code": recovery})); + assert_eq!(st, 200); + assert_eq!(v["used_recovery"], true); + let t2 = v["token"].as_str().unwrap().to_string(); + let ch = post(port, "/relay/login", "", json!({"user": "d2", "password": "12345678"})).1["challenge"].as_str().unwrap().to_string(); + assert_eq!(post(port, "/relay/2fa/verify", "", json!({"challenge": ch, "code": recovery})).0, 401); + // A stolen session, kicked from one page: every device is out. + assert_eq!(get(port, "/relay/me", &t1).0, 200); + post(port, "/relay/sessions/logout", &t2, json!({})); + assert_eq!(get(port, "/relay/me", &t1).0, 401); + assert_eq!(get(port, "/relay/me", &t2).0, 401); + // Off again (proving the code — the login right after is still 2FA: 關掉也要驗證碼): + // sign in with the code, disable with the code, and the password signs in by itself. + let ch = post(port, "/relay/login", "", json!({"user": "d2", "password": "12345678"})).1["challenge"].as_str().unwrap().to_string(); + let s2 = post(port, "/relay/2fa/verify", "", json!({"challenge": ch, "code": httpd::totp_code(&secret, step_now())})).1["token"].as_str().unwrap().to_string(); + assert_eq!(post(port, "/relay/2fa/disable", &s2, json!({"code": httpd::totp_code(&secret, step_now())})).0, 200); + let (_, v) = post(port, "/relay/login", "", json!({"user": "d2", "password": "12345678"})); + assert!(v["token"].is_string()); + } + #[test] fn account_host_and_phone_round_trip() { let dir = std::env::temp_dir().join(format!("anybot-relay-test-{}", std::process::id())); diff --git a/src/httpd.rs b/src/httpd.rs index 155c527..74ec233 100644 --- a/src/httpd.rs +++ b/src/httpd.rs @@ -304,9 +304,34 @@ pub fn sha256_hex(s: &str) -> String { const PBKDF2_ROUNDS: u32 = 120_000; /// A password stored as "salt$hash" (PBKDF2-HMAC-SHA256). +/// Password hashes: argon2id (memory-hard, OWASP parameters) in the form +/// `argon2id$$`. Old accounts keep their `salt$pbkdf2` string and are +/// checked with it (check_password reads the format from the stored string). pub fn hash_password(pw: &str) -> String { - let salt = random_hex(16); - format!("{salt}${}", pbkdf2_hex(pw, &salt)) + let salt = random_bytes(16); + let out = argon2_hash(pw, &salt); + format!("argon2id${}${}", hex_of(&salt), hex_of(&out)) +} + +fn argon2_hash(pw: &str, salt: &[u8]) -> Vec { + use argon2::{Algorithm, Argon2, Params, Version}; + // m=19 MiB, t=2, p=1 (OWASP's argon2id recommendation); 32-byte tag. + let params = Params::new(19_456, 2, 1, Some(32)).expect("params"); + let a = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut out = vec![0u8; 32]; + a.hash_password_into(pw.as_bytes(), salt, &mut out).expect("argon2"); + out +} + +pub fn random_bytes(n: usize) -> Vec { + use ring::rand::SecureRandom; + let mut b = vec![0u8; n]; + ring::rand::SystemRandom::new().fill(&mut b).expect("random"); + b +} + +fn hex_of(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() } fn pbkdf2_hex(pw: &str, salt: &str) -> String { @@ -316,10 +341,93 @@ fn pbkdf2_hex(pw: &str, salt: &str) -> String { } pub fn check_password(pw: &str, stored: &str) -> bool { + if let Some(rest) = stored.strip_prefix("argon2id$") { + let Some((salt, hash)) = rest.split_once('$') else { return false }; + let salt = hex_of(&hex_bytes(salt)); // normalise + let mine = hex_of(&argon2_hash(pw, &hex_bytes(&salt))); + return constant_time_eq(&mine, hash); + } let Some((salt, hash)) = stored.split_once('$') else { return false }; let mine = pbkdf2_hex(pw, salt); // Compare without stopping at the first difference. - mine.len() == hash.len() && mine.bytes().zip(hash.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0 + constant_time_eq(&mine, hash) +} + +fn constant_time_eq(a: &str, b: &str) -> bool { + a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +fn hex_bytes(s: &str) -> Vec { + (0..s.len() / 2).map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap_or(0)).collect() +} + +// ── TOTP (RFC 6238, SHA-1, 30 s, 6 digits): the codes authenticator apps show. ── + +const B32: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +pub fn base32_encode(b: &[u8]) -> String { + let mut out = String::new(); + let mut acc = 0u32; + let mut bits = 0u32; + for x in b { + acc = (acc << 8) | *x as u32; + bits += 8; + while bits >= 5 { + bits -= 5; + out.push(B32[((acc >> bits) & 31) as usize] as char); + } + } + if bits > 0 { + out.push(B32[((acc << (5 - bits)) & 31) as usize] as char); + } + out +} + +fn base32_decode(s: &str) -> Vec { + let mut out = Vec::new(); + let (mut acc, mut bits) = (0u32, 0u32); + for c in s.to_uppercase().chars() { + if c == '=' || c == ' ' { + continue; + } + let Some(v) = B32.iter().position(|&x| x as char == c) else { continue }; + acc = (acc << 5) | v as u32; + bits += 5; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + } + } + out +} + +/// A fresh shared secret (20 random bytes, the size authenticator apps expect). +pub fn totp_new_secret() -> String { + base32_encode(&random_bytes(20)) +} + +pub fn totp_uri(secret: &str, user: &str) -> String { + format!("otpauth://totp/AnyBot:{user}?secret={secret}&issuer=AnyBot&period=30&digits=6") +} + +pub fn totp_code(secret: &str, step: u64) -> String { + use ring::hmac; + let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, &base32_decode(secret)); + let tag = hmac::sign(&key, &step.to_be_bytes()); + let t = tag.as_ref(); + let off = (t[t.len() - 1] & 0x0F) as usize; + let n = (u32::from(t[off] & 0x7F) << 24) | (u32::from(t[off + 1]) << 16) | (u32::from(t[off + 2]) << 8) | u32::from(t[off + 3]); + format!("{:06}", n % 1_000_000) +} + +/// A code is good for its 30-second step and one step each way (clocks drift). +pub fn totp_check(secret: &str, code: &str) -> bool { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() / 30).unwrap_or(0); + let code: String = code.chars().filter(|c| c.is_ascii_digit()).collect(); + if code.len() != 6 { + return false; + } + (now.saturating_sub(1)..=now + 1).any(|s| constant_time_eq(&totp_code(secret, s), &code)) } /// A 256-bit key from a password (for the encrypted backup kept by the relay). @@ -359,6 +467,28 @@ pub fn open(key: &[u8; 32], sealed: &[u8]) -> Option> { mod tests { use super::*; + #[test] + fn totp_matches_the_rfc_6238_vectors() { + // RFC 6238's SHA-1 test secret, 8-digit codes; the last six of each. + let s = base32_encode(b"12345678901234567890"); + assert_eq!(s, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"); + assert_eq!(&totp_code(&s, 59 / 30), "287082"); + assert_eq!(&totp_code(&s, 1_111_111_109 / 30), "081804"); + assert_eq!(&totp_code(&s, 1_111_111_111 / 30), "050471"); + assert_eq!(&totp_code(&s, 1_234_567_890 / 30), "005924"); + } + + #[test] + fn password_hashes_are_argon2_and_old_ones_still_sign_in() { + let h = hash_password(" hunter2 "); + assert!(h.starts_with("argon2id$")); + assert!(check_password(" hunter2 ", &h)); + assert!(!check_password("hunter2", &h)); + // A hash made with the old PBKDF2 format keeps working (no forced password reset). + let old = format!("{}${}", "abcd", pbkdf2_hex("hunter2", "abcd")); + assert!(check_password("hunter2", &old)); + } + #[test] fn passwords_and_sealing() { let h = hash_password("貓咪 123");