Relay 2FA: TOTP + recovery codes, argon2id, one-page logout (S2)

- login with 2FA on returns a 5-minute challenge, not a session; the session
  comes from /relay/2fa/verify with an authenticator code — or one of eight
  recovery codes, each good once (hash slot emptied)
- /relay/2fa/enroll hands out the secret (QR) + codes once; enabling completes
  only when a real code proves the app has the secret; disable proves the code
- /relay/sessions/logout kicks every device of the account from one page
- password hashes are argon2id (memory-hard, OWASP parameters); stored PBKDF2
  hashes still sign in — no forced reset, upgrade on next password change
- TOTP is RFC 6238 (vectors pinned in tests); mobile page prompts for the
  second step; protocol.md documents the endpoints
- fixes a double-lock in /relay/me found by the flow test (handlers hold one
  data guard: read through it)
This commit is contained in:
AnyBot Dev 2026-11-09 17:52:42 +08:00
parent adc4759a6b
commit b23ac40c0b
8 changed files with 507 additions and 20 deletions

52
Cargo.lock generated
View File

@ -337,6 +337,7 @@ dependencies = [
"agent-desktop-macos", "agent-desktop-macos",
"agent-desktop-windows", "agent-desktop-windows",
"arboard", "arboard",
"argon2",
"ashpd", "ashpd",
"async-io", "async-io",
"base64 0.22.1", "base64 0.22.1",
@ -408,6 +409,18 @@ dependencies = [
"x11rb 0.13.2", "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]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.9" version = "0.3.9"
@ -651,6 +664,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.10.0" version = "0.10.0"
@ -678,6 +697,15 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" 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]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -924,7 +952,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.3.1", "cpufeatures 0.3.1",
"rand_core", "rand_core 0.10.1",
] ]
[[package]] [[package]]
@ -1376,6 +1404,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer 0.10.4", "block-buffer 0.10.4",
"crypto-common 0.1.7", "crypto-common 0.1.7",
"subtle",
] ]
[[package]] [[package]]
@ -2082,7 +2111,7 @@ dependencies = [
"js-sys", "js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core", "rand_core 0.10.1",
"wasm-bindgen", "wasm-bindgen",
] ]
@ -3670,6 +3699,17 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" 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]] [[package]]
name = "pdf-extract" name = "pdf-extract"
version = "0.12.1" version = "0.12.1"
@ -4080,9 +4120,15 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [ dependencies = [
"chacha20", "chacha20",
"getrandom 0.4.3", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.10.1" version = "0.10.1"

View File

@ -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. # 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. # 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" } agent-desktop-core = { git = "https://github.com/vercel-labs/agent-desktop", tag = "v1.1.0" }
argon2 = "0.5"
[profile.release] [profile.release]
opt-level = 2 opt-level = 2

View File

@ -18,9 +18,15 @@
| 方法 | 路徑 | 說明 | | 方法 | 路徑 | 說明 |
|---|---|---| |---|---|---|
| POST | `/relay/signup` | `{user, password, invite}`。第一個帳號不用邀請碼,之後要 `ANYBOT_RELAY_INVITE` | | 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 | | POST | `/relay/logout` | 登出這個 session |
| GET | `/relay/me` | 帳號、可用的 AnyBot(含 `online`、`uses`、`hands`)、登入過的裝置、有沒有備份 | | GET | `/relay/me` | 帳號、`two_factor`、可用的 AnyBot(含 `online`、`uses`、`hands`)、登入過的裝置、有沒有備份 |
密碼雜湊為 argon2id(memory-hard);舊 PBKDF2 雜湊仍可登入,下次改密碼時自動升級。
### 主機 ### 主機

View File

@ -723,6 +723,13 @@ $("b-login").onclick = async () => {
try { try {
if (S.relay && signingUp) await api("relay/signup", {json: {user: $("f-user").value.trim(), password: $("f-pass").value, invite: $("f-invite").value.trim()}}); 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}}); 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 = ""; S.token = v.token; localStorage.setItem("anybot.token", S.token); $("f-pass").value = "";
if (S.relay) await pickHost(); else start(); if (S.relay) await pickHost(); else start();
} catch (e) { $("login-err").textContent = e.message; } } catch (e) { $("login-err").textContent = e.message; }

116
relay/Cargo.lock generated
View File

@ -21,6 +21,7 @@ dependencies = [
name = "anybot-relay" name = "anybot-relay"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"argon2",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"ring", "ring",
@ -30,6 +31,18 @@ dependencies = [
"ureq", "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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@ -48,6 +61,30 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" 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]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.12.1" version = "0.12.1"
@ -92,8 +129,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.3.1",
"rand_core", "rand_core 0.10.1",
] ]
[[package]] [[package]]
@ -121,6 +158,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.3.1" version = "0.3.1"
@ -139,6 +185,16 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.2.2" version = "0.2.2"
@ -154,15 +210,26 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" 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]] [[package]]
name = "digest" name = "digest"
version = "0.11.3" version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.12.1",
"const-oid", "const-oid",
"crypto-common", "crypto-common 0.2.2",
] ]
[[package]] [[package]]
@ -206,6 +273,16 @@ dependencies = [
"slab", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@ -226,7 +303,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi", "r-efi",
"rand_core", "rand_core 0.10.1",
] ]
[[package]] [[package]]
@ -338,6 +415,17 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@ -382,9 +470,15 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [ dependencies = [
"chacha20", "chacha20",
"getrandom 0.4.3", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.10.1" version = "0.10.1"
@ -496,8 +590,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.3.1",
"digest", "digest 0.11.3",
] ]
[[package]] [[package]]
@ -635,6 +729,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"

