mirror of
https://github.com/morgan9e/bitwarden-desktop-agent
synced 2026-04-15 00:34:11 +09:00
add Secure Enclave backend with Swift helper
This commit is contained in:
13
Makefile
13
Makefile
@@ -1,4 +1,5 @@
|
|||||||
PREFIX ?= $(HOME)/.local/bin
|
PREFIX ?= $(HOME)/.local/bin
|
||||||
|
IDENTITY ?= -
|
||||||
|
|
||||||
all:
|
all:
|
||||||
cargo build --release
|
cargo build --release
|
||||||
@@ -8,8 +9,15 @@ install: all
|
|||||||
install -m 755 target/release/bw-agent $(PREFIX)/bw-agent
|
install -m 755 target/release/bw-agent $(PREFIX)/bw-agent
|
||||||
install -m 755 target/release/bw-proxy $(PREFIX)/bw-proxy
|
install -m 755 target/release/bw-proxy $(PREFIX)/bw-proxy
|
||||||
|
|
||||||
|
sep:
|
||||||
|
swiftc -O -o target/release/sep-helper src/sep/sep-helper.swift
|
||||||
|
codesign --force --sign "$(IDENTITY)" --entitlements src/sep/sep-helper.entitlements target/release/sep-helper
|
||||||
|
|
||||||
|
install-sep: sep
|
||||||
|
install -m 755 target/release/sep-helper $(PREFIX)/sep-helper
|
||||||
|
|
||||||
uninstall:
|
uninstall:
|
||||||
rm -f $(PREFIX)/bw-agent $(PREFIX)/bw-proxy
|
rm -f $(PREFIX)/bw-agent $(PREFIX)/bw-proxy $(PREFIX)/sep-helper
|
||||||
|
|
||||||
launchd:
|
launchd:
|
||||||
mkdir -p $(HOME)/Library/LaunchAgents
|
mkdir -p $(HOME)/Library/LaunchAgents
|
||||||
@@ -36,5 +44,6 @@ systemd-unload:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
|
rm -f target/release/sep-helper
|
||||||
|
|
||||||
.PHONY: all install uninstall launchd launchd-unload systemd systemd-unload clean
|
.PHONY: all install sep install-sep uninstall launchd launchd-unload systemd systemd-unload clean
|
||||||
|
|||||||
@@ -171,7 +171,11 @@ 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 pw = if self.store.name() == "sep" {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
(self.prompt)(&format!("Enter {} password:", self.store.name()))?
|
||||||
|
};
|
||||||
match self.store.load(&self.uid, &pw) {
|
match self.store.load(&self.uid, &pw) {
|
||||||
Ok(mut raw) => {
|
Ok(mut raw) => {
|
||||||
let len = raw.len();
|
let len = raw.len();
|
||||||
|
|||||||
19
src/main.rs
19
src/main.rs
@@ -78,13 +78,18 @@ fn main() {
|
|||||||
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);
|
||||||
log::info(&format!("authenticated, uid={server_uid}"));
|
log::info(&format!("authenticated, uid={server_uid}"));
|
||||||
|
|
||||||
let auth = prompt(&format!("choose {} password:", store.name()))
|
let auth = if store.name() == "sep" {
|
||||||
.unwrap_or_else(|| log::fatal("no password provided"));
|
String::new()
|
||||||
let auth2 = prompt(&format!("confirm {} password:", store.name()))
|
} else {
|
||||||
.unwrap_or_else(|| log::fatal("no password provided"));
|
let a = prompt(&format!("choose {} password:", store.name()))
|
||||||
if auth != auth2 {
|
.unwrap_or_else(|| log::fatal("no password provided"));
|
||||||
log::fatal("passwords don't match");
|
let a2 = prompt(&format!("confirm {} password:", store.name()))
|
||||||
}
|
.unwrap_or_else(|| log::fatal("no password provided"));
|
||||||
|
if a != a2 {
|
||||||
|
log::fatal("passwords don't match");
|
||||||
|
}
|
||||||
|
a
|
||||||
|
};
|
||||||
|
|
||||||
store
|
store
|
||||||
.store(&uid, &key_bytes, &auth)
|
.store(&uid, &key_bytes, &auth)
|
||||||
|
|||||||
10
src/sep/sep-helper.entitlements
Normal file
10
src/sep/sep-helper.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)com.bitwarden.agent</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
171
src/sep/sep-helper.swift
Normal file
171
src/sep/sep-helper.swift
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
let service = "com.bitwarden.agent"
|
||||||
|
let algo = SecKeyAlgorithm.eciesEncryptionCofactorVariableIVX963SHA256AESGCM
|
||||||
|
|
||||||
|
func sepTag(_ label: String) -> Data {
|
||||||
|
"\(service).sep.\(label)".data(using: .utf8)!
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSEPKey(_ label: String) -> SecKey? {
|
||||||
|
let q: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassKey,
|
||||||
|
kSecAttrApplicationTag as String: sepTag(label),
|
||||||
|
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||||
|
kSecReturnRef as String: true,
|
||||||
|
]
|
||||||
|
var ref: CFTypeRef?
|
||||||
|
return SecItemCopyMatching(q as CFDictionary, &ref) == errSecSuccess ? (ref as! SecKey) : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSEPKey(_ label: String) -> SecKey {
|
||||||
|
var err: Unmanaged<CFError>?
|
||||||
|
guard let access = SecAccessControlCreateWithFlags(
|
||||||
|
nil,
|
||||||
|
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
|
[.privateKeyUsage, .biometryCurrentSet],
|
||||||
|
&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],
|
||||||
|
]
|
||||||
|
|
||||||
|
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>\n".data(using: .utf8)!)
|
||||||
|
exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
if args.count < 3 { usage() }
|
||||||
|
let cmd = args[1]
|
||||||
|
let label = args[2]
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "store":
|
||||||
|
let data = readStdin()
|
||||||
|
let privKey = getSEPKey(label) ?? createSEPKey(label)
|
||||||
|
guard let pubKey = SecKeyCopyPublicKey(privKey) else { fatal("no public key") }
|
||||||
|
let ct = encrypt(pubKey, data)
|
||||||
|
storeBlob(label, ct)
|
||||||
|
|
||||||
|
case "load":
|
||||||
|
guard let privKey = getSEPKey(label) else { fatal("no SEP key for \(label)") }
|
||||||
|
guard let ct = loadBlob(label) else { fatal("no data for \(label)") }
|
||||||
|
let pt = decrypt(privKey, ct)
|
||||||
|
let b64 = pt.base64EncodedString()
|
||||||
|
print(b64)
|
||||||
|
|
||||||
|
case "remove":
|
||||||
|
removeBlob(label)
|
||||||
|
removeSEPKey(label)
|
||||||
|
|
||||||
|
case "has":
|
||||||
|
exit(hasBlob(label) ? 0 : 1)
|
||||||
|
|
||||||
|
default:
|
||||||
|
usage()
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
pub mod pin;
|
pub mod pin;
|
||||||
|
pub mod sep;
|
||||||
|
|
||||||
pub trait KeyStore {
|
pub trait KeyStore {
|
||||||
fn name(&self) -> &str;
|
fn name(&self) -> &str;
|
||||||
#[allow(dead_code)]
|
|
||||||
fn is_available(&self) -> bool;
|
fn is_available(&self) -> bool;
|
||||||
fn has_key(&self, uid: &str) -> bool;
|
fn has_key(&self, uid: &str) -> bool;
|
||||||
fn store(&self, uid: &str, data: &[u8], auth: &str) -> Result<(), String>;
|
fn store(&self, uid: &str, data: &[u8], auth: &str) -> Result<(), String>;
|
||||||
@@ -14,6 +14,14 @@ 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") => Box::new(pin::PinKeyStore::new(None)),
|
Some("pin") => Box::new(pin::PinKeyStore::new(None)),
|
||||||
_ => Box::new(pin::PinKeyStore::new(None)),
|
Some("sep") => Box::new(sep::SEPKeyStore::new()),
|
||||||
|
None => {
|
||||||
|
let s = sep::SEPKeyStore::new();
|
||||||
|
if s.is_available() {
|
||||||
|
return Box::new(s);
|
||||||
|
}
|
||||||
|
Box::new(pin::PinKeyStore::new(None))
|
||||||
|
}
|
||||||
|
Some(other) => crate::log::fatal(&format!("unknown backend: {other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/storage/sep.rs
Normal file
84
src/storage/sep.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||||
|
|
||||||
|
use super::KeyStore;
|
||||||
|
|
||||||
|
fn helper_path() -> PathBuf {
|
||||||
|
let exe = std::env::current_exe().unwrap_or_default();
|
||||||
|
let dir = exe.parent().unwrap_or(std::path::Path::new("."));
|
||||||
|
dir.join("sep-helper")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SEPKeyStore;
|
||||||
|
|
||||||
|
impl SEPKeyStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyStore for SEPKeyStore {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"sep"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
helper_path().exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_key(&self, uid: &str) -> bool {
|
||||||
|
Command::new(helper_path())
|
||||||
|
.args(["has", uid])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(&self, uid: &str, data: &[u8], _auth: &str) -> Result<(), String> {
|
||||||
|
let b64 = B64.encode(data);
|
||||||
|
let out = Command::new(helper_path())
|
||||||
|
.args(["store", uid])
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.and_then(|mut child| {
|
||||||
|
use std::io::Write;
|
||||||
|
child.stdin.take().unwrap().write_all(b64.as_bytes())?;
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(&self, uid: &str, _auth: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let out = Command::new(helper_path())
|
||||||
|
.args(["load", uid])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let b64 = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
B64.decode(&b64).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove(&self, uid: &str) {
|
||||||
|
Command::new(helper_path())
|
||||||
|
.args(["remove", uid])
|
||||||
|
.output()
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_key(&self) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user