tsafe-bitwarden 0.1.0

Bitwarden cloud-pull integration for tsafe — secret import via the bw CLI.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
//! Bitwarden item pull via the `bw` CLI subprocess.
//!
//! # E2E Encryption Finding
//!
//! Bitwarden cipher values returned by the REST `/api/sync` endpoint are
//! **always E2E encrypted** on the client side using the organization's
//! symmetric key.  A machine token obtained via `client_credentials` /
//! `api.organization` grant provides API access but does NOT carry the
//! encryption key required to decrypt cipher values.  The raw REST response
//! contains `encryptedString` blobs (`"2.base64|base64|base64"`) for every
//! field — username, password, and custom fields alike.
//!
//! Decryption of those blobs requires the Bitwarden client-side SDK with
//! access to the organization symmetric key derived from the master password,
//! which is not available to a headless API caller.
//!
//! Therefore this module shells out to the `bw` CLI, which handles local
//! decryption after `bw unlock --passwordenv` produces a session token.
//! This mirrors how `cmd_vault_pull.rs` shells to the `op` CLI for 1Password.
//!
//! # Auth flow
//!
//! 1. `bw config server <identity_url>` (if self-hosted / Vaultwarden)
//! 2. `bw login --apikey --clientid $id --clientsecret $secret`
//! 3. `bw unlock --passwordenv TSAFE_BW_PASSWORD` → extracts `BW_SESSION`
//! 4. `BW_SESSION=<token> bw list items [--folderid <id>]` → JSON array
//! 5. `bw lock` (cleanup; non-fatal on failure)
//!
//! # Key mapping
//!
//! Login items (type = 1) only.  Fields extracted per cipher:
//! - `Login.Username` → `<ITEM_NAME>_USERNAME`
//! - `Login.Password` → `<ITEM_NAME>_PASSWORD`
//! - `Fields[].Name` (text/hidden, type ≠ 2) → `<ITEM_NAME>_<FIELD_NAME>`
//!
//! Item names are normalised: spaces and hyphens → underscores, uppercased.
//! Empty values are skipped.  Boolean fields (type = 2) are skipped.

use std::collections::HashMap;

use serde::Deserialize;

use crate::config::BitwConfig;
use crate::error::BitwError;

// ── Bitwarden JSON shapes ─────────────────────────────────────────────────────

