mirror of
https://github.com/morgan9e/bitwarden-cli
synced 2026-04-13 15:54:05 +09:00
Inital commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
Cargo.lock
|
||||
33
Cargo.toml
Normal file
33
Cargo.toml
Normal 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
171
src/agent.rs
Normal 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
278
src/auth.rs
Normal 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
51
src/cache.rs
Normal 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
129
src/crypto.rs
Normal 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
416
src/main.rs
Normal 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
221
src/vault.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user