Skip to content

Commit 1f15447

Browse files
Merge pull request #14 from Spacecraft-Software/apikey-auth
feat(vault): Bitwarden personal API-key auth (2FA accounts)
2 parents 461eb59 + b619c82 commit 1f15447

15 files changed

Lines changed: 815 additions & 30 deletions

File tree

CHANGELOG.md

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

1111
### Added
1212

13+
- **Bitwarden personal API-key authentication (2FA accounts).** A new auth path
14+
that uses the OAuth2 `client_credentials` grant, which is *not* 2FA-challenged
15+
— so an account with two-factor auth enabled can finally authenticate without
16+
an interactive TOTP prompt (Vault has no TOTP entry). The API key authenticates
17+
the *session* only; the `Key` it returns is still wrapped under the stretched
18+
master key, so **the master password is still required at every unlock** to
19+
decrypt the vault.
20+
- `vault login --api-key`: reads `$BW_CLIENTID` / `$BW_CLIENTSECRET` (matching
21+
the official `bw` CLI), else prompts, then reads the master password. On
22+
success the agent authenticates via `client_credentials` and **persists the
23+
key** (0600 `apikey.json` in the account data dir) so plain `vault unlock`
24+
(and the TUI unlock) auto-reuse it — no 2FA, no re-supplying creds.
25+
- `vault apikey status` / `vault apikey forget` manage the stored key (status
26+
echoes only the non-secret `client_id`; forget falls back to the password
27+
grant).
28+
- `vault-api`: `BitwardenClient::login_api_key` (`grant_type=client_credentials`,
29+
`scope=api`). `vault-store`: `ApiKeyCreds` + `save`/`load`/`delete_apikey_to_dir`
30+
(atomic, 0600, custom `Debug` that redacts the secret). `vault-ipc`: optional
31+
`api_key` on `Request::Unlock` (serde-defaulted, forward-compatible) plus
32+
`ApiKeyStatus` / `ApiKeyForget` verbs and an `ApiKeyStatus` response (never
33+
carries the secret). `vault-agent`: grant selection (request creds → persisted
34+
key → password) with persistence on enrollment, and `ensure_online` now falls
35+
back to API-key re-auth — so a PIN/offline session of an API-key account can
36+
still go online for `sync`/edits even when the grant issued no refresh token.
37+
- The key is protected at rest by filesystem permissions (0600) only: it must
38+
be readable *before* unlock (it's used during auth), so it can't be wrapped
39+
under the user key — the same trust level as the stored refresh token.
40+
- Tests: `vault-api` wiremock for the `client_credentials` form + a bad-key
41+
`ServerStatus`; `vault-store` round-trip / delete / `NotFound` / 0600-mode /
42+
Debug-redaction units; `vault-ipc` transport round-trips (`api_key` present
43+
+ absent-decodes-to-`None`, `ApiKeyStatus`). No new external dependencies.
44+
1345
- **TUI in-place unlock.** When the agent is locked, `vault-tui` no longer
1446
dead-ends at a "run `vault unlock`" banner — it shows an interactive unlock
1547
prompt for the registered account: type the master password, or `Tab` to a

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,35 @@ stored token (no master password needed); only a genuinely offline box stays
8282
read-only. Five wrong PINs disable the PIN and require a master-password unlock
8383
— bounding brute-force of a short secret.
8484

85+
### API-key login (2FA accounts)
86+
87+
If your account has two-factor auth enabled, the password grant is rejected
88+
with a 2FA challenge that Vault can't answer interactively. Generate a
89+
**personal API key** in the web vault (Settings → Security → Keys → *View API
90+
Key*) and log in with it — the `client_credentials` grant skips the 2FA prompt:
91+
92+
```sh
93+
BW_CLIENTID=user.xxxx BW_CLIENTSECRET=… vault login --api-key
94+
# or, interactively (prompts for client_id / client_secret), then:
95+
# Master password: …
96+
```
97+
98+
The API key authenticates the *session* only — **you still enter your master
99+
password** to decrypt the vault; the key just gets you past 2FA. On success the
100+
agent stores the key (`apikey.json`, `0600`, in the account data dir), so plain
101+
`vault unlock` and the TUI unlock reuse it automatically — no further 2FA, no
102+
re-entering the key. A stored key also lets a PIN/offline session go back online
103+
for `sync` and edits.
104+
105+
```sh
106+
vault apikey status # configured? (shows the non-secret client_id)
107+
vault apikey forget # delete the stored key; logins revert to the password grant
108+
```
109+
110+
The key is protected at rest by filesystem permissions (`0600`) only: it must
111+
be usable *before* the vault is unlocked, so it can't be encrypted under your
112+
key — the same trust level as the stored refresh token or an SSH private key.
113+
85114
## Configuration
86115

87116
Persistent settings live at `$XDG_CONFIG_HOME/vault/config.toml`, managed with

crates/vault-agent/src/server.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,19 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
8181
email,
8282
password,
8383
device_id,
84+
api_key,
8485
} => {
8586
// Wrap the password so it is zeroised on drop no matter how
8687
// perform_unlock fares; deref coercion hands it to the API as &[u8].
8788
let password = zeroize::Zeroizing::new(password);
88-
let unlock_res = perform_unlock(&server, &email, &password, device_id.as_deref()).await;
89+
let unlock_res = perform_unlock(
90+
&server,
91+
&email,
92+
&password,
93+
device_id.as_deref(),
94+
api_key.as_ref(),
95+
)
96+
.await;
8997
match unlock_res {
9098
Ok(vault) => {
9199
let mut s = state.lock().await;
@@ -130,6 +138,15 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
130138
Request::PinStatus { server, email } => {
131139
Response::PinStatus(crate::unlock::pin_status(&server, &email))
132140
}
141+
Request::ApiKeyStatus { server, email } => {
142+
Response::ApiKeyStatus(crate::unlock::apikey_status(&server, &email))
143+
}
144+
Request::ApiKeyForget { server, email } => {
145+
match crate::unlock::apikey_forget(&server, &email) {
146+
Ok(()) => Response::Ok,
147+
Err(e) => Response::Error(e),
148+
}
149+
}
133150
Request::Lock => {
134151
let mut s = state.lock().await;
135152
s.lock();

crates/vault-agent/src/state.rs

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -100,28 +100,53 @@ impl Vault {
100100
if self.client.is_some() {
101101
return Ok(());
102102
}
103-
let Some(refresh) = self.refresh_token.as_ref() else {
104-
return Err(IpcError::Offline);
105-
};
106103
let urls = vault_api::BaseUrls::self_hosted(&self.server)
107104
.map_err(|e| IpcError::Internal(e.to_string()))?;
108105
let device =
109106
uuid::Uuid::parse_str(&self.device_id).unwrap_or_else(|_| uuid::Uuid::new_v4());
110-
let mut client = vault_api::BitwardenClient::with_refresh_token(
111-
urls,
112-
device,
113-
"vault-agent",
114-
rt_string(refresh),
115-
)
116-
.map_err(|e| IpcError::Internal(e.to_string()))?;
117-
// A failed refresh (network down, or token revoked) → stay offline.
118-
client.refresh().await.map_err(|_| IpcError::Offline)?;
119-
// Keep the (possibly rotated) refresh token in memory for re-persist.
120-
if let Some(rt) = client.refresh_token() {
121-
self.refresh_token = Some(Zeroizing::new(rt.to_owned()));
107+
108+
// 1. Prefer the stored refresh token — cheapest, no creds re-sent.
109+
if let Some(refresh) = self.refresh_token.as_ref()
110+
&& let Ok(mut client) = vault_api::BitwardenClient::with_refresh_token(
111+
urls.clone(),
112+
device,
113+
"vault-agent",
114+
rt_string(refresh),
115+
)
116+
&& client.refresh().await.is_ok()
117+
{
118+
// Keep the (possibly rotated) refresh token in memory for re-persist.
119+
if let Some(rt) = client.refresh_token() {
120+
self.refresh_token = Some(Zeroizing::new(rt.to_owned()));
121+
}
122+
self.client = Some(client);
123+
return Ok(());
122124
}
123-
self.client = Some(client);
124-
Ok(())
125+
126+
// 2. Fall back to a stored API key: the client_credentials grant
127+
// re-authenticates without 2FA or a refresh token, and needs only a
128+
// live client (the user key is already in memory). This is what lets
129+
// a PIN/offline session of an API-key account go online for writes.
130+
if let Some(dir) = vault_store::default_data_dir()
131+
.map(|d| d.join(account_dir_name(&self.server, &self.email)))
132+
&& let Ok(creds) = vault_store::load_apikey_from_dir(&dir)
133+
{
134+
let mut client = vault_api::BitwardenClient::new(urls, device, "vault-agent")
135+
.map_err(|e| IpcError::Internal(e.to_string()))?;
136+
if client
137+
.login_api_key(&creds.client_id, creds.client_secret.as_bytes())
138+
.await
139+
.is_ok()
140+
{
141+
if let Some(rt) = client.refresh_token() {
142+
self.refresh_token = Some(Zeroizing::new(rt.to_owned()));
143+
}
144+
self.client = Some(client);
145+
return Ok(());
146+
}
147+
}
148+
149+
Err(IpcError::Offline)
125150
}
126151
}
127152

crates/vault-agent/src/unlock.rs

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ use std::collections::HashMap;
77
use uuid::Uuid;
88
use zeroize::Zeroizing;
99

10-
use vault_api::{BaseUrls, BitwardenClient, SyncResponse};
10+
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::Error as IpcError;
13+
use vault_ipc::proto::{ApiKeyCreds, Error as IpcError};
1414
use vault_store::VaultCache;
1515

1616
use crate::state::{Vault, account_dir_name};
@@ -26,8 +26,9 @@ pub async fn perform_unlock(
2626
email: &str,
2727
password: &[u8],
2828
device_id: Option<&str>,
29+
api_key: Option<&ApiKeyCreds>,
2930
) -> Result<Vault, IpcError> {
30-
match online_unlock(server, email, password, device_id).await {
31+
match online_unlock(server, email, password, device_id, api_key).await {
3132
Ok(vault) => Ok(vault),
3233
Err(IpcError::Network(net)) => {
3334
// Network down — recover from cache if we have one for this account.
@@ -47,6 +48,7 @@ async fn online_unlock(
4748
email: &str,
4849
password: &[u8],
4950
device_id: Option<&str>,
51+
api_key: Option<&ApiKeyCreds>,
5052
) -> Result<Vault, IpcError> {
5153
let email_lower = email.trim().to_lowercase();
5254
let urls = BaseUrls::self_hosted(server).map_err(|e| IpcError::Internal(e.to_string()))?;
@@ -70,10 +72,19 @@ async fn online_unlock(
7072
let stretch_enc = Zeroizing::new(stretch_enc);
7173
let stretch_mac = Zeroizing::new(stretch_mac);
7274

73-
let token = client
74-
.login_password(&email_lower, password, params)
75-
.await
76-
.map_err(translate_login_err)?;
75+
// Authenticate. An API key (request-supplied or previously persisted) uses
76+
// the client_credentials grant, which skips 2FA; otherwise the password
77+
// grant. Either way the master key derived above is what decrypts the vault.
78+
let token = obtain_token(
79+
&mut client,
80+
&email_lower,
81+
password,
82+
params,
83+
api_key,
84+
server,
85+
email,
86+
)
87+
.await?;
7788

7889
let encrypted_user_key = token
7990
.key
@@ -114,6 +125,81 @@ async fn online_unlock(
114125
Ok(vault)
115126
}
116127

128+
/// Authenticate `client` and return the token. Grant selection: request-supplied
129+
/// API-key creds (persisted on success, so future unlocks reuse them) → a
130+
/// previously stored API key → the password grant. The API-key grants use
131+
/// `client_credentials`, which skips 2FA; the password grant can surface
132+
/// [`IpcError::TwoFactorRequired`].
133+
async fn obtain_token(
134+
client: &mut BitwardenClient,
135+
email_lower: &str,
136+
password: &[u8],
137+
params: KdfParams,
138+
api_key: Option<&ApiKeyCreds>,
139+
server: &str,
140+
email: &str,
141+
) -> Result<TokenResponse, IpcError> {
142+
if let Some(creds) = api_key {
143+
let token = client
144+
.login_api_key(&creds.client_id, &creds.client_secret)
145+
.await
146+
.map_err(api_err)?;
147+
// Enrollment succeeded — persist the key so plain `unlock` reuses it.
148+
persist_apikey(server, email, creds)?;
149+
return Ok(token);
150+
}
151+
if let Some(stored) =
152+
cache_dir(server, email).and_then(|d| vault_store::load_apikey_from_dir(&d).ok())
153+
{
154+
return client
155+
.login_api_key(&stored.client_id, stored.client_secret.as_bytes())
156+
.await
157+
.map_err(api_err);
158+
}
159+
client
160+
.login_password(email_lower, password, params)
161+
.await
162+
.map_err(translate_login_err)
163+
}
164+
165+
/// Persist a request-supplied API key (0600) for the account. The
166+
/// `client_secret` is valid UTF-8 here — `login_api_key` already verified it
167+
/// against the server.
168+
fn persist_apikey(server: &str, email: &str, creds: &ApiKeyCreds) -> Result<(), IpcError> {
169+
let dir = cache_dir(server, email)
170+
.ok_or_else(|| IpcError::Internal("no data directory".to_owned()))?;
171+
let client_secret = String::from_utf8(creds.client_secret.clone())
172+
.map_err(|_| IpcError::Internal("api-key client_secret is not valid UTF-8".to_owned()))?;
173+
let store_creds = vault_store::ApiKeyCreds {
174+
client_id: creds.client_id.clone(),
175+
client_secret,
176+
};
177+
vault_store::save_apikey_to_dir(&dir, &store_creds)
178+
.map_err(|e| IpcError::Internal(format!("write api key: {e}")))?;
179+
Ok(())
180+
}
181+
182+
/// Report whether an API key is stored for the account (never the secret).
183+
#[must_use]
184+
pub fn apikey_status(server: &str, email: &str) -> vault_ipc::proto::ApiKeyStatus {
185+
let creds = cache_dir(server, email).and_then(|d| vault_store::load_apikey_from_dir(&d).ok());
186+
vault_ipc::proto::ApiKeyStatus {
187+
configured: creds.is_some(),
188+
client_id: creds.map(|c| c.client_id),
189+
}
190+
}
191+
192+
/// Forget the stored API key for the account (idempotent — no key is success).
193+
///
194+
/// # Errors
195+
///
196+
/// [`IpcError::Internal`] if the data dir can't be located or the removal fails.
197+
pub fn apikey_forget(server: &str, email: &str) -> Result<(), IpcError> {
198+
let dir = cache_dir(server, email)
199+
.ok_or_else(|| IpcError::Internal("no data directory".to_owned()))?;
200+
vault_store::delete_apikey_from_dir(&dir).map_err(|e| IpcError::Internal(e.to_string()))
201+
}
202+
117203
/// Offline unlock from the encrypted local cache — no network. Recovers the
118204
/// user key from the master password via the cached protected key, then builds
119205
/// a read-only (`client = None`) vault. A wrong password is `BadPassword`.

crates/vault-api/src/client.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,65 @@ impl BitwardenClient {
264264
}
265265
}
266266

267+
/// `POST /connect/token` with `grant_type=client_credentials` — authenticate
268+
/// with a Bitwarden **personal API key** (`client_id = "user.<uuid>"` +
269+
/// `client_secret`, from the web vault). This grant is **not** 2FA-challenged,
270+
/// so it's the way to obtain a token for an account with two-factor auth
271+
/// enabled without an interactive TOTP prompt.
272+
///
273+
/// The API key authenticates the *session* only: the returned
274+
/// [`TokenResponse`] still carries the user key wrapped under the stretched
275+
/// master key (`key`), so the caller must still derive the master key from
276+
/// the master password to decrypt the vault — the API key never replaces it.
277+
///
278+
/// # Errors
279+
///
280+
/// Returns [`Error::Credential`] if `client_secret` is not valid UTF-8,
281+
/// [`Error::ServerStatus`] on a rejected API key (a plain `400` — there is
282+
/// no 2FA branch on this grant), or [`Error::Transport`] on transport failure.
283+
pub async fn login_api_key(
284+
&mut self,
285+
client_id: &str,
286+
client_secret: &[u8],
287+
) -> Result<TokenResponse> {
288+
// Held in Zeroizing so the secret scrubs on drop even on early return.
289+
let secret = Zeroizing::new(
290+
std::str::from_utf8(client_secret)
291+
.map_err(|_| Error::Credential("api-key client_secret is not valid UTF-8"))?
292+
.to_owned(),
293+
);
294+
let url = self
295+
.urls
296+
.identity
297+
.join("connect/token")
298+
.map_err(|_| Error::BaseUrl("could not build token URL"))?;
299+
300+
let device = self.device_id.to_string();
301+
let form: [(&str, &str); 7] = [
302+
("grant_type", "client_credentials"),
303+
("scope", "api"),
304+
("client_id", client_id),
305+
("client_secret", secret.as_str()),
306+
("deviceType", "14"),
307+
("deviceIdentifier", &device),
308+
("deviceName", &self.device_name),
309+
];
310+
311+
let resp = self.http.post(url).form(&form).send().await?;
312+
let status = resp.status();
313+
if status.is_success() {
314+
let token: TokenResponse = resp.json().await?;
315+
self.access_token = Some(Zeroizing::new(token.access_token.clone()));
316+
self.refresh_token = token.refresh_token.clone().map(Zeroizing::new);
317+
Ok(token)
318+
} else {
319+
Err(Error::ServerStatus {
320+
status: status.as_u16(),
321+
message: resp.text().await.unwrap_or_default(),
322+
})
323+
}
324+
}
325+
267326
/// `GET /sync` — fetch the full encrypted vault. Requires a prior `login_password`.
268327
///
269328
/// # Errors

crates/vault-api/src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ pub enum Error {
2828
#[error("invalid base URL: {0}")]
2929
BaseUrl(&'static str),
3030

31+
/// A caller-supplied credential was malformed (e.g. a non-UTF-8 API-key secret).
32+
#[error("invalid credential: {0}")]
33+
Credential(&'static str),
34+
3135
/// Operation requires a two-factor token Vault doesn't yet support (M2).
3236
#[error("two-factor authentication required (provider {0:?}) — not yet implemented")]
3337
TwoFactorRequired(Vec<u32>),

0 commit comments

Comments
 (0)