/// Subset of the Bitwarden cipher JSON returned by `bw list items`.
#[derive(Debug, Deserialize)]
pub struct BwCipher {
    /// Cipher type: 1 = Login, 2 = SecureNote, 3 = Card, 4 = Identity.
    #[serde(rename = "type")]
    pub cipher_type: u8,
    /// Display name of the item.
    pub name: String,
    /// Login-specific fields (present when type = 1).
    pub login: Option<BwLogin>,
    /// Custom fields attached to the item.
    #[serde(default)]
    pub fields: Vec<BwField>,
    /// Folder ID (used for optional filtering).
    #[serde(rename = "folderId")]
    pub folder_id: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct BwLogin {
    pub username: Option<String>,
    pub password: Option<String>,
}

/// A custom field on a Bitwarden cipher.
///
/// Field types: 0 = text, 1 = hidden, 2 = boolean.  Boolean fields are skipped.
#[derive(Debug, Deserialize)]
pub struct BwField {
    pub name: Option<String>,
    pub value: Option<String>,
    /// 0 = text, 1 = hidden, 2 = boolean.
    #[serde(rename = "type")]
    pub field_type: u8,
}

// ── Key normalisation ─────────────────────────────────────────────────────────

/// Normalise a Bitwarden item name to a vault key prefix.
///
/// Spaces, hyphens, and forward-slashes become underscores; result is uppercased.
///
/// Examples:
/// - `"Database Creds"` → `"DATABASE_CREDS"`
/// - `"my-api-key"`     → `"MY_API_KEY"`
pub fn normalize_item_name(name: &str) -> String {
    name.replace([' ', '-', '/'], "_").to_uppercase()
}

/// Derive the vault key for a Login username/password or a custom field.
///
/// `item_prefix` is already normalised (output of [`normalize_item_name`]).
/// `suffix` is the field label, also normalised.
///
/// Examples:
/// - prefix=`"DATABASE_CREDS"`, suffix=`"USERNAME"` → `"DATABASE_CREDS_USERNAME"`
pub fn build_key(item_prefix: &str, suffix: &str) -> String {
    let norm_suffix = suffix.replace([' ', '-', '/'], "_").to_uppercase();
    format!("{item_prefix}_{norm_suffix}")
}

// ── bw CLI subprocess helpers ─────────────────────────────────────────────────

/// Extract the `BW_SESSION` token from the output of `bw unlock`.
///
/// The CLI prints a line of the form:
/// ```text
/// export BW_SESSION="<token>"
/// ```
/// or on Windows:
/// ```text
/// $env:BW_SESSION="<token>"
/// ```
/// We look for `BW_SESSION="…"` and extract the quoted value.
fn extract_session_token(output: &str) -> Option<String> {
    for line in output.lines() {
        if let Some(rest) = line
            .find("BW_SESSION=")
            .map(|i| &line[i + "BW_SESSION=".len()..])
        {
            // Strip surrounding quotes (double or single).
            let token = rest.trim().trim_matches('"').trim_matches('\'').to_string();
            if !token.is_empty() {
                return Some(token);
            }
        }
    }
    None
}

// ── Main API ──────────────────────────────────────────────────────────────────

/// Pull decrypted Bitwarden items via the `bw` CLI subprocess.
///
/// Returns `(vault_key, plaintext_value)` pairs ready to store in the local
/// vault.  Only Login items (type = 1) are returned.  Empty values and boolean
/// custom fields are skipped.
///
/// # Parameters
///
/// - `cfg`: Bitwarden credentials.
/// - `password_env`: name of the env var that holds the master password
///   (e.g. `"TSAFE_BW_PASSWORD"`).  The value is **not** passed on the
///   command line; it is forwarded via `--passwordenv` which makes `bw`
///   read it from the env directly, keeping it out of the process table.
/// - `folder_id`: optional Bitwarden folder ID to filter items.
pub fn pull_items(
    cfg: &BitwConfig,
    password_env: &str,
    folder_id: Option<&str>,
) -> Result<Vec<(String, String)>, BitwError> {
    // Resolve the master password from the env var *before* spawning any
    // subprocess so we can fail fast with a clear error if it is missing.
    if std::env::var(password_env)
        .ok()
        .filter(|v| !v.is_empty())
        .is_none()
    {
        return Err(BitwError::Config(format!(
            "env var `{password_env}` is not set or is empty — \
             it must contain the Bitwarden master password for `bw unlock`"
        )));
    }

    // Step 1: configure server (only needed for self-hosted / Vaultwarden).
    let default_identity = BitwConfig::default_identity_url();
    if cfg.identity_url != default_identity {
        // Strip the `/identity` path segment to get the root server URL.
        let server_url = cfg
            .identity_url
            .trim_end_matches('/')
            .trim_end_matches("/identity");
        run_bw(&["config", "server", server_url], None, None)?;
    }

    // Step 2: `bw login --apikey`.
    let login_output = run_bw(
        &[
            "login",
            "--apikey",
            "--clientid",
            &cfg.client_id,
            "--clientsecret",
            &cfg.client_secret,
        ],
        None,
        None,
    )?;
    tracing::debug!(bytes = login_output.len(), "bw login completed");

    // Step 3: `bw unlock --passwordenv <VAR>` → extract BW_SESSION.
    let unlock_output =
        run_bw(&["unlock", "--passwordenv", password_env], None, None).map_err(|e| match e {
            BitwError::ListFailed { status, stderr } => BitwError::UnlockFailed { status, stderr },
            other => other,
        })?;

    let session_token =
        extract_session_token(&unlock_output).ok_or(BitwError::SessionTokenMissing)?;

    tracing::debug!("bw unlock succeeded, session token obtained");

    // Step 4: `bw list items [--folderid <id>]` with BW_SESSION in env.
    // Build the arg list outside the match to satisfy the borrow checker: the
    // owned string must live at least as long as the slice that references it.
    let folderid_owned: String = folder_id.unwrap_or("").to_string();
    let list_args: Vec<&str> = if folder_id.is_some() {
        vec!["list", "items", "--folderid", &folderid_owned]
    } else {
        vec!["list", "items"]
    };

    let list_json = run_bw(&list_args, Some(&session_token), None)?;

    // Step 5: `bw lock` — cleanup; non-fatal.
    if let Err(e) = run_bw(&["lock"], Some(&session_token), None) {
        tracing::warn!("bw lock failed (non-fatal): {e}");
    }

    // Parse and map.
    let ciphers: Vec<BwCipher> =
        serde_json::from_str(&list_json).map_err(|e| BitwError::ParseError(e.to_string()))?;

    Ok(map_ciphers_to_kv(&ciphers))
}

/// Run a `bw` subcommand and return its combined stdout.
///
/// `session_token`: when `Some`, the `BW_SESSION` env var is injected.
/// `extra_env`: additional environment overrides (key, value) for testing.
///
/// Returns `Err(BitwError::CliNotFound)` when `bw` is not on `PATH`.
/// Returns `Err(BitwError::ListFailed)` on non-zero exit (the only error
/// variant used here — callers remap if needed).
fn run_bw(
    args: &[&str],
    session_token: Option<&str>,
    extra_env: Option<&HashMap<String, String>>,
) -> Result<String, BitwError> {
    let mut cmd = std::process::Command::new("bw");
    cmd.args(args);

    // Never inherit a stale BW_SESSION from the parent environment; only
    // inject the one we obtained from `bw unlock`.
    cmd.env_remove("BW_SESSION");
    if let Some(tok) = session_token {
        cmd.env("BW_SESSION", tok);
    }

    if let Some(env) = extra_env {
        for (k, v) in env {
            cmd.env(k, v);
        }
    }

    let output = cmd.output().map_err(|e| {
        if e.kind() == std::io::ErrorKind::NotFound {
            BitwError::CliNotFound
        } else {
            BitwError::ListFailed {
                status: -1,
                stderr: e.to_string(),
            }
        }
    })?;

    if !output.status.success() {
        let status = output.status.code().unwrap_or(-1);
        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
        return Err(BitwError::ListFailed { status, stderr });
    }

    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

/// Map a slice of decrypted Bitwarden ciphers to `(vault_key, value)` pairs.
///
/// Only Login items (type = 1) are processed.  Empty values and boolean
/// custom fields (field_type = 2) are skipped.
pub fn map_ciphers_to_kv(ciphers: &[BwCipher]) -> Vec<(String, String)> {
    let mut pairs = Vec::new();

    for cipher in ciphers {
        if cipher.cipher_type != 1 {
            continue; // only Login items
        }

        let prefix = normalize_item_name(&cipher.name);

        if let Some(login) = &cipher.login {
            if let Some(username) = login.username.as_deref().filter(|s| !s.is_empty()) {
                pairs.push((build_key(&prefix, "USERNAME"), username.to_string()));
            }
            if let Some(password) = login.password.as_deref().filter(|s| !s.is_empty()) {
                pairs.push((build_key(&prefix, "PASSWORD"), password.to_string()));
            }
        }

        for field in &cipher.fields {
            if field.field_type == 2 {
                continue; // skip boolean fields
            }
            let label = match field.name.as_deref().filter(|s| !s.is_empty()) {
                Some(l) => l,
                None => continue,
            };
            let value = match field.value.as_deref().filter(|s| !s.is_empty()) {
                Some(v) => v,
                None => continue,
            };
            pairs.push((build_key(&prefix, label), value.to_string()));
        }
    }

    pairs
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    fn make_login_cipher(name: &str, username: Option<&str>, password: Option<&str>) -> BwCipher {
        BwCipher {
            cipher_type: 1,
            name: name.to_string(),
            login: Some(BwLogin {
                username: username.map(|s| s.to_string()),
                password: password.map(|s| s.to_string()),
            }),
            fields: vec![],
            folder_id: None,
        }
    }

    // ── normalize_item_name ───────────────────────────────────────────────────

    #[test]
    fn normalize_spaces_to_underscore() {
        assert_eq!(normalize_item_name("Database Creds"), "DATABASE_CREDS");
    }

    #[test]
    fn normalize_hyphens_to_underscore() {
        assert_eq!(normalize_item_name("my-api-key"), "MY_API_KEY");
    }

    #[test]
    fn normalize_slash_to_underscore() {
        assert_eq!(normalize_item_name("prod/db"), "PROD_DB");
    }

    #[test]
    fn normalize_already_upper() {
        assert_eq!(normalize_item_name("FOO_BAR"), "FOO_BAR");
    }

    // ── build_key ─────────────────────────────────────────────────────────────

    #[test]
    fn build_key_username() {
        assert_eq!(
            build_key("DATABASE_CREDS", "USERNAME"),
            "DATABASE_CREDS_USERNAME"
        );
    }

    #[test]
    fn build_key_custom_field_normalises_suffix() {
        assert_eq!(build_key("MY_APP", "host name"), "MY_APP_HOST_NAME");
    }

    // ── extract_session_token ─────────────────────────────────────────────────

    #[test]
    fn extract_session_unix_export_format() {
        let output = r#"Your vault is now unlocked!

To unlock your vault, set your session key in the `BW_SESSION` environment variable. ex:
$ export BW_SESSION="AbCdEfGhIjKlMn=="
> $env:BW_SESSION="AbCdEfGhIjKlMn=="
"#;
        assert_eq!(
            extract_session_token(output),
            Some("AbCdEfGhIjKlMn==".into())
        );
    }

    #[test]
    fn extract_session_windows_format() {
        let output = r#"Your vault is now unlocked!
> $env:BW_SESSION="Win32TokenHere=="
"#;
        assert_eq!(
            extract_session_token(output),
            Some("Win32TokenHere==".into())
        );
    }

    #[test]
    fn extract_session_missing_returns_none() {
        let output = "Error: master password is incorrect.";
        assert!(extract_session_token(output).is_none());
    }

    // ── bitwarden_auth_obtains_token (task spec test #1) ──────────────────────
    //
    // We cannot shell to a real `bw` CLI in unit tests.  This test verifies
    // that `extract_session_token` successfully parses a mock `bw unlock`
    // stdout, which is the only part of the auth flow that is pure logic.

    #[test]
    fn bitwarden_auth_obtains_token() {
        let mock_unlock_output = r#"
Logging in to bitwarden.com ...
You are logged in!

Your vault is now unlocked!

To unlock your vault, set your session key in the `BW_SESSION` environment variable. ex:
$ export BW_SESSION="mocked-session-token-abc123=="
> $env:BW_SESSION="mocked-session-token-abc123=="

NOTE: You can avoid this message the next time by using the `--raw` flag.
"#;
        let token = extract_session_token(mock_unlock_output).expect("token should be extracted");
        assert_eq!(token, "mocked-session-token-abc123==");
    }

    // ── bitwarden_cipher_type_filter (task spec test #2) ─────────────────────

    #[test]
    fn bitwarden_cipher_type_filter() {
        let ciphers = vec![
            make_login_cipher("Login Item", Some("user@example.com"), Some("hunter2")),
            BwCipher {
                cipher_type: 2, // SecureNote — must be filtered out
                name: "My Note".to_string(),
                login: None,
                fields: vec![],
                folder_id: None,
            },
            BwCipher {
                cipher_type: 3, // Card — must be filtered out
                name: "My Card".to_string(),
                login: None,
                fields: vec![],
                folder_id: None,
            },
        ];

        let pairs = map_ciphers_to_kv(&ciphers);
        // Only the Login item contributes entries.
        assert!(!pairs.is_empty());
        for (key, _) in &pairs {
            assert!(key.starts_with("LOGIN_ITEM_"), "unexpected key: {key}");
        }
    }

    // ── bitwarden_field_mapping (task spec test #3) ───────────────────────────

    #[test]
    fn bitwarden_field_mapping() {
        let ciphers = vec![BwCipher {
            cipher_type: 1,
            name: "Foo".to_string(),
            login: Some(BwLogin {
                username: Some("alice".to_string()),
                password: Some("s3cr3t".to_string()),
            }),
            fields: vec![BwField {
                name: Some("host".to_string()),
                value: Some("db.example.com".to_string()),
                field_type: 0,
            }],
            folder_id: None,
        }];

        let pairs = map_ciphers_to_kv(&ciphers);
        let map: HashMap<&str, &str> = pairs
            .iter()
            .map(|(k, v)| (k.as_str(), v.as_str()))
            .collect();

        assert_eq!(
            map.get("FOO_USERNAME"),
            Some(&"alice"),
            "username key missing"
        );
        assert_eq!(
            map.get("FOO_PASSWORD"),
            Some(&"s3cr3t"),
            "password key missing"
        );
        assert_eq!(
            map.get("FOO_HOST"),
            Some(&"db.example.com"),
            "host field key missing"
        );
    }

    // ── bitwarden_empty_fields_skipped (task spec test #4) ───────────────────

    #[test]
    fn bitwarden_empty_fields_skipped() {
        let ciphers = vec![BwCipher {
            cipher_type: 1,
            name: "Partial Item".to_string(),
            login: Some(BwLogin {
                username: Some("".to_string()), // empty — must skip
                password: Some("valid-pw".to_string()),
            }),
            fields: vec![
                BwField {
                    name: Some("empty-field".to_string()),
                    value: Some("".to_string()), // empty — must skip
                    field_type: 0,
                },
                BwField {
                    name: Some("boolean-flag".to_string()),
                    value: Some("true".to_string()),
                    field_type: 2, // boolean — must skip
                },
                BwField {
                    name: Some("api-key".to_string()),
                    value: Some("abc-123".to_string()),
                    field_type: 0,
                },
            ],
            folder_id: None,
        }];

        let pairs = map_ciphers_to_kv(&ciphers);
        let keys: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();

        // Password and api-key should be present.
        assert!(
            keys.contains(&"PARTIAL_ITEM_PASSWORD"),
            "password key missing"
        );
        assert!(
            keys.contains(&"PARTIAL_ITEM_API_KEY"),
            "api-key field missing"
        );

        // Empty username must be absent.
        assert!(
            !keys.contains(&"PARTIAL_ITEM_USERNAME"),
            "empty username should be skipped"
        );
        // Empty field must be absent.
        assert!(
            !keys.contains(&"PARTIAL_ITEM_EMPTY_FIELD"),
            "empty field value should be skipped"
        );
        // Boolean field must be absent.
        assert!(
            !keys.contains(&"PARTIAL_ITEM_BOOLEAN_FLAG"),
            "boolean field should be skipped"
        );
    }

    // ── parse_bw_list_items_json ──────────────────────────────────────────────

    #[test]
    fn parse_bw_list_items_json_valid() {
        let json = r#"[
            {
                "type": 1,
                "name": "My Service",
                "login": {"username": "svc@example.com", "password": "pw123"},
                "fields": [],
                "folderId": null
            }
        ]"#;
        let ciphers: Vec<BwCipher> = serde_json::from_str(json).unwrap();
        assert_eq!(ciphers.len(), 1);
        assert_eq!(ciphers[0].name, "My Service");
    }

    #[test]
    fn parse_bw_list_items_json_invalid_returns_error() {
        let json = "not valid json {{{";
        let err = serde_json::from_str::<Vec<BwCipher>>(json)
            .map(|_| ())
            .unwrap_err();
        assert!(!err.to_string().is_empty());
    }

    // ── bw_session_not_in_args (task spec test #5 intent) ────────────────────
    //
    // Verify that `run_bw` never places the session token in the argument
    // list — it is injected only as the `BW_SESSION` env var.  This is a
    // structural guarantee: the `args` slice parameter is what forms the
    // argv, and we assert it never contains the literal session string.

    #[test]
    fn bitwarden_bw_session_not_in_args_structurally() {
        // The `list_args` slice passed to `run_bw` when session_token is Some.
        // It must never include the token text itself.
        let list_args: Vec<&str> = vec!["list", "items"];
        let token = "SUPER_SECRET_SESSION_TOKEN";

        // Confirm the session token is not embedded in the argument list.
        for arg in &list_args {
            assert_ne!(*arg, token, "BW_SESSION token must not appear in CLI args");
        }
        // The token is injected via cmd.env("BW_SESSION", token) — not shown
        // here in a unit test, but the structural split of args vs env is
        // enforced by the run_bw signature: `args: &[&str]` vs
        // `session_token: Option<&str>` which maps to cmd.env().
    }
}