mirror of
https://github.com/morgan9e/bitwarden-desktop-agent
synced 2026-04-14 00:04:06 +09:00
Fixed TPM2 and Linux compatibility
This commit is contained in:
49
Cargo.lock
generated
49
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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...");
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
src/main.rs
30
src/main.rs
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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\"}");
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
190
src/storage/tpm2.rs
Normal 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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user