Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ range may break in any release.

### Added

- **Interactive TOTP two-factor auth.** A 2FA-enabled account is no longer a
dead end on the password grant: on `Error::TwoFactorRequired`, prompt for the
authenticator code and resubmit the token grant with it.
- `vault-api`: `login_password` gains `two_factor: Option<&TwoFactor>` and
appends `twoFactorToken`/`twoFactorProvider`/`twoFactorRemember`; a wrong
code re-challenges so the caller re-prompts.
- `vault-ipc`: `TwoFactorCode` + serde-defaulted `two_factor` on
`Request::Unlock` (forward-compatible). `vault-agent` threads it into the
password grant (provider `0` = authenticator).
- `vault-cli`: `vault login`/`unlock` resubmit on a challenge, reading the
code from the **controlling terminal** (`/dev/tty`) so it works even with
the password piped to stdin; `--totp` / `$BW_TOTP` supply it non-
interactively. `vault-tui`: an "Authenticator code" step in the unlock
screen (stashes the password, resubmits with the code).
- Scope: the authenticator/TOTP provider only; other providers (email, Duo,
WebAuthn) and "remember this device" are future work. An API key still
bypasses 2FA entirely.
- Tests: `vault-api` wiremock challenge→resubmit; `vault-ipc` transport
round-trip; `vault-tui` `begin_2fa`/request-with-code units.

