mirror of
https://github.com/morgan9e/bitwarden-desktop-agent
synced 2026-04-14 16:24:08 +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
|
||||
IDENTITY ?= -
|
||||
|
||||
all:
|
||||
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-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:
|
||||
rm -f $(PREFIX)/bw-agent $(PREFIX)/bw-proxy
|
||||
rm -f $(PREFIX)/bw-agent $(PREFIX)/bw-proxy $(PREFIX)/sep-helper
|
||||
|
||||
launchd:
|
||||
mkdir -p $(HOME)/Library/LaunchAgents
|
||||
@@ -36,5 +44,6 @@ systemd-unload:
|
||||
|
||||
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> {
|
||||
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) {
|
||||
Ok(mut raw) => {
|
||||
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);
|
||||
log::info(&format!("authenticated, uid={server_uid}"));
|
||||
|
||||
let auth = prompt(&format!("choose {} password:", store.name()))
|
||||
.unwrap_or_else(|| log::fatal("no password provided"));
|
||||
let auth2 = prompt(&format!("confirm {} password:", store.name()))
|
||||
.unwrap_or_else(|| log::fatal("no password provided"));
|
||||
if auth != auth2 {
|
||||
log::fatal("passwords don't match");
|
||||
}
|
||||
let auth = if store.name() == "sep" {
|
||||
String::new()
|
||||
} else {
|
||||
let a = prompt(&format!("choose {} password:", store.name()))
|
||||
.unwrap_or_else(|| log::fatal("no password provided"));
|
||||
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(&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 sep;
|
||||
|
||||
pub trait KeyStore {
|
||||
fn name(&self) -> &str;
|
||||
#[allow(dead_code)]
|
||||
fn is_available(&self) -> bool;
|
||||
fn has_key(&self, uid: &str) -> bool;
|
||||
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> {
|
||||
match preferred {
|
||||
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