Skip to main content

tsafe_bitwarden/
sync.rs

1//! Bitwarden item pull via the `bw` CLI subprocess.
2//!
3//! # E2E Encryption Finding
4//!
5//! Bitwarden cipher values returned by the REST `/api/sync` endpoint are
6//! **always E2E encrypted** on the client side using the organization's
7//! symmetric key.  A machine token obtained via `client_credentials` /
8//! `api.organization` grant provides API access but does NOT carry the
9//! encryption key required to decrypt cipher values.  The raw REST response
10//! contains `encryptedString` blobs (`"2.base64|base64|base64"`) for every
11//! field — username, password, and custom fields alike.
12//!
13//! Decryption of those blobs requires the Bitwarden client-side SDK with
14//! access to the organization symmetric key derived from the master password,
15//! which is not available to a headless API caller.
16//!
17//! Therefore this module shells out to the `bw` CLI, which handles local
18//! decryption after `bw unlock --passwordenv` produces a session token.
19//! This mirrors how `cmd_vault_pull.rs` shells to the `op` CLI for 1Password.
20//!
21//! # Auth flow
22//!
23//! 1. `bw config server <identity_url>` (if self-hosted / Vaultwarden)
24//! 2. `bw login --apikey --clientid $id --clientsecret $secret`
25//! 3. `bw unlock --passwordenv TSAFE_BW_PASSWORD` → extracts `BW_SESSION`
26//! 4. `BW_SESSION=<token> bw list items [--folderid <id>]` → JSON array
27//! 5. `bw lock` (cleanup; non-fatal on failure)
28//!
29//! # Key mapping
30//!
31//! Login items (type = 1) only.  Fields extracted per cipher:
32//! - `Login.Username` → `<ITEM_NAME>_USERNAME`
33//! - `Login.Password` → `<ITEM_NAME>_PASSWORD`
34//! - `Fields[].Name` (text/hidden, type ≠ 2) → `<ITEM_NAME>_<FIELD_NAME>`
35//!
36//! Item names are normalised: spaces and hyphens → underscores, uppercased.
37//! Empty values are skipped.  Boolean fields (type = 2) are skipped.
38
39use std::collections::HashMap;
40
41use serde::Deserialize;
42
43use crate::config::BitwConfig;
44use crate::error::BitwError;
45
46// ── Bitwarden JSON shapes ─────────────────────────────────────────────────────
47
48/// Subset of the Bitwarden cipher JSON returned by `bw list items`.
49#[derive(Debug, Deserialize)]
50pub struct BwCipher {
51    /// Cipher type: 1 = Login, 2 = SecureNote, 3 = Card, 4 = Identity.
52    #[serde(rename = "type")]
53    pub cipher_type: u8,
54    /// Display name of the item.
55    pub name: String,
56    /// Login-specific fields (present when type = 1).
57    pub login: Option<BwLogin>,
58    /// Custom fields attached to the item.
59    #[serde(default)]
60    pub fields: Vec<BwField>,
61    /// Folder ID (used for optional filtering).
62    #[serde(rename = "folderId")]
63    pub folder_id: Option<String>,
64}
65
66#[derive(Debug, Deserialize)]
67pub struct BwLogin {
68    pub username: Option<String>,
69    pub password: Option<String>,
70}
71
72/// A custom field on a Bitwarden cipher.
73///
74/// Field types: 0 = text, 1 = hidden, 2 = boolean.  Boolean fields are skipped.
75#[derive(Debug, Deserialize)]
76pub struct BwField {
77    pub name: Option<String>,
78    pub value: Option<String>,
79    /// 0 = text, 1 = hidden, 2 = boolean.
80    #[serde(rename = "type")]
81    pub field_type: u8,
82}
83
84// ── Key normalisation ─────────────────────────────────────────────────────────
85
86/// Normalise a Bitwarden item name to a vault key prefix.
87///
88/// Spaces, hyphens, and forward-slashes become underscores; result is uppercased.
89///
90/// Examples:
91/// - `"Database Creds"` → `"DATABASE_CREDS"`
92/// - `"my-api-key"`     → `"MY_API_KEY"`
93pub fn normalize_item_name(name: &str) -> String {
94    name.replace([' ', '-', '/'], "_").to_uppercase()
95}
96
97/// Derive the vault key for a Login username/password or a custom field.
98///
99/// `item_prefix` is already normalised (output of [`normalize_item_name`]).
100/// `suffix` is the field label, also normalised.
101///
102/// Examples:
103/// - prefix=`"DATABASE_CREDS"`, suffix=`"USERNAME"` → `"DATABASE_CREDS_USERNAME"`
104pub fn build_key(item_prefix: &str, suffix: &str) -> String {
105    let norm_suffix = suffix.replace([' ', '-', '/'], "_").to_uppercase();
106    format!("{item_prefix}_{norm_suffix}")
107}
108
109// ── bw CLI subprocess helpers ─────────────────────────────────────────────────
110
111/// Extract the `BW_SESSION` token from the output of `bw unlock`.
112///
113/// The CLI prints a line of the form:
114/// ```text
115/// export BW_SESSION="<token>"
116/// ```
117/// or on Windows:
118/// ```text
119/// $env:BW_SESSION="<token>"
120/// ```
121/// We look for `BW_SESSION="…"` and extract the quoted value.
122fn extract_session_token(output: &str) -> Option<String> {
123    for line in output.lines() {
124        if let Some(rest) = line
125            .find("BW_SESSION=")
126            .map(|i| &line[i + "BW_SESSION=".len()..])
127        {
128            // Strip surrounding quotes (double or single).
129            let token = rest.trim().trim_matches('"').trim_matches('\'').to_string();
130            if !token.is_empty() {
131                return Some(token);
132            }
133        }
134    }
135    None
136}
137
138// ── Main API ──────────────────────────────────────────────────────────────────
139
140/// Pull decrypted Bitwarden items via the `bw` CLI subprocess.
141///
142/// Returns `(vault_key, plaintext_value)` pairs ready to store in the local
143/// vault.  Only Login items (type = 1) are returned.  Empty values and boolean
144/// custom fields are skipped.
145///
146/// # Parameters
147///
148/// - `cfg`: Bitwarden credentials.
149/// - `password_env`: name of the env var that holds the master password
150///   (e.g. `"TSAFE_BW_PASSWORD"`).  The value is **not** passed on the
151///   command line; it is forwarded via `--passwordenv` which makes `bw`
152///   read it from the env directly, keeping it out of the process table.
153/// - `folder_id`: optional Bitwarden folder ID to filter items.
154pub fn pull_items(
155    cfg: &BitwConfig,
156    password_env: &str,
157    folder_id: Option<&str>,
158) -> Result<Vec<(String, String)>, BitwError> {
159    // Resolve the master password from the env var *before* spawning any
160    // subprocess so we can fail fast with a clear error if it is missing.
161    if std::env::var(password_env)
162        .ok()
163        .filter(|v| !v.is_empty())
164        .is_none()
165    {
166        return Err(BitwError::Config(format!(
167            "env var `{password_env}` is not set or is empty — \
168             it must contain the Bitwarden master password for `bw unlock`"
169        )));
170    }
171
172    // Step 1: configure server (only needed for self-hosted / Vaultwarden).
173    let default_identity = BitwConfig::default_identity_url();
174    if cfg.identity_url != default_identity {
175        // Strip the `/identity` path segment to get the root server URL.
176        let server_url = cfg
177            .identity_url
178            .trim_end_matches('/')
179            .trim_end_matches("/identity");
180        run_bw(&["config", "server", server_url], None, None)?;
181    }
182
183    // Step 2: `bw login --apikey`.
184    let login_output = run_bw(
185        &[
186            "login",
187            "--apikey",
188            "--clientid",
189            &cfg.client_id,
190            "--clientsecret",
191            &cfg.client_secret,
192        ],
193        None,
194        None,
195    )?;
196    tracing::debug!(bytes = login_output.len(), "bw login completed");
197
198    // Step 3: `bw unlock --passwordenv <VAR>` → extract BW_SESSION.
199    let unlock_output =
200        run_bw(&["unlock", "--passwordenv", password_env], None, None).map_err(|e| match e {
201            BitwError::ListFailed { status, stderr } => BitwError::UnlockFailed { status, stderr },
202            other => other,
203        })?;
204
205    let session_token =
206        extract_session_token(&unlock_output).ok_or(BitwError::SessionTokenMissing)?;
207
208    tracing::debug!("bw unlock succeeded, session token obtained");
209
210    // Step 4: `bw list items [--folderid <id>]` with BW_SESSION in env.
211    // Build the arg list outside the match to satisfy the borrow checker: the
212    // owned string must live at least as long as the slice that references it.
213    let folderid_owned: String = folder_id.unwrap_or("").to_string();
214    let list_args: Vec<&str> = if folder_id.is_some() {
215        vec!["list", "items", "--folderid", &folderid_owned]
216    } else {
217        vec!["list", "items"]
218    };
219
220    let list_json = run_bw(&list_args, Some(&session_token), None)?;
221
222    // Step 5: `bw lock` — cleanup; non-fatal.
223    if let Err(e) = run_bw(&["lock"], Some(&session_token), None) {
224        tracing::warn!("bw lock failed (non-fatal): {e}");
225    }
226
227    // Parse and map.
228    let ciphers: Vec<BwCipher> =
229        serde_json::from_str(&list_json).map_err(|e| BitwError::ParseError(e.to_string()))?;
230
231    Ok(map_ciphers_to_kv(&ciphers))
232}
233
234/// Run a `bw` subcommand and return its combined stdout.
235///
236/// `session_token`: when `Some`, the `BW_SESSION` env var is injected.
237/// `extra_env`: additional environment overrides (key, value) for testing.
238///
239/// Returns `Err(BitwError::CliNotFound)` when `bw` is not on `PATH`.
240/// Returns `Err(BitwError::ListFailed)` on non-zero exit (the only error
241/// variant used here — callers remap if needed).
242fn run_bw(
243    args: &[&str],
244    session_token: Option<&str>,
245    extra_env: Option<&HashMap<String, String>>,
246) -> Result<String, BitwError> {
247    let mut cmd = std::process::Command::new("bw");
248    cmd.args(args);
249
250    // Never inherit a stale BW_SESSION from the parent environment; only
251    // inject the one we obtained from `bw unlock`.
252    cmd.env_remove("BW_SESSION");
253    if let Some(tok) = session_token {
254        cmd.env("BW_SESSION", tok);
255    }
256
257    if let Some(env) = extra_env {
258        for (k, v) in env {
259            cmd.env(k, v);
260        }
261    }
262
263    let output = cmd.output().map_err(|e| {
264        if e.kind() == std::io::ErrorKind::NotFound {
265            BitwError::CliNotFound
266        } else {
267            BitwError::ListFailed {
268                status: -1,
269                stderr: e.to_string(),
270            }
271        }
272    })?;
273
274    if !output.status.success() {
275        let status = output.status.code().unwrap_or(-1);
276        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
277        return Err(BitwError::ListFailed { status, stderr });
278    }
279
280    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
281}
282
283/// Map a slice of decrypted Bitwarden ciphers to `(vault_key, value)` pairs.
284///
285/// Only Login items (type = 1) are processed.  Empty values and boolean
286/// custom fields (field_type = 2) are skipped.
287pub fn map_ciphers_to_kv(ciphers: &[BwCipher]) -> Vec<(String, String)> {
288    let mut pairs = Vec::new();
289
290    for cipher in ciphers {
291        if cipher.cipher_type != 1 {
292            continue; // only Login items
293        }
294
295        let prefix = normalize_item_name(&cipher.name);
296
297        if let Some(login) = &cipher.login {
298            if let Some(username) = login.username.as_deref().filter(|s| !s.is_empty()) {
299                pairs.push((build_key(&prefix, "USERNAME"), username.to_string()));
300            }
301            if let Some(password) = login.password.as_deref().filter(|s| !s.is_empty()) {
302                pairs.push((build_key(&prefix, "PASSWORD"), password.to_string()));
303            }
304        }
305
306        for field in &cipher.fields {
307            if field.field_type == 2 {
308                continue; // skip boolean fields
309            }
310            let label = match field.name.as_deref().filter(|s| !s.is_empty()) {
311                Some(l) => l,
312                None => continue,
313            };
314            let value = match field.value.as_deref().filter(|s| !s.is_empty()) {
315                Some(v) => v,
316                None => continue,
317            };
318            pairs.push((build_key(&prefix, label), value.to_string()));
319        }
320    }
321
322    pairs
323}
324
325// ── Tests ─────────────────────────────────────────────────────────────────────
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    fn make_login_cipher(name: &str, username: Option<&str>, password: Option<&str>) -> BwCipher {
332        BwCipher {
333            cipher_type: 1,
334            name: name.to_string(),
335            login: Some(BwLogin {
336                username: username.map(|s| s.to_string()),
337                password: password.map(|s| s.to_string()),
338            }),
339            fields: vec![],
340            folder_id: None,
341        }
342    }
343
344    // ── normalize_item_name ───────────────────────────────────────────────────
345
346    #[test]
347    fn normalize_spaces_to_underscore() {
348        assert_eq!(normalize_item_name("Database Creds"), "DATABASE_CREDS");
349    }
350
351    #[test]
352    fn normalize_hyphens_to_underscore() {
353        assert_eq!(normalize_item_name("my-api-key"), "MY_API_KEY");
354    }
355
356    #[test]
357    fn normalize_slash_to_underscore() {
358        assert_eq!(normalize_item_name("prod/db"), "PROD_DB");
359    }
360
361    #[test]
362    fn normalize_already_upper() {
363        assert_eq!(normalize_item_name("FOO_BAR"), "FOO_BAR");
364    }
365
366    // ── build_key ─────────────────────────────────────────────────────────────
367
368    #[test]
369    fn build_key_username() {
370        assert_eq!(
371            build_key("DATABASE_CREDS", "USERNAME"),
372            "DATABASE_CREDS_USERNAME"
373        );
374    }
375
376    #[test]
377    fn build_key_custom_field_normalises_suffix() {
378        assert_eq!(build_key("MY_APP", "host name"), "MY_APP_HOST_NAME");
379    }
380
381    // ── extract_session_token ─────────────────────────────────────────────────
382
383    #[test]
384    fn extract_session_unix_export_format() {
385        let output = r#"Your vault is now unlocked!
386
387To unlock your vault, set your session key in the `BW_SESSION` environment variable. ex:
388$ export BW_SESSION="AbCdEfGhIjKlMn=="
389> $env:BW_SESSION="AbCdEfGhIjKlMn=="
390"#;
391        assert_eq!(
392            extract_session_token(output),
393            Some("AbCdEfGhIjKlMn==".into())
394        );
395    }
396
397    #[test]
398    fn extract_session_windows_format() {
399        let output = r#"Your vault is now unlocked!
400> $env:BW_SESSION="Win32TokenHere=="
401"#;
402        assert_eq!(
403            extract_session_token(output),
404            Some("Win32TokenHere==".into())
405        );
406    }
407
408    #[test]
409    fn extract_session_missing_returns_none() {
410        let output = "Error: master password is incorrect.";
411        assert!(extract_session_token(output).is_none());
412    }
413
414    // ── bitwarden_auth_obtains_token (task spec test #1) ──────────────────────
415    //
416    // We cannot shell to a real `bw` CLI in unit tests.  This test verifies
417    // that `extract_session_token` successfully parses a mock `bw unlock`
418    // stdout, which is the only part of the auth flow that is pure logic.
419
420    #[test]
421    fn bitwarden_auth_obtains_token() {
422        let mock_unlock_output = r#"
423Logging in to bitwarden.com ...
424You are logged in!
425
426Your vault is now unlocked!
427
428To unlock your vault, set your session key in the `BW_SESSION` environment variable. ex:
429$ export BW_SESSION="mocked-session-token-abc123=="
430> $env:BW_SESSION="mocked-session-token-abc123=="
431
432NOTE: You can avoid this message the next time by using the `--raw` flag.
433"#;
434        let token = extract_session_token(mock_unlock_output).expect("token should be extracted");
435        assert_eq!(token, "mocked-session-token-abc123==");
436    }
437
438    // ── bitwarden_cipher_type_filter (task spec test #2) ─────────────────────
439
440    #[test]
441    fn bitwarden_cipher_type_filter() {
442        let ciphers = vec![
443            make_login_cipher("Login Item", Some("user@example.com"), Some("hunter2")),
444            BwCipher {
445                cipher_type: 2, // SecureNote — must be filtered out
446                name: "My Note".to_string(),
447                login: None,
448                fields: vec![],
449                folder_id: None,
450            },
451            BwCipher {
452                cipher_type: 3, // Card — must be filtered out
453                name: "My Card".to_string(),
454                login: None,
455                fields: vec![],
456                folder_id: None,
457            },
458        ];
459
460        let pairs = map_ciphers_to_kv(&ciphers);
461        // Only the Login item contributes entries.
462        assert!(!pairs.is_empty());
463        for (key, _) in &pairs {
464            assert!(key.starts_with("LOGIN_ITEM_"), "unexpected key: {key}");
465        }
466    }
467
468    // ── bitwarden_field_mapping (task spec test #3) ───────────────────────────
469
470    #[test]
471    fn bitwarden_field_mapping() {
472        let ciphers = vec![BwCipher {
473            cipher_type: 1,
474            name: "Foo".to_string(),
475            login: Some(BwLogin {
476                username: Some("alice".to_string()),
477                password: Some("s3cr3t".to_string()),
478            }),
479            fields: vec![BwField {
480                name: Some("host".to_string()),
481                value: Some("db.example.com".to_string()),
482                field_type: 0,
483            }],
484            folder_id: None,
485        }];
486
487        let pairs = map_ciphers_to_kv(&ciphers);
488        let map: HashMap<&str, &str> = pairs
489            .iter()
490            .map(|(k, v)| (k.as_str(), v.as_str()))
491            .collect();
492
493        assert_eq!(
494            map.get("FOO_USERNAME"),
495            Some(&"alice"),
496            "username key missing"
497        );
498        assert_eq!(
499            map.get("FOO_PASSWORD"),
500            Some(&"s3cr3t"),
501            "password key missing"
502        );
503        assert_eq!(
504            map.get("FOO_HOST"),
505            Some(&"db.example.com"),
506            "host field key missing"
507        );
508    }
509
510    // ── bitwarden_empty_fields_skipped (task spec test #4) ───────────────────
511
512    #[test]
513    fn bitwarden_empty_fields_skipped() {
514        let ciphers = vec![BwCipher {
515            cipher_type: 1,
516            name: "Partial Item".to_string(),
517            login: Some(BwLogin {
518                username: Some("".to_string()), // empty — must skip
519                password: Some("valid-pw".to_string()),
520            }),
521            fields: vec![
522                BwField {
523                    name: Some("empty-field".to_string()),
524                    value: Some("".to_string()), // empty — must skip
525                    field_type: 0,
526                },
527                BwField {
528                    name: Some("boolean-flag".to_string()),
529                    value: Some("true".to_string()),
530                    field_type: 2, // boolean — must skip
531                },
532                BwField {
533                    name: Some("api-key".to_string()),
534                    value: Some("abc-123".to_string()),
535                    field_type: 0,
536                },
537            ],
538            folder_id: None,
539        }];
540
541        let pairs = map_ciphers_to_kv(&ciphers);
542        let keys: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
543
544        // Password and api-key should be present.
545        assert!(
546            keys.contains(&"PARTIAL_ITEM_PASSWORD"),
547            "password key missing"
548        );
549        assert!(
550            keys.contains(&"PARTIAL_ITEM_API_KEY"),
551            "api-key field missing"
552        );
553
554        // Empty username must be absent.
555        assert!(
556            !keys.contains(&"PARTIAL_ITEM_USERNAME"),
557            "empty username should be skipped"
558        );
559        // Empty field must be absent.
560        assert!(
561            !keys.contains(&"PARTIAL_ITEM_EMPTY_FIELD"),
562            "empty field value should be skipped"
563        );
564        // Boolean field must be absent.
565        assert!(
566            !keys.contains(&"PARTIAL_ITEM_BOOLEAN_FLAG"),
567            "boolean field should be skipped"
568        );
569    }
570
571    // ── parse_bw_list_items_json ──────────────────────────────────────────────
572
573    #[test]
574    fn parse_bw_list_items_json_valid() {
575        let json = r#"[
576            {
577                "type": 1,
578                "name": "My Service",
579                "login": {"username": "svc@example.com", "password": "pw123"},
580                "fields": [],
581                "folderId": null
582            }
583        ]"#;
584        let ciphers: Vec<BwCipher> = serde_json::from_str(json).unwrap();
585        assert_eq!(ciphers.len(), 1);
586        assert_eq!(ciphers[0].name, "My Service");
587    }
588
589    #[test]
590    fn parse_bw_list_items_json_invalid_returns_error() {
591        let json = "not valid json {{{";
592        let err = serde_json::from_str::<Vec<BwCipher>>(json)
593            .map(|_| ())
594            .unwrap_err();
595        assert!(!err.to_string().is_empty());
596    }
597
598    // ── bw_session_not_in_args (task spec test #5 intent) ────────────────────
599    //
600    // Verify that `run_bw` never places the session token in the argument
601    // list — it is injected only as the `BW_SESSION` env var.  This is a
602    // structural guarantee: the `args` slice parameter is what forms the
603    // argv, and we assert it never contains the literal session string.
604
605    #[test]
606    fn bitwarden_bw_session_not_in_args_structurally() {
607        // The `list_args` slice passed to `run_bw` when session_token is Some.
608        // It must never include the token text itself.
609        let list_args: Vec<&str> = vec!["list", "items"];
610        let token = "SUPER_SECRET_SESSION_TOKEN";
611
612        // Confirm the session token is not embedded in the argument list.
613        for arg in &list_args {
614            assert_ne!(*arg, token, "BW_SESSION token must not appear in CLI args");
615        }
616        // The token is injected via cmd.env("BW_SESSION", token) — not shown
617        // here in a unit test, but the structural split of args vs env is
618        // enforced by the run_bw signature: `args: &[&str]` vs
619        // `session_token: Option<&str>` which maps to cmd.env().
620    }
621}