- **`tui.vim` — vim jump motions in the TUI.** Opt-in (`vault config set tui.vim
true`): on top of the default `hjkl`, the browser gains `gg` (top), `G`
(bottom), and `Ctrl-d`/`Ctrl-u` (half-page; a fixed step — the pure-render
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ config file; `login` and `unlock` then resolve those from the profile, so their
once registered. `login` is the first-time "authenticate and verify sync";
`unlock` is the routine "hand the agent my key again".

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

### PIN unlock

For quick access without re-typing the master password, enroll a PIN (after an
Expand Down
2 changes: 2 additions & 0 deletions crates/vault-agent/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
password,
device_id,
api_key,
two_factor,
} => {
// Wrap the password so it is zeroised on drop no matter how
// perform_unlock fares; deref coercion hands it to the API as &[u8].
Expand All @@ -92,6 +93,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
&password,
device_id.as_deref(),
api_key.as_ref(),
two_factor.as_ref(),
)
.await;
match unlock_res {
Expand Down
18 changes: 15 additions & 3 deletions crates/vault-agent/src/unlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use zeroize::Zeroizing;
use vault_api::{BaseUrls, BitwardenClient, SyncResponse, TokenResponse};
use vault_core::cipher::{Cipher, decrypt_user_key};
use vault_core::kdf::{KdfParams, derive_master_key, stretch_master_key};
use vault_ipc::proto::{ApiKeyCreds, Error as IpcError};
use vault_ipc::proto::{ApiKeyCreds, Error as IpcError, TwoFactorCode};
use vault_store::VaultCache;

use crate::state::{Vault, account_dir_name};
Expand All @@ -27,8 +27,9 @@ pub async fn perform_unlock(
password: &[u8],
device_id: Option<&str>,
api_key: Option<&ApiKeyCreds>,
two_factor: Option<&TwoFactorCode>,
) -> Result<Vault, IpcError> {
match online_unlock(server, email, password, device_id, api_key).await {
match online_unlock(server, email, password, device_id, api_key, two_factor).await {
Ok(vault) => Ok(vault),
Err(IpcError::Network(net)) => {
// Network down — recover from cache if we have one for this account.
Expand All @@ -49,6 +50,7 @@ async fn online_unlock(
password: &[u8],
device_id: Option<&str>,
api_key: Option<&ApiKeyCreds>,
two_factor: Option<&TwoFactorCode>,
) -> Result<Vault, IpcError> {
let email_lower = email.trim().to_lowercase();
let urls = BaseUrls::self_hosted(server).map_err(|e| IpcError::Internal(e.to_string()))?;
Expand Down Expand Up @@ -81,6 +83,7 @@ async fn online_unlock(
password,
params,
api_key,
two_factor,
server,
email,
)
Expand Down Expand Up @@ -130,12 +133,14 @@ async fn online_unlock(
/// previously stored API key → the password grant. The API-key grants use
/// `client_credentials`, which skips 2FA; the password grant can surface
/// [`IpcError::TwoFactorRequired`].
#[allow(clippy::too_many_arguments)] // a flat unlock helper; grouping into a struct would obscure more than it helps
async fn obtain_token(
client: &mut BitwardenClient,
email_lower: &str,
password: &[u8],
params: KdfParams,
api_key: Option<&ApiKeyCreds>,
two_factor: Option<&TwoFactorCode>,
server: &str,
email: &str,
) -> Result<TokenResponse, IpcError> {
Expand All @@ -156,8 +161,15 @@ async fn obtain_token(
.await
.map_err(api_err);
}
// Password grant: the authenticator code (provider 0), when the client
// resends one after a `TwoFactorRequired` challenge.
let tf = two_factor.map(|t| vault_api::TwoFactor {
provider: 0,
token: t.token.clone(),
remember: false,
});
client
.login_password(email_lower, password, params)
.login_password(email_lower, password, params, tf.as_ref())
.await
.map_err(translate_login_err)
}
Expand Down
40 changes: 35 additions & 5 deletions crates/vault-api/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ pub const CLIENT_ID: &str = "cli";
/// Bitwarden device-type for desktop / CLI clients.
pub const DEVICE_TYPE_CLI: u32 = 14;

/// A two-factor token to satisfy a 2FA challenge on the password grant.
///
/// `token` is the code (e.g. a 6-digit authenticator code); `provider` is the
/// Bitwarden two-factor provider id (`0` = authenticator/TOTP); `remember` asks
/// the server to skip 2FA on later logins from this device.
#[derive(Clone, Debug)]
pub struct TwoFactor {
/// Bitwarden two-factor provider id (`0` = authenticator/TOTP).
pub provider: u32,
/// The one-time code entered by the user.
pub token: String,
/// Whether to ask the server to remember this device.
pub remember: bool,
}

/// REST client for a single Bitwarden / Vaultwarden account.
#[derive(Debug)]
pub struct BitwardenClient {
Expand Down Expand Up @@ -193,16 +208,22 @@ impl BitwardenClient {
/// The supplied password is consumed to compute the master-password hash
/// and discarded; only the resulting hash leaves this function.
///
/// When the account has 2FA enabled, the first call (with `two_factor =
/// None`) returns [`Error::TwoFactorRequired`]; the caller prompts for a
/// code and calls again with `two_factor = Some(..)`. A wrong code returns
/// `TwoFactorRequired` again, so the caller can re-prompt.
///
/// # Errors
///
/// Returns [`Error::TwoFactorRequired`] if the account has 2FA enabled,
/// [`Error::ServerStatus`] on a bad password / other non-success status,
/// or a crypto error if master-key derivation fails.
/// Returns [`Error::TwoFactorRequired`] if 2FA is required (and not yet, or
/// incorrectly, supplied), [`Error::ServerStatus`] on a bad password / other
/// non-success status, or a crypto error if master-key derivation fails.
pub async fn login_password(
&mut self,
email: &str,
password: &[u8],
params: KdfParams,
two_factor: Option<&TwoFactor>,
) -> Result<TokenResponse> {
let email_lower = email.trim().to_lowercase();
let master_key = derive_master_key(password, email_lower.as_bytes(), params)?;
Expand All @@ -214,16 +235,25 @@ impl BitwardenClient {
.join("connect/token")
.map_err(|_| Error::BaseUrl("could not build token URL"))?;

let form: [(&str, &str); 8] = [
let device = self.device_id.to_string();
let mut form: Vec<(&str, &str)> = vec![
("grant_type", "password"),
("username", email_lower.as_str()),
("password", hash.as_str()),
("scope", "api offline_access"),
("client_id", CLIENT_ID),
("deviceType", "14"),
("deviceIdentifier", &self.device_id.to_string()),
("deviceIdentifier", &device),
("deviceName", &self.device_name),
];
// Bind the 2FA field strings so they outlive the borrow into `form`.
let provider_str;
if let Some(tf) = two_factor {
provider_str = tf.provider.to_string();
form.push(("twoFactorToken", tf.token.as_str()));
form.push(("twoFactorProvider", provider_str.as_str()));
form.push(("twoFactorRemember", if tf.remember { "1" } else { "0" }));
}

let resp = self
.http
Expand Down
2 changes: 1 addition & 1 deletion crates/vault-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pub mod identity;
pub mod sync;
pub mod urls;

pub use client::{BitwardenClient, CLIENT_ID, DEVICE_TYPE_CLI};
pub use client::{BitwardenClient, CLIENT_ID, DEVICE_TYPE_CLI, TwoFactor};
pub use error::{Error, Result};
pub use identity::{PreloginResponse, TokenResponse};
pub use sync::SyncResponse;
Expand Down
67 changes: 62 additions & 5 deletions crates/vault-api/tests/login_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ async fn login_sync_cache_round_trip() {
let prelogin = client.prelogin(EMAIL).await.unwrap();
let params = prelogin.into_kdf_params().unwrap();
let token = client
.login_password(EMAIL, PASSWORD.as_bytes(), params)
.login_password(EMAIL, PASSWORD.as_bytes(), params, None)
.await
.unwrap();
assert_eq!(token.access_token, "test-access-token");
Expand Down Expand Up @@ -180,7 +180,7 @@ async fn wrong_password_surfaces_server_status() {

let mut client = BitwardenClient::new(urls, Uuid::new_v4(), "vault-test").unwrap();
let err = client
.login_password(EMAIL, b"wrong", pbkdf2_params())
.login_password(EMAIL, b"wrong", pbkdf2_params(), None)
.await
.unwrap_err();
assert!(matches!(
Expand Down Expand Up @@ -220,7 +220,7 @@ async fn delete_cipher_sends_authorized_delete() {
let mut client = BitwardenClient::new(urls, Uuid::new_v4(), "vault-test").unwrap();
// Prime token via login_password (uses the token mock above).
client
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params())
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params(), None)
.await
.unwrap();

Expand Down Expand Up @@ -286,7 +286,7 @@ async fn create_and_update_cipher_send_authorized_requests() {

let mut client = BitwardenClient::new(urls, Uuid::new_v4(), "vault-test").unwrap();
client
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params())
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params(), None)
.await
.unwrap();

Expand Down Expand Up @@ -343,7 +343,7 @@ async fn create_secure_note_carries_securenote_marker() {

let mut client = BitwardenClient::new(urls, Uuid::new_v4(), "vault-test").unwrap();
client
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params())
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params(), None)
.await
.unwrap();

Expand Down Expand Up @@ -407,6 +407,63 @@ async fn login_api_key_bad_key_surfaces_server_status() {
));
}

#[tokio::test]
#[ignore = "links ring into a test binary; see file preamble"]
async fn login_password_two_factor_challenge_then_resubmit() {
let server = MockServer::start().await;
let urls = BaseUrls::self_hosted(&server.uri()).unwrap();

// First attempt → 400 with the 2FA-required shape. `up_to_n_times(1)` so it
// matches only the first request; the coded resubmit falls through to the
// success mock below.
Mock::given(method("POST"))
.and(path("/identity/connect/token"))
.and(body_string_contains("grant_type=password"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"TwoFactorProviders": [0],
"TwoFactorProviders2": { "0": {} }
})))
.up_to_n_times(1)
.mount(&server)
.await;

// Resubmit carrying the authenticator code → 200 + token.
Mock::given(method("POST"))
.and(path("/identity/connect/token"))
.and(body_string_contains("twoFactorToken=123456"))
.and(body_string_contains("twoFactorProvider=0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "tfa-token",
"expires_in": 3600,
"token_type": "Bearer",
"Key": "2.dGVzdA==|dGVzdA==|dGVzdA==",
})))
.mount(&server)
.await;

let mut client = BitwardenClient::new(urls, Uuid::new_v4(), "vault-test").unwrap();
let err = client
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params(), None)
.await
.unwrap_err();
assert!(
matches!(err, vault_api::Error::TwoFactorRequired(_)),
"first attempt must 2FA-challenge: {err:?}"
);

let tf = vault_api::TwoFactor {
provider: 0,
token: "123456".to_owned(),
remember: false,
};
let token = client
.login_password(EMAIL, PASSWORD.as_bytes(), pbkdf2_params(), Some(&tf))
.await
.unwrap();
assert_eq!(token.access_token, "tfa-token");
assert!(client.is_authenticated());
}

fn urlencoded(s: &str) -> String {
// wiremock body_string_contains needs the literal bytes that appear in
// the form-encoded request body. reqwest's serde_urlencoded percent-
Expand Down
Loading
Loading