View File

@ -10,6 +10,7 @@ edition = "2021"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
ring = "0.17" ring = "0.17"
argon2 = "0.5"
base64 = "0.22" base64 = "0.22"
chrono = "0.4" chrono = "0.4"
ureq = "3" ureq = "3"

View File

@ -64,6 +64,14 @@ struct User {
name: String, name: String,
pass: String, pass: String,
created: i64, 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<String>,
} }
#[derive(Clone, Serialize, Deserialize, Default)] #[derive(Clone, Serialize, Deserialize, Default)]
@ -215,6 +223,8 @@ struct Relay {
/// When each host last asked (in memory; saved now and then). /// When each host last asked (in memory; saved now and then).
seen: Mutex<HashMap<String, Instant>>, seen: Mutex<HashMap<String, Instant>>,
fails: Mutex<HashMap<String, (u32, Instant)>>, fails: Mutex<HashMap<String, (u32, Instant)>>,
/// 2FA: a right password opens a five-minute challenge (user, device) awaiting the code.
challenges: Mutex<HashMap<String, (String, String, Instant)>>,
invite: String, invite: String,
vapid: push::Vapid, vapid: push::Vapid,
contact: String, contact: String,
@ -247,6 +257,7 @@ impl Relay {
pairs: Mutex::new(HashMap::new()), pairs: Mutex::new(HashMap::new()),
seen: Mutex::new(HashMap::new()), seen: Mutex::new(HashMap::new()),
fails: Mutex::new(HashMap::new()), fails: Mutex::new(HashMap::new()),
challenges: Mutex::new(HashMap::new()),
invite: std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(), invite: std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(),
vapid, vapid,
// Push services want a way to reach whoever runs the server. // 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", "signup"]) => self.signup(&req),
("POST", ["relay", "login"]) => self.login(&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"]) => { ("POST", ["relay", "logout"]) => {
let h = httpd::sha256_hex(&req.bearer()); let h = httpd::sha256_hex(&req.bearer());
let mut d = self.data.lock().unwrap(); let mut d = self.data.lock().unwrap();
@ -704,7 +719,9 @@ impl Relay {
}) })
.collect(); .collect();
let sessions: Vec<Value> = d.sessions.iter().filter(|s| s.user == user).map(|s| json!({"device": s.device, "created": s.created, "last": s.last})).collect(); let sessions: Vec<Value> = 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"]) => { ("POST", ["relay", "hosts"]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; 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) { if d.users.iter().any(|u| u.name == user) {
return Resp::error(409, "這個帳號已經有人用了"); 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); self.save(&d);
Resp::json(&json!({"ok": true})) Resp::json(&json!({"ok": true}))
} }
@ -1011,13 +1028,137 @@ impl Relay {
std::thread::sleep(Duration::from_millis(500)); std::thread::sleep(Duration::from_millis(500));
return Resp::error(401, "帳號或密碼不對"); 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::<String>())}))
}
/// 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 token = httpd::random_hex(32);
let mut d = self.data.lock().unwrap(); 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; let cutoff = now() - SESSION_DAYS * 86_400;
d.sessions.retain(|s| s.last > cutoff); d.sessions.retain(|s| s.last > cutoff);
self.save(&d); 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<String> = (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 "離線(上次…)"). /// 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()) (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] #[test]
fn account_host_and_phone_round_trip() { fn account_host_and_phone_round_trip() {
let dir = std::env::temp_dir().join(format!("anybot-relay-test-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("anybot-relay-test-{}", std::process::id()));

View File

@ -304,9 +304,34 @@ pub fn sha256_hex(s: &str) -> String {
const PBKDF2_ROUNDS: u32 = 120_000; const PBKDF2_ROUNDS: u32 = 120_000;
/// A password stored as "salt$hash" (PBKDF2-HMAC-SHA256). /// A password stored as "salt$hash" (PBKDF2-HMAC-SHA256).
/// Password hashes: argon2id (memory-hard, OWASP parameters) in the form
/// `argon2id$<salt-hex>$<hash-hex>`. 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 { pub fn hash_password(pw: &str) -> String {
let salt = random_hex(16); let salt = random_bytes(16);
format!("{salt}${}", pbkdf2_hex(pw, &salt)) let out = argon2_hash(pw, &salt);
format!("argon2id${}${}", hex_of(&salt), hex_of(&out))
}
fn argon2_hash(pw: &str, salt: &[u8]) -> Vec<u8> {
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<u8> {
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 { 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 { 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 Some((salt, hash)) = stored.split_once('$') else { return false };
let mine = pbkdf2_hex(pw, salt); let mine = pbkdf2_hex(pw, salt);
// Compare without stopping at the first difference. // 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<u8> {
(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<u8> {
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). /// 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<Vec<u8>> {
mod tests { mod tests {
use super::*; 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] #[test]
fn passwords_and_sealing() { fn passwords_and_sealing() {
let h = hash_password("貓咪 123"); let h = hash_password("貓咪 123");