From b6db3d8f1c76bf72ec9ce3c635e81208f0751d41 Mon Sep 17 00:00:00 2001 From: "Morgan J." Date: Sun, 22 Mar 2026 05:20:20 +0900 Subject: [PATCH] add Secure Enclave backend with Swift helper --- Makefile | 13 ++- src/bridge.rs | 6 +- src/main.rs | 19 ++-- src/sep/sep-helper.entitlements | 10 ++ src/sep/sep-helper.swift | 171 ++++++++++++++++++++++++++++++++ src/storage/mod.rs | 12 ++- src/storage/sep.rs | 84 ++++++++++++++++ 7 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 src/sep/sep-helper.entitlements create mode 100644 src/sep/sep-helper.swift create mode 100644 src/storage/sep.rs diff --git a/Makefile b/Makefile index 7f247af..2672ef8 100644 --- a/Makefile +++ b/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 diff --git a/src/bridge.rs b/src/bridge.rs index 09b34bf..b83381a 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -171,7 +171,11 @@ impl BiometricBridge { } fn unseal_key(&self) -> Option { - 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(); diff --git a/src/main.rs b/src/main.rs index 4909197..3abe142 100644 --- a/src/main.rs +++ b/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) diff --git a/src/sep/sep-helper.entitlements b/src/sep/sep-helper.entitlements new file mode 100644 index 0000000..71cef54 --- /dev/null +++ b/src/sep/sep-helper.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.bitwarden.agent + + + diff --git a/src/sep/sep-helper.swift b/src/sep/sep-helper.swift new file mode 100644 index 0000000..b57457d --- /dev/null +++ b/src/sep/sep-helper.swift @@ -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? + 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? + 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