commit ada564241f41f116495a86bcfd9fef4ae8ff2992 Author: Morgan Date: Mon Mar 30 18:12:10 2026 +0900 Inital commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d3da254 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "bw-vault" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "bw-vault" +path = "src/main.rs" + +[dependencies] +aes = "0.8" +argon2 = "0.5" +base64 = "0.22" +cbc = { version = "0.1", features = ["alloc"] } +clap = { version = "4", features = ["derive"] } +crossterm = "0.28" +dialoguer = { version = "0.11", features = ["fuzzy-select"] } +hex = "0.4" +hmac = "0.12" +pbkdf2 = { version = "0.12", features = ["sha2"] } +rand = "0.8" +rsa = { version = "0.9", features = ["sha1"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha1 = "0.10" +sha2 = "0.10" +ureq = { version = "2", features = ["json"] } +uuid = { version = "1", features = ["v4"] } +zeroize = { version = "1", features = ["derive"] } + +[profile.release] +strip = true +lto = true diff --git a/src/agent.rs b/src/agent.rs new file mode 100644 index 0000000..8fa7394 --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,171 @@ +//! IPC client for bw-agent — performs the encrypted handshake and +//! requests the user key via biometric unlock. + +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; + +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use rsa::{pkcs1::EncodeRsaPublicKey, Oaep, RsaPrivateKey}; +use serde_json::{json, Value}; +use zeroize::Zeroize; + +use crate::crypto::{self, SymKey}; + +const MAX_MSG: usize = 1024 * 1024; + +/// Connect to a running bw-agent, do the encrypted handshake, +/// request unlock, and return the raw 64-byte user key. +pub fn get_user_key(sock: &Path, verbose: bool) -> Result, String> { + let mut conn = UnixStream::connect(sock) + .map_err(|e| format!("connect {}: {e}", sock.display()))?; + if verbose { + eprintln!(" connected to {}", sock.display()); + } + + // generate ephemeral RSA keypair for handshake + if verbose { + eprintln!(" generating RSA-2048 keypair..."); + } + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048) + .map_err(|e| format!("RSA keygen: {e}"))?; + let pub_der = priv_key + .to_public_key() + .to_pkcs1_der() + .map_err(|e| format!("DER encode: {e}"))?; + if verbose { + eprintln!(" public key: {} bytes DER", pub_der.as_bytes().len()); + } + + let app_id = uuid::Uuid::new_v4().to_string(); + + // send setupEncryption + let handshake_req = json!({ + "appId": &app_id, + "message": { + "command": "setupEncryption", + "publicKey": B64.encode(pub_der.as_bytes()), + } + }); + if verbose { + eprintln!(" -> setupEncryption (appId={})", &app_id[..8]); + } + send(&mut conn, &handshake_req)?; + + // receive shared secret (RSA-encrypted) + if verbose { + eprintln!(" waiting for handshake response..."); + } + let resp = recv(&mut conn)?; + if verbose { + let keys: Vec<&String> = resp.as_object().map(|o| o.keys().collect()).unwrap_or_default(); + eprintln!(" <- response keys: {:?}", keys); + } + let shared_b64 = resp + .get("sharedSecret") + .and_then(|s| s.as_str()) + .ok_or("no sharedSecret in handshake response")?; + let encrypted_shared = B64.decode(shared_b64).map_err(|_| "bad base64 in sharedSecret")?; + if verbose { + eprintln!(" decrypting shared secret ({} bytes)...", encrypted_shared.len()); + } + + let shared_raw = priv_key + .decrypt(Oaep::new::(), &encrypted_shared) + .map_err(|e| format!("RSA decrypt: {e}"))?; + if verbose { + eprintln!(" session key established ({} bytes)", shared_raw.len()); + } + + let session_key = SymKey::new(shared_raw); + + // send unlock request (encrypted with session key) + let unlock_req = json!({ + "command": "unlockWithBiometricsForUser", + "messageId": 1, + }); + let req_str = serde_json::to_string(&unlock_req).unwrap(); + let encrypted_msg = crypto::encrypt(&req_str, &session_key); + + if verbose { + eprintln!(" -> unlockWithBiometricsForUser (encrypted)"); + } + send( + &mut conn, + &json!({ + "appId": &app_id, + "message": crypto::enc_to_json(&encrypted_msg), + }), + )?; + + // receive encrypted response + if verbose { + eprintln!(" waiting for unlock response..."); + } + let enc_resp = recv(&mut conn)?; + if verbose { + let keys: Vec<&String> = enc_resp.as_object().map(|o| o.keys().collect()).unwrap_or_default(); + eprintln!(" <- response keys: {:?}", keys); + } + let enc_message = enc_resp + .get("message") + .ok_or("no message in unlock response")?; + let enc_str = crypto::json_to_enc(enc_message); + let plaintext = crypto::decrypt(&enc_str, &session_key) + .map_err(|e| format!("decrypt response: {e}"))?; + if verbose { + eprintln!(" decrypted {} bytes", plaintext.len()); + } + + let data: Value = + serde_json::from_slice(&plaintext).map_err(|e| format!("parse response: {e}"))?; + + let granted = data + .get("response") + .and_then(|r| r.as_bool()) + .unwrap_or(false); + if !granted { + return Err("unlock denied by agent".into()); + } + + let mut key_b64 = data + .get("userKeyB64") + .and_then(|k| k.as_str()) + .ok_or("no userKeyB64 in response")? + .to_string(); + + let key_bytes = B64.decode(&key_b64).map_err(|_| "bad base64 in userKeyB64")?; + key_b64.zeroize(); + + if key_bytes.len() != 64 { + return Err(format!("unexpected key length: {}", key_bytes.len())); + } + + if verbose { + eprintln!(" got user key (64 bytes)"); + } + Ok(key_bytes) +} + +fn send(conn: &mut UnixStream, msg: &Value) -> Result<(), String> { + let data = serde_json::to_vec(msg).unwrap(); + let len = (data.len() as u32).to_ne_bytes(); + conn.write_all(&len) + .and_then(|_| conn.write_all(&data)) + .map_err(|e| format!("send: {e}")) +} + +fn recv(conn: &mut UnixStream) -> Result { + let mut hdr = [0u8; 4]; + conn.read_exact(&mut hdr) + .map_err(|e| format!("recv header: {e}"))?; + let len = u32::from_ne_bytes(hdr) as usize; + if len == 0 || len > MAX_MSG { + return Err(format!("bad message length: {len}")); + } + let mut buf = vec![0u8; len]; + conn.read_exact(&mut buf) + .map_err(|e| format!("recv body: {e}"))?; + serde_json::from_slice(&buf).map_err(|e| format!("parse: {e}")) +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..107ec39 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,278 @@ +//! Login flow — prelogin, KDF, token exchange, user key decryption. +//! Standalone reimplementation (does not touch bw-agent code). + +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use zeroize::{Zeroize, Zeroizing}; + +use crate::crypto::{self, SymKey}; + +pub struct LoginResult { + pub access_token: String, + pub refresh_token: String, + pub user_key: Vec, +} + +/// Full login: prelogin → KDF → token → decrypt user key. +pub fn login(email: &str, password: &str, server: &str, totp_fn: &dyn Fn() -> Option) -> LoginResult { + let base = server.trim_end_matches('/'); + let (api, identity) = split_urls(base); + + // prelogin — get KDF params + let prelogin: serde_json::Value = ureq::post(&format!("{api}/accounts/prelogin")) + .set("Content-Type", "application/json") + .send_string(&serde_json::json!({"email": email}).to_string()) + .unwrap_or_else(|e| die(&format!("prelogin: {e}"))) + .into_json() + .unwrap(); + + let kdf_type = ju64(&prelogin, "kdf").unwrap_or(0); + let kdf_iter = ju64(&prelogin, "kdfIterations").unwrap_or(600000) as u32; + let kdf_mem = ju64(&prelogin, "kdfMemory").unwrap_or(64) as u32; + let kdf_par = ju64(&prelogin, "kdfParallelism").unwrap_or(4) as u32; + + eprintln!( + " kdf: {} iter={kdf_iter}", + if kdf_type == 0 { "pbkdf2" } else { "argon2id" } + ); + + // derive master key + let master_key = Zeroizing::new(derive_master_key( + password, email, kdf_type, kdf_iter, kdf_mem, kdf_par, + )); + + // password hash for auth + let mut pw_hash = { + let mut buf = [0u8; 32]; + pbkdf2::pbkdf2_hmac::(master_key.as_slice(), password.as_bytes(), 1, &mut buf); + let h = B64.encode(buf); + buf.zeroize(); + h + }; + + let device_id = uuid::Uuid::new_v4().to_string(); + let form = [ + ("grant_type", "password"), + ("username", email), + ("password", pw_hash.as_str()), + ("scope", "api offline_access"), + ("client_id", "connector"), + ("deviceType", "8"), + ("deviceIdentifier", device_id.as_str()), + ("deviceName", "bw-vault"), + ]; + + let token_resp = token_exchange(&format!("{identity}/connect/token"), &form, totp_fn); + pw_hash.zeroize(); + + let access_token = jstr(&token_resp, "access_token") + .unwrap_or_else(|| die("no access_token")) + .to_string(); + let refresh_token = jstr(&token_resp, "refresh_token") + .unwrap_or("") + .to_string(); + + // decrypt user key + let enc_key = extract_enc_user_key(&token_resp); + let stretched = stretch(&master_key); + let user_key = crypto::decrypt(&enc_key, &stretched) + .unwrap_or_else(|e| die(&format!("decrypt user key: {e}"))); + + if user_key.len() != 64 { + die(&format!("unexpected user key length: {}", user_key.len())); + } + + LoginResult { + access_token, + refresh_token, + user_key: user_key.to_vec(), + } +} + +/// Use a refresh token to get a new access token. +pub fn refresh(server: &str, refresh_token: &str) -> Result<(String, String), String> { + let base = server.trim_end_matches('/'); + let (_, identity) = split_urls(base); + + let form = [ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", "connector"), + ]; + + let resp = post_form(&format!("{identity}/connect/token"), &form) + .map_err(|e| format!("refresh failed: {}", &e[..e.len().min(200)]))?; + + let access = jstr(&resp, "access_token") + .ok_or("no access_token in refresh response")? + .to_string(); + let refresh = jstr(&resp, "refresh_token") + .unwrap_or("") + .to_string(); + + Ok((access, refresh)) +} + +// --- internals --- + +fn split_urls(base: &str) -> (String, String) { + if base.contains("bitwarden.com") || base.contains("bitwarden.eu") { + ( + base.replace("vault.", "api."), + base.replace("vault.", "identity."), + ) + } else { + (format!("{base}/api"), format!("{base}/identity")) + } +} + +fn token_exchange( + url: &str, + form: &[(&str, &str)], + totp_fn: &dyn Fn() -> Option, +) -> serde_json::Value { + match post_form(url, form) { + Ok(v) => v, + Err(e) => { + if !e.contains("TwoFactor") { + die(&format!("login: {}", &e[..e.len().min(200)])); + } + let body: serde_json::Value = + serde_json::from_str(&e).unwrap_or(serde_json::Value::Null); + let providers = body + .get("twoFactorProviders2") + .or_else(|| body.get("TwoFactorProviders2")) + .unwrap_or(&serde_json::Value::Null); + if providers.get("0").is_none() { + die("2FA required but TOTP (type 0) not available"); + } + + let code = totp_fn().unwrap_or_else(|| die("no TOTP code")); + let trimmed = code.trim().to_string(); + let mut form2: Vec<(&str, &str)> = form.to_vec(); + form2.push(("twoFactorToken", &trimmed)); + form2.push(("twoFactorProvider", "0")); + + post_form(url, &form2) + .unwrap_or_else(|e| die(&format!("login: {}", &e[..e.len().min(200)]))) + } + } +} + +fn post_form(url: &str, form: &[(&str, &str)]) -> Result { + let body: String = form + .iter() + .map(|(k, v)| format!("{}={}", k, urlenc(v))) + .collect::>() + .join("&"); + + match ureq::post(url) + .set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + .set("Accept", "application/json") + .set("Device-Type", "8") + .send_string(&body) + { + Ok(r) => r.into_json().map_err(|e| e.to_string()), + Err(ureq::Error::Status(_, resp)) => Err(resp.into_string().unwrap_or_default()), + Err(e) => Err(e.to_string()), + } +} + +fn urlenc(s: &str) -> String { + let mut out = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +fn extract_enc_user_key(resp: &serde_json::Value) -> String { + if let Some(udo) = jobj(resp, "userDecryptionOptions") { + if let Some(mpu) = jobj(udo, "masterPasswordUnlock") { + if let Some(k) = jstr(mpu, "masterKeyEncryptedUserKey") { + return k.to_string(); + } + } + } + if let Some(k) = jstr(resp, "key") { + return k.to_string(); + } + die("no encrypted user key in response"); +} + +fn derive_master_key(pw: &str, email: &str, kdf: u64, iters: u32, mem: u32, par: u32) -> Vec { + let salt = email.to_lowercase().trim().as_bytes().to_vec(); + match kdf { + 0 => { + let mut key = vec![0u8; 32]; + pbkdf2::pbkdf2_hmac::(pw.as_bytes(), &salt, iters, &mut key); + key + } + 1 => { + let params = argon2::Params::new(mem * 1024, iters, par, Some(32)) + .unwrap_or_else(|e| die(&format!("argon2 params: {e}"))); + let argon = + argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + let mut key = vec![0u8; 32]; + argon + .hash_password_into(pw.as_bytes(), &salt, &mut key) + .unwrap_or_else(|e| die(&format!("argon2: {e}"))); + key + } + _ => die(&format!("unsupported kdf: {kdf}")), + } +} + +fn stretch(master_key: &[u8]) -> SymKey { + let mut enc_hmac = Hmac::::new_from_slice(master_key).unwrap(); + enc_hmac.update(b"enc\x01"); + let enc = enc_hmac.finalize().into_bytes(); + + let mut mac_hmac = Hmac::::new_from_slice(master_key).unwrap(); + mac_hmac.update(b"mac\x01"); + let mac = mac_hmac.finalize().into_bytes(); + + let mut combined = Vec::with_capacity(64); + combined.extend_from_slice(&enc); + combined.extend_from_slice(&mac); + SymKey::new(combined) +} + +fn jstr<'a>(v: &'a serde_json::Value, key: &str) -> Option<&'a str> { + v.get(key) + .or_else(|| { + let mut c = key.chars(); + let p = c.next()?.to_uppercase().to_string() + c.as_str(); + v.get(&p) + }) + .and_then(|v| v.as_str()) +} + +fn ju64(v: &serde_json::Value, key: &str) -> Option { + v.get(key) + .or_else(|| { + let mut c = key.chars(); + let p = c.next()?.to_uppercase().to_string() + c.as_str(); + v.get(&p) + }) + .and_then(|v| v.as_u64()) +} + +fn jobj<'a>(v: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> { + v.get(key).or_else(|| { + let mut c = key.chars(); + let p = c.next()?.to_uppercase().to_string() + c.as_str(); + v.get(&p) + }) +} + +fn die(msg: &str) -> ! { + eprintln!("error: {msg}"); + std::process::exit(1); +} diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..90eb278 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,51 @@ +//! Simple file-based cache for tokens and encrypted vault data. +//! Stored in XDG_CACHE_HOME/bw-vault// + +use std::fs; +use std::path::PathBuf; + +use sha2::{Digest, Sha256}; + +pub struct Cache { + dir: PathBuf, +} + +impl Cache { + pub fn new(server: &str, email: &str) -> Self { + let mut h = Sha256::new(); + h.update(server.to_lowercase().as_bytes()); + h.update(b":"); + h.update(email.to_lowercase().trim().as_bytes()); + let hash = hex::encode(&h.finalize()[..8]); + + let base = std::env::var("XDG_CACHE_HOME").unwrap_or_else(|_| { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + format!("{home}/.cache") + }); + let dir = PathBuf::from(base).join("bw-vault").join(hash); + fs::create_dir_all(&dir).ok(); + Self { dir } + } + + pub fn get(&self, key: &str) -> Option { + fs::read_to_string(self.dir.join(key)).ok() + } + + pub fn set(&self, key: &str, value: &str) { + fs::write(self.dir.join(key), value).ok(); + } + + pub fn remove(&self, key: &str) { + fs::remove_file(self.dir.join(key)).ok(); + } + + /// Store the sync JSON (encrypted ciphers etc.) + pub fn set_sync(&self, data: &serde_json::Value) { + self.set("sync.json", &serde_json::to_string(data).unwrap_or_default()); + } + + pub fn get_sync(&self) -> Option { + let s = self.get("sync.json")?; + serde_json::from_str(&s).ok() + } +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..96faaae --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,129 @@ +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::Sha256; +use zeroize::Zeroizing; + +type Aes256CbcEnc = cbc::Encryptor; +type Aes256CbcDec = cbc::Decryptor; + +pub struct SymKey { + raw: Zeroizing>, +} + +impl SymKey { + pub fn new(data: Vec) -> Self { + assert_eq!(data.len(), 64); + Self { + raw: Zeroizing::new(data), + } + } + + pub fn generate() -> Self { + let mut buf = vec![0u8; 64]; + rand::thread_rng().fill_bytes(&mut buf); + Self::new(buf) + } + + pub fn raw(&self) -> &[u8] { + &self.raw + } + + fn enc_key(&self) -> &[u8] { + &self.raw[..32] + } + + fn mac_key(&self) -> &[u8] { + &self.raw[32..] + } +} + +/// Encrypt a plaintext string into "2.iv|ct|mac" format. +pub fn encrypt(plaintext: &str, key: &SymKey) -> String { + let mut iv = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut iv); + + let ct = Aes256CbcEnc::new(key.enc_key().into(), &iv.into()) + .encrypt_padded_vec_mut::(plaintext.as_bytes()); + + let mut hmac = Hmac::::new_from_slice(key.mac_key()).unwrap(); + hmac.update(&iv); + hmac.update(&ct); + let mac = hmac.finalize().into_bytes(); + + format!("2.{}|{}|{}", B64.encode(iv), B64.encode(&ct), B64.encode(mac)) +} + +/// Decrypt a "TYPE.iv|ct|mac" enc-string, return raw bytes. +pub fn decrypt(enc_str: &str, key: &SymKey) -> Result>, &'static str> { + let (_t, rest) = enc_str.split_once('.').ok_or("bad format")?; + let parts: Vec<&str> = rest.split('|').collect(); + if parts.len() < 2 { + return Err("bad format"); + } + + let iv = B64.decode(parts[0]).map_err(|_| "bad iv")?; + let ct = B64.decode(parts[1]).map_err(|_| "bad ct")?; + + if parts.len() > 2 { + let mac = B64.decode(parts[2]).map_err(|_| "bad mac")?; + let mut hmac = Hmac::::new_from_slice(key.mac_key()).unwrap(); + hmac.update(&iv); + hmac.update(&ct); + hmac.verify_slice(&mac).map_err(|_| "MAC mismatch")?; + } + + let pt = Aes256CbcDec::new(key.enc_key().into(), iv.as_slice().into()) + .decrypt_padded_vec_mut::(&ct) + .map_err(|_| "decrypt failed")?; + + Ok(Zeroizing::new(pt)) +} + +/// Decrypt an enc-string to a UTF-8 string, or empty on failure. +pub fn decrypt_str(enc: Option<&str>, key: &SymKey) -> String { + match enc { + Some(s) if !s.is_empty() => decrypt(s, key) + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .unwrap_or_default(), + _ => String::new(), + } +} + +/// Serialize enc-string to the JSON form Bitwarden IPC expects. +pub fn enc_to_json(enc_str: &str) -> serde_json::Value { + let (t, rest) = enc_str.split_once('.').unwrap_or(("2", enc_str)); + let parts: Vec<&str> = rest.split('|').collect(); + let mut m = serde_json::Map::new(); + m.insert( + "encryptionType".into(), + serde_json::json!(t.parse::().unwrap_or(2)), + ); + m.insert("encryptedString".into(), serde_json::json!(enc_str)); + if let Some(iv) = parts.first() { + m.insert("iv".into(), serde_json::json!(iv)); + } + if let Some(data) = parts.get(1) { + m.insert("data".into(), serde_json::json!(data)); + } + if let Some(mac) = parts.get(2) { + m.insert("mac".into(), serde_json::json!(mac)); + } + serde_json::Value::Object(m) +} + +/// Parse the JSON enc-string form back to "TYPE.iv|data|mac". +pub fn json_to_enc(v: &serde_json::Value) -> String { + if let Some(s) = v.get("encryptedString").and_then(|s| s.as_str()) { + return s.to_string(); + } + let t = v + .get("encryptionType") + .and_then(|t| t.as_u64()) + .unwrap_or(2); + let iv = v.get("iv").and_then(|s| s.as_str()).unwrap_or(""); + let data = v.get("data").and_then(|s| s.as_str()).unwrap_or(""); + let mac = v.get("mac").and_then(|s| s.as_str()).unwrap_or(""); + format!("{t}.{iv}|{data}|{mac}") +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9e5f67f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,416 @@ +mod agent; +mod auth; +mod cache; +mod crypto; +mod vault; + +use std::io::{self, Write}; +use std::path::PathBuf; +use std::process::Command; + +use clap::Parser; +use dialoguer::{theme::ColorfulTheme, FuzzySelect, Input}; +use zeroize::Zeroize; + +use crypto::SymKey; + +#[derive(Parser)] +#[command(about = "Interactive Bitwarden vault browser")] +struct Args { + /// bw-agent socket path + #[arg(long, default_value_t = default_socket())] + agent: String, + + /// Server URL + #[arg(long, default_value = "https://vault.bitwarden.com")] + server: String, + + /// Email (required on first login) + #[arg(long)] + email: Option, + + /// Master password (prompted if needed) + #[arg(long)] + password: Option, + + /// Force fresh login (ignore cached tokens) + #[arg(long)] + login: bool, + + /// Force re-sync vault from server + #[arg(long)] + sync: bool, + + /// Verbose output + #[arg(long, short)] + verbose: bool, +} + +fn default_socket() -> String { + let cache = std::env::var("XDG_CACHE_HOME").unwrap_or_else(|_| { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + format!("{home}/.cache") + }); + format!("{cache}/com.bitwarden.desktop/s.bw") +} + +fn main() { + let args = Args::parse(); + + let email = args.email.as_deref().unwrap_or_else(|| { + eprintln!("--email required"); + std::process::exit(1); + }); + + let cache = cache::Cache::new(&args.server, email); + + // --- get access token (cached or fresh) --- + let token = get_token(&args, &cache, email); + + // --- get user key (agent or login) --- + let user_key = get_user_key(&args, &cache); + + let key = SymKey::new(user_key); + + // --- get vault data (cached or fresh sync) --- + let items = get_vault(&args, &cache, &token, &key); + eprintln!("{} items\n", items.len()); + + interactive_loop(&items); +} + +fn get_token(args: &Args, cache: &cache::Cache, email: &str) -> String { + // try cached refresh token first + if !args.login { + if let Some(refresh) = cache.get("refresh_token") { + if args.verbose { + eprintln!(" refreshing token..."); + } + match auth::refresh(&args.server, &refresh) { + Ok((access, new_refresh)) => { + cache.set("access_token", &access); + if !new_refresh.is_empty() { + cache.set("refresh_token", &new_refresh); + } + if args.verbose { + eprintln!(" token refreshed"); + } + return access; + } + Err(e) => { + if args.verbose { + eprintln!(" refresh failed: {e}"); + } + // fall through to full login + } + } + } + } + + // full login + eprintln!("logging in as {email}..."); + let mut pw = args + .password + .clone() + .unwrap_or_else(|| prompt_password()); + let result = auth::login(email, &pw, &args.server, &|| prompt_totp()); + pw.zeroize(); + + cache.set("access_token", &result.access_token); + if !result.refresh_token.is_empty() { + cache.set("refresh_token", &result.refresh_token); + } + // also cache the user key from login (for --no-agent fallback, but encrypted... for now just skip) + eprintln!("logged in"); + result.access_token +} + +fn get_user_key(args: &Args, _cache: &cache::Cache) -> Vec { + let sock = PathBuf::from(&args.agent); + if sock.exists() { + eprintln!("connecting to agent..."); + match agent::get_user_key(&sock, args.verbose) { + Ok(key) => { + eprintln!("unlocked via agent"); + return key; + } + Err(e) => { + eprintln!("agent error: {e}"); + eprintln!("falling back to password login..."); + } + } + } else if args.verbose { + eprintln!(" agent socket not found, skipping"); + } + + // fallback: need to login to get the key + // (this means we login twice if token refresh worked — not ideal, + // but correct. the key only comes from agent or password.) + let email = args.email.as_deref().unwrap_or_else(|| { + eprintln!("--email required (no agent available)"); + std::process::exit(1); + }); + let mut pw = args + .password + .clone() + .unwrap_or_else(|| prompt_password()); + eprintln!("deriving key from password..."); + let result = auth::login(email, &pw, &args.server, &|| prompt_totp()); + pw.zeroize(); + result.user_key +} + +fn get_vault( + args: &Args, + cache: &cache::Cache, + token: &str, + key: &SymKey, +) -> Vec { + // try cache first + if !args.sync { + if let Some(sync_data) = cache.get_sync() { + if args.verbose { + eprintln!(" using cached vault"); + } + return vault::decrypt_sync(&sync_data, key); + } + } + + eprintln!("syncing vault..."); + let sync_data = vault::fetch_sync(token, &args.server); + cache.set_sync(&sync_data); + vault::decrypt_sync(&sync_data, key) +} + +// --- interactive UI --- + +fn interactive_loop(items: &[vault::Item]) { + let summaries: Vec = items.iter().map(|i| i.summary()).collect(); + + loop { + let selection = FuzzySelect::with_theme(&ColorfulTheme::default()) + .with_prompt("search (ESC to quit)") + .items(&summaries) + .default(0) + .interact_opt(); + + match selection { + Ok(Some(idx)) => show_item(&items[idx]), + Ok(None) | Err(_) => break, + } + } +} + +fn show_item(item: &vault::Item) { + let mut revealed = false; + let mut show_notes = false; + + loop { + // clear screen and redraw + print!("\x1b[2J\x1b[H"); + io::stdout().flush().ok(); + + println!("\x1b[1;36m[{}] {}\x1b[0m", item.kind, item.name); + + for uri in &item.uris { + println!(" \x1b[2murl:\x1b[0m {uri}"); + } + if !item.username.is_empty() { + println!(" \x1b[2muser:\x1b[0m {}", item.username); + } + if !item.password.is_empty() { + if revealed { + println!(" \x1b[2mpass:\x1b[0m {}", item.password); + } else { + println!(" \x1b[2mpass:\x1b[0m ********"); + } + } + if !item.totp.is_empty() { + if revealed { + println!(" \x1b[2mtotp:\x1b[0m {}", item.totp); + } else { + println!(" \x1b[2mtotp:\x1b[0m (present)"); + } + } + if !item.notes.is_empty() { + if show_notes { + println!(" \x1b[2mnote:\x1b[0m"); + for line in item.notes.lines() { + println!(" {line}"); + } + } else { + let first = item.notes.lines().next().unwrap_or(""); + let count = item.notes.lines().count(); + if count > 1 { + println!( + " \x1b[2mnote:\x1b[0m {first} \x1b[2m(+{} lines)\x1b[0m", + count - 1 + ); + } else { + println!(" \x1b[2mnote:\x1b[0m {first}"); + } + } + } + for (k, v) in &item.card { + if (k == "number" || k == "code") && !revealed { + println!(" \x1b[2m{k}:\x1b[0m ********"); + } else { + println!(" \x1b[2m{k}:\x1b[0m {v}"); + } + } + for (k, v) in &item.identity { + println!(" \x1b[2m{k}:\x1b[0m {v}"); + } + for (name, value, ftype) in &item.fields { + if *ftype == 1 && !revealed { + println!(" \x1b[2m{name} (hidden):\x1b[0m ********"); + } else { + println!(" \x1b[2m{name}:\x1b[0m {value}"); + } + } + for pk in &item.passkeys { + println!( + " \x1b[2mpasskey:\x1b[0m {} ({})", + pk.rp_id, pk.user_name + ); + if revealed && !pk.key_value.is_empty() { + println!(" \x1b[2malgo:\x1b[0m {} {}", pk.key_algorithm, pk.key_curve); + println!(" \x1b[2mkey:\x1b[0m {}", pk.key_value); + } + } + + // status line + if revealed { + println!("\n \x1b[33m(secrets revealed)\x1b[0m"); + } + + // actions + println!(); + let mut hints = vec!["[c]opy pass", "[u]ser"]; + if !revealed { + hints.push("[r]eveal"); + } else { + hints.push("[h]ide"); + if !item.passkeys.is_empty() { + hints.push("[k] copy passkey"); + } + } + if !item.notes.is_empty() { + hints.push(if show_notes { + "[n] hide notes" + } else { + "[n]otes" + }); + } + hints.push("[enter] back"); + + print!( + " \x1b[2m{}\x1b[0m ", + hints.join(" ") + ); + io::stdout().flush().ok(); + + match read_key() { + 'c' if !item.password.is_empty() => { + copy_to_clipboard(&item.password); + // brief flash — will redraw + } + 'u' if !item.username.is_empty() => { + copy_to_clipboard(&item.username); + } + 'r' if !revealed => { + revealed = true; + } + 'h' if revealed => { + revealed = false; + } + 'n' if !item.notes.is_empty() => { + show_notes = !show_notes; + } + 'k' if revealed && !item.passkeys.is_empty() => { + // copy first passkey's private key + copy_to_clipboard(&item.passkeys[0].key_value); + } + _ => break, + } + } +} + +fn read_key() -> char { + crossterm::terminal::enable_raw_mode().ok(); + let ch = loop { + if let Ok(crossterm::event::Event::Key(key)) = crossterm::event::read() { + break match key.code { + crossterm::event::KeyCode::Char(c) => c, + _ => '\n', + }; + } + }; + crossterm::terminal::disable_raw_mode().ok(); + println!(); + ch +} + +fn copy_to_clipboard(text: &str) { + // wayland + if Command::new("wl-copy") + .arg(text) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + return; + } + // X11 + if let Ok(mut child) = Command::new("xclip") + .args(["-selection", "clipboard"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(text.as_bytes()).ok(); + } + child.wait().ok(); + return; + } + eprintln!(" \x1b[33mno clipboard tool (wl-copy, xclip)\x1b[0m"); +} + +fn prompt_password() -> String { + eprint!("master password: "); + io::stderr().flush().ok(); + crossterm::terminal::enable_raw_mode().ok(); + let mut pw = String::new(); + loop { + if let Ok(crossterm::event::Event::Key(key)) = crossterm::event::read() { + match key.code { + crossterm::event::KeyCode::Enter => break, + crossterm::event::KeyCode::Char(c) => pw.push(c), + crossterm::event::KeyCode::Backspace => { + pw.pop(); + } + _ => {} + } + } + } + crossterm::terminal::disable_raw_mode().ok(); + eprintln!(); + pw +} + +fn prompt_totp() -> Option { + eprint!("TOTP code: "); + io::stderr().flush().ok(); + let code: String = Input::new().interact_text().ok()?; + let trimmed = code.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} diff --git a/src/vault.rs b/src/vault.rs new file mode 100644 index 0000000..37c4da9 --- /dev/null +++ b/src/vault.rs @@ -0,0 +1,221 @@ +//! Fetch and decrypt vault ciphers from the Bitwarden/Vaultwarden API. + +use serde_json::Value; + +use crate::crypto::{self, SymKey}; + +/// A decrypted vault item. +pub struct Item { + pub name: String, + pub kind: &'static str, + pub username: String, + pub password: String, + pub totp: String, + pub uris: Vec, + pub notes: String, + pub fields: Vec<(String, String, u64)>, // (name, value, type) + pub passkeys: Vec, + pub card: Vec<(String, String)>, + pub identity: Vec<(String, String)>, +} + +pub struct Passkey { + pub rp_id: String, + pub rp_name: String, + pub user_name: String, + pub credential_id: String, + pub key_type: String, + pub key_algorithm: String, + pub key_curve: String, + pub key_value: String, // PKCS#8 private key, base64url + pub discoverable: String, +} + +impl Item { + /// One-line summary for list display. + pub fn summary(&self) -> String { + let extra = if !self.username.is_empty() { + format!(" — {}", self.username) + } else { + String::new() + }; + format!("[{}] {}{}", self.kind, self.name, extra) + } +} + +pub fn api_url(server: &str) -> String { + let base = server.trim_end_matches('/'); + if base.contains("bitwarden.com") || base.contains("bitwarden.eu") { + base.replace("vault.", "api.") + } else { + format!("{base}/api") + } +} + +/// Fetch raw sync JSON from API. +pub fn fetch_sync(token: &str, server: &str) -> Value { + let api = api_url(server); + ureq::get(&format!("{api}/sync")) + .set("Authorization", &format!("Bearer {token}")) + .call() + .unwrap_or_else(|e| { + eprintln!("sync failed: {e}"); + std::process::exit(1); + }) + .into_json() + .unwrap() +} + +/// Decrypt ciphers from a sync response. +pub fn decrypt_sync(sync_data: &Value, user_key: &SymKey) -> Vec { + let ciphers = jv(sync_data, "ciphers") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + ciphers.iter().map(|c| decrypt_cipher(c, user_key)).collect() +} + +fn decrypt_cipher(c: &Value, user_key: &SymKey) -> Item { + let cipher_key = jv_str(c, "key") + .and_then(|ck| crypto::decrypt(ck, user_key).ok()) + .and_then(|raw| (raw.len() == 64).then(|| SymKey::new(raw.to_vec()))); + let key = cipher_key.as_ref().unwrap_or(user_key); + + let t = c + .get("type") + .or_else(|| c.get("Type")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let kind = match t { + 1 => "login", + 2 => "note", + 3 => "card", + 4 => "identity", + _ => "other", + }; + + let mut item = Item { + name: dec(jv_str(c, "name"), key), + kind, + username: String::new(), + password: String::new(), + totp: String::new(), + uris: Vec::new(), + notes: dec(jv_str(c, "notes"), key), + fields: Vec::new(), + passkeys: Vec::new(), + card: Vec::new(), + identity: Vec::new(), + }; + + match t { + 1 => decrypt_login(c, key, &mut item), + 3 => decrypt_card(c, key, &mut item), + 4 => decrypt_identity(c, key, &mut item), + _ => {} + } + decrypt_fields(c, key, &mut item); + item +} + +fn decrypt_login(c: &Value, key: &SymKey, item: &mut Item) { + let Some(login) = jv(c, "login") else { return }; + item.username = dec(jv_str(login, "username"), key); + item.password = dec(jv_str(login, "password"), key); + item.totp = dec(jv_str(login, "totp"), key); + + if let Some(uris) = jv(login, "uris").and_then(|v| v.as_array()) { + item.uris = uris + .iter() + .map(|u| dec(jv_str(u, "uri"), key)) + .filter(|s| !s.is_empty()) + .collect(); + } + + if let Some(creds) = jv(login, "fido2Credentials").and_then(|v| v.as_array()) { + item.passkeys = creds + .iter() + .map(|cr| Passkey { + rp_id: dec(jv_str(cr, "rpId"), key), + rp_name: dec(jv_str(cr, "rpName"), key), + user_name: dec(jv_str(cr, "userName"), key), + credential_id: dec(jv_str(cr, "credentialId"), key), + key_type: dec(jv_str(cr, "keyType"), key), + key_algorithm: dec(jv_str(cr, "keyAlgorithm"), key), + key_curve: dec(jv_str(cr, "keyCurve"), key), + key_value: dec(jv_str(cr, "keyValue"), key), + discoverable: dec(jv_str(cr, "discoverable"), key), + }) + .collect(); + } +} + +fn decrypt_card(c: &Value, key: &SymKey, item: &mut Item) { + let Some(card) = jv(c, "card") else { return }; + for f in ["cardholderName", "number", "code", "brand", "expMonth", "expYear"] { + let v = dec(jv_str(card, f), key); + if !v.is_empty() { + item.card.push((f.to_string(), v)); + } + } +} + +fn decrypt_identity(c: &Value, key: &SymKey, item: &mut Item) { + let Some(id) = jv(c, "identity") else { return }; + for f in [ + "firstName", + "lastName", + "email", + "phone", + "company", + "address1", + "city", + "state", + "postalCode", + "country", + ] { + let v = dec(jv_str(id, f), key); + if !v.is_empty() { + item.identity.push((f.to_string(), v)); + } + } +} + +fn decrypt_fields(c: &Value, key: &SymKey, item: &mut Item) { + let Some(fields) = jv(c, "fields").and_then(|v| v.as_array()) else { + return; + }; + for f in fields { + let name = dec(jv_str(f, "name"), key); + let value = dec(jv_str(f, "value"), key); + let ftype = f + .get("type") + .or_else(|| f.get("Type")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + item.fields.push((name, value, ftype)); + } +} + +fn dec(enc: Option<&str>, key: &SymKey) -> String { + crypto::decrypt_str(enc, key) +} + +fn jv_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> { + v.get(key) + .or_else(|| v.get(&pascal(key))) + .and_then(|v| v.as_str()) +} + +fn jv<'a>(v: &'a Value, key: &str) -> Option<&'a Value> { + v.get(key).or_else(|| v.get(&pascal(key))) +} + +fn pascal(key: &str) -> String { + let mut c = key.chars(); + match c.next() { + Some(first) => first.to_uppercase().to_string() + c.as_str(), + None => String::new(), + } +}