Fixed TPM2 and Linux compatibility

This commit is contained in:
2026-03-23 11:06:12 +09:00
parent f2c25da1aa
commit 2b540edab3
16 changed files with 339 additions and 232 deletions

49
Cargo.lock generated
View File

@@ -351,6 +351,7 @@ dependencies = [
"serde_json", "serde_json",
"sha1", "sha1",
"sha2", "sha2",
"tempfile",
"ureq", "ureq",
"uuid", "uuid",
"zeroize", "zeroize",
@@ -397,6 +398,22 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -687,6 +704,12 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.1" version = "0.8.1"
@@ -993,6 +1016,19 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.37" version = "0.23.37"
@@ -1210,6 +1246,19 @@ dependencies = [
"syn", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.2" version = "0.8.2"

View File

@@ -33,6 +33,9 @@ uuid = { version = "1", features = ["v4"] }
zeroize = { version = "1", features = ["derive"] } zeroize = { version = "1", features = ["derive"] }
aes-gcm = "0.10" aes-gcm = "0.10"
[target.'cfg(target_os = "linux")'.dependencies]
tempfile = "3"
[profile.release] [profile.release]
strip = true strip = true
lto = true lto = true

View File

@@ -24,14 +24,14 @@ launchd-unload:
systemd: systemd:
mkdir -p $(HOME)/.config/systemd/user mkdir -p $(HOME)/.config/systemd/user
sed 's|%h/.local/bin|$(PREFIX)|' docs/bw-agent.service \ sed 's|%h/.local/bin|$(PREFIX)|' docs/com.bitwarden.agent.service \
> $(HOME)/.config/systemd/user/bw-agent.service > $(HOME)/.config/systemd/user/com.bitwarden.agent.service
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable --now bw-agent systemctl --user enable --now com.bitwarden.agent
systemd-unload: systemd-unload:
systemctl --user disable --now bw-agent 2>/dev/null || true systemctl --user disable --now com.bitwarden.agent 2>/dev/null || true
rm -f $(HOME)/.config/systemd/user/bw-agent.service rm -f $(HOME)/.config/systemd/user/com.bitwarden.agent.service
systemctl --user daemon-reload systemctl --user daemon-reload
clean: clean:

View File

@@ -80,12 +80,12 @@ fn ssh_askpass() -> Option<Prompter> {
} }
fn which(name: &str) -> Option<String> { fn which(name: &str) -> Option<String> {
Command::new("which") std::env::var_os("PATH")?
.arg(name) .to_str()?
.output() .split(':')
.ok() .map(|dir| std::path::Path::new(dir).join(name))
.filter(|o| o.status.success()) .find(|p| p.is_file())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .map(|p| p.to_string_lossy().into_owned())
} }
pub fn get_prompter(name: Option<&str>) -> Prompter { pub fn get_prompter(name: Option<&str>) -> Prompter {

View File

@@ -1,7 +1,7 @@
use base64::{engine::general_purpose::STANDARD as B64, Engine}; use base64::{engine::general_purpose::STANDARD as B64, Engine};
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use sha2::Sha256; use sha2::Sha256;
use zeroize::Zeroizing; use zeroize::{Zeroize, Zeroizing};
use crate::askpass::Prompter; use crate::askpass::Prompter;
use crate::crypto::{enc_string_decrypt_bytes, SymmetricKey}; 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, password, email, kdf_type, kdf_iter, kdf_mem, kdf_par,
)); ));
let pw_hash = { let mut pw_hash = {
let mut buf = [0u8; 32]; let mut buf = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(master_key.as_slice(), password.as_bytes(), 1, &mut buf); pbkdf2::pbkdf2_hmac::<Sha256>(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(); let device_id = uuid::Uuid::new_v4().to_string();
@@ -64,6 +66,7 @@ pub fn login(
crate::log::info(&format!("token {identity}/connect/token")); crate::log::info(&format!("token {identity}/connect/token"));
let token_resp = try_login(&format!("{identity}/connect/token"), &form, prompt); let token_resp = try_login(&format!("{identity}/connect/token"), &form, prompt);
pw_hash.zeroize();
let enc_user_key = extract_encrypted_user_key(&token_resp); let enc_user_key = extract_encrypted_user_key(&token_resp);
crate::log::info("decrypting user key..."); crate::log::info("decrypting user key...");

View File

@@ -8,7 +8,8 @@ use zeroize::Zeroize;
use crate::askpass::Prompter; use crate::askpass::Prompter;
use crate::crypto::{ 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; use crate::storage::KeyStore;
@@ -95,7 +96,7 @@ impl BiometricBridge {
let key = self.sessions.get(app_id).unwrap(); let key = self.sessions.get(app_id).unwrap();
let enc_str = json_to_enc_string(enc_msg); 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, Ok(p) => p,
Err(_) => { Err(_) => {
crate::log::error("message decryption failed"); 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 cmd = data.get("command")?.as_str()?.to_string();
let mid = data.get("messageId").and_then(|m| m.as_i64()).unwrap_or(0); 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 resp = self.dispatch(&cmd, mid)?;
let key = self.sessions.get(app_id).unwrap(); 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); let encrypted = enc_string_encrypt(&resp_json, key);
resp_json.zeroize();
Some(json!({ Some(json!({
"appId": app_id, "appId": app_id,
@@ -171,20 +173,21 @@ impl BiometricBridge {
} }
fn unseal_key(&self) -> Option<String> { fn unseal_key(&self) -> Option<String> {
let pw = (self.prompt)(&format!("Enter {} password:", self.store.name()))?; let mut pw = (self.prompt)(&format!("Enter {} password:", self.store.name()))?;
match self.store.load(&self.uid, &pw) { let result = match self.store.load(&self.uid, &pw) {
Ok(mut raw) => { Ok(mut raw) => {
let len = raw.len(); let len = raw.len();
let b64 = B64.encode(&raw); let b64 = B64.encode(&raw);
raw.zeroize(); raw.zeroize();
crate::log::info(&format!("unsealed {len}B from {}", self.store.name())); crate::log::info(&format!("unsealed {len}B from {}", self.store.name()));
crate::log::info("wiped key from memory");
Some(b64) Some(b64)
} }
Err(e) => { Err(e) => {
crate::log::error(&format!("unseal failed: {e}")); crate::log::error(&format!("unseal failed: {e}"));
None None
} }
} };
pw.zeroize();
result
} }
} }

View File

@@ -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<String, &'static str> {
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<Zeroizing<Vec<u8>>, &'static str> { pub fn enc_string_decrypt_bytes(enc_str: &str, key: &SymmetricKey) -> Result<Zeroizing<Vec<u8>>, &'static str> {
let (_t, rest) = enc_str.split_once('.').ok_or("bad format")?; let (_t, rest) = enc_str.split_once('.').ok_or("bad format")?;
let parts: Vec<&str> = rest.split('|').collect(); let parts: Vec<&str> = rest.split('|').collect();

View File

@@ -11,6 +11,12 @@ pub fn socket_path() -> PathBuf {
} }
fn dirs_cache() -> 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") dirs_home().join(".cache")
} }

View File

@@ -26,7 +26,8 @@ struct Args {
#[arg(long, default_value = "https://vault.bitwarden.com")] #[arg(long, default_value = "https://vault.bitwarden.com")]
server: String, 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<String>, backend: Option<String>,
#[arg(long)] #[arg(long)]
@@ -68,7 +69,7 @@ fn main() {
"re-enrolling" "re-enrolling"
}); });
let pw = args let mut pw = args
.password .password
.clone() .clone()
.or_else(|| prompt("master password:")) .or_else(|| prompt("master password:"))
@@ -76,22 +77,26 @@ fn main() {
log::info(&format!("logging in as {email}")); log::info(&format!("logging in as {email}"));
let (mut key_bytes, server_uid) = auth::login(email, &pw, &args.server, &prompt); let (mut key_bytes, server_uid) = auth::login(email, &pw, &args.server, &prompt);
pw.zeroize();
log::info(&format!("authenticated, uid={server_uid}")); 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")); .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")); .unwrap_or_else(|| log::fatal("no password provided"));
if auth != auth2 { if auth != auth2 {
auth.zeroize();
auth2.zeroize();
log::fatal("passwords don't match"); log::fatal("passwords don't match");
} }
auth2.zeroize();
store store
.store(&uid, &key_bytes, &auth) .store(&uid, &key_bytes, &auth)
.unwrap_or_else(|e| log::fatal(&format!("store failed: {e}"))); .unwrap_or_else(|e| log::fatal(&format!("store failed: {e}")));
auth.zeroize();
key_bytes.zeroize(); key_bytes.zeroize();
log::info(&format!("key sealed via {}", store.name())); log::info(&format!("key sealed via {}", store.name()));
log::info("wiped key from memory");
return; return;
} }
@@ -101,26 +106,31 @@ fn main() {
None => log::fatal("no enrolled key found"), 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")); .unwrap_or_else(|| log::fatal("no password provided"));
let mut data = store let mut data = store
.load(&uid, &old_pw) .load(&uid, &old_pw)
.unwrap_or_else(|e| log::fatal(&format!("unseal failed: {e}"))); .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")); .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")); .unwrap_or_else(|| log::fatal("no password provided"));
if new_pw != new_pw2 { if new_pw != new_pw2 {
new_pw.zeroize();
new_pw2.zeroize();
data.zeroize();
log::fatal("passwords don't match"); log::fatal("passwords don't match");
} }
new_pw2.zeroize();
store store
.store(&uid, &data, &new_pw) .store(&uid, &data, &new_pw)
.unwrap_or_else(|e| log::fatal(&format!("seal failed: {e}"))); .unwrap_or_else(|e| log::fatal(&format!("seal failed: {e}")));
new_pw.zeroize();
data.zeroize(); data.zeroize();
log::info("pin changed"); log::info("password changed");
log::info("wiped key from memory");
return; return;
} }

