Inital commit

This commit is contained in:
2026-03-30 18:12:10 +09:00
commit ada564241f
8 changed files with 1301 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
target/
Cargo.lock

33
Cargo.toml Normal file
View File

@@ -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

171
src/agent.rs Normal file
View File

@@ -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<Vec<u8>, 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::<sha1::Sha1>(), &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<Value, String> {
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}"))
}

278
src/auth.rs Normal file
View File

@@ -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<u8>,
}
/// Full login: prelogin → KDF → token → decrypt user key.
pub fn login(email: &str, password: &str, server: &str, totp_fn: &dyn Fn() -> Option<String>) -> 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::<Sha256>(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<String>,
) -> 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<serde_json::Value, String> {
let body: String = form
.iter()
.map(|(k, v)| format!("{}={}", k, urlenc(v)))
.collect::<Vec<_>>()
.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<u8> {
let salt = email.to_lowercase().trim().as_bytes().to_vec();
match kdf {
0 => {
let mut key = vec![0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(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::<Sha256>::new_from_slice(master_key).unwrap();
enc_hmac.update(b"enc\x01");
let enc = enc_hmac.finalize().into_bytes();
let mut mac_hmac = Hmac::<Sha256>::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<u64> {
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);
}

51
src/cache.rs Normal file
View File

@@ -0,0 +1,51 @@
//! Simple file-based cache for tokens and encrypted vault data.
//! Stored in XDG_CACHE_HOME/bw-vault/<hash>/
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<String> {
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<serde_json::Value> {
let s = self.get("sync.json")?;
serde_json::from_str(&s).ok()
}
}

129
src/crypto.rs Normal file
View File

@@ -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<aes::Aes256>;
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
pub struct SymKey {
raw: Zeroizing<Vec<u8>>,
}
impl SymKey {
pub fn new(data: Vec<u8>) -> 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::<Pkcs7>(plaintext.as_bytes());
let mut hmac = Hmac::<Sha256>::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<Zeroizing<Vec<u8>>, &'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::<Sha256>::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::<Pkcs7>(&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::<u32>().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}")
}

416
src/main.rs Normal file
View File

@@ -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<String>,
/// Master password (prompted if needed)
#[arg(long)]
password: Option<String>,
/// 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<u8> {
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<vault::Item> {
// 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<String> = 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<String> {
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)
}
}

221
src/vault.rs Normal file
View File

@@ -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<String>,
pub notes: String,
pub fields: Vec<(String, String, u64)>, // (name, value, type)
pub passkeys: Vec<Passkey>,
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<Item> {
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(),
}
}