Skip to content

Commit 21a61df

Browse files
Merge pull request #21 from Spacecraft-Software/totp-2fa
feat(vault): interactive TOTP two-factor auth
2 parents 5721a6c + 40ee045 commit 21a61df

14 files changed

Lines changed: 382 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@ range may break in any release.
1010

1111
### Added
1212

13+
- **Interactive TOTP two-factor auth.** A 2FA-enabled account is no longer a
14+
dead end on the password grant: on `Error::TwoFactorRequired`, prompt for the
15+
authenticator code and resubmit the token grant with it.
16+
- `vault-api`: `login_password` gains `two_factor: Option<&TwoFactor>` and
17+
appends `twoFactorToken`/`twoFactorProvider`/`twoFactorRemember`; a wrong
18+
code re-challenges so the caller re-prompts.
19+
- `vault-ipc`: `TwoFactorCode` + serde-defaulted `two_factor` on
20+
`Request::Unlock` (forward-compatible). `vault-agent` threads it into the
21+
password grant (provider `0` = authenticator).
22+
- `vault-cli`: `vault login`/`unlock` resubmit on a challenge, reading the
23+
code from the **controlling terminal** (`/dev/tty`) so it works even with
24+
the password piped to stdin; `--totp` / `$BW_TOTP` supply it non-
25+
interactively. `vault-tui`: an "Authenticator code" step in the unlock
26+
screen (stashes the password, resubmits with the code).
27+
- Scope: the authenticator/TOTP provider only; other providers (email, Duo,
28+
WebAuthn) and "remember this device" are future work. An API key still
29+
bypasses 2FA entirely.
30+
- Tests: `vault-api` wiremock challenge→resubmit; `vault-ipc` transport
31+
round-trip; `vault-tui` `begin_2fa`/request-with-code units.
32+
1333
- **`tui.vim` — vim jump motions in the TUI.** Opt-in (`vault config set tui.vim
1434
true`): on top of the default `hjkl`, the browser gains `gg` (top), `G`
1535
(bottom), and `Ctrl-d`/`Ctrl-u` (half-page; a fixed step — the pure-render

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ config file; `login` and `unlock` then resolve those from the profile, so their
6161
once registered. `login` is the first-time "authenticate and verify sync";
6262
`unlock` is the routine "hand the agent my key again".
6363

64+
If the account has two-factor auth, `login`/`unlock` prompt for your
65+
**authenticator (TOTP) code** after the password (read from the controlling
66+
terminal, so it works even with the password piped in); supply it up front with
67+
`--totp 123456` or `$BW_TOTP` for scripts. The TUI shows an "Authenticator code"
68+
step in its unlock screen. An [API key](#api-key-login-2fa-accounts) skips the
69+
2FA prompt entirely; only the authenticator provider is supported so far.
70+
6471
### PIN unlock
6572

6673
For quick access without re-typing the master password, enroll a PIN (after an

crates/vault-agent/src/server.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
8282
password,
8383
device_id,
8484
api_key,
85+
two_factor,
8586
} => {
8687
// Wrap the password so it is zeroised on drop no matter how
8788
// perform_unlock fares; deref coercion hands it to the API as &[u8].
@@ -92,6 +93,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
9293
&password,
9394
device_id.as_deref(),
9495
api_key.as_ref(),
96+
two_factor.as_ref(),
9597
)
9698
.await;
9799
match unlock_res {

crates/vault-agent/src/unlock.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use zeroize::Zeroizing;
1010
use vault_api::{BaseUrls, BitwardenClient, SyncResponse, TokenResponse};
1111
use vault_core::cipher::{Cipher, decrypt_user_key};
1212
use vault_core::kdf::{KdfParams, derive_master_key, stretch_master_key};
13-
use vault_ipc::proto::{ApiKeyCreds, Error as IpcError};
13+
use vault_ipc::proto::{ApiKeyCreds, Error as IpcError, TwoFactorCode};
1414
use vault_store::VaultCache;
1515

1616
use crate::state::{Vault, account_dir_name};
@@ -27,8 +27,9 @@ pub async fn perform_unlock(
2727
password: &[u8],
2828
device_id: Option<&str>,
2929
api_key: Option<&ApiKeyCreds>,
30+
two_factor: Option<&TwoFactorCode>,
3031
) -> Result<Vault, IpcError> {
31-
match online_unlock(server, email, password, device_id, api_key).await {
32+
match online_unlock(server, email, password, device_id, api_key, two_factor).await {
3233
Ok(vault) => Ok(vault),
3334
Err(IpcError::Network(net)) => {
3435
// Network down — recover from cache if we have one for this account.
@@ -49,6 +50,7 @@ async fn online_unlock(
4950
password: &[u8],
5051
device_id: Option<&str>,
5152
api_key: Option<&ApiKeyCreds>,
53+
two_factor: Option<&TwoFactorCode>,
5254
) -> Result<Vault, IpcError> {
5355
let email_lower = email.trim().to_lowercase();
5456
let urls = BaseUrls::self_hosted(server).map_err(|e| IpcError::Internal(e.to_string()))?;
@@ -81,6 +83,7 @@ async fn online_unlock(
8183
password,
8284
params,
8385
api_key,
86+
two_factor,
8487
server,
8588
email,
8689
)
@@ -130,12 +133,14 @@ async fn online_unlock(
130133
/// previously stored API key → the password grant. The API-key grants use
131134
/// `client_credentials`, which skips 2FA; the password grant can surface
132135
/// [`IpcError::TwoFactorRequired`].
136+
#[allow(clippy::too_many_arguments)] // a flat unlock helper; grouping into a struct would obscure more than it helps
133137
async fn obtain_token(
134138
client: &mut BitwardenClient,
135139
email_lower: &str,
136140
password: &[u8],
137141
params: KdfParams,
138142
api_key: Option<&ApiKeyCreds>,
143+
two_factor: Option<&TwoFactorCode>,
139144
server: &str,
140145
email: &str,
141146
) -> Result<TokenResponse, IpcError> {
@@ -156,8 +161,15 @@ async fn obtain_token(
156161
.await
157162
.map_err(api_err);
158163
}
164+
// Password grant: the authenticator code (provider 0), when the client
165+
// resends one after a `TwoFactorRequired` challenge.
166+
let tf = two_factor.map(|t| vault_api::TwoFactor {
167+
provider: 0,
168+
token: t.token.clone(),
169+
remember: false,
170+
});
159171
client
160-
.login_password(email_lower, password, params)
172+
.login_password(email_lower, password, params, tf.as_ref())
161173
.await
162174
.map_err(translate_login_err)
163175
}

crates/vault-api/src/client.rs

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ pub const CLIENT_ID: &str = "cli";
2525
/// Bitwarden device-type for desktop / CLI clients.
2626
pub const DEVICE_TYPE_CLI: u32 = 14;
2727

28+
/// A two-factor token to satisfy a 2FA challenge on the password grant.
29+
///
30+
/// `token` is the code (e.g. a 6-digit authenticator code); `provider` is the
31+
/// Bitwarden two-factor provider id (`0` = authenticator/TOTP); `remember` asks
32+
/// the server to skip 2FA on later logins from this device.
33+
#[derive(Clone, Debug)]
34+
pub struct TwoFactor {
35+
/// Bitwarden two-factor provider id (`0` = authenticator/TOTP).
36+
pub provider: u32,
37+
/// The one-time code entered by the user.
38+
pub token: String,
39+
/// Whether to ask the server to remember this device.
40+
pub remember: bool,
41+
}
42+
2843
/// REST client for a single Bitwarden / Vaultwarden account.
2944
#[derive(Debug)]
3045
pub struct BitwardenClient {
@@ -193,16 +208,22 @@ impl BitwardenClient {
193208
/// The supplied password is consumed to compute the master-password hash
194209
/// and discarded; only the resulting hash leaves this function.
195210
///
211+
/// When the account has 2FA enabled, the first call (with `two_factor =
212+
/// None`) returns [`Error::TwoFactorRequired`]; the caller prompts for a
213+
/// code and calls again with `two_factor = Some(..)`. A wrong code returns
214+
/// `TwoFactorRequired` again, so the caller can re-prompt.
215+
///
196216
/// # Errors
197217
///
198-
/// Returns [`Error::TwoFactorRequired`] if the account has 2FA enabled,
199-
/// [`Error::ServerStatus`] on a bad password / other non-success status,
200-
/// or a crypto error if master-key derivation fails.
218+
/// Returns [`Error::TwoFactorRequired`] if 2FA is required (and not yet, or
219+
/// incorrectly, supplied), [`Error::ServerStatus`] on a bad password / other
220+
/// non-success status, or a crypto error if master-key derivation fails.
201221
pub async fn login_password(
202222
&mut self,
203223
email: &str,
204224
password: &[u8],
205225
params: KdfParams,
226+
two_factor: Option<&TwoFactor>,
206227
) -> Result<TokenResponse> {
207228
let email_lower = email.trim().to_lowercase();
208229
let master_key = derive_master_key(password, email_lower.as_bytes(), params)?;
@@ -214,16 +235,25 @@ impl BitwardenClient {
214235
.join("connect/token")
215236
.map_err(|_| Error::BaseUrl("could not build token URL"))?;
216237

217-
let form: [(&str, &str); 8] = [
238+
let device = self.device_id.to_string();
239+
let mut form: Vec<(&str, &str)> = vec![
218240
("grant_type", "password"),
219241
("username", email_lower.as_str()),
220242
("password", hash.as_str()),
221243
("scope", "api offline_access"),
222244
("client_id", CLIENT_ID),
223245
("deviceType", "14"),
224-
("deviceIdentifier", &self.device_id.to_string()),
246+
("deviceIdentifier", &device),
225247
("deviceName", &self.device_name),
226248
];
249+
// Bind the 2FA field strings so they outlive the borrow into `form`.
250+
let provider_str;
251+
if let Some(tf) = two_factor {
252+
provider_str = tf.provider.to_string();
253+
form.push(("twoFactorToken", tf.token.as_str()));
254+
form.push(("twoFactorProvider", provider_str.as_str()));
255+
form.push(("twoFactorRemember", if tf.remember { "1" } else { "0" }));
256+
}
227257

228258
let resp = self
229259
.http

crates/vault-api/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ pub mod identity;
1212
pub mod sync;
1313
pub mod urls;
1414

15-
pub use client::{BitwardenClient, CLIENT_ID, DEVICE_TYPE_CLI};
15+
pub use client::{BitwardenClient, CLIENT_ID, DEVICE_TYPE_CLI, TwoFactor};
1616
pub use error::{Error, Result};
1717
pub use identity::{PreloginResponse, TokenResponse};
1818
pub use sync::SyncResponse;

crates/vault-api/tests/login_sync.rs

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ async fn login_sync_cache_round_trip() {
129129
let prelogin = client.prelogin(EMAIL).await.unwrap();
130130
let params = prelogin.into_kdf_params().unwrap();
131131
let token = client
132-
.login_password(EMAIL, PASSWORD.as_bytes(), params)
132+
.login_password(EMAIL, PASSWORD.as_bytes(), params, None)
133133
.await
134134
.unwrap();
135135
assert_eq!(token.access_token, "test-access-token");
@@ -180,7 +180,7 @@ async fn wrong_password_surfaces_server_status() {
180180

181181
let mut client = BitwardenClient::new(urls, Uuid::new_v4(), "vault-test").unwrap();
182182
let err = client
183-
.login_password(EMAIL, b"wrong", pbkdf2_params())
183+
.login_password(EMAIL, b"wrong", pbkdf2_params(), None)
184184
.await
185185
.unwrap_err();
186186
assert!(matches!(
@@ -220,7 +220,7 @@ async fn delete_cipher_sends_authorized_delete() {
220220
let mut client = BitwardenClient::new(urls, Uuid::new_v4(), "vault-test").unwrap();
221221
// Prime token via login_password (uses the token mock above).
222222
client
223-
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params())
223+
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params(), None)
224224
.await
225225
.unwrap();
226226

@@ -286,7 +286,7 @@ async fn create_and_update_cipher_send_authorized_requests() {
286286

287287
let mut client = BitwardenClient::new(urls, Uuid::new_v4(), "vault-test").unwrap();
288288
client
289-
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params())
289+
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params(), None)
290290
.await
291291
.unwrap();
292292

@@ -343,7 +343,7 @@ async fn create_secure_note_carries_securenote_marker() {
343343

344344
let mut client = BitwardenClient::new(urls, Uuid::new_v4(), "vault-test").unwrap();
345345
client
346-
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params())
346+
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params(), None)
347347
.await
348348
.unwrap();
349349

@@ -407,6 +407,63 @@ async fn login_api_key_bad_key_surfaces_server_status() {
407407
));
408408
}
409409

410+
#[tokio::test]
411+
#[ignore = "links ring into a test binary; see file preamble"]
412+
async fn login_password_two_factor_challenge_then_resubmit() {
413+
let server = MockServer::start().await;
414+
let urls = BaseUrls::self_hosted(&server.uri()).unwrap();
415+
416+
// First attempt → 400 with the 2FA-required shape. `up_to_n_times(1)` so it
417+
// matches only the first request; the coded resubmit falls through to the
418+
// success mock below.
419+
Mock::given(method("POST"))
420+
.and(path("/identity/connect/token"))
421+
.and(body_string_contains("grant_type=password"))
422+
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
423+
"TwoFactorProviders": [0],
424+
"TwoFactorProviders2": { "0": {} }
425+
})))
426+
.up_to_n_times(1)
427+
.mount(&server)
428+
.await;
429+
430+
// Resubmit carrying the authenticator code → 200 + token.
431+
Mock::given(method("POST"))
432+
.and(path("/identity/connect/token"))
433+
.and(body_string_contains("twoFactorToken=123456"))
434+
.and(body_string_contains("twoFactorProvider=0"))
435+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
436+
"access_token": "tfa-token",
437+
"expires_in": 3600,
438+
"token_type": "Bearer",
439+
"Key": "2.dGVzdA==|dGVzdA==|dGVzdA==",
440+
})))
441+
.mount(&server)
442+
.await;
443+
444+
let mut client = BitwardenClient::new(urls, Uuid::new_v4(), "vault-test").unwrap();
445+
let err = client
446+
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params(), None)
447+
.await
448+
.unwrap_err();
449+
assert!(
450+
matches!(err, vault_api::Error::TwoFactorRequired(_)),
451+
"first attempt must 2FA-challenge: {err:?}"
452+
);
453+
454+
let tf = vault_api::TwoFactor {
455+
provider: 0,
456+
token: "123456".to_owned(),
457+
remember: false,
458+
};
459+
let token = client
460+
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params(), Some(&tf))
461+
.await
462+
.unwrap();
463+
assert_eq!(token.access_token, "tfa-token");
464+
assert!(client.is_authenticated());
465+
}
466+
410467
fn urlencoded(s: &str) -> String {
411468
// wiremock body_string_contains needs the literal bytes that appear in
412469
// the form-encoded request body. reqwest's serde_urlencoded percent-

0 commit comments

Comments
 (0)