View File

@@ -6,6 +6,8 @@ use std::thread;
const MAX_MSG: usize = 1024 * 1024; const MAX_MSG: usize = 1024 * 1024;
fn socket_path() -> String { 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()); let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
PathBuf::from(home) PathBuf::from(home)
.join(".cache") .join(".cache")
@@ -65,7 +67,11 @@ fn send_ipc(sock: &mut UnixStream, data: &[u8]) {
} }
fn main() { 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\"}"); send_ipc(&mut sock, b"{\"command\":\"connected\"}");

View File

@@ -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<CFError>?
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<CFError>?
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<CFError>?
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 <store|load|remove|has> <label> [password]\n".data(using: .utf8)!)
exit(2)
}
let args = CommandLine.arguments
if args.count < 3 { usage() }
let cmd = args[1]
let label = args[2]
let password: String? = args.count > 3 ? args[3] : nil
switch cmd {
case "store":
guard let pw = password else { fatal("password required for store") }
let data = readStdin()
removeSEPKey(label)
let privKey = createSEPKey(label, password: pw)
guard let pubKey = SecKeyCopyPublicKey(privKey) else { fatal("no public key") }
let ct = encrypt(pubKey, data)
storeBlob(label, ct)
case "load":
guard let pw = password else { fatal("password required for load") }
guard let privKey = getSEPKey(label, password: pw) else { fatal("no SEP key for \(label)") }
guard let ct = loadBlob(label) else { fatal("no data for \(label)") }
let pt = decrypt(privKey, ct)
print(pt.base64EncodedString())
case "remove":
removeBlob(label)
removeSEPKey(label)
case "has":
exit(hasBlob(label) ? 0 : 1)
default:
usage()
}

