From 6d8bb71963932376fa1d7b81442e9c527fb01fff Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 11 Oct 2026 18:10:48 +0000 Subject: [PATCH] feat(auth): passkeys as a second factor (#106) An account can now hold an authenticator app, passkeys, or both. Two-factor is on while at least one exists: the first factor added issues the recovery codes and ends earlier sessions, removing the last one turns it off. The WebAuthn ceremony runs in the browser, on the portal. A client opens it with a one-time handle and gets a single-use code back, by a loopback redirect on desktop, a voltius:// link on a phone, or in the response for the portal's own sign-in. A sign-in code only fits the pending token it was opened for, so pending tokens now carry a unique id: two issued in the same second used to be identical. Step-up actions accept a passkey code beside a TOTP or recovery code, and adding a factor to an account that has one needs proof of it. A TOTP seed that was never confirmed no longer passes as a factor at sign-in. The relying party comes from WEBAUTHN_RP_ID and VOLTIUS_APP_URL; without it the server offers no passkeys and /v1/meta says so. webauthn-rs links OpenSSL statically, hence openssl-libs-static in the builder image. --- .env.example | 11 + Cargo.lock | 470 +++++++++++++++++++++++++- Cargo.toml | 3 + Dockerfile | 2 +- compose.prod.yml | 1 + compose.yml | 1 + migrations/060_user_passkeys.sql | 28 ++ src/auth/jwt.rs | 19 +- src/auth/mod.rs | 1 + src/email.rs | 2 +- src/main.rs | 33 +- src/passkeys.rs | 493 +++++++++++++++++++++++++++ src/routes/admin.rs | 7 +- src/routes/auth.rs | 61 ++-- src/routes/meta.rs | 25 +- src/routes/mod.rs | 1 + src/routes/passkeys.rs | 561 +++++++++++++++++++++++++++++++ src/routes/terminal.rs | 1 + src/routes/two_factor.rs | 231 +++++++++---- src/two_factor.rs | 159 +++++++-- 20 files changed, 1968 insertions(+), 142 deletions(-) create mode 100644 migrations/060_user_passkeys.sql create mode 100644 src/passkeys.rs create mode 100644 src/routes/passkeys.rs diff --git a/.env.example b/.env.example index 6ea100c..d36b68b 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,17 @@ JWT_SECRET= # generate with: openssl rand -hex 32 # not let through, until they use a recovery code or an admin clears their # two-factor from the dashboard. # TOTP_ENC_KEY= +# +# Passkeys as a second factor. Set this to the domain the web portal is +# served on, or a parent of it: with VOLTIUS_APP_URL=https://app.example.com +# either app.example.com or example.com works. Unset, the server offers no +# passkeys. The portal must be reachable over HTTPS at VOLTIUS_APP_URL, since +# that is where the browser creates and uses them. +# +# Passkeys are bound to this value for good: changing it later makes every +# registered passkey unusable, and their owners fall back to an authenticator +# code or a recovery code. +# WEBAUTHN_RP_ID= # Account deletion (optional) --------------------------------------------- diff --git a/Cargo.lock b/Cargo.lock index 412ccfd..f208800 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,67 @@ dependencies = [ "password-hash", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -94,7 +155,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", - "base64", + "base64 0.22.1", "bytes", "futures-util", "http", @@ -150,6 +211,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -162,6 +229,23 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "base64urlsafedata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -367,6 +451,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -448,6 +538,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -639,6 +749,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -648,6 +773,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -721,6 +861,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -809,6 +950,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbag" version = "0.1.13" @@ -977,7 +1129,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1188,7 +1340,7 @@ version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "getrandom 0.2.17", "hmac", @@ -1248,7 +1400,7 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "plain", "redox_syscall 0.7.3", @@ -1351,7 +1503,7 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" dependencies = [ - "base64", + "base64 0.22.1", "evmap", "indexmap", "metrics", @@ -1383,6 +1535,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -1394,6 +1552,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1403,6 +1571,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1419,6 +1597,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1449,6 +1644,15 @@ dependencies = [ "libm", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1461,6 +1665,43 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "p256" version = "0.13.2" @@ -1525,6 +1766,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1605,6 +1852,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1843,7 +2096,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1852,7 +2105,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1861,7 +2114,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1887,7 +2140,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -1978,6 +2231,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustls" version = "0.23.38" @@ -2067,6 +2329,26 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -2249,7 +2531,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -2326,8 +2608,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", - "bitflags", + "base64 0.22.1", + "bitflags 2.11.0", "byteorder", "bytes", "chrono", @@ -2370,8 +2652,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", - "bitflags", + "base64 0.22.1", + "bitflags 2.11.0", "byteorder", "chrono", "crc", @@ -2531,6 +2813,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2654,7 +2966,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -2843,6 +3155,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2893,7 +3206,7 @@ version = "0.1.0" dependencies = [ "argon2", "axum", - "base64", + "base64 0.22.1", "chacha20poly1305", "chrono", "dashmap", @@ -2919,7 +3232,10 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "uuid", + "webauthn-authenticator-rs", + "webauthn-rs", ] [[package]] @@ -3044,7 +3360,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -3070,6 +3386,107 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-authenticator-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779e9c80ff248c7e12ea967f909249101f6e86f70fccd742d4b66c490c1710b4" +dependencies = [ + "async-stream", + "async-trait", + "base64 0.21.7", + "base64urlsafedata", + "bitflags 1.3.2", + "futures", + "hex", + "nom", + "num-derive", + "num-traits", + "openssl", + "openssl-sys", + "serde", + "serde_bytes", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "unicode-normalization", + "url", + "uuid", + "webauthn-rs-core", + "webauthn-rs-proto", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.9.4", + "rand_chacha 0.9.0", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3385,7 +3802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -3421,6 +3838,23 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index be554b2..e471cf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ hex = "0.4" sha1 = "0.10" chacha20poly1305 = "0.10" data-encoding = "2" +webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation"] } +url = "2" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } dashmap = "6" metrics = "0.24" @@ -43,3 +45,4 @@ metrics-exporter-prometheus = { version = "0.18", default-features = false } # `oneshot` for driving middleware through a throwaway Router in authz tests. tower = { version = "0.5", features = ["util"] } tokio-tungstenite = "0.24" +webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } diff --git a/Dockerfile b/Dockerfile index b50a9c0..0e66b9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # --- Builder Stage --- FROM rust:alpine AS builder WORKDIR /app -RUN apk add --no-cache musl-dev pkgconfig openssl-dev +RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static COPY Cargo.toml Cargo.lock ./ COPY src ./src COPY migrations ./migrations diff --git a/compose.prod.yml b/compose.prod.yml index fb7575a..1d7fbf2 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -27,6 +27,7 @@ services: WAITLIST_RATE_LIMIT: ${WAITLIST_RATE_LIMIT:-10} ADMIN_SECRET: ${ADMIN_SECRET} TOTP_ENC_KEY: ${TOTP_ENC_KEY:-} + WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-} CORS_ORIGINS: ${CORS_ORIGINS:-https://voltius.app,https://www.voltius.app,https://app.voltius.app,tauri://localhost,https://tauri.localhost,http://localhost:1420,http://localhost:1421} LEMONSQUEEZY_SIGNING_SECRET: ${LEMONSQUEEZY_SIGNING_SECRET} LEMONSQUEEZY_STORE_ID: ${LEMONSQUEEZY_STORE_ID} diff --git a/compose.yml b/compose.yml index 4c1f876..654c35a 100644 --- a/compose.yml +++ b/compose.yml @@ -40,6 +40,7 @@ services: TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} TRUSTED_PROXY_IP: ${TRUSTED_PROXY_IP:-} TOTP_ENC_KEY: ${TOTP_ENC_KEY:-} + WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-} AUTH_RATE_LIMIT: ${AUTH_RATE_LIMIT:-} SYNC_RATE_LIMIT: ${SYNC_RATE_LIMIT:-} REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-} diff --git a/migrations/060_user_passkeys.sql b/migrations/060_user_passkeys.sql new file mode 100644 index 0000000..9451e7d --- /dev/null +++ b/migrations/060_user_passkeys.sql @@ -0,0 +1,28 @@ +CREATE TABLE user_passkeys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + credential JSONB NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ NULL +); + +CREATE INDEX idx_user_passkeys_user ON user_passkeys (user_id); + +CREATE TABLE passkey_ceremonies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + purpose TEXT NOT NULL CHECK (purpose IN ('sign_in', 'step_up', 'register')), + handle_hash TEXT NOT NULL UNIQUE, + binding TEXT NULL, + return_to JSONB NOT NULL, + name TEXT NULL, + state JSONB NULL, + credential JSONB NULL, + code_hash TEXT NULL UNIQUE, + consumed_at TIMESTAMPTZ NULL, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_passkey_ceremonies_expiry ON passkey_ceremonies (expires_at); diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index e216265..6830f88 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -18,6 +18,9 @@ pub struct Claims { pub email_verified: bool, #[serde(default)] pub ver: i32, + /// Tells apart two pending sign-ins issued to one account within the same second. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jti: Option, } impl Claims { @@ -58,6 +61,7 @@ pub fn create_access_token( is_banned, email_verified, ver: 0, + jti: None, }; encode( &Header::default(), @@ -74,6 +78,7 @@ fn bare_token( kind: &str, lifetime: Duration, token_version: i32, + jti: Option, ) -> Result { let now = Utc::now(); let claims = Claims { @@ -88,6 +93,7 @@ fn bare_token( is_banned: false, email_verified: false, ver: token_version, + jti, }; encode( &Header::default(), @@ -100,14 +106,14 @@ pub fn create_refresh_token( user_id: Uuid, token_version: i32, ) -> Result { - bare_token(user_id, "refresh", Duration::days(90), token_version) + bare_token(user_id, "refresh", Duration::days(90), token_version, None) } pub fn create_pending_token( user_id: Uuid, token_version: i32, ) -> Result { - bare_token(user_id, PENDING_SECOND_FACTOR, Duration::minutes(5), token_version) + bare_token(user_id, PENDING_SECOND_FACTOR, Duration::minutes(5), token_version, Some(Uuid::new_v4())) } pub fn validate_token( @@ -205,6 +211,15 @@ pub(crate) mod tests { assert!(validate_token(&token, "refresh").is_err()); } + #[test] + fn two_pending_tokens_for_one_account_never_match() { + let _guard = crate::test_support::env_lock(); + std::env::set_var("JWT_SECRET", "test-secret"); + let user = Uuid::new_v4(); + + assert_ne!(create_pending_token(user, 3).unwrap(), create_pending_token(user, 3).unwrap()); + } + #[test] fn refresh_token_carries_the_token_version() { let _guard = crate::test_support::env_lock(); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index ef45b0e..fc6154c 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -166,6 +166,7 @@ mod authz_tests { is_banned, email_verified: true, ver: 0, + jti: None, } } diff --git a/src/email.rs b/src/email.rs index ebf8e1a..e539a70 100644 --- a/src/email.rs +++ b/src/email.rs @@ -113,7 +113,7 @@ fn branded_email_html( ) } -fn app_url() -> String { +pub(crate) fn app_url() -> String { std::env::var("VOLTIUS_APP_URL").unwrap_or_else(|_| "https://app.voltius.app".to_string()) } diff --git a/src/main.rs b/src/main.rs index 04f1b39..920c222 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod team_plan; mod vault_key_exposure; mod sync_notifier; mod terminal_manager; +mod passkeys; mod two_factor; mod user_purge; #[cfg(test)] @@ -273,7 +274,12 @@ async fn main() { "Configured features" ); let seed_cipher = two_factor::SeedCipher::from_env(); - tracing::info!(available = seed_cipher.available(), "Configured two-factor authentication"); + let passkeys = passkeys::Passkeys::from_env(); + tracing::info!( + totp = seed_cipher.available(), + passkeys = passkeys.available(), + "Configured two-factor authentication" + ); // Register — stricter limit: 5/day per IP on top of the general auth 10/min let register_route = Router::new() @@ -290,6 +296,7 @@ async fn main() { .route("/v1/auth/challenge", get(routes::auth::challenge)) .route("/v1/auth/login", post(routes::auth::login)) .route("/v1/auth/login/2fa", post(routes::auth::login_second_factor)) + .route("/v1/auth/login/2fa/passkey", post(routes::passkeys::sign_in)) .route("/v1/auth/verify-email", post(routes::auth::verify_email)) .route( "/v1/invitations/:token", @@ -298,14 +305,20 @@ async fn main() { .layer(middleware::from_fn(rate_limit::auth_rate_limit)) .layer(Extension(auth_limiter)); - // A revoked session retries refresh every few seconds; it must not spend the sign-in budget. + // A revoked session retries refresh every few seconds, and a passkey ceremony runs in the + // browser beside the sign-in it completes; neither may spend the sign-in budget. + let beside_sign_in = || { + RateLimiter::::new(auth_per_minute * 3, Duration::from_secs(60)) + }; let refresh_route = Router::new() .route("/v1/auth/refresh", post(routes::auth::refresh)) .layer(middleware::from_fn(rate_limit::auth_rate_limit)) - .layer(Extension(RateLimiter::::new( - auth_per_minute * 3, - Duration::from_secs(60), - ))); + .layer(Extension(beside_sign_in())); + let passkey_ceremony = Router::new() + .route("/v1/auth/passkey/begin", post(routes::passkeys::begin)) + .route("/v1/auth/passkey/finish", post(routes::passkeys::finish)) + .layer(middleware::from_fn(rate_limit::auth_rate_limit)) + .layer(Extension(beside_sign_in())); let waitlist = Router::new() .route( @@ -376,6 +389,12 @@ async fn main() { .route("/v1/auth/2fa/setup", post(routes::two_factor::setup)) .route("/v1/auth/2fa/enable", post(routes::two_factor::enable)) .route("/v1/auth/2fa/disable", post(routes::two_factor::disable)) + .route("/v1/auth/2fa", get(routes::two_factor::factors)) + .route("/v1/auth/2fa/totp/remove", post(routes::two_factor::remove_totp)) + .route("/v1/auth/2fa/passkeys/step-up", post(routes::passkeys::step_up)) + .route("/v1/auth/2fa/passkeys/register", post(routes::passkeys::register)) + .route("/v1/auth/2fa/passkeys/activate", post(routes::passkeys::activate)) + .route("/v1/auth/2fa/passkeys/:id/remove", post(routes::passkeys::remove_passkey)) .route( "/v1/auth/2fa/recovery-codes", post(routes::two_factor::regenerate_recovery_codes), @@ -785,6 +804,7 @@ async fn main() { let app = Router::new() .merge(public) .merge(refresh_route) + .merge(passkey_ceremony) .merge(waitlist) .merge(register_route) .merge(webhooks) @@ -826,6 +846,7 @@ async fn main() { ) .layer(middleware::from_fn(observability::track_requests)) .layer(Extension(seed_cipher)) + .layer(Extension(passkeys)) .with_state(pool); let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string()); diff --git a/src/passkeys.rs b/src/passkeys.rs new file mode 100644 index 0000000..022a3b5 --- /dev/null +++ b/src/passkeys.rs @@ -0,0 +1,493 @@ +//! Passkeys as a second factor: the relying party, browser ceremonies and the codes they hand back. + +use std::sync::Arc; + +use axum::http::StatusCode; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64URL, Engine}; +use chrono::{DateTime, Utc}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use sqlx::{types::Json, PgConnection, PgPool}; +use tracing::{error, warn}; +use url::Url; +use uuid::Uuid; +use webauthn_rs::prelude::{ + Passkey, PasskeyAuthentication, PasskeyRegistration, PublicKeyCredential, RegisterPublicKeyCredential, +}; +use webauthn_rs::{Webauthn, WebauthnBuilder}; + +use crate::two_factor::{db_failure, Refusal}; + +const CEREMONY_MINUTES: i32 = 5; +const MAX_PASSKEYS: usize = 10; +const MAX_NAME_CHARS: usize = 64; +const LOWEST_LOOPBACK_PORT: u16 = 1024; +const MAX_STATE_CHARS: usize = 64; + +struct RelyingParty { + webauthn: Webauthn, + portal: Url, +} + +/// The relying party named by `WEBAUTHN_RP_ID`. Without it the server offers no passkeys. +#[derive(Clone)] +pub struct Passkeys(Option>); + +/// Where the browser sends the code once the ceremony is done. `state` comes back with it, +/// so a client can tell its own ceremony from a link someone else forged. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ReturnTo { + /// The page that ran the ceremony keeps the code: the portal's own sign-in. + Inline, + App { + #[serde(default)] + state: String, + }, + Loopback { + port: u16, + #[serde(default)] + state: String, + }, +} + +pub enum Ceremony { + SignIn { pending_token: String }, + StepUp, + Register { name: String }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Purpose { + SignIn, + StepUp, + Register, +} + +impl Purpose { + fn as_str(self) -> &'static str { + match self { + Purpose::SignIn => "sign_in", + Purpose::StepUp => "step_up", + Purpose::Register => "register", + } + } + + fn parse(stored: &str) -> Option { + [Purpose::SignIn, Purpose::StepUp, Purpose::Register].into_iter().find(|p| p.as_str() == stored) + } +} + +#[derive(Serialize)] +pub struct Handle { + pub handle: String, + pub url: String, +} + +#[derive(Serialize)] +pub struct Begun { + pub purpose: Purpose, + pub email: String, + pub options: Value, +} + +#[derive(Debug, Serialize)] +pub struct Finished { + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub redirect: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct PasskeyInfo { + pub id: Uuid, + pub name: String, + pub created_at: DateTime, + pub last_used_at: Option>, +} + +#[derive(sqlx::FromRow)] +struct OpenCeremony { + id: Uuid, + user_id: Uuid, + purpose: String, + return_to: Json, + state: Option>, +} + +fn secret() -> String { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + B64URL.encode(bytes) +} + +fn digest(value: &str) -> String { + hex::encode(Sha256::digest(value.as_bytes())) +} + +fn clean_name(name: &str) -> String { + let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME_CHARS).collect(); + if name.is_empty() { + "Passkey".to_string() + } else { + name + } +} + +fn ceremony_failure(err: impl std::fmt::Display) -> Refusal { + error!(error = %err, "Passkey ceremony failed"); + Refusal::Status(StatusCode::INTERNAL_SERVER_ERROR) +} + +fn malformed(err: impl std::fmt::Display) -> Refusal { + warn!(error = %err, "Passkey response rejected"); + Refusal::Status(StatusCode::BAD_REQUEST) +} + +async fn held(conn: &mut PgConnection, user_id: Uuid) -> Result, Refusal> { + let rows: Vec<(Json,)> = + sqlx::query_as("SELECT credential FROM user_passkeys WHERE user_id = $1 ORDER BY created_at") + .bind(user_id) + .fetch_all(conn) + .await + .map_err(db_failure)?; + Ok(rows.into_iter().map(|(Json(passkey),)| passkey).collect()) +} + +async fn open_ceremony(conn: &mut PgConnection, handle: &str) -> Result { + sqlx::query_as( + "SELECT id, user_id, purpose, return_to, state FROM passkey_ceremonies + WHERE handle_hash = $1 AND code_hash IS NULL AND expires_at > now() FOR UPDATE", + ) + .bind(digest(handle)) + .fetch_optional(conn) + .await + .map_err(db_failure)? + .ok_or(Refusal::Status(StatusCode::GONE)) +} + +impl ReturnTo { + fn acceptable(&self) -> bool { + let (port, state) = match self { + ReturnTo::Inline => return true, + ReturnTo::App { state } => (None, state), + ReturnTo::Loopback { port, state } => (Some(*port), state), + }; + port.is_none_or(|port| port >= LOWEST_LOOPBACK_PORT) + && state.len() <= MAX_STATE_CHARS + && state.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + } + + fn deliver(self, code: String) -> Finished { + let (target, state) = match self { + ReturnTo::Inline => return Finished { code: Some(code), redirect: None }, + ReturnTo::App { state } => ("voltius://passkey".to_string(), state), + ReturnTo::Loopback { port, state } => (format!("http://127.0.0.1:{port}/passkey"), state), + }; + let state = if state.is_empty() { state } else { format!("&s={state}") }; + Finished { code: None, redirect: Some(format!("{target}?c={code}{state}")) } + } +} + +impl Passkeys { + pub fn from_env() -> Self { + Self::parse(std::env::var("WEBAUTHN_RP_ID").ok().as_deref(), &crate::email::app_url()) + .unwrap_or_else(|why| panic!("WEBAUTHN_RP_ID {why}")) + } + + fn parse(rp_id: Option<&str>, portal: &str) -> Result { + let Some(rp_id) = rp_id.map(str::trim).filter(|v| !v.is_empty()) else { + return Ok(Self(None)); + }; + let portal = Url::parse(portal).map_err(|_| format!("needs VOLTIUS_APP_URL to be a URL, got {portal:?}"))?; + let webauthn = WebauthnBuilder::new(rp_id, &portal) + .map_err(|err| format!("does not cover VOLTIUS_APP_URL {portal}: {err}"))? + .rp_name("Voltius") + .build() + .map_err(|err| format!("is not usable: {err}"))?; + Ok(Self(Some(Arc::new(RelyingParty { webauthn, portal })))) + } + + pub fn available(&self) -> bool { + self.0.is_some() + } + + fn party(&self) -> Result<&RelyingParty, Refusal> { + self.0.as_deref().ok_or(Refusal::Unavailable) + } + + pub async fn open( + &self, + pool: &PgPool, + user_id: Uuid, + ceremony: Ceremony, + return_to: ReturnTo, + ) -> Result { + let party = self.party()?; + if !return_to.acceptable() { + return Err(StatusCode::BAD_REQUEST.into()); + } + let mut conn = pool.acquire().await.map_err(db_failure)?; + let count = held(&mut conn, user_id).await?.len(); + let (purpose, binding, name) = match ceremony { + Ceremony::Register { .. } if count >= MAX_PASSKEYS => return Err(Refusal::PasskeyLimit), + Ceremony::Register { name } => (Purpose::Register, None, Some(clean_name(&name))), + _ if count == 0 => return Err(Refusal::NotEnabled), + Ceremony::SignIn { pending_token } => (Purpose::SignIn, Some(digest(&pending_token)), None), + Ceremony::StepUp => (Purpose::StepUp, None, None), + }; + + sqlx::query("DELETE FROM passkey_ceremonies WHERE expires_at < now()") + .execute(&mut *conn) + .await + .map_err(db_failure)?; + let handle = secret(); + sqlx::query( + "INSERT INTO passkey_ceremonies (user_id, purpose, handle_hash, binding, return_to, name, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, now() + make_interval(mins => $7))", + ) + .bind(user_id) + .bind(purpose.as_str()) + .bind(digest(&handle)) + .bind(binding) + .bind(Json(return_to)) + .bind(name) + .bind(CEREMONY_MINUTES) + .execute(&mut *conn) + .await + .map_err(db_failure)?; + + let mut url = party.portal.join("2fa/passkey").map_err(ceremony_failure)?; + url.set_fragment(Some(&format!("h={handle}"))); + Ok(Handle { handle, url: url.into() }) + } + + pub async fn begin(&self, pool: &PgPool, handle: &str) -> Result { + let party = self.party()?; + let mut tx = pool.begin().await.map_err(db_failure)?; + let ceremony = open_ceremony(&mut tx, handle).await?; + let purpose = Purpose::parse(&ceremony.purpose).ok_or(Refusal::Status(StatusCode::GONE))?; + let (email, locked): (String, bool) = + sqlx::query_as("SELECT email, COALESCE(totp_locked_until > now(), FALSE) FROM users WHERE id = $1") + .bind(ceremony.user_id) + .fetch_one(&mut *tx) + .await + .map_err(db_failure)?; + let held = held(&mut tx, ceremony.user_id).await?; + + let (options, state) = if purpose == Purpose::Register { + let exclude = held.iter().map(|passkey| passkey.cred_id().clone()).collect(); + let (options, state) = party + .webauthn + .start_passkey_registration(ceremony.user_id, &email, &email, Some(exclude)) + .map_err(ceremony_failure)?; + (serde_json::to_value(options), serde_json::to_value(state)) + } else { + if locked { + return Err(Refusal::Locked); + } + let (options, state) = party.webauthn.start_passkey_authentication(&held).map_err(ceremony_failure)?; + (serde_json::to_value(options), serde_json::to_value(state)) + }; + let options = options.map_err(ceremony_failure)?; + + sqlx::query("UPDATE passkey_ceremonies SET state = $2 WHERE id = $1") + .bind(ceremony.id) + .bind(Json(state.map_err(ceremony_failure)?)) + .execute(&mut *tx) + .await + .map_err(db_failure)?; + tx.commit().await.map_err(db_failure)?; + Ok(Begun { purpose, email, options }) + } + + pub async fn finish(&self, pool: &PgPool, handle: &str, response: Value) -> Result { + let party = self.party()?; + let mut tx = pool.begin().await.map_err(db_failure)?; + let ceremony = open_ceremony(&mut tx, handle).await?; + let Some(Json(state)) = ceremony.state else { + return Err(Refusal::Status(StatusCode::GONE)); + }; + + if ceremony.purpose == Purpose::Register.as_str() { + let state: PasskeyRegistration = serde_json::from_value(state).map_err(ceremony_failure)?; + let response: RegisterPublicKeyCredential = serde_json::from_value(response).map_err(malformed)?; + let passkey = party.webauthn.finish_passkey_registration(&response, &state).map_err(malformed)?; + sqlx::query("UPDATE passkey_ceremonies SET credential = $2 WHERE id = $1") + .bind(ceremony.id) + .bind(Json(passkey)) + .execute(&mut *tx) + .await + .map_err(db_failure)?; + } else { + let state: PasskeyAuthentication = serde_json::from_value(state).map_err(ceremony_failure)?; + let response: PublicKeyCredential = serde_json::from_value(response).map_err(malformed)?; + let result = party.webauthn.finish_passkey_authentication(&response, &state).map_err(|err| { + warn!(user_id = %ceremony.user_id, error = %err, "Passkey assertion rejected"); + Refusal::Invalid + })?; + let credential_id = B64URL.encode(result.cred_id()); + let stored: Option<(Json,)> = sqlx::query_as( + "SELECT credential FROM user_passkeys WHERE user_id = $1 AND credential_id = $2 FOR UPDATE", + ) + .bind(ceremony.user_id) + .bind(&credential_id) + .fetch_optional(&mut *tx) + .await + .map_err(db_failure)?; + let Some((Json(mut passkey),)) = stored else { + return Err(Refusal::Invalid); + }; + passkey.update_credential(&result); + sqlx::query("UPDATE user_passkeys SET credential = $2, last_used_at = now() WHERE credential_id = $1") + .bind(&credential_id) + .bind(Json(passkey)) + .execute(&mut *tx) + .await + .map_err(db_failure)?; + } + + let code = secret(); + sqlx::query("UPDATE passkey_ceremonies SET code_hash = $2, state = NULL WHERE id = $1") + .bind(ceremony.id) + .bind(digest(&code)) + .execute(&mut *tx) + .await + .map_err(db_failure)?; + tx.commit().await.map_err(db_failure)?; + Ok(ceremony.return_to.0.deliver(code)) + } +} + +/// Spends the code of a finished assertion. A sign-in code only fits the pending token it was opened for. +pub async fn spend_code( + conn: &mut PgConnection, + user_id: Uuid, + code: &str, + pending_token: Option<&str>, +) -> Result { + let purpose = if pending_token.is_some() { Purpose::SignIn } else { Purpose::StepUp }; + let spent = sqlx::query( + "UPDATE passkey_ceremonies SET consumed_at = now() + WHERE user_id = $1 AND code_hash = $2 AND purpose = $3 AND binding IS NOT DISTINCT FROM $4 + AND consumed_at IS NULL AND expires_at > now()", + ) + .bind(user_id) + .bind(digest(code.trim())) + .bind(purpose.as_str()) + .bind(pending_token.map(digest)) + .execute(conn) + .await?; + Ok(spent.rows_affected() == 1) +} + +/// Turns the credential a finished registration left behind into one of the account's passkeys. +pub async fn activate( + conn: &mut PgConnection, + user_id: Uuid, + code: &str, +) -> Result, sqlx::Error> { + let registered: Option<(Option>, Option)> = sqlx::query_as( + "UPDATE passkey_ceremonies SET consumed_at = now() + WHERE user_id = $1 AND code_hash = $2 AND purpose = 'register' + AND consumed_at IS NULL AND expires_at > now() + RETURNING credential, name", + ) + .bind(user_id) + .bind(digest(code.trim())) + .fetch_optional(&mut *conn) + .await?; + let Some((Some(Json(passkey)), name)) = registered else { + return Ok(None); + }; + sqlx::query_as( + "INSERT INTO user_passkeys (user_id, credential_id, credential, name) VALUES ($1, $2, $3, $4) + RETURNING id, name, created_at, last_used_at", + ) + .bind(user_id) + .bind(B64URL.encode(passkey.cred_id())) + .bind(Json(&passkey)) + .bind(name.unwrap_or_else(|| clean_name(""))) + .fetch_one(conn) + .await + .map(Some) +} + +pub async fn list(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT id, name, created_at, last_used_at FROM user_passkeys WHERE user_id = $1 ORDER BY created_at", + ) + .bind(user_id) + .fetch_all(pool) + .await +} + +pub async fn remove(conn: &mut PgConnection, user_id: Uuid, passkey_id: Uuid) -> Result { + let removed = sqlx::query("DELETE FROM user_passkeys WHERE user_id = $1 AND id = $2") + .bind(user_id) + .bind(passkey_id) + .execute(conn) + .await?; + Ok(removed.rows_affected() == 1) +} + +#[cfg(test)] +impl Passkeys { + pub const TEST_ORIGIN: &'static str = "https://app.voltius.test"; + + pub fn for_tests() -> Self { + Self::parse(Some("voltius.test"), Self::TEST_ORIGIN).unwrap() + } + + pub fn absent() -> Self { + Self(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_relying_party_must_cover_the_portal() { + assert!(!Passkeys::parse(None, "https://app.voltius.app").unwrap().available()); + assert!(!Passkeys::parse(Some(" "), "https://app.voltius.app").unwrap().available()); + assert!(Passkeys::parse(Some("voltius.app"), "https://app.voltius.app").unwrap().available()); + assert!(Passkeys::parse(Some("localhost"), "http://localhost:3000").unwrap().available()); + assert!(Passkeys::parse(Some("example.com"), "https://app.voltius.app").is_err()); + assert!(Passkeys::parse(Some("voltius.app"), "not a url").is_err()); + } + + #[test] + fn the_code_goes_back_where_the_ceremony_was_opened_from() { + let inline = ReturnTo::Inline.deliver("c0de".into()); + assert_eq!((inline.code.as_deref(), inline.redirect), (Some("c0de"), None)); + let app = ReturnTo::App { state: "n0nce".into() }.deliver("c0de".into()); + assert_eq!(app.redirect.as_deref(), Some("voltius://passkey?c=c0de&s=n0nce")); + let loopback: ReturnTo = serde_json::from_str(r#"{"kind":"loopback","port":49152}"#).unwrap(); + assert_eq!(loopback, ReturnTo::Loopback { port: 49152, state: String::new() }); + let loopback = loopback.deliver("c0de".into()); + assert_eq!((loopback.code, loopback.redirect.as_deref()), (None, Some("http://127.0.0.1:49152/passkey?c=c0de"))); + } + + #[test] + fn a_return_target_cannot_point_at_a_system_port_or_smuggle_a_query() { + let loopback = |port, state: &str| ReturnTo::Loopback { port, state: state.to_string() }; + assert!(loopback(49152, "aZ0-_").acceptable()); + assert!(!loopback(80, "").acceptable()); + assert!(!loopback(49152, "a&c=other").acceptable()); + assert!(!ReturnTo::App { state: "x".repeat(MAX_STATE_CHARS + 1) }.acceptable()); + assert!(ReturnTo::Inline.acceptable()); + } + + #[test] + fn a_passkey_name_is_trimmed_bounded_and_never_empty() { + assert_eq!(clean_name(" Laptop \n"), "Laptop"); + assert_eq!(clean_name(" \t "), "Passkey"); + assert_eq!(clean_name(&"x".repeat(200)).chars().count(), MAX_NAME_CHARS); + } +} diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 7d721c0..25be809 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -561,16 +561,17 @@ pub async fn get_user( State(pool): State, Path(user_id): Path, ) -> Result, StatusCode> { - let user = sqlx::query_as::<_, UserDetail>( + let user = sqlx::query_as::<_, UserDetail>(&format!( r#" SELECT id, email, handle, account_id, subscription_tier, trial_ends_at, trial_used, is_banned, is_admin, ban_reason, banned_at, admin_notes, discount_pct, ls_customer_id, ls_subscription_id, admin_override, created_at, seat_count, last_seen_on, deleted_at, deletion_reason, deleted_by, - totp_enabled_at IS NOT NULL AS two_factor_enabled + {} AS two_factor_enabled FROM users WHERE id = $1 "#, - ) + crate::two_factor::ENABLED_SQL + )) .bind(user_id) .fetch_optional(&pool) .await diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 64abdb7..1c25ed0 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -20,6 +20,7 @@ use crate::routes::email_undeliverable_response; use crate::routes::invitations::{accept_pending_invitation, PendingInvitation, PENDING_INVITATION_COLUMNS}; use crate::self_host; use crate::sync_notifier::SyncNotifier; +use crate::passkeys::Passkeys; use crate::two_factor::{self, Refusal, SecondFactorProof, SeedCipher}; // ─── Tier helper ───────────────────────────────────────────────────────────── @@ -488,11 +489,13 @@ impl IntoResponse for LoginOutcome { pub async fn login( State(pool): State, axum::Extension(cipher): axum::Extension, + axum::Extension(passkeys): axum::Extension, Json(body): Json, ) -> Result { - let user = sqlx::query_as::<_, (Uuid, String, bool, Option, bool)>( - "SELECT id, auth_hash, is_banned, wrapped_user_secrets, totp_enabled_at IS NOT NULL FROM users WHERE account_id = $1", - ) + let user = sqlx::query_as::<_, (Uuid, String, bool, Option, bool)>(&format!( + "SELECT id, auth_hash, is_banned, wrapped_user_secrets, {} FROM users WHERE account_id = $1", + two_factor::ENABLED_SQL + )) .bind(body.account_id) .fetch_optional(&pool) .await @@ -532,7 +535,9 @@ pub async fn login( return Ok(LoginOutcome::SecondFactorRequired(SecondFactorChallenge { error: "TWO_FACTOR_REQUIRED", pending_token, - methods: two_factor::methods(&cipher), + methods: two_factor::methods(&pool, &cipher, &passkeys, user_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, })); } @@ -548,25 +553,30 @@ pub struct SecondFactorLoginRequest { pub proof: SecondFactorProof, } -pub async fn login_second_factor( - State(pool): State, - axum::Extension(cipher): axum::Extension, - Json(body): Json, -) -> Result, Refusal> { - let claims = validate_token(&body.pending_token, PENDING_SECOND_FACTOR).map_err(|_| { +pub struct PendingSignIn { + pub user_id: Uuid, + tier: TierInfo, + wrapped_user_secrets: Option, +} + +/// The account a pending token still stands for: password accepted, second factor owed. +pub async fn pending_sign_in(pool: &PgPool, pending_token: &str) -> Result { + let claims = validate_token(pending_token, PENDING_SECOND_FACTOR).map_err(|_| { warn!("Second factor login failed: invalid pending token"); StatusCode::UNAUTHORIZED })?; let user_id = claims.sub; - let (two_factor_enabled, wrapped_user_secrets): (bool, Option) = - sqlx::query_as("SELECT totp_enabled_at IS NOT NULL, wrapped_user_secrets FROM users WHERE id = $1") - .bind(user_id) - .fetch_optional(&pool) - .await - .map_err(two_factor::db_failure)? - .ok_or(StatusCode::UNAUTHORIZED)?; - let tier = fetch_tier(&pool, user_id).await?; + let (two_factor_enabled, wrapped_user_secrets): (bool, Option) = sqlx::query_as(&format!( + "SELECT {}, wrapped_user_secrets FROM users WHERE id = $1", + two_factor::ENABLED_SQL + )) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(two_factor::db_failure)? + .ok_or(StatusCode::UNAUTHORIZED)?; + let tier = fetch_tier(pool, user_id).await?; if tier.is_banned { return Err(StatusCode::FORBIDDEN.into()); @@ -574,7 +584,17 @@ pub async fn login_second_factor( if !two_factor_enabled || claims.ver != tier.token_version { return Err(StatusCode::UNAUTHORIZED.into()); } - two_factor::verify(&pool, &cipher, user_id, &body.proof).await?; + Ok(PendingSignIn { user_id, tier, wrapped_user_secrets }) +} + +pub async fn login_second_factor( + State(pool): State, + axum::Extension(cipher): axum::Extension, + Json(body): Json, +) -> Result, Refusal> { + let PendingSignIn { user_id, tier, wrapped_user_secrets } = pending_sign_in(&pool, &body.pending_token).await?; + let context = two_factor::Context::SignIn { pending_token: &body.pending_token }; + two_factor::verify_in(&pool, &cipher, user_id, &body.proof, context).await?; info!(user_id = %user_id, tier = %tier.tier, "User logged in with second factor"); @@ -801,7 +821,7 @@ pub(crate) async fn fetch_me_inner( handle_managed: bool, ) -> Result { let row = sqlx::query_as::<_, (String, String, Uuid, Option, String, bool, bool, bool, bool)>( - "SELECT email, handle AS display_name, account_id, wrapped_user_secrets, handle, handle_is_custom, allow_stranger_invites, email_undeliverable_at IS NOT NULL, totp_enabled_at IS NOT NULL FROM users WHERE id = $1", + &format!("SELECT email, handle AS display_name, account_id, wrapped_user_secrets, handle, handle_is_custom, allow_stranger_invites, email_undeliverable_at IS NOT NULL, {} FROM users WHERE id = $1", two_factor::ENABLED_SQL), ) .bind(user_id) .fetch_one(pool) @@ -1302,6 +1322,7 @@ mod handler_tests { let outcome = login( State(pool.clone()), Extension(SeedCipher::for_tests()), + Extension(Passkeys::for_tests()), Json(LoginRequest { account_id, auth_key: auth_key.to_string(), diff --git a/src/routes/meta.rs b/src/routes/meta.rs index f30d71f..1e6f71b 100644 --- a/src/routes/meta.rs +++ b/src/routes/meta.rs @@ -7,7 +7,7 @@ use axum::{Extension, Json}; use serde::Serialize; -use crate::{features::Features, routes::team_objects, self_host, two_factor::SeedCipher}; +use crate::{features::Features, passkeys::Passkeys, routes::team_objects, self_host, two_factor::SeedCipher}; #[derive(Serialize)] pub struct MetaResponse { @@ -21,11 +21,14 @@ pub struct MetaResponse { pub team_secret_types: Vec<&'static str>, /// Whether accounts can enrol a second factor here; absent on servers that predate it. pub two_factor: bool, + /// Whether a passkey can be a second factor here: needs a relying party and the portal. + pub passkeys: bool, } pub async fn get_meta( Extension(features): Extension, Extension(cipher): Extension, + Extension(passkeys): Extension, ) -> Json { let self_hosted = self_host::is_self_hosted(); Json(MetaResponse { @@ -37,6 +40,7 @@ pub async fn get_meta( handles_from_email: features.handles_from_email, team_secret_types: team_objects::secret_types(), two_factor: cipher.available(), + passkeys: passkeys.available(), }) } @@ -46,14 +50,14 @@ mod tests { #[tokio::test] async fn meta_advertises_identity_picks() { - let Json(meta) = get_meta(Extension(Features::open()), Extension(SeedCipher::for_tests())).await; + let Json(meta) = get_meta(Extension(Features::open()), Extension(SeedCipher::for_tests()), Extension(Passkeys::for_tests())).await; let body = serde_json::to_value(&meta).unwrap(); assert_eq!(body["identity_picks"], serde_json::json!(true)); } #[tokio::test] async fn meta_advertises_the_team_secret_types_it_accepts() { - let Json(meta) = get_meta(Extension(Features::open()), Extension(SeedCipher::for_tests())).await; + let Json(meta) = get_meta(Extension(Features::open()), Extension(SeedCipher::for_tests()), Extension(Passkeys::for_tests())).await; let body = serde_json::to_value(&meta).unwrap(); let types = body["team_secret_types"].as_array().unwrap(); assert!(types.contains(&serde_json::json!("connection_knock_sequence"))); @@ -62,15 +66,24 @@ mod tests { #[tokio::test] async fn meta_reports_the_handle_switch() { - let Json(meta) = get_meta(Extension(Features { handles_from_email: true, ..Features::open() }), Extension(SeedCipher::for_tests())).await; + let Json(meta) = get_meta(Extension(Features { handles_from_email: true, ..Features::open() }), Extension(SeedCipher::for_tests()), Extension(Passkeys::for_tests())).await; assert_eq!(serde_json::to_value(&meta).unwrap()["handles_from_email"], serde_json::json!(true)); } #[tokio::test] async fn meta_offers_two_factor_only_with_a_seed_key() { for (cipher, offered) in [(SeedCipher::for_tests(), true), (SeedCipher::absent(), false)] { - let Json(meta) = get_meta(Extension(Features::open()), Extension(cipher)).await; - assert_eq!(serde_json::to_value(&meta).unwrap()["two_factor"], serde_json::json!(offered)); + let Json(meta) = get_meta(Extension(Features::open()), Extension(cipher), Extension(Passkeys::absent())).await; + let body = serde_json::to_value(&meta).unwrap(); + assert_eq!(body["two_factor"], serde_json::json!(offered)); + assert_eq!(body["passkeys"], serde_json::json!(false)); } } + + #[tokio::test] + async fn meta_offers_passkeys_only_with_a_relying_party() { + let Json(meta) = + get_meta(Extension(Features::open()), Extension(SeedCipher::absent()), Extension(Passkeys::for_tests())).await; + assert_eq!(serde_json::to_value(&meta).unwrap()["passkeys"], serde_json::json!(true)); + } } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index af830d8..65dd895 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -21,6 +21,7 @@ pub mod team_object_prefs; pub mod team_rule_sets; pub mod teams; pub mod terminal; +pub mod passkeys; pub mod two_factor; pub mod users; pub mod waitlist; diff --git a/src/routes/passkeys.rs b/src/routes/passkeys.rs new file mode 100644 index 0000000..f044e33 --- /dev/null +++ b/src/routes/passkeys.rs @@ -0,0 +1,561 @@ +use axum::{ + extract::{Path, State}, + Extension, Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::passkeys::{self, Begun, Ceremony, Finished, Handle, PasskeyInfo, Passkeys, ReturnTo}; +use crate::routes::auth::{pending_sign_in, require_auth_key}; +use crate::routes::two_factor::{conclude_addition, remove, Factor, FactorAdded, Removal, StepUpRequest}; +use crate::sync_notifier::SyncNotifier; +use crate::two_factor::{self, db_failure, Refusal, SeedCipher}; + +#[derive(Deserialize)] +pub struct SignInRequest { + pub pending_token: String, + pub return_to: ReturnTo, +} + +pub async fn sign_in( + State(pool): State, + Extension(passkeys): Extension, + Json(body): Json, +) -> Result, Refusal> { + let user_id = pending_sign_in(&pool, &body.pending_token).await?.user_id; + let ceremony = Ceremony::SignIn { pending_token: body.pending_token }; + passkeys.open(&pool, user_id, ceremony, body.return_to).await.map(Json) +} + +#[derive(Deserialize)] +pub struct StepUpHandleRequest { + pub return_to: ReturnTo, +} + +pub async fn step_up( + State(pool): State, + Extension(auth): Extension, + Extension(passkeys): Extension, + Json(body): Json, +) -> Result, Refusal> { + passkeys.open(&pool, auth.0, Ceremony::StepUp, body.return_to).await.map(Json) +} + +#[derive(Deserialize)] +pub struct RegisterRequest { + #[serde(flatten)] + pub step_up: StepUpRequest, + #[serde(default)] + pub name: String, + pub return_to: ReturnTo, +} + +pub async fn register( + State(pool): State, + Extension(auth): Extension, + Extension(cipher): Extension, + Extension(passkeys): Extension, + Json(body): Json, +) -> Result, Refusal> { + if !passkeys.available() { + return Err(Refusal::Unavailable); + } + require_auth_key(&pool, auth.0, &body.step_up.auth_key).await?; + two_factor::verify_if_enabled(&pool, &cipher, auth.0, &body.step_up.proof).await?; + passkeys.open(&pool, auth.0, Ceremony::Register { name: body.name }, body.return_to).await.map(Json) +} + +#[derive(Deserialize)] +pub struct BeginRequest { + pub handle: String, +} + +pub async fn begin( + State(pool): State, + Extension(passkeys): Extension, + Json(body): Json, +) -> Result, Refusal> { + passkeys.begin(&pool, &body.handle).await.map(Json) +} + +#[derive(Deserialize)] +pub struct FinishRequest { + pub handle: String, + pub response: Value, +} + +pub async fn finish( + State(pool): State, + Extension(passkeys): Extension, + Json(body): Json, +) -> Result, Refusal> { + passkeys.finish(&pool, &body.handle, body.response).await.map(Json) +} + +#[derive(Deserialize)] +pub struct ActivateRequest { + pub passkey_code: String, +} + +#[derive(Serialize)] +pub struct Activated { + pub passkey: PasskeyInfo, + #[serde(flatten)] + pub added: FactorAdded, +} + +pub async fn activate( + State(pool): State, + Extension(auth): Extension, + Extension(notifier): Extension, + Json(body): Json, +) -> Result, Refusal> { + let mut tx = pool.begin().await.map_err(db_failure)?; + let was_enabled = two_factor::lock_enabled(&mut tx, auth.0).await.map_err(db_failure)?; + let passkey = passkeys::activate(&mut tx, auth.0, &body.passkey_code) + .await + .map_err(db_failure)? + .ok_or(Refusal::Invalid)?; + let added = conclude_addition(&pool, ¬ifier, tx, auth.0, was_enabled).await?; + Ok(Json(Activated { passkey, added })) +} + +pub async fn remove_passkey( + State(pool): State, + Extension(auth): Extension, + Extension(cipher): Extension, + Extension(notifier): Extension, + Path(passkey_id): Path, + Json(body): Json, +) -> Result, Refusal> { + remove(&pool, &cipher, ¬ifier, auth.0, &body, Factor::Passkey(passkey_id)).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::jwt::create_refresh_token; + use crate::passkeys::Purpose; + use crate::routes::auth::{login, LoginOutcome, LoginRequest}; + use crate::routes::two_factor::tests::{ + begin_setup_with, challenge_with, code, confirm, enrol, finish as sign_in_with, jwt_env, recovery, refreshes, + step_up_request, unused_code, AUTH_KEY, + }; + use crate::routes::two_factor::{factors, regenerate_recovery_codes, remove_totp}; + use crate::test_pool_or_skip; + use crate::test_support::seed_user_with_credentials; + use crate::two_factor::SecondFactorProof; + use axum::http::StatusCode; + use url::Url; + use webauthn_authenticator_rs::{softpasskey::SoftPasskey, WebauthnAuthenticator}; + use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse}; + + fn desktop() -> ReturnTo { + ReturnTo::Loopback { port: 49152, state: "n0nce".to_string() } + } + + fn phone() -> ReturnTo { + ReturnTo::App { state: String::new() } + } + + struct Device(WebauthnAuthenticator); + + impl Device { + fn new() -> Self { + Self(WebauthnAuthenticator::new(SoftPasskey::new(true))) + } + + async fn answer(&mut self, pool: &PgPool, handle: &Handle) -> Result { + let request = BeginRequest { handle: handle.handle.clone() }; + let Json(begun) = begin(State(pool.clone()), Extension(Passkeys::for_tests()), Json(request)).await?; + let origin = Url::parse(Passkeys::TEST_ORIGIN).unwrap(); + Ok(if begun.purpose == Purpose::Register { + let options: CreationChallengeResponse = serde_json::from_value(begun.options).unwrap(); + serde_json::to_value(self.0.do_registration(origin, options).expect("registration")).unwrap() + } else { + let options: RequestChallengeResponse = serde_json::from_value(begun.options).unwrap(); + serde_json::to_value(self.0.do_authentication(origin, options).expect("assertion")).unwrap() + }) + } + + /// The browser's half of a ceremony; returns the code the client gets back. + async fn complete(&mut self, pool: &PgPool, handle: &Handle) -> Result { + let response = self.answer(pool, handle).await?; + let done = submit(pool, handle, response).await?; + Ok(done.code.unwrap_or_else(|| { + let redirect = done.redirect.expect("a redirect"); + let query = redirect.split_once("c=").expect("a code").1; + query.split('&').next().unwrap().to_string() + })) + } + } + + async fn submit(pool: &PgPool, handle: &Handle, response: Value) -> Result { + let request = FinishRequest { handle: handle.handle.clone(), response }; + finish(State(pool.clone()), Extension(Passkeys::for_tests()), Json(request)).await.map(|Json(done)| done) + } + + fn passkey(code: &str) -> SecondFactorProof { + SecondFactorProof { passkey_code: Some(code.to_string()), ..Default::default() } + } + + async fn open_registration( + pool: &PgPool, + passkeys: Passkeys, + uid: Uuid, + proof: SecondFactorProof, + ) -> Result { + register( + State(pool.clone()), + Extension(AuthUser(uid)), + Extension(SeedCipher::for_tests()), + Extension(passkeys), + Json(RegisterRequest { + step_up: step_up_request(AUTH_KEY, proof), + name: " Laptop ".to_string(), + return_to: desktop(), + }), + ) + .await + .map(|Json(handle)| handle) + } + + async fn add_passkey( + pool: &PgPool, + uid: Uuid, + device: &mut Device, + proof: SecondFactorProof, + ) -> Result { + let handle = open_registration(pool, Passkeys::for_tests(), uid, proof).await?; + let passkey_code = device.complete(pool, &handle).await?; + activate( + State(pool.clone()), + Extension(AuthUser(uid)), + Extension(SyncNotifier::new()), + Json(ActivateRequest { passkey_code }), + ) + .await + .map(|Json(activated)| activated) + } + + async fn open_sign_in(pool: &PgPool, pending_token: &str, return_to: ReturnTo) -> Result { + let request = SignInRequest { pending_token: pending_token.to_string(), return_to }; + sign_in(State(pool.clone()), Extension(Passkeys::for_tests()), Json(request)).await.map(|Json(handle)| handle) + } + + async fn step_up_code(pool: &PgPool, uid: Uuid, device: &mut Device) -> String { + let Json(handle) = step_up( + State(pool.clone()), + Extension(AuthUser(uid)), + Extension(Passkeys::for_tests()), + Json(StepUpHandleRequest { return_to: phone() }), + ) + .await + .expect("handle"); + device.complete(pool, &handle).await.expect("assertion") + } + + struct Holder { + uid: Uuid, + account_id: Uuid, + device: Device, + added: Activated, + } + + async fn with_passkey(pool: &PgPool) -> Holder { + let account_id = Uuid::new_v4(); + let uid = seed_user_with_credentials(pool, account_id, AUTH_KEY).await; + let mut device = Device::new(); + let added = add_passkey(pool, uid, &mut device, SecondFactorProof::default()).await.expect("first passkey"); + Holder { uid, account_id, device, added } + } + + async fn signs_in_with_the_password_alone(pool: &PgPool, account_id: Uuid) -> bool { + let outcome = login( + State(pool.clone()), + Extension(SeedCipher::for_tests()), + Extension(Passkeys::for_tests()), + Json(LoginRequest { account_id, auth_key: AUTH_KEY.to_string() }), + ) + .await + .expect("password accepted"); + matches!(outcome, LoginOutcome::Session(_)) + } + + async fn remove_one(pool: &PgPool, uid: Uuid, passkey_id: Uuid, proof: SecondFactorProof) -> Result { + remove_passkey( + State(pool.clone()), + Extension(AuthUser(uid)), + Extension(SeedCipher::for_tests()), + Extension(SyncNotifier::new()), + Path(passkey_id), + Json(step_up_request(AUTH_KEY, proof)), + ) + .await + .map(|Json(removal)| removal) + } + + #[tokio::test] + async fn a_first_passkey_turns_two_factor_on() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let account_id = Uuid::new_v4(); + let uid = seed_user_with_credentials(&pool, account_id, AUTH_KEY).await; + let before = create_refresh_token(uid, 0).unwrap(); + let mut device = Device::new(); + + let handle = open_registration(&pool, Passkeys::for_tests(), uid, SecondFactorProof::default()) + .await + .expect("the password is enough without a factor"); + assert!(handle.url.starts_with("https://app.voltius.test/2fa/passkey#h=")); + let passkey_code = device.complete(&pool, &handle).await.expect("registration"); + assert!(signs_in_with_the_password_alone(&pool, account_id).await, "nothing is on until the client confirms"); + assert!(refreshes(&pool, &before).await); + + let activate_with = |passkey_code: String| { + activate( + State(pool.clone()), + Extension(AuthUser(uid)), + Extension(SyncNotifier::new()), + Json(ActivateRequest { passkey_code }), + ) + }; + assert_eq!(activate_with("not-the-code".to_string()).await.err(), Some(Refusal::Invalid)); + let Json(added) = activate_with(passkey_code.clone()).await.expect("activation"); + assert_eq!(added.passkey.name, "Laptop"); + assert_eq!(added.added.recovery_codes.len(), two_factor::RECOVERY_CODE_COUNT); + assert!(!refreshes(&pool, &before).await); + assert!(refreshes(&pool, &added.added.session.refresh_token).await); + assert_eq!(activate_with(passkey_code).await.err(), Some(Refusal::Invalid), "a code registers one passkey"); + + let Json(held) = factors(State(pool.clone()), Extension(AuthUser(uid))).await.unwrap(); + assert!(!held.totp); + assert_eq!(held.passkeys.len(), 1); + assert_eq!(held.recovery_codes_left, two_factor::RECOVERY_CODE_COUNT as i64); + + let challenge = challenge_with(&pool, SeedCipher::for_tests(), account_id).await; + assert_eq!(challenge.methods, ["passkey", "recovery"]); + let recovery_code = &added.added.recovery_codes[0]; + assert!(sign_in_with(&pool, &challenge.pending_token, recovery(recovery_code)).await.is_ok()); + } + + #[tokio::test] + async fn a_passkey_code_fits_only_the_sign_in_it_was_opened_for() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let mut user = with_passkey(&pool).await; + let mine = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + let theirs = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + + let handle = open_sign_in(&pool, &mine.pending_token, desktop()).await.expect("handle"); + let response = user.device.answer(&pool, &handle).await.expect("assertion"); + let done = submit(&pool, &handle, response).await.expect("finish"); + let redirect = done.redirect.expect("a desktop sign-in is sent to the loopback listener"); + let passkey_code = redirect + .strip_prefix("http://127.0.0.1:49152/passkey?c=") + .and_then(|query| query.strip_suffix("&s=n0nce")) + .expect("the loopback address, the code, then the client's state") + .to_string(); + assert_eq!(done.code, None); + + assert_eq!( + sign_in_with(&pool, &theirs.pending_token, passkey(&passkey_code)).await.err(), + Some(Refusal::Invalid), + "a second sign-in with the same password must not ride on this ceremony" + ); + let step_up_with = |passkey_code: &str| { + regenerate_recovery_codes( + State(pool.clone()), + Extension(AuthUser(user.uid)), + Extension(SeedCipher::for_tests()), + Json(passkey(passkey_code)), + ) + }; + assert_eq!(step_up_with(&passkey_code).await.err(), Some(Refusal::Invalid)); + + let session = sign_in_with(&pool, &mine.pending_token, passkey(&passkey_code)).await.expect("signed in"); + assert_eq!(session.user_id, user.uid); + assert_eq!( + sign_in_with(&pool, &mine.pending_token, passkey(&passkey_code)).await.err(), + Some(Refusal::Invalid), + "a code opens one session" + ); + + let confirmed = step_up_code(&pool, user.uid, &mut user.device).await; + assert_eq!( + sign_in_with(&pool, &mine.pending_token, passkey(&confirmed)).await.err(), + Some(Refusal::Invalid), + "a step-up code is not a sign-in" + ); + assert!(step_up_with(&confirmed).await.is_ok()); + } + + #[tokio::test] + async fn the_portal_keeps_the_code_and_the_phone_gets_a_deep_link() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let mut user = with_passkey(&pool).await; + let challenge = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + + for (return_to, in_body) in [(ReturnTo::Inline, true), (phone(), false)] { + let handle = open_sign_in(&pool, &challenge.pending_token, return_to).await.expect("handle"); + let response = user.device.answer(&pool, &handle).await.expect("assertion"); + let done = submit(&pool, &handle, response).await.expect("finish"); + assert_eq!(done.code.is_some(), in_body); + assert_eq!(done.redirect.is_some_and(|url| url.starts_with("voltius://passkey?c=")), !in_body); + } + assert_eq!( + open_sign_in(&pool, &challenge.pending_token, ReturnTo::Loopback { port: 80, state: String::new() }).await.err(), + Some(Refusal::Status(StatusCode::BAD_REQUEST)) + ); + assert_eq!( + open_sign_in(&pool, "not-a-token", phone()).await.err(), + Some(Refusal::Status(StatusCode::UNAUTHORIZED)) + ); + } + + #[tokio::test] + async fn an_assertion_answers_one_challenge_and_a_handle_runs_one_ceremony() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let mut user = with_passkey(&pool).await; + let challenge = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + + let first = open_sign_in(&pool, &challenge.pending_token, phone()).await.expect("handle"); + let untouched = Handle { handle: first.handle.clone(), url: String::new() }; + assert_eq!( + submit(&pool, &untouched, serde_json::json!({})).await.err(), + Some(Refusal::Status(StatusCode::GONE)), + "nothing to finish before a challenge was issued" + ); + let response = user.device.answer(&pool, &first).await.expect("assertion"); + assert!(submit(&pool, &first, response.clone()).await.is_ok()); + assert_eq!(submit(&pool, &first, response.clone()).await.err(), Some(Refusal::Status(StatusCode::GONE))); + + let second = open_sign_in(&pool, &challenge.pending_token, phone()).await.expect("handle"); + user.device.answer(&pool, &second).await.expect("a fresh challenge"); + assert_eq!(submit(&pool, &second, response).await.err(), Some(Refusal::Invalid)); + + let unknown = Handle { handle: "nobody-opened-this".to_string(), url: String::new() }; + assert_eq!(user.device.answer(&pool, &unknown).await.err(), Some(Refusal::Status(StatusCode::GONE))); + + sqlx::query("UPDATE users SET totp_locked_until = now() + interval '1 minute' WHERE id = $1") + .bind(user.uid) + .execute(&pool) + .await + .unwrap(); + assert_eq!(user.device.answer(&pool, &second).await.err(), Some(Refusal::Locked)); + } + + #[tokio::test] + async fn another_factor_needs_proof_of_one_already_held() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let mut user = with_passkey(&pool).await; + let mut second_device = Device::new(); + + assert_eq!( + add_passkey(&pool, user.uid, &mut second_device, SecondFactorProof::default()).await.err(), + Some(Refusal::Required) + ); + let proof = passkey(&step_up_code(&pool, user.uid, &mut user.device).await); + let second = add_passkey(&pool, user.uid, &mut second_device, proof).await.expect("second passkey"); + assert!(second.added.recovery_codes.is_empty(), "the first set of recovery codes stays valid"); + assert!(refreshes(&pool, &user.added.added.session.refresh_token).await, "nobody is signed out"); + + let cipher = SeedCipher::for_tests(); + assert_eq!( + begin_setup_with(&pool, &cipher, user.uid, SecondFactorProof::default()).await.err(), + Some(Refusal::Required) + ); + let proof = passkey(&step_up_code(&pool, user.uid, &mut second_device).await); + let seed = begin_setup_with(&pool, &cipher, user.uid, proof).await.expect("setup"); + + let challenge = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + let pending_code = two_factor::totp_at(&seed, two_factor::current_step()); + assert_eq!( + sign_in_with(&pool, &challenge.pending_token, code(&pending_code)).await.err(), + Some(Refusal::Invalid), + "a seed is no factor until its first code confirmed it" + ); + + let enabled = confirm(&pool, user.uid, code(&pending_code)).await.expect("enable"); + assert!(enabled.recovery_codes.is_empty()); + let challenge = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + assert_eq!(challenge.methods, ["totp", "passkey", "recovery"]); + } + + #[tokio::test] + async fn removing_the_last_factor_turns_two_factor_off() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = enrol(&pool).await; + let mut device = Device::new(); + let proof = code(&unused_code(&pool, &user).await); + let added = add_passkey(&pool, user.uid, &mut device, proof).await.expect("passkey"); + + let proof = code(&unused_code(&pool, &user).await); + let Json(without_totp) = remove_totp( + State(pool.clone()), + Extension(AuthUser(user.uid)), + Extension(SeedCipher::for_tests()), + Extension(SyncNotifier::new()), + Json(step_up_request(AUTH_KEY, proof)), + ) + .await + .expect("authenticator removed"); + assert!(without_totp.two_factor_enabled); + assert!(refreshes(&pool, &user.session.refresh_token).await); + let challenge = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + assert_eq!(challenge.methods, ["passkey", "recovery"]); + + assert_eq!( + remove_one(&pool, user.uid, Uuid::new_v4(), recovery(&user.recovery_codes[0])).await.err(), + Some(Refusal::Status(StatusCode::NOT_FOUND)) + ); + let proof = passkey(&step_up_code(&pool, user.uid, &mut device).await); + let off = remove_one(&pool, user.uid, added.passkey.id, proof).await.expect("passkey removed"); + assert!(!off.two_factor_enabled); + assert!(!refreshes(&pool, &user.session.refresh_token).await); + assert!(refreshes(&pool, &off.session.refresh_token).await); + assert!(signs_in_with_the_password_alone(&pool, user.account_id).await); + let leftover: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_recovery_codes WHERE user_id = $1") + .bind(user.uid) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(leftover, 0); + } + + #[tokio::test] + async fn an_admin_clear_removes_passkeys_too() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = with_passkey(&pool).await; + + let mut tx = pool.begin().await.unwrap(); + assert!(two_factor::clear(&mut tx, user.uid).await.unwrap()); + assert!(!two_factor::clear(&mut tx, user.uid).await.unwrap()); + tx.commit().await.unwrap(); + + assert!(signs_in_with_the_password_alone(&pool, user.account_id).await); + assert!(!refreshes(&pool, &user.added.added.session.refresh_token).await); + } + + #[tokio::test] + async fn a_server_without_a_relying_party_offers_no_passkeys() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = with_passkey(&pool).await; + + assert_eq!( + open_registration(&pool, Passkeys::absent(), user.uid, SecondFactorProof::default()).await.err(), + Some(Refusal::Unavailable) + ); + let offered = two_factor::methods(&pool, &SeedCipher::for_tests(), &Passkeys::absent(), user.uid).await; + assert_eq!(offered, Ok(vec!["recovery"])); + } +} diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index 678655b..a714a64 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -1927,6 +1927,7 @@ mod authz_tests { is_banned: false, email_verified: true, ver: 0, + jti: None, }) } diff --git a/src/routes/two_factor.rs b/src/routes/two_factor.rs index 5bb8737..3cc1107 100644 --- a/src/routes/two_factor.rs +++ b/src/routes/two_factor.rs @@ -1,16 +1,21 @@ -use axum::{extract::State, Extension, Json}; +use axum::{extract::State, http::StatusCode, Extension, Json}; use serde::{Deserialize, Serialize}; -use sqlx::PgPool; +use sqlx::{PgPool, Postgres, Transaction}; use tracing::info; +use uuid::Uuid; use crate::auth::AuthUser; +use crate::passkeys::{self, PasskeyInfo}; use crate::routes::auth::{fresh_session, require_auth_key, SessionTokens}; use crate::sync_notifier::SyncNotifier; -use crate::two_factor::{self, db_failure, Refusal, SecondFactorProof, SeedCipher}; +use crate::two_factor::{self, db_failure, Context, Refusal, SecondFactorProof, SeedCipher}; +/// The master password plus, once the account has a factor, proof of one. #[derive(Deserialize)] -pub struct SetupRequest { +pub struct StepUpRequest { pub auth_key: String, + #[serde(flatten)] + pub proof: SecondFactorProof, } #[derive(Serialize)] @@ -19,15 +24,27 @@ pub struct SetupResponse { pub otpauth_uri: String, } +async fn totp_enabled(pool: &PgPool, user_id: Uuid) -> Result { + sqlx::query_scalar("SELECT totp_enabled_at IS NOT NULL FROM users WHERE id = $1") + .bind(user_id) + .fetch_one(pool) + .await + .map_err(db_failure) +} + pub async fn setup( State(pool): State, Extension(auth): Extension, Extension(cipher): Extension, - Json(body): Json, + Json(body): Json, ) -> Result, Refusal> { let seed = two_factor::generate_seed(); let sealed = cipher.seal(&seed).ok_or(Refusal::Unavailable)?; require_auth_key(&pool, auth.0, &body.auth_key).await?; + if totp_enabled(&pool, auth.0).await? { + return Err(Refusal::AlreadyEnabled); + } + two_factor::verify_if_enabled(&pool, &cipher, auth.0, &body.proof).await?; let email: Option = sqlx::query_scalar( "UPDATE users SET totp_secret = $1, totp_last_step = NULL @@ -46,29 +63,50 @@ pub async fn setup( })) } +/// Recovery codes are only issued with the account's first factor; later ones leave the list empty. #[derive(Serialize)] -pub struct EnableResponse { +pub struct FactorAdded { pub recovery_codes: Vec, #[serde(flatten)] pub session: SessionTokens, } +pub async fn conclude_addition( + pool: &PgPool, + notifier: &SyncNotifier, + mut tx: Transaction<'_, Postgres>, + user_id: Uuid, + was_enabled: bool, +) -> Result { + let recovery_codes = + two_factor::first_factor_added(&mut tx, user_id, was_enabled).await.map_err(db_failure)?; + tx.commit().await.map_err(db_failure)?; + + let session = fresh_session(pool, user_id).await?; + if !was_enabled { + notifier.notify(user_id, "token_invalidated".to_string()); + info!(user_id = %user_id, "Two-factor authentication enabled"); + } + Ok(FactorAdded { recovery_codes, session }) +} + pub async fn enable( State(pool): State, Extension(auth): Extension, Extension(cipher): Extension, Extension(notifier): Extension, Json(proof): Json, -) -> Result, Refusal> { - if two_factor::is_enabled(&pool, auth.0).await? { +) -> Result, Refusal> { + if totp_enabled(&pool, auth.0).await? { return Err(Refusal::AlreadyEnabled); } - let code_only = SecondFactorProof { code: proof.code, recovery_code: None }; - two_factor::verify(&pool, &cipher, auth.0, &code_only).await?; + let code_only = SecondFactorProof { code: proof.code, ..Default::default() }; + two_factor::verify_in(&pool, &cipher, auth.0, &code_only, Context::Enrolment).await?; let mut tx = pool.begin().await.map_err(db_failure)?; + let was_enabled = two_factor::lock_enabled(&mut tx, auth.0).await.map_err(db_failure)?; let enabled = sqlx::query( - "UPDATE users SET totp_enabled_at = now(), token_version = token_version + 1 + "UPDATE users SET totp_enabled_at = now() WHERE id = $1 AND totp_enabled_at IS NULL AND totp_secret IS NOT NULL", ) .bind(auth.0) @@ -78,21 +116,56 @@ pub async fn enable( if enabled.rows_affected() == 0 { return Err(Refusal::AlreadyEnabled); } - let recovery_codes = two_factor::replace_recovery_codes(&mut tx, auth.0).await.map_err(db_failure)?; - tx.commit().await.map_err(db_failure)?; - - let session = fresh_session(&pool, auth.0).await?; - notifier.notify(auth.0, "token_invalidated".to_string()); - info!(user_id = %auth.0, "Two-factor authentication enabled"); + conclude_addition(&pool, ¬ifier, tx, auth.0, was_enabled).await.map(Json) +} - Ok(Json(EnableResponse { recovery_codes, session })) +pub enum Factor { + All, + Totp, + Passkey(Uuid), } -#[derive(Deserialize)] -pub struct DisableRequest { - pub auth_key: String, +#[derive(Serialize)] +pub struct Removal { + pub two_factor_enabled: bool, #[serde(flatten)] - pub proof: SecondFactorProof, + pub session: SessionTokens, +} + +/// Removing the last factor turns two-factor off, which ends every other session. +pub async fn remove( + pool: &PgPool, + cipher: &SeedCipher, + notifier: &SyncNotifier, + user_id: Uuid, + body: &StepUpRequest, + factor: Factor, +) -> Result, Refusal> { + if !two_factor::is_enabled(pool, user_id).await? { + return Err(Refusal::NotEnabled); + } + require_auth_key(pool, user_id, &body.auth_key).await?; + two_factor::verify(pool, cipher, user_id, &body.proof).await?; + + let mut tx = pool.begin().await.map_err(db_failure)?; + let removed = match factor { + Factor::All => two_factor::remove_all(&mut tx, user_id).await, + Factor::Totp => two_factor::remove_totp(&mut tx, user_id).await, + Factor::Passkey(id) => passkeys::remove(&mut tx, user_id, id).await, + } + .map_err(db_failure)?; + if !removed { + return Err(StatusCode::NOT_FOUND.into()); + } + let two_factor_enabled = two_factor::settle_after_removal(&mut tx, user_id).await.map_err(db_failure)?; + tx.commit().await.map_err(db_failure)?; + + let session = fresh_session(pool, user_id).await?; + if !two_factor_enabled { + notifier.notify(user_id, "token_invalidated".to_string()); + info!(user_id = %user_id, "Two-factor authentication disabled"); + } + Ok(Json(Removal { two_factor_enabled, session })) } pub async fn disable( @@ -100,23 +173,43 @@ pub async fn disable( Extension(auth): Extension, Extension(cipher): Extension, Extension(notifier): Extension, - Json(body): Json, -) -> Result, Refusal> { - if !two_factor::is_enabled(&pool, auth.0).await? { - return Err(Refusal::NotEnabled); - } - require_auth_key(&pool, auth.0, &body.auth_key).await?; - two_factor::verify(&pool, &cipher, auth.0, &body.proof).await?; + Json(body): Json, +) -> Result, Refusal> { + remove(&pool, &cipher, ¬ifier, auth.0, &body, Factor::All).await +} - let mut tx = pool.begin().await.map_err(db_failure)?; - two_factor::clear(&mut tx, auth.0).await.map_err(db_failure)?; - tx.commit().await.map_err(db_failure)?; +pub async fn remove_totp( + State(pool): State, + Extension(auth): Extension, + Extension(cipher): Extension, + Extension(notifier): Extension, + Json(body): Json, +) -> Result, Refusal> { + remove(&pool, &cipher, ¬ifier, auth.0, &body, Factor::Totp).await +} - let session = fresh_session(&pool, auth.0).await?; - notifier.notify(auth.0, "token_invalidated".to_string()); - info!(user_id = %auth.0, "Two-factor authentication disabled"); +#[derive(Serialize)] +pub struct Factors { + pub totp: bool, + pub passkeys: Vec, + pub recovery_codes_left: i64, +} - Ok(Json(session)) +pub async fn factors( + State(pool): State, + Extension(auth): Extension, +) -> Result, Refusal> { + let recovery_codes_left = + sqlx::query_scalar("SELECT COUNT(*) FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL") + .bind(auth.0) + .fetch_one(&pool) + .await + .map_err(db_failure)?; + Ok(Json(Factors { + totp: totp_enabled(&pool, auth.0).await?, + passkeys: passkeys::list(&pool, auth.0).await.map_err(db_failure)?, + recovery_codes_left, + })) } #[derive(Serialize)] @@ -144,44 +237,49 @@ pub async fn regenerate_recovery_codes( } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::auth::jwt::{create_refresh_token, validate_token}; use crate::routes::auth::{ login, login_second_factor, refresh, update_password, LoginOutcome, LoginRequest, RefreshRequest, SecondFactorChallenge, SecondFactorLoginRequest, UpdatePasswordRequest, }; + use crate::passkeys::Passkeys; use crate::test_pool_or_skip; use crate::test_support::{env_lock, seed_user_with_credentials, EnvLockGuard}; use axum::http::StatusCode; use uuid::Uuid; - const AUTH_KEY: &str = "auth-key-secret"; + pub(crate) const AUTH_KEY: &str = "auth-key-secret"; - struct Enrolled { - uid: Uuid, - account_id: Uuid, - seed: Vec, - recovery_codes: Vec, - session: SessionTokens, + pub(crate) struct Enrolled { + pub uid: Uuid, + pub account_id: Uuid, + pub seed: Vec, + pub recovery_codes: Vec, + pub session: SessionTokens, } - fn jwt_env() -> EnvLockGuard { + pub(crate) fn jwt_env() -> EnvLockGuard { let guard = env_lock(); std::env::set_var("JWT_SECRET", "ci-test-secret"); EnvLockGuard(guard) } - fn code(value: &str) -> SecondFactorProof { - SecondFactorProof { code: Some(value.to_string()), recovery_code: None } + pub(crate) fn code(value: &str) -> SecondFactorProof { + SecondFactorProof { code: Some(value.to_string()), ..Default::default() } + } + + pub(crate) fn recovery(value: &str) -> SecondFactorProof { + SecondFactorProof { recovery_code: Some(value.to_string()), ..Default::default() } } - fn recovery(value: &str) -> SecondFactorProof { - SecondFactorProof { code: None, recovery_code: Some(value.to_string()) } + pub(crate) fn step_up_request(auth_key: &str, proof: SecondFactorProof) -> StepUpRequest { + StepUpRequest { auth_key: auth_key.to_string(), proof } } /// A code the server has not seen yet: replay protection would refuse the one used a moment ago. - async fn unused_code(pool: &PgPool, user: &Enrolled) -> String { + pub(crate) async fn unused_code(pool: &PgPool, user: &Enrolled) -> String { sqlx::query("UPDATE users SET totp_last_step = NULL WHERE id = $1") .bind(user.uid) .execute(pool) @@ -190,19 +288,28 @@ mod tests { two_factor::totp_at(&user.seed, two_factor::current_step()) } - async fn begin_setup(pool: &PgPool, cipher: &SeedCipher, uid: Uuid) -> Result, Refusal> { + pub(crate) async fn begin_setup_with( + pool: &PgPool, + cipher: &SeedCipher, + uid: Uuid, + proof: SecondFactorProof, + ) -> Result, Refusal> { let Json(pending) = setup( State(pool.clone()), Extension(AuthUser(uid)), Extension(cipher.clone()), - Json(SetupRequest { auth_key: AUTH_KEY.to_string() }), + Json(step_up_request(AUTH_KEY, proof)), ) .await?; assert!(pending.otpauth_uri.contains(&pending.secret)); Ok(data_encoding::BASE32_NOPAD.decode(pending.secret.as_bytes()).unwrap()) } - async fn confirm(pool: &PgPool, uid: Uuid, proof: SecondFactorProof) -> Result { + async fn begin_setup(pool: &PgPool, cipher: &SeedCipher, uid: Uuid) -> Result, Refusal> { + begin_setup_with(pool, cipher, uid, SecondFactorProof::default()).await + } + + pub(crate) async fn confirm(pool: &PgPool, uid: Uuid, proof: SecondFactorProof) -> Result { enable( State(pool.clone()), Extension(AuthUser(uid)), @@ -214,7 +321,7 @@ mod tests { .map(|Json(r)| r) } - async fn enrol(pool: &PgPool) -> Enrolled { + pub(crate) async fn enrol(pool: &PgPool) -> Enrolled { let account_id = Uuid::new_v4(); let uid = seed_user_with_credentials(pool, account_id, AUTH_KEY).await; let seed = begin_setup(pool, &SeedCipher::for_tests(), uid).await.expect("setup ok"); @@ -224,10 +331,11 @@ mod tests { Enrolled { uid, account_id, seed, recovery_codes: enabled.recovery_codes, session: enabled.session } } - async fn challenge_with(pool: &PgPool, cipher: SeedCipher, account_id: Uuid) -> SecondFactorChallenge { + pub(crate) async fn challenge_with(pool: &PgPool, cipher: SeedCipher, account_id: Uuid) -> SecondFactorChallenge { let outcome = login( State(pool.clone()), Extension(cipher), + Extension(Passkeys::for_tests()), Json(LoginRequest { account_id, auth_key: AUTH_KEY.to_string() }), ) .await @@ -253,7 +361,7 @@ mod tests { .map(|Json(r)| r) } - async fn finish( + pub(crate) async fn finish( pool: &PgPool, pending_token: &str, proof: SecondFactorProof, @@ -261,7 +369,7 @@ mod tests { finish_with(pool, SeedCipher::for_tests(), pending_token, proof).await } - async fn refreshes(pool: &PgPool, refresh_token: &str) -> bool { + pub(crate) async fn refreshes(pool: &PgPool, refresh_token: &str) -> bool { refresh(State(pool.clone()), Json(RefreshRequest { refresh_token: refresh_token.to_string() })) .await .is_ok() @@ -397,7 +505,7 @@ mod tests { Extension(AuthUser(user.uid)), Extension(SeedCipher::for_tests()), Extension(SyncNotifier::new()), - Json(DisableRequest { auth_key: auth_key.to_string(), proof }), + Json(step_up_request(auth_key, proof)), ) }; @@ -408,14 +516,16 @@ mod tests { ); assert_eq!(turn_off(AUTH_KEY, code("000000")).await.err(), Some(Refusal::Invalid)); - let Json(session) = turn_off(AUTH_KEY, code(&good)).await.expect("disable ok"); + let Json(off) = turn_off(AUTH_KEY, code(&good)).await.expect("disable ok"); + assert!(!off.two_factor_enabled); assert!(!refreshes(&pool, &user.session.refresh_token).await); - assert!(refreshes(&pool, &session.refresh_token).await); + assert!(refreshes(&pool, &off.session.refresh_token).await); assert_eq!(turn_off(AUTH_KEY, code(&good)).await.err(), Some(Refusal::NotEnabled)); let outcome = login( State(pool.clone()), Extension(SeedCipher::for_tests()), + Extension(Passkeys::for_tests()), Json(LoginRequest { account_id: user.account_id, auth_key: AUTH_KEY.to_string() }), ) .await @@ -551,6 +661,7 @@ mod tests { let outcome = login( State(pool.clone()), Extension(SeedCipher::for_tests()), + Extension(Passkeys::for_tests()), Json(LoginRequest { account_id: user.account_id, auth_key: AUTH_KEY.to_string() }), ) .await diff --git a/src/two_factor.rs b/src/two_factor.rs index 5a960a1..b36cca9 100644 --- a/src/two_factor.rs +++ b/src/two_factor.rs @@ -1,4 +1,4 @@ -//! Optional TOTP second factor: seed encryption, code checks, recovery codes. +//! Optional second factor: TOTP seeds and codes, recovery codes, and the checks every factor goes through. use axum::{ http::StatusCode, @@ -21,6 +21,7 @@ use std::sync::Arc; use tracing::{error, warn}; use uuid::Uuid; +use crate::passkeys::{self, Passkeys}; use crate::routes::coded_error; const STEP_SECONDS: i64 = 30; @@ -30,6 +31,10 @@ const LOCK_MINUTES: i32 = 15; pub const RECOVERY_CODE_COUNT: usize = 10; const RECOVERY_ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; +/// Whether a `users` row has any second factor. Splice into a query that reads `users` unaliased. +pub const ENABLED_SQL: &str = + "(totp_enabled_at IS NOT NULL OR EXISTS (SELECT 1 FROM user_passkeys WHERE user_passkeys.user_id = users.id))"; + /// Seals TOTP seeds with `TOTP_ENC_KEY`. Without the key the server offers no enrolment. #[derive(Clone)] pub struct SeedCipher(Option>); @@ -156,6 +161,23 @@ pub struct SecondFactorProof { pub code: Option, #[serde(default)] pub recovery_code: Option, + /// What a finished passkey ceremony handed back to the client. + #[serde(default)] + pub passkey_code: Option, +} + +impl SecondFactorProof { + fn is_empty(&self) -> bool { + self.code.is_none() && self.recovery_code.is_none() && self.passkey_code.is_none() + } +} + +/// What a proof is checked for. Enrolment confirms a seed that is not enabled yet. +#[derive(Clone, Copy)] +pub enum Context<'a> { + SignIn { pending_token: &'a str }, + StepUp, + Enrolment, } #[derive(Debug, PartialEq, Eq)] @@ -166,6 +188,7 @@ pub enum Refusal { Unavailable, AlreadyEnabled, NotEnabled, + PasskeyLimit, Status(StatusCode), } @@ -184,6 +207,7 @@ impl IntoResponse for Refusal { Refusal::Unavailable => coded_error(StatusCode::SERVICE_UNAVAILABLE, "TWO_FACTOR_UNAVAILABLE"), Refusal::AlreadyEnabled => coded_error(StatusCode::CONFLICT, "TWO_FACTOR_ALREADY_ENABLED"), Refusal::NotEnabled => coded_error(StatusCode::CONFLICT, "TWO_FACTOR_NOT_ENABLED"), + Refusal::PasskeyLimit => coded_error(StatusCode::CONFLICT, "PASSKEY_LIMIT"), Refusal::Status(status) => status.into_response(), } } @@ -194,23 +218,43 @@ pub fn db_failure(err: sqlx::Error) -> Refusal { Refusal::Status(StatusCode::INTERNAL_SERVER_ERROR) } -/// What a sign-in challenge may offer: a code needs the seed key, a recovery code never does. -pub fn methods(cipher: &SeedCipher) -> Vec<&'static str> { - if cipher.available() { - vec!["totp", "recovery"] - } else { - vec!["recovery"] - } +/// What a sign-in challenge may offer this account. A recovery code needs neither key nor relying party. +pub async fn methods( + pool: &PgPool, + cipher: &SeedCipher, + passkeys: &Passkeys, + user_id: Uuid, +) -> Result, Refusal> { + let (totp, passkey): (bool, bool) = sqlx::query_as( + "SELECT totp_enabled_at IS NOT NULL, EXISTS (SELECT 1 FROM user_passkeys WHERE user_id = users.id) + FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_one(pool) + .await + .map_err(db_failure)?; + let offered = [("totp", totp && cipher.available()), ("passkey", passkey && passkeys.available())]; + Ok(offered.into_iter().filter_map(|(method, on)| on.then_some(method)).chain(["recovery"]).collect()) } pub async fn is_enabled(pool: &PgPool, user_id: Uuid) -> Result { - sqlx::query_scalar("SELECT totp_enabled_at IS NOT NULL FROM users WHERE id = $1") + sqlx::query_scalar(&format!("SELECT {ENABLED_SQL} FROM users WHERE id = $1")) .bind(user_id) .fetch_one(pool) .await .map_err(db_failure) } +/// Reads whether the account has a factor while holding its row, so adding or removing one cannot race. +pub async fn lock_enabled(tx: &mut sqlx::PgConnection, user_id: Uuid) -> Result { + let enabled: Option = + sqlx::query_scalar(&format!("SELECT {ENABLED_SQL} FROM users WHERE id = $1 FOR UPDATE")) + .bind(user_id) + .fetch_optional(tx) + .await?; + Ok(enabled.unwrap_or(false)) +} + /// The step every sensitive change takes once the account has a second factor. pub async fn verify_if_enabled( pool: &PgPool, @@ -230,24 +274,34 @@ pub async fn verify( user_id: Uuid, proof: &SecondFactorProof, ) -> Result<(), Refusal> { - verify_at(pool, cipher, user_id, proof, current_step()).await + verify_in(pool, cipher, user_id, proof, Context::StepUp).await +} + +pub async fn verify_in( + pool: &PgPool, + cipher: &SeedCipher, + user_id: Uuid, + proof: &SecondFactorProof, + context: Context<'_>, +) -> Result<(), Refusal> { + verify_at(pool, cipher, user_id, proof, context, current_step()).await } -/// Checks the stored seed whether or not it is enabled yet, so enrolment confirms through the same path. pub async fn verify_at( pool: &PgPool, cipher: &SeedCipher, user_id: Uuid, proof: &SecondFactorProof, + context: Context<'_>, now_step: i64, ) -> Result<(), Refusal> { // A client that predates the feature sends nothing: that is not a wrong guess. - if proof.code.is_none() && proof.recovery_code.is_none() { + if proof.is_empty() { return Err(Refusal::Required); } let mut tx = pool.begin().await.map_err(db_failure)?; - let (sealed, last_step, locked): (Option, Option, bool) = sqlx::query_as( - "SELECT totp_secret, totp_last_step, COALESCE(totp_locked_until > now(), FALSE) + let (sealed, last_step, locked, totp_enabled): (Option, Option, bool, bool) = sqlx::query_as( + "SELECT totp_secret, totp_last_step, COALESCE(totp_locked_until > now(), FALSE), totp_enabled_at IS NOT NULL FROM users WHERE id = $1 FOR UPDATE", ) .bind(user_id) @@ -272,14 +326,23 @@ pub async fn verify_at( (spent.rows_affected() == 1).then_some(last_step) } else if let Some(code) = &proof.code { match sealed { - Some(sealed) => { + Some(sealed) if totp_enabled || matches!(context, Context::Enrolment) => { let seed = cipher.open(&sealed).ok_or(Refusal::Unavailable)?; matching_step(&seed, code, now_step) .filter(|step| last_step.is_none_or(|last| *step > last)) .map(Some) } - None => None, + _ => None, } + } else if let Some(passkey_code) = &proof.passkey_code { + let pending_token = match context { + Context::SignIn { pending_token } => Some(pending_token), + Context::StepUp | Context::Enrolment => None, + }; + passkeys::spend_code(&mut tx, user_id, passkey_code, pending_token) + .await + .map_err(db_failure)? + .then_some(last_step) } else { None }; @@ -335,20 +398,66 @@ pub async fn replace_recovery_codes( Ok(codes) } -pub async fn clear(tx: &mut sqlx::PgConnection, user_id: Uuid) -> Result { - let cleared = sqlx::query( - "UPDATE users SET totp_secret = NULL, totp_enabled_at = NULL, totp_last_step = NULL, - totp_failed_attempts = 0, totp_locked_until = NULL, token_version = token_version + 1 +/// Recovery codes for an account gaining its first factor, which also ends its earlier sessions. +pub async fn first_factor_added( + tx: &mut sqlx::PgConnection, + user_id: Uuid, + was_enabled: bool, +) -> Result, sqlx::Error> { + if was_enabled { + return Ok(Vec::new()); + } + sqlx::query("UPDATE users SET token_version = token_version + 1 WHERE id = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; + replace_recovery_codes(tx, user_id).await +} + +pub async fn remove_totp(tx: &mut sqlx::PgConnection, user_id: Uuid) -> Result { + let removed = sqlx::query( + "UPDATE users SET totp_secret = NULL, totp_enabled_at = NULL, totp_last_step = NULL WHERE id = $1 AND totp_enabled_at IS NOT NULL", ) .bind(user_id) + .execute(tx) + .await?; + Ok(removed.rows_affected() == 1) +} + +/// Turns two-factor off once the last factor is gone. Returns whether the account still has one. +pub async fn settle_after_removal(tx: &mut sqlx::PgConnection, user_id: Uuid) -> Result { + if lock_enabled(&mut *tx, user_id).await? { + return Ok(true); + } + sqlx::query( + "UPDATE users SET totp_secret = NULL, totp_last_step = NULL, totp_failed_attempts = 0, + totp_locked_until = NULL, token_version = token_version + 1 + WHERE id = $1", + ) + .bind(user_id) .execute(&mut *tx) .await?; - sqlx::query("DELETE FROM user_recovery_codes WHERE user_id = $1") - .bind(user_id) - .execute(&mut *tx) - .await?; - Ok(cleared.rows_affected() == 1) + for table in ["user_recovery_codes", "passkey_ceremonies"] { + sqlx::query(&format!("DELETE FROM {table} WHERE user_id = $1")).bind(user_id).execute(&mut *tx).await?; + } + Ok(false) +} + +pub async fn remove_all(tx: &mut sqlx::PgConnection, user_id: Uuid) -> Result { + remove_totp(&mut *tx, user_id).await?; + sqlx::query("DELETE FROM user_passkeys WHERE user_id = $1").bind(user_id).execute(tx).await?; + Ok(true) +} + +/// Removes every factor without proof of one. Returns whether the account had any. +pub async fn clear(tx: &mut sqlx::PgConnection, user_id: Uuid) -> Result { + if !lock_enabled(&mut *tx, user_id).await? { + return Ok(false); + } + remove_all(&mut *tx, user_id).await?; + settle_after_removal(tx, user_id).await?; + Ok(true) } #[cfg(test)]