From 2b540edab3f68c34f4e87b616aadcb78e956570c Mon Sep 17 00:00:00 2001 From: Morgan Date: Mon, 23 Mar 2026 11:06:12 +0900 Subject: [PATCH] Fixed TPM2 and Linux compatibility --- Cargo.lock | 49 +++++ Cargo.toml | 3 + Makefile | 10 +- ...rden.json.json => com.8bit.bitwarden.json} | 0 ...nt.service => com.bitwarden.agent.service} | 0 src/askpass.rs | 12 +- src/auth.rs | 9 +- src/bridge.rs | 19 +- src/crypto.rs | 5 - src/ipc.rs | 6 + src/main.rs | 30 ++- src/proxy.rs | 8 +- src/sep-helper.swift | 190 ------------------ src/storage/mod.rs | 25 ++- src/storage/pin.rs | 15 +- src/storage/tpm2.rs | 190 ++++++++++++++++++ 16 files changed, 339 insertions(+), 232 deletions(-) rename docs/{com.8bit.bitwarden.json.json => com.8bit.bitwarden.json} (100%) rename docs/{bw-agent.service => com.bitwarden.agent.service} (100%) delete mode 100644 src/sep-helper.swift create mode 100644 src/storage/tpm2.rs diff --git a/Cargo.lock b/Cargo.lock index 0288730..ddd446c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,6 +351,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "tempfile", "ureq", "uuid", "zeroize", @@ -397,6 +398,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -687,6 +704,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -993,6 +1016,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1210,6 +1246,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tinystr" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index a243488..8be23ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,9 @@ uuid = { version = "1", features = ["v4"] } zeroize = { version = "1", features = ["derive"] } aes-gcm = "0.10" +[target.'cfg(target_os = "linux")'.dependencies] +tempfile = "3" + [profile.release] strip = true lto = true diff --git a/Makefile b/Makefile index 7f247af..3b73611 100644 --- a/Makefile +++ b/Makefile @@ -24,14 +24,14 @@ launchd-unload: systemd: mkdir -p $(HOME)/.config/systemd/user - sed 's|%h/.local/bin|$(PREFIX)|' docs/bw-agent.service \ - > $(HOME)/.config/systemd/user/bw-agent.service + sed 's|%h/.local/bin|$(PREFIX)|' docs/com.bitwarden.agent.service \ + > $(HOME)/.config/systemd/user/com.bitwarden.agent.service systemctl --user daemon-reload - systemctl --user enable --now bw-agent + systemctl --user enable --now com.bitwarden.agent systemd-unload: - systemctl --user disable --now bw-agent 2>/dev/null || true - rm -f $(HOME)/.config/systemd/user/bw-agent.service + systemctl --user disable --now com.bitwarden.agent 2>/dev/null || true + rm -f $(HOME)/.config/systemd/user/com.bitwarden.agent.service systemctl --user daemon-reload clean: diff --git a/docs/com.8bit.bitwarden.json.json b/docs/com.8bit.bitwarden.json similarity index 100% rename from docs/com.8bit.bitwarden.json.json rename to docs/com.8bit.bitwarden.json diff --git a/docs/bw-agent.service b/docs/com.bitwarden.agent.service similarity index 100% rename from docs/bw-agent.service rename to docs/com.bitwarden.agent.service diff --git a/src/askpass.rs b/src/askpass.rs index 2ec33e6..061dbf1 100644 --- a/src/askpass.rs +++ b/src/askpass.rs @@ -80,12 +80,12 @@ fn ssh_askpass() -> Option { } fn which(name: &str) -> Option { - Command::new("which") - .arg(name) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + std::env::var_os("PATH")? + .to_str()? + .split(':') + .map(|dir| std::path::Path::new(dir).join(name)) + .find(|p| p.is_file()) + .map(|p| p.to_string_lossy().into_owned()) } pub fn get_prompter(name: Option<&str>) -> Prompter { diff --git a/src/auth.rs b/src/auth.rs index 6a90f9c..0f3a482 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,7 +1,7 @@ use base64::{engine::general_purpose::STANDARD as B64, Engine}; use hmac::{Hmac, Mac}; use sha2::Sha256; -use zeroize::Zeroizing; +use zeroize::{Zeroize, Zeroizing}; use crate::askpass::Prompter; use crate::crypto::{enc_string_decrypt_bytes, SymmetricKey}; @@ -44,10 +44,12 @@ pub fn login( password, email, kdf_type, kdf_iter, kdf_mem, kdf_par, )); - let pw_hash = { + let mut pw_hash = { let mut buf = [0u8; 32]; pbkdf2::pbkdf2_hmac::(master_key.as_slice(), password.as_bytes(), 1, &mut buf); - B64.encode(buf) + let h = B64.encode(buf); + buf.zeroize(); + h }; let device_id = uuid::Uuid::new_v4().to_string(); @@ -64,6 +66,7 @@ pub fn login( crate::log::info(&format!("token {identity}/connect/token")); let token_resp = try_login(&format!("{identity}/connect/token"), &form, prompt); + pw_hash.zeroize(); let enc_user_key = extract_encrypted_user_key(&token_resp); crate::log::info("decrypting user key..."); diff --git a/src/bridge.rs b/src/bridge.rs index 09b34bf..68d66ca 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -8,7 +8,8 @@ use zeroize::Zeroize; use crate::askpass::Prompter; use crate::crypto::{ - enc_string_decrypt, enc_string_encrypt, enc_string_to_json, json_to_enc_string, SymmetricKey, + enc_string_decrypt_bytes, enc_string_encrypt, enc_string_to_json, json_to_enc_string, + SymmetricKey, }; use crate::storage::KeyStore; @@ -95,7 +96,7 @@ impl BiometricBridge { let key = self.sessions.get(app_id).unwrap(); let enc_str = json_to_enc_string(enc_msg); - let plaintext = match enc_string_decrypt(&enc_str, key) { + let plaintext = match enc_string_decrypt_bytes(&enc_str, key) { Ok(p) => p, Err(_) => { crate::log::error("message decryption failed"); @@ -103,7 +104,7 @@ impl BiometricBridge { } }; - let data: Value = serde_json::from_str(&plaintext).ok()?; + let data: Value = serde_json::from_slice(&plaintext).ok()?; let cmd = data.get("command")?.as_str()?.to_string(); let mid = data.get("messageId").and_then(|m| m.as_i64()).unwrap_or(0); @@ -111,8 +112,9 @@ impl BiometricBridge { let resp = self.dispatch(&cmd, mid)?; let key = self.sessions.get(app_id).unwrap(); - let resp_json = serde_json::to_string(&resp).unwrap(); + let mut resp_json = serde_json::to_string(&resp).unwrap(); let encrypted = enc_string_encrypt(&resp_json, key); + resp_json.zeroize(); Some(json!({ "appId": app_id, @@ -171,20 +173,21 @@ impl BiometricBridge { } fn unseal_key(&self) -> Option { - let pw = (self.prompt)(&format!("Enter {} password:", self.store.name()))?; - match self.store.load(&self.uid, &pw) { + let mut pw = (self.prompt)(&format!("Enter {} password:", self.store.name()))?; + let result = match self.store.load(&self.uid, &pw) { Ok(mut raw) => { let len = raw.len(); let b64 = B64.encode(&raw); raw.zeroize(); crate::log::info(&format!("unsealed {len}B from {}", self.store.name())); - crate::log::info("wiped key from memory"); Some(b64) } Err(e) => { crate::log::error(&format!("unseal failed: {e}")); None } - } + }; + pw.zeroize(); + result } } diff --git a/src/crypto.rs b/src/crypto.rs index 80ced31..c423d6d 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -60,11 +60,6 @@ pub fn enc_string_encrypt(plaintext: &str, key: &SymmetricKey) -> String { ) } -pub fn enc_string_decrypt(enc_str: &str, key: &SymmetricKey) -> Result { - let raw = enc_string_decrypt_bytes(enc_str, key)?; - String::from_utf8(raw.to_vec()).map_err(|_| "invalid utf8") -} - pub fn enc_string_decrypt_bytes(enc_str: &str, key: &SymmetricKey) -> Result>, &'static str> { let (_t, rest) = enc_str.split_once('.').ok_or("bad format")?; let parts: Vec<&str> = rest.split('|').collect(); diff --git a/src/ipc.rs b/src/ipc.rs index 82542e9..2c1260c 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -11,6 +11,12 @@ pub fn socket_path() -> PathBuf { } fn dirs_cache() -> PathBuf { + #[cfg(target_os = "linux")] + if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { + if !xdg.is_empty() { + return PathBuf::from(xdg); + } + } dirs_home().join(".cache") } diff --git a/src/main.rs b/src/main.rs index 4909197..feff539 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,8 @@ struct Args { #[arg(long, default_value = "https://vault.bitwarden.com")] server: String, - #[arg(long)] + #[cfg_attr(target_os = "linux", arg(long, help = "Key storage backend [pin, tpm2]"))] + #[cfg_attr(not(target_os = "linux"), arg(long, help = "Key storage backend [pin]"))] backend: Option, #[arg(long)] @@ -68,7 +69,7 @@ fn main() { "re-enrolling" }); - let pw = args + let mut pw = args .password .clone() .or_else(|| prompt("master password:")) @@ -76,22 +77,26 @@ fn main() { log::info(&format!("logging in as {email}")); let (mut key_bytes, server_uid) = auth::login(email, &pw, &args.server, &prompt); + pw.zeroize(); log::info(&format!("authenticated, uid={server_uid}")); - let auth = prompt(&format!("choose {} password:", store.name())) + let mut auth = prompt(&format!("choose {} password:", store.name())) .unwrap_or_else(|| log::fatal("no password provided")); - let auth2 = prompt(&format!("confirm {} password:", store.name())) + let mut auth2 = prompt(&format!("confirm {} password:", store.name())) .unwrap_or_else(|| log::fatal("no password provided")); if auth != auth2 { + auth.zeroize(); + auth2.zeroize(); log::fatal("passwords don't match"); } + auth2.zeroize(); store .store(&uid, &key_bytes, &auth) .unwrap_or_else(|e| log::fatal(&format!("store failed: {e}"))); + auth.zeroize(); key_bytes.zeroize(); log::info(&format!("key sealed via {}", store.name())); - log::info("wiped key from memory"); return; } @@ -101,26 +106,31 @@ fn main() { None => log::fatal("no enrolled key found"), }; - let old_pw = prompt(&format!("current {} password:", store.name())) + let mut old_pw = prompt(&format!("current {} password:", store.name())) .unwrap_or_else(|| log::fatal("no password provided")); let mut data = store .load(&uid, &old_pw) .unwrap_or_else(|e| log::fatal(&format!("unseal failed: {e}"))); + old_pw.zeroize(); - let new_pw = prompt(&format!("new {} password:", store.name())) + let mut new_pw = prompt(&format!("new {} password:", store.name())) .unwrap_or_else(|| log::fatal("no password provided")); - let new_pw2 = prompt(&format!("confirm {} password:", store.name())) + let mut new_pw2 = prompt(&format!("confirm {} password:", store.name())) .unwrap_or_else(|| log::fatal("no password provided")); if new_pw != new_pw2 { + new_pw.zeroize(); + new_pw2.zeroize(); + data.zeroize(); log::fatal("passwords don't match"); } + new_pw2.zeroize(); store .store(&uid, &data, &new_pw) .unwrap_or_else(|e| log::fatal(&format!("seal failed: {e}"))); + new_pw.zeroize(); data.zeroize(); - log::info("pin changed"); - log::info("wiped key from memory"); + log::info("password changed"); return; } diff --git a/src/proxy.rs b/src/proxy.rs index 9c4de1b..559f315 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -6,6 +6,8 @@ use std::thread; const MAX_MSG: usize = 1024 * 1024; fn socket_path() -> String { + // always use $HOME/.cache — never XDG_CACHE_HOME, which Flatpak + // overrides to a sandboxed path the agent can't see let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); PathBuf::from(home) .join(".cache") @@ -65,7 +67,11 @@ fn send_ipc(sock: &mut UnixStream, data: &[u8]) { } fn main() { - let mut sock = UnixStream::connect(socket_path()).unwrap_or_else(|_| std::process::exit(1)); + let sock_addr = socket_path(); + let mut sock = UnixStream::connect(&sock_addr).unwrap_or_else(|e| { + eprintln!("bw-proxy: connect {sock_addr}: {e}"); + std::process::exit(1); + }); send_ipc(&mut sock, b"{\"command\":\"connected\"}"); diff --git a/src/sep-helper.swift b/src/sep-helper.swift deleted file mode 100644 index b4ba4fd..0000000 --- a/src/sep-helper.swift +++ /dev/null @@ -1,190 +0,0 @@ -import Foundation -import Security -import LocalAuthentication - -let service = "com.bitwarden.agent" -let algo = SecKeyAlgorithm.eciesEncryptionCofactorVariableIVX963SHA256AESGCM - -func sepTag(_ label: String) -> Data { - "\(service).sep.\(label)".data(using: .utf8)! -} - -func getSEPKey(_ label: String, password: String?) -> SecKey? { - var q: [String: Any] = [ - kSecClass as String: kSecClassKey, - kSecAttrApplicationTag as String: sepTag(label), - kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, - kSecReturnRef as String: true, - ] - - if let pw = password { - let ctx = LAContext() - ctx.setCredential(pw.data(using: .utf8), type: .applicationPassword) - q[kSecUseAuthenticationContext as String] = ctx - } - - var ref: CFTypeRef? - let s = SecItemCopyMatching(q as CFDictionary, &ref) - if s == errSecSuccess { return (ref as! SecKey) } - if s != errSecItemNotFound { fatal("keychain query: \(s)") } - return nil -} - -func createSEPKey(_ label: String, password: String) -> SecKey { - var err: Unmanaged? - - let ctx = LAContext() - ctx.setCredential(password.data(using: .utf8), type: .applicationPassword) - - guard let access = SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - [.privateKeyUsage, .applicationPassword], - &err - ) else { - fatal("access control: \(err!.takeRetainedValue())") - } - - let attrs: [String: Any] = [ - kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, - kSecAttrKeySizeInBits as String: 256, - kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, - kSecPrivateKeyAttrs as String: [ - kSecAttrIsPermanent as String: true, - kSecAttrApplicationTag as String: sepTag(label), - kSecAttrAccessControl as String: access, - ] as [String: Any], - kSecUseAuthenticationContext as String: ctx, - ] - - guard let key = SecKeyCreateRandomKey(attrs as CFDictionary, &err) else { - fatal("create SEP key: \(err!.takeRetainedValue())") - } - return key -} - -func removeSEPKey(_ label: String) { - let q: [String: Any] = [ - kSecClass as String: kSecClassKey, - kSecAttrApplicationTag as String: sepTag(label), - kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, - ] - SecItemDelete(q as CFDictionary) -} - -func storeBlob(_ label: String, _ data: Data) { - let q: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: label, - ] - SecItemDelete(q as CFDictionary) - - var add = q - add[kSecValueData as String] = data - add[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly - - let s = SecItemAdd(add as CFDictionary, nil) - if s != errSecSuccess { fatal("store blob: \(s)") } -} - -func loadBlob(_ label: String) -> Data? { - let q: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: label, - kSecReturnData as String: true, - ] - var ref: CFTypeRef? - return SecItemCopyMatching(q as CFDictionary, &ref) == errSecSuccess ? (ref as! Data) : nil -} - -func removeBlob(_ label: String) { - let q: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: label, - ] - SecItemDelete(q as CFDictionary) -} - -func hasBlob(_ label: String) -> Bool { - let q: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: label, - ] - return SecItemCopyMatching(q as CFDictionary, nil) == errSecSuccess -} - -func encrypt(_ pubKey: SecKey, _ data: Data) -> Data { - var err: Unmanaged? - guard let ct = SecKeyCreateEncryptedData(pubKey, algo, data as CFData, &err) else { - fatal("encrypt: \(err!.takeRetainedValue())") - } - return ct as Data -} - -func decrypt(_ privKey: SecKey, _ data: Data) -> Data { - var err: Unmanaged? - guard let pt = SecKeyCreateDecryptedData(privKey, algo, data as CFData, &err) else { - fatal("decrypt: \(err!.takeRetainedValue())") - } - return pt as Data -} - -func readStdin() -> Data { - var buf = Data() - while let line = readLine(strippingNewline: false) { - buf.append(line.data(using: .utf8)!) - } - let trimmed = String(data: buf, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines) - guard let decoded = Data(base64Encoded: trimmed) else { - fatal("invalid base64 on stdin") - } - return decoded -} - -func fatal(_ msg: String) -> Never { - FileHandle.standardError.write("sep-helper: \(msg)\n".data(using: .utf8)!) - exit(1) -} - -func usage() -> Never { - FileHandle.standardError.write("usage: sep-helper