View File

@@ -1,4 +1,6 @@
pub mod pin; pub mod pin;
#[cfg(target_os = "linux")]
pub mod tpm2;
pub trait KeyStore { pub trait KeyStore {
fn name(&self) -> &str; fn name(&self) -> &str;
@@ -12,7 +14,28 @@ pub trait KeyStore {
pub fn get_backend(preferred: Option<&str>) -> Box<dyn KeyStore> { pub fn get_backend(preferred: Option<&str>) -> Box<dyn KeyStore> {
match preferred { match preferred {
Some("pin") | None => Box::new(pin::PinKeyStore::new(None)), Some("pin") => Box::new(pin::PinKeyStore::new(None)),
#[cfg(target_os = "linux")]
Some("tpm2") => Box::new(tpm2::Tpm2KeyStore::new(None)),
None => {
let pin = pin::PinKeyStore::new(None);
#[cfg(target_os = "linux")]
{
let tpm = tpm2::Tpm2KeyStore::new(None);
// if keys already exist, use whichever backend owns them
if tpm.find_key().is_some() {
return Box::new(tpm);
}
if pin.find_key().is_some() {
return Box::new(pin);
}
// no existing keys — prefer TPM2 if available
if tpm.is_available() {
return Box::new(tpm);
}
}
Box::new(pin)
}
Some(other) => crate::log::fatal(&format!("unknown backend: {other}")), Some(other) => crate::log::fatal(&format!("unknown backend: {other}")),
} }
} }

View File

@@ -16,10 +16,19 @@ const SCRYPT_LOG_N: u8 = 17;
const SCRYPT_R: u32 = 8; const SCRYPT_R: u32 = 8;
const SCRYPT_P: u32 = 1; const SCRYPT_P: u32 = 1;
fn store_dir() -> PathBuf { pub(super) fn cache_dir() -> PathBuf {
#[cfg(target_os = "linux")]
if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg);
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
PathBuf::from(home) PathBuf::from(home).join(".cache")
.join(".cache") }
fn store_dir() -> PathBuf {
cache_dir()
.join("com.bitwarden.desktop") .join("com.bitwarden.desktop")
.join("keys") .join("keys")
} }

190
src/storage/tpm2.rs Normal file
View File

@@ -0,0 +1,190 @@
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use zeroize::Zeroize;
use super::KeyStore;
fn store_dir() -> PathBuf {
super::pin::cache_dir()
.join("com.bitwarden.desktop")
.join("keys")
}
pub struct Tpm2KeyStore {
dir: PathBuf,
}
impl Tpm2KeyStore {
pub fn new(dir: Option<PathBuf>) -> Self {
let dir = dir.unwrap_or_else(store_dir);
fs::create_dir_all(&dir).ok();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).ok();
}
Self { dir }
}
fn pub_path(&self, uid: &str) -> PathBuf {
self.dir.join(format!("{uid}.pub"))
}
fn priv_path(&self, uid: &str) -> PathBuf {
self.dir.join(format!("{uid}.priv"))
}
}
impl KeyStore for Tpm2KeyStore {
fn name(&self) -> &str {
"tpm2"
}
fn is_available(&self) -> bool {
Command::new("tpm2_getcap")
.arg("properties-fixed")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn has_key(&self, uid: &str) -> bool {
self.pub_path(uid).exists() && self.priv_path(uid).exists()
}
fn store(&self, uid: &str, data: &[u8], auth: &str) -> Result<(), String> {
let tmp = tempfile::tempdir().map_err(|e| e.to_string())?;
let ctx = tmp.path().join("primary.ctx");
let dat = tmp.path().join("data.bin");
fs::write(&dat, data).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dat, fs::Permissions::from_mode(0o600)).ok();
}
run(&[
"tpm2_createprimary", "-C", "o", "-g", "sha256", "-G", "aes256cfb",
"-c", &ctx.to_string_lossy(),
], None)?;
let pub_path = self.pub_path(uid);
let priv_path = self.priv_path(uid);
let result = run(&[
"tpm2_create", "-C", &ctx.to_string_lossy(),
"-i", &dat.to_string_lossy(),
"-u", &pub_path.to_string_lossy(),
"-r", &priv_path.to_string_lossy(),
"-p", "file:-",
], Some(auth.as_bytes()));
// overwrite plaintext before temp dir cleanup
let mut zeros = vec![0u8; data.len()];
fs::File::create(&dat).ok().map(|mut f| f.write_all(&zeros));
zeros.zeroize();
result?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&pub_path, fs::Permissions::from_mode(0o600)).ok();
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).ok();
}
Ok(())
}
fn load(&self, uid: &str, auth: &str) -> Result<Vec<u8>, String> {
let tmp = tempfile::tempdir().map_err(|e| e.to_string())?;
let ctx = tmp.path().join("primary.ctx");
let loaded = tmp.path().join("loaded.ctx");
run(&[
"tpm2_createprimary", "-C", "o", "-g", "sha256", "-G", "aes256cfb",
"-c", &ctx.to_string_lossy(),
], None)?;
run(&[
"tpm2_load", "-C", &ctx.to_string_lossy(),
"-u", &self.pub_path(uid).to_string_lossy(),
"-r", &self.priv_path(uid).to_string_lossy(),
"-c", &loaded.to_string_lossy(),
], None)?;
let mut child = Command::new("tpm2_unseal")
.args(["-c", &loaded.to_string_lossy(), "-p", "file:-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(auth.as_bytes());
}
let mut out = child.wait_with_output().map_err(|e| e.to_string())?;
if !out.status.success() {
out.stdout.zeroize();
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
out.stderr.zeroize();
Ok(out.stdout)
}
fn remove(&self, uid: &str) {
for p in [self.pub_path(uid), self.priv_path(uid)] {
if p.exists() {
fs::remove_file(p).ok();
}
}
}
fn find_key(&self) -> Option<String> {
let entries = fs::read_dir(&self.dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("pub") {
let uid = path.file_stem()?.to_str()?;
if self.priv_path(uid).exists() {
return Some(uid.to_string());
}
}
}
None
}
}
fn run(args: &[&str], stdin_data: Option<&[u8]>) -> Result<(), String> {
let mut cmd = Command::new(args[0]);
cmd.args(&args[1..]).stderr(Stdio::piped());
if stdin_data.is_some() {
cmd.stdin(Stdio::piped());
}
let mut child = cmd.spawn().map_err(|e| format!("{}: {e}", args[0]))?;
if let Some(data) = stdin_data {
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(data);
}
}
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
Ok(())
}