From 1088e6bac82fb694d0202cd03c5be663806969b7 Mon Sep 17 00:00:00 2001 From: Antony Kellermann Date: Fri, 24 Apr 2026 23:21:49 -0700 Subject: [PATCH 1/5] add optional webauthn 2FA support Rebases doy/rbw#116 onto current main and upgrades the webauthn-rs stack from the PR's 0.5.0-dev git pins to crates.io 0.5.4 stable. Migrates Error::TwoFactorRequired and ConnectErrorRes to a HashMap keyed by TwoFactorProviderType so the per-provider WebAuthn challenge (PublicKeyCredentialRequestOptions) from TwoFactorProviders2 can be threaded through to the agent's 2FA flow. Origin for the assertion comes from Config::ui_url() so it works for default-cloud accounts where base_url is unset. Gated behind the off-by-default `webauthn` cargo feature; non-feature builds use a stub PublicKeyCredentialRequestOptions to keep the type signatures identical. --- Cargo.lock | 732 ++++++++++++++++++++++++++++++++++- Cargo.toml | 9 + src/api.rs | 20 +- src/bin/rbw-agent/actions.rs | 62 ++- src/error.rs | 5 +- src/lib.rs | 2 + src/webauthn.rs | 114 ++++++ 7 files changed, 923 insertions(+), 21 deletions(-) create mode 100644 src/webauthn.rs diff --git a/Cargo.lock b/Cargo.lock index c8b0964d..abbf6756 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,67 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[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" @@ -201,6 +262,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" +[[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" @@ -213,6 +280,37 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +[[package]] +name = "base64urlsafedata" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f7f6be94fa637132933fd0a68b9140bcb60e3d46164cb68e82a2bb8d102b3a" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -289,6 +387,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -311,6 +418,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.53" @@ -433,6 +551,12 @@ dependencies = [ "libc", ] +[[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" @@ -507,6 +631,29 @@ 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" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -602,6 +749,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -688,6 +841,31 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fido-hid-rs" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba2a273ad2118e8491a7803b6c88d3d7dce276edf1e3aca3b8c72c627c29b4d" +dependencies = [ + "async-trait", + "bindgen", + "bitflags 2.10.0", + "core-foundation", + "futures", + "lazy_static", + "libc", + "mach2 0.6.0", + "nix", + "num-derive", + "num-traits", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "udev", + "windows", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -700,6 +878,21 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[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" @@ -846,6 +1039,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "group" version = "0.13.0" @@ -857,6 +1056,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 = "hashbrown" version = "0.16.1" @@ -869,12 +1079,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "hermit-abi" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.12.4" @@ -989,7 +1211,7 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1129,6 +1351,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1160,7 +1393,7 @@ version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ - "hermit-abi", + "hermit-abi 0.5.2", "libc", "windows-sys 0.61.2", ] @@ -1181,6 +1414,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.16" @@ -1236,6 +1478,16 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.15" @@ -1252,6 +1504,16 @@ dependencies = [ "libc", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1294,6 +1556,12 @@ dependencies = [ "libc", ] +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "matchit" version = "0.8.4" @@ -1329,6 +1597,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -1339,6 +1619,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1355,6 +1645,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[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" @@ -1458,6 +1765,15 @@ dependencies = [ "objc2-core-foundation", ] +[[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.3" @@ -1481,12 +1797,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "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-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-sys" +version = "0.9.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -1575,6 +1929,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -1679,6 +2039,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1688,6 +2054,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -1864,7 +2240,7 @@ dependencies = [ "arrayvec", "axum", "base32", - "base64", + "base64 0.22.1", "block-padding", "cbc", "clap", @@ -1914,6 +2290,9 @@ dependencies = [ "url", "urlencoding", "uuid", + "webauthn-authenticator-rs", + "webauthn-rs", + "webauthn-rs-proto", "zeroize", ] @@ -1974,7 +2353,7 @@ checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" dependencies = [ "bitflags 1.3.2", "libc", - "mach2", + "mach2 0.4.3", "windows-sys 0.52.0", ] @@ -1984,7 +2363,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-channel", "futures-core", @@ -2096,6 +2475,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 = "rustix" version = "1.1.3" @@ -2245,6 +2633,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" @@ -2600,6 +3008,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2672,6 +3111,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -2770,9 +3210,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -2826,6 +3278,18 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + [[package]] name = "unicode-ident" version = "1.0.22" @@ -2838,6 +3302,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-width" version = "0.2.2" @@ -2894,9 +3367,16 @@ checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", + "serde_core", "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3075,12 +3555,138 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fafcf13f7dc1fb292ed4aea22cdd3757c285d7559e9748950ee390249da4da6b" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-authenticator-rs" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b41ed08aba475a969094226ae0691a286686210ae497bb2c5d0ed722d8d526" +dependencies = [ + "async-stream", + "async-trait", + "base64 0.21.7", + "base64urlsafedata", + "bitflags 1.3.2", + "fido-hid-rs", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b24d082d3360258fefb6ffe56123beef7d6868c765c779f97b7a2fcf06727f8" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.9.2", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1fb2580ce73baa42d3011a24de2ceab0d428de1879ece06e02e8c416e497c" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "windows" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3ed69de2c1f8d0524a8a3417a80a85dd316a071745fbfdf5eb028b310058ab" +dependencies = [ + "windows_aarch64_gnullvm 0.41.0", + "windows_aarch64_msvc 0.41.0", + "windows_i686_gnu 0.41.0", + "windows_i686_msvc 0.41.0", + "windows_x86_64_gnu 0.41.0", + "windows_x86_64_gnullvm 0.41.0", + "windows_x86_64_msvc 0.41.0", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3108,6 +3714,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3141,6 +3762,18 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163d2761774f2278ecb4e6719e80b2b5e92e5a2be73a7bcd3ef624dd5e3091fd" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3153,6 +3786,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef005ff2bceb00d3b84166a359cc19084f9459754fd3fe5a504dee3dddcd0a0c" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3165,6 +3810,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b4df2d51e32f03f8b4b228e487828c03bcb36d97b216fc5463bcea5bb1440b" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3189,6 +3846,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568a966834571f2f3267f07dd72b4d8507381f25e53d056808483b2637385ef7" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3201,6 +3870,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc395dac1adf444e276d096d933ae7961361c8cda3245cffef7a9b3a70a8f994" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3213,6 +3894,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e8ec22b715d5b436e1d59c8adad6c744dc20cd984710121d5836b4e8dbb5e0" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3225,6 +3918,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9761f0216b669019df1512f6e25e5ee779bf61c5cdc43c7293858e7efd7926" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3284,6 +3989,23 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[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.1" diff --git a/Cargo.toml b/Cargo.toml index d348fe04..579e753a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,9 +88,18 @@ arboard = { version = "3.6.1", default-features = false, features = [ "wayland-data-control", ], optional = true } +webauthn-rs = { version = "0.5.4", optional = true } +webauthn-rs-proto = { version = "0.5.4", optional = true } +webauthn-authenticator-rs = { version = "0.5.4", default-features = false, features = ["usb"], optional = true } + [features] default = ["clipboard"] clipboard = ["arboard"] +webauthn = [ + "dep:webauthn-rs", + "dep:webauthn-rs-proto", + "dep:webauthn-authenticator-rs", +] [lints.clippy] cargo = { level = "warn", priority = -1 } diff --git a/src/api.rs b/src/api.rs index a817fb26..5a49e21d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -6,7 +6,14 @@ use crate::prelude::*; use rand::distr::SampleString as _; use sha2::Digest as _; +use std::collections::HashMap; use tokio::io::AsyncReadExt as _; +#[cfg(feature = "webauthn")] +pub use webauthn_rs_proto::PublicKeyCredentialRequestOptions; + +#[cfg(not(feature = "webauthn"))] +#[derive(serde::Deserialize, Debug, Clone)] +pub struct PublicKeyCredentialRequestOptions {} use crate::json::{ DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _, @@ -47,7 +54,7 @@ impl std::fmt::Display for UriMatchType { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum TwoFactorProviderType { Authenticator = 0, Email = 1, @@ -65,6 +72,7 @@ impl TwoFactorProviderType { Self::Authenticator => "Enter the 6 digit verification code from your authenticator app.", Self::Yubikey => "Insert your Yubikey and push the button.", Self::Email => "Enter the PIN you received via email.", + Self::WebAuthn => "Enter your security key PIN.", _ => "Enter the code." } } @@ -74,6 +82,7 @@ impl TwoFactorProviderType { Self::Authenticator => "Authenticator App", Self::Yubikey => "Yubikey", Self::Email => "Email Code", + Self::WebAuthn => "Security Key PIN", _ => "Two Factor Authentication", } } @@ -347,8 +356,13 @@ struct ConnectErrorRes { error_description: Option, #[serde(rename = "ErrorModel", alias = "errorModel")] error_model: Option, - #[serde(rename = "TwoFactorProviders", alias = "twoFactorProviders")] - two_factor_providers: Option>, + #[serde(rename = "TwoFactorProviders2", alias = "twoFactorProviders2")] + two_factor_providers: Option< + HashMap< + TwoFactorProviderType, + Option, + >, + >, #[serde( rename = "SsoEmail2faSessionToken", alias = "ssoEmail2faSessionToken" diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9ddd2ad9..bcf2e2fc 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -1,5 +1,9 @@ use anyhow::Context as _; +#[cfg(not(feature = "webauthn"))] +use rbw::api::PublicKeyCredentialRequestOptions; use sha2::Digest as _; +#[cfg(feature = "webauthn")] +use webauthn_rs_proto::PublicKeyCredentialRequestOptions; pub async fn register( sock: &mut crate::sock::Sock, @@ -146,13 +150,17 @@ pub async fn login( sso_email_2fa_session_token, }) => { let supported_types = vec![ + #[cfg(feature = "webauthn")] + rbw::api::TwoFactorProviderType::WebAuthn, rbw::api::TwoFactorProviderType::Authenticator, rbw::api::TwoFactorProviderType::Yubikey, rbw::api::TwoFactorProviderType::Email, ]; for provider in supported_types { - if providers.contains(&provider) { + if let Some(provider_data) = providers.get(&provider) + { + let provider_data = provider_data.clone(); if provider == rbw::api::TwoFactorProviderType::Email { @@ -179,6 +187,7 @@ pub async fn login( &email, password.clone(), provider, + provider_data, ) .await?; login_success( @@ -229,6 +238,8 @@ async fn two_factor( email: &str, password: rbw::locked::Password, provider: rbw::api::TwoFactorProviderType, + #[cfg_attr(not(feature = "webauthn"), allow(unused_variables))] + provider_data: Option, ) -> anyhow::Result<( String, String, @@ -247,17 +258,44 @@ async fn two_factor( } else { None }; - let code = rbw::pinentry::getpin( - &config_pinentry().await?, - provider.header(), - provider.message(), - err.as_deref(), - environment, - provider.grab(), - ) - .await - .context("failed to read code from pinentry")?; - let code = std::str::from_utf8(code.password()) + let token = match provider { + #[cfg(feature = "webauthn")] + rbw::api::TwoFactorProviderType::WebAuthn => { + let token_pin = rbw::pinentry::getpin( + &config_pinentry().await?, + provider.header(), + provider.message(), + err.as_deref(), + environment, + provider.grab(), + ) + .await + .context("failed to read security key PIN from pinentry")?; + let challenge = provider_data.clone().context( + "webauthn challenge missing from server response", + )?; + let pin_str = std::str::from_utf8(token_pin.password()) + .context("security key PIN was not valid utf8")?; + match rbw::webauthn::webauthn(challenge, pin_str).await { + Ok(token) => token, + Err(e) => { + err_msg = Some(format!("{e:#}")); + continue; + } + } + } + _ => rbw::pinentry::getpin( + &config_pinentry().await?, + provider.header(), + provider.message(), + err.as_deref(), + environment, + provider.grab(), + ) + .await + .context("failed to read code from pinentry")?, + }; + let code = std::str::from_utf8(token.password()) .context("code was not valid utf8")?; match rbw::actions::login( email, diff --git a/src/error.rs b/src/error.rs index b7789a92..37750e87 100644 --- a/src/error.rs +++ b/src/error.rs @@ -231,7 +231,10 @@ pub enum Error { #[error("two factor required")] TwoFactorRequired { - providers: Vec, + providers: std::collections::HashMap< + crate::api::TwoFactorProviderType, + Option, + >, sso_email_2fa_session_token: Option, }, diff --git a/src/lib.rs b/src/lib.rs index b0f7c788..75210275 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,4 +14,6 @@ pub mod pinentry; mod prelude; pub mod protocol; pub mod pwgen; +#[cfg(feature = "webauthn")] +pub mod webauthn; pub mod wordlist; diff --git a/src/webauthn.rs b/src/webauthn.rs new file mode 100644 index 00000000..db3f3f5a --- /dev/null +++ b/src/webauthn.rs @@ -0,0 +1,114 @@ +use anyhow::Context as _; +use futures::StreamExt as _; +use webauthn_authenticator_rs::{ + ctap2::CtapAuthenticator, + transport::{AnyTransport, TokenEvent, Transport as _}, + types::{CableRequestType, CableState, EnrollSampleStatus}, + ui::UiCallback, + AuthenticatorBackend as _, +}; +use webauthn_rs_proto::PublicKeyCredentialRequestOptions; + +use crate::locked::Password; + +pub async fn webauthn( + challenge: PublicKeyCredentialRequestOptions, + pin: &str, +) -> anyhow::Result { + let transport = AnyTransport::new() + .await + .context("failed to set up webauthn transport")?; + + let ui = Pinentry { + pin: pin.to_string(), + }; + + let mut events = transport + .watch() + .await + .context("failed to watch webauthn transport")?; + + let mut authenticator = loop { + match events.next().await { + Some(TokenEvent::Added(token)) => { + if let Some(auth) = CtapAuthenticator::new(token, &ui).await { + break auth; + } + } + Some(TokenEvent::EnumerationComplete) => { + eprintln!("rbw: connect a FIDO2 security key to continue..."); + } + Some(TokenEvent::Removed(_)) => {} + None => { + anyhow::bail!( + "webauthn transport closed before a token connected" + ); + } + } + }; + + let origin = crate::config::Config::load_async() + .await + .context("failed to load rbw config")? + .ui_url(); + let origin = reqwest::Url::parse(&origin) + .context("failed to parse vault url as URL")?; + + let result = authenticator + .perform_auth(origin, challenge, 60_000) + .map_err(|e| { + anyhow::anyhow!("webauthn authentication failed: {e:?}") + })?; + + // Bitwarden's server expects a slightly different shape than the + // webauthn-rs default serialization: `appid` must be `false` (not null) + // and the credential field is camelCase `clientDataJson` rather than + // `clientDataJSON`. See doy/rbw#116 for context. + let out = serde_json::to_string(&result) + .context("failed to serialize webauthn assertion")? + .replace("\"appid\":null,\"hmac_get_secret\":null", "\"appid\":false") + .replace("clientDataJSON", "clientDataJson"); + + let mut buf = crate::locked::Vec::new(); + buf.extend(out.as_bytes().iter().copied()); + Ok(Password::new(buf)) +} + +#[derive(Debug)] +struct Pinentry { + pin: String, +} + +impl UiCallback for Pinentry { + fn request_pin(&self) -> Option { + Some(self.pin.clone()) + } + + fn request_touch(&self) { + eprintln!("rbw: touch your security key to continue..."); + } + + fn fingerprint_enrollment_feedback( + &self, + _remaining_samples: u32, + _feedback: Option, + ) { + log::warn!("webauthn: fingerprint_enrollment_feedback unimplemented"); + } + + fn cable_qr_code(&self, _request_type: CableRequestType, _url: String) { + log::warn!("webauthn: cable_qr_code unimplemented"); + } + + fn dismiss_qr_code(&self) { + log::warn!("webauthn: dismiss_qr_code unimplemented"); + } + + fn cable_status_update(&self, _state: CableState) { + log::warn!("webauthn: cable_status_update unimplemented"); + } + + fn processing(&self) { + log::debug!("webauthn: processing..."); + } +} From eb60faba0c5e2461abba7c1c47c60254e0a20a6a Mon Sep 17 00:00:00 2001 From: Antony Kellermann Date: Sat, 25 Apr 2026 00:13:54 -0700 Subject: [PATCH 2/5] webauthn: replace string-replace JSON munging with a typed wire struct Bitwarden's API expects camelCase `clientDataJson` (vs. webauthn-rs-proto's W3C-spec `clientDataJSON`) and a non-nullable `appid: bool` in the extensions object. The previous approach serialized the upstream type and patched the string; this only worked when both extension fields happened to be `None` in a specific order, and would silently break on any upstream change. Introduce a dedicated `BitwardenAssertion` wire type with the exact shape the server wants and convert `PublicKeyCredential` into it before serializing. Also clean up the WebAuthn pinentry prompt strings (dedicated header / description with a touch reminder), drop a stray `eprintln!` from `request_touch` in favor of `log::debug!`, and pull in `base64urlsafedata` behind the `webauthn` feature for the new wire type's byte fields. --- Cargo.lock | 1 + Cargo.toml | 2 ++ src/api.rs | 2 +- src/webauthn.rs | 65 +++++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index abbf6756..affb1cfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2241,6 +2241,7 @@ dependencies = [ "axum", "base32", "base64 0.22.1", + "base64urlsafedata", "block-padding", "cbc", "clap", diff --git a/Cargo.toml b/Cargo.toml index 579e753a..b00beea4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ arboard = { version = "3.6.1", default-features = false, features = [ "wayland-data-control", ], optional = true } +base64urlsafedata = { version = "0.5.4", optional = true } webauthn-rs = { version = "0.5.4", optional = true } webauthn-rs-proto = { version = "0.5.4", optional = true } webauthn-authenticator-rs = { version = "0.5.4", default-features = false, features = ["usb"], optional = true } @@ -96,6 +97,7 @@ webauthn-authenticator-rs = { version = "0.5.4", default-features = false, featu default = ["clipboard"] clipboard = ["arboard"] webauthn = [ + "dep:base64urlsafedata", "dep:webauthn-rs", "dep:webauthn-rs-proto", "dep:webauthn-authenticator-rs", diff --git a/src/api.rs b/src/api.rs index 5a49e21d..a41f13b7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -72,7 +72,7 @@ impl TwoFactorProviderType { Self::Authenticator => "Enter the 6 digit verification code from your authenticator app.", Self::Yubikey => "Insert your Yubikey and push the button.", Self::Email => "Enter the PIN you received via email.", - Self::WebAuthn => "Enter your security key PIN.", + Self::WebAuthn => "Enter your security key PIN, then touch the key when it blinks.", _ => "Enter the code." } } diff --git a/src/webauthn.rs b/src/webauthn.rs index db3f3f5a..348f0fca 100644 --- a/src/webauthn.rs +++ b/src/webauthn.rs @@ -36,7 +36,9 @@ pub async fn webauthn( } } Some(TokenEvent::EnumerationComplete) => { - eprintln!("rbw: connect a FIDO2 security key to continue..."); + log::info!( + "rbw: connect a FIDO2 security key to continue" + ); } Some(TokenEvent::Removed(_)) => {} None => { @@ -60,20 +62,63 @@ pub async fn webauthn( anyhow::anyhow!("webauthn authentication failed: {e:?}") })?; - // Bitwarden's server expects a slightly different shape than the - // webauthn-rs default serialization: `appid` must be `false` (not null) - // and the credential field is camelCase `clientDataJson` rather than - // `clientDataJSON`. See doy/rbw#116 for context. - let out = serde_json::to_string(&result) - .context("failed to serialize webauthn assertion")? - .replace("\"appid\":null,\"hmac_get_secret\":null", "\"appid\":false") - .replace("clientDataJSON", "clientDataJson"); + let out = serde_json::to_string(&BitwardenAssertion::from(result)) + .context("failed to serialize webauthn assertion")?; let mut buf = crate::locked::Vec::new(); buf.extend(out.as_bytes().iter().copied()); Ok(Password::new(buf)) } +// Bitwarden's server expects a slightly different shape than what +// webauthn-rs-proto serializes by default: the response field is camelCase +// `clientDataJson` rather than the W3C-spec `clientDataJSON`, and the +// extensions object must use a non-nullable `appid: bool`. We build a +// dedicated wire type instead of munging the JSON. +#[derive(serde::Serialize)] +struct BitwardenAssertion { + id: String, + #[serde(rename = "rawId")] + raw_id: base64urlsafedata::Base64UrlSafeData, + response: BitwardenAssertionResponse, + extensions: BitwardenAssertionExtensions, + #[serde(rename = "type")] + type_: String, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct BitwardenAssertionResponse { + authenticator_data: base64urlsafedata::Base64UrlSafeData, + client_data_json: base64urlsafedata::Base64UrlSafeData, + signature: base64urlsafedata::Base64UrlSafeData, + user_handle: Option, +} + +#[derive(serde::Serialize)] +struct BitwardenAssertionExtensions { + appid: bool, +} + +impl From for BitwardenAssertion { + fn from(c: webauthn_rs_proto::PublicKeyCredential) -> Self { + Self { + id: c.id, + raw_id: c.raw_id, + response: BitwardenAssertionResponse { + authenticator_data: c.response.authenticator_data, + client_data_json: c.response.client_data_json, + signature: c.response.signature, + user_handle: c.response.user_handle, + }, + extensions: BitwardenAssertionExtensions { + appid: c.extensions.appid.unwrap_or(false), + }, + type_: c.type_, + } + } +} + #[derive(Debug)] struct Pinentry { pin: String, @@ -85,7 +130,7 @@ impl UiCallback for Pinentry { } fn request_touch(&self) { - eprintln!("rbw: touch your security key to continue..."); + log::debug!("webauthn: waiting for user presence (touch the key)"); } fn fingerprint_enrollment_feedback( From f6576fe0fba4d762b7ea08ee2914edd5c89df76e Mon Sep 17 00:00:00 2001 From: Antony Kellermann Date: Sat, 25 Apr 2026 00:32:29 -0700 Subject: [PATCH 3/5] webauthn: harden 2FA dispatch, origin derivation, and blocking call Three fixes on top of the initial WebAuthn support: * Restore the legacy `TwoFactorProviders` Vec deserialization and read the per-provider challenge data from a separate `TwoFactorProviders2` field (now `providers_data` on `Error::TwoFactorRequired`). The previous combined HashMap shape regressed login for any server response that only emitted the legacy array (older Vaultwarden, malformed challenges), and affected non-WebAuthn builds too. Provider dispatch is once again driven by the Vec; the HashMap is consulted only to pull the WebAuthn challenge for the WebAuthn arm. * Derive the assertion origin from the challenge's `rp_id` rather than `Config::ui_url()`. The authenticator only requires the origin host to match (or be a subdomain of) `rp_id`, and Bitwarden registers credentials against the vault host it serves; using `rp_id` directly avoids silent signature rejection on self-hosted setups where the user's configured `ui_url` doesn't line up with the registered host. * Wrap the synchronous `perform_auth` USB HID call in `tokio::task::block_in_place` so a 60s wait on the security key doesn't starve other tasks on the agent's tokio worker. `spawn_blocking` is not usable here because `CtapAuthenticator` borrows from the local `UiCallback` and is not `'static`. --- src/api.rs | 20 ++++++++++++++------ src/bin/rbw-agent/actions.rs | 18 +++++++++++++++--- src/error.rs | 3 ++- src/webauthn.rs | 29 +++++++++++++++++------------ 4 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/api.rs b/src/api.rs index a41f13b7..25830a73 100644 --- a/src/api.rs +++ b/src/api.rs @@ -356,12 +356,16 @@ struct ConnectErrorRes { error_description: Option, #[serde(rename = "ErrorModel", alias = "errorModel")] error_model: Option, - #[serde(rename = "TwoFactorProviders2", alias = "twoFactorProviders2")] - two_factor_providers: Option< - HashMap< - TwoFactorProviderType, - Option, - >, + #[serde(rename = "TwoFactorProviders", alias = "twoFactorProviders")] + two_factor_providers: Option>, + // TwoFactorProviders2 is the only place the WebAuthn challenge is + // delivered; for non-WebAuthn methods the value is `null` and we just + // ignore it. Deserialized independently of `two_factor_providers` so + // a server that only emits the legacy array (or a malformed challenge + // shape) still drives the TOTP/Yubikey/Email paths correctly. + #[serde(default, rename = "TwoFactorProviders2", alias = "twoFactorProviders2")] + two_factor_providers_data: Option< + HashMap>, >, #[serde( rename = "SsoEmail2faSessionToken", @@ -1738,6 +1742,10 @@ fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { { return Error::TwoFactorRequired { providers: providers.clone(), + providers_data: error_res + .two_factor_providers_data + .clone() + .unwrap_or_default(), sso_email_2fa_session_token: error_res .sso_email_2fa_session_token .clone(), diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index bcf2e2fc..556962c3 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -147,6 +147,11 @@ pub async fn login( } Err(rbw::error::Error::TwoFactorRequired { providers, + #[cfg_attr( + not(feature = "webauthn"), + allow(unused_variables) + )] + providers_data, sso_email_2fa_session_token, }) => { let supported_types = vec![ @@ -158,9 +163,16 @@ pub async fn login( ]; for provider in supported_types { - if let Some(provider_data) = providers.get(&provider) - { - let provider_data = provider_data.clone(); + if providers.contains(&provider) { + #[cfg(feature = "webauthn")] + let provider_data = providers_data + .get(&provider) + .cloned() + .flatten(); + #[cfg(not(feature = "webauthn"))] + let provider_data: Option< + rbw::api::PublicKeyCredentialRequestOptions, + > = None; if provider == rbw::api::TwoFactorProviderType::Email { diff --git a/src/error.rs b/src/error.rs index 37750e87..5f2313d0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -231,7 +231,8 @@ pub enum Error { #[error("two factor required")] TwoFactorRequired { - providers: std::collections::HashMap< + providers: Vec, + providers_data: std::collections::HashMap< crate::api::TwoFactorProviderType, Option, >, diff --git a/src/webauthn.rs b/src/webauthn.rs index 348f0fca..b0909ce3 100644 --- a/src/webauthn.rs +++ b/src/webauthn.rs @@ -49,18 +49,23 @@ pub async fn webauthn( } }; - let origin = crate::config::Config::load_async() - .await - .context("failed to load rbw config")? - .ui_url(); - let origin = reqwest::Url::parse(&origin) - .context("failed to parse vault url as URL")?; - - let result = authenticator - .perform_auth(origin, challenge, 60_000) - .map_err(|e| { - anyhow::anyhow!("webauthn authentication failed: {e:?}") - })?; + // Derive the origin from the challenge's rp_id rather than rbw's + // configured vault URL: the authenticator enforces that origin's host + // matches (or is a subdomain of) rp_id, and Bitwarden registers + // credentials against the web vault host. Using rp_id directly avoids + // mismatches on self-hosted setups where the user's configured ui_url + // doesn't line up with the host the credential was registered against. + let origin = reqwest::Url::parse(&format!("https://{}", challenge.rp_id)) + .context("failed to construct webauthn origin from rp_id")?; + + // perform_auth is synchronous and blocks for up to the timeout waiting + // on USB HID. Use block_in_place so it doesn't stall other tasks on + // the tokio worker. The authenticator borrows from `ui`, so we can't + // move it across a spawn_blocking boundary. + let result = tokio::task::block_in_place(|| { + authenticator.perform_auth(origin, challenge, 60_000) + }) + .map_err(|e| anyhow::anyhow!("webauthn authentication failed: {e:?}"))?; let out = serde_json::to_string(&BitwardenAssertion::from(result)) .context("failed to serialize webauthn assertion")?; From c799d3a23a9a0bb4e31ede438cec403b57912dbe Mon Sep 17 00:00:00 2001 From: Antony Kellermann Date: Sat, 25 Apr 2026 01:08:35 -0700 Subject: [PATCH 4/5] webauthn: drop per-attempt timeout and prompt for PIN lazily The 60s perform_auth timeout was stricter than peer 2FA paths, which block on pinentry indefinitely; replace it with u32::MAX so Ctrl+C is the only escape, matching the rest. Drop the unconditional eager PIN prompt: bridge UiCallback::request_pin into async pinentry on demand via Handle::current().spawn + a sync channel under block_in_place, so users whose keys have no client_pin skip the prompt entirely. Treat webauthn() failures as fatal (propagate via ?) instead of looping into unreachable!(). --- src/api.rs | 2 -- src/bin/rbw-agent/actions.rs | 26 ++++++-------------- src/webauthn.rs | 47 ++++++++++++++++++++++++++++++------ 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/src/api.rs b/src/api.rs index 25830a73..3dfbeb1e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -72,7 +72,6 @@ impl TwoFactorProviderType { Self::Authenticator => "Enter the 6 digit verification code from your authenticator app.", Self::Yubikey => "Insert your Yubikey and push the button.", Self::Email => "Enter the PIN you received via email.", - Self::WebAuthn => "Enter your security key PIN, then touch the key when it blinks.", _ => "Enter the code." } } @@ -82,7 +81,6 @@ impl TwoFactorProviderType { Self::Authenticator => "Authenticator App", Self::Yubikey => "Yubikey", Self::Email => "Email Code", - Self::WebAuthn => "Security Key PIN", _ => "Two Factor Authentication", } } diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 556962c3..422cdaff 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -273,28 +273,16 @@ async fn two_factor( let token = match provider { #[cfg(feature = "webauthn")] rbw::api::TwoFactorProviderType::WebAuthn => { - let token_pin = rbw::pinentry::getpin( - &config_pinentry().await?, - provider.header(), - provider.message(), - err.as_deref(), - environment, - provider.grab(), - ) - .await - .context("failed to read security key PIN from pinentry")?; let challenge = provider_data.clone().context( "webauthn challenge missing from server response", )?; - let pin_str = std::str::from_utf8(token_pin.password()) - .context("security key PIN was not valid utf8")?; - match rbw::webauthn::webauthn(challenge, pin_str).await { - Ok(token) => token, - Err(e) => { - err_msg = Some(format!("{e:#}")); - continue; - } - } + rbw::webauthn::webauthn( + challenge, + config_pinentry().await?, + environment.clone(), + ) + .await + .context("webauthn authentication failed")? } _ => rbw::pinentry::getpin( &config_pinentry().await?, diff --git a/src/webauthn.rs b/src/webauthn.rs index b0909ce3..5a2558b7 100644 --- a/src/webauthn.rs +++ b/src/webauthn.rs @@ -13,14 +13,16 @@ use crate::locked::Password; pub async fn webauthn( challenge: PublicKeyCredentialRequestOptions, - pin: &str, + pinentry: String, + environment: crate::protocol::Environment, ) -> anyhow::Result { let transport = AnyTransport::new() .await .context("failed to set up webauthn transport")?; - let ui = Pinentry { - pin: pin.to_string(), + let ui = Ui { + pinentry, + environment, }; let mut events = transport @@ -63,7 +65,7 @@ pub async fn webauthn( // the tokio worker. The authenticator borrows from `ui`, so we can't // move it across a spawn_blocking boundary. let result = tokio::task::block_in_place(|| { - authenticator.perform_auth(origin, challenge, 60_000) + authenticator.perform_auth(origin, challenge, u32::MAX) }) .map_err(|e| anyhow::anyhow!("webauthn authentication failed: {e:?}"))?; @@ -125,13 +127,42 @@ impl From for BitwardenAssertion { } #[derive(Debug)] -struct Pinentry { - pin: String, +struct Ui { + pinentry: String, + environment: crate::protocol::Environment, } -impl UiCallback for Pinentry { +impl UiCallback for Ui { + // The library calls this synchronously from inside `perform_auth` only + // when the authenticator actually demands a PIN (Required/Preferred + // policy, or the key has client_pin set). Bridge into async pinentry + // by spawning on the current runtime and waiting on a sync channel — + // safe under `block_in_place`, which is how `perform_auth` is invoked. fn request_pin(&self) -> Option { - Some(self.pin.clone()) + let pinentry = self.pinentry.clone(); + let environment = self.environment.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + tokio::runtime::Handle::current().spawn(async move { + let res = crate::pinentry::getpin( + &pinentry, + "Security Key PIN", + "Enter your security key PIN.", + None, + &environment, + true, + ) + .await; + let _ = tx.send(res); + }); + match rx.recv().ok()? { + Ok(pw) => std::str::from_utf8(pw.password()) + .ok() + .map(str::to_string), + Err(e) => { + log::warn!("webauthn: pinentry failed: {e}"); + None + } + } } fn request_touch(&self) { From 02471b8a798e8021a10ff6799f7e997a71a4070a Mon Sep 17 00:00:00 2001 From: Antony Kellermann Date: Sat, 25 Apr 2026 01:09:52 -0700 Subject: [PATCH 5/5] webauthn: source pinentry strings from TwoFactorProviderType Restore the WebAuthn arms on header()/message() and look them up via TwoFactorProviderType::WebAuthn so the user-facing strings live in one place, like every other provider. --- src/api.rs | 2 ++ src/webauthn.rs | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/api.rs b/src/api.rs index 3dfbeb1e..26a65a92 100644 --- a/src/api.rs +++ b/src/api.rs @@ -72,6 +72,7 @@ impl TwoFactorProviderType { Self::Authenticator => "Enter the 6 digit verification code from your authenticator app.", Self::Yubikey => "Insert your Yubikey and push the button.", Self::Email => "Enter the PIN you received via email.", + Self::WebAuthn => "Enter your security key PIN.", _ => "Enter the code." } } @@ -81,6 +82,7 @@ impl TwoFactorProviderType { Self::Authenticator => "Authenticator App", Self::Yubikey => "Yubikey", Self::Email => "Email Code", + Self::WebAuthn => "Security Key PIN", _ => "Two Factor Authentication", } } diff --git a/src/webauthn.rs b/src/webauthn.rs index 5a2558b7..a5e9437c 100644 --- a/src/webauthn.rs +++ b/src/webauthn.rs @@ -143,13 +143,14 @@ impl UiCallback for Ui { let environment = self.environment.clone(); let (tx, rx) = std::sync::mpsc::channel(); tokio::runtime::Handle::current().spawn(async move { + let provider = crate::api::TwoFactorProviderType::WebAuthn; let res = crate::pinentry::getpin( &pinentry, - "Security Key PIN", - "Enter your security key PIN.", + provider.header(), + provider.message(), None, &environment, - true, + provider.grab(), ) .await; let _ = tx.send(res);