add Secure Enclave backend with Swift helper

This commit is contained in:
2026-03-22 05:20:20 +09:00
parent 91ea660687
commit b6db3d8f1c
7 changed files with 303 additions and 12 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -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)

View 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
View 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()
}

View File

@@ -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
View 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
}
}