diff --git a/.github/workflows/release-swift.yml b/.github/workflows/release-swift.yml
index 9582e9f2..f44a3897 100644
--- a/.github/workflows/release-swift.yml
+++ b/.github/workflows/release-swift.yml
@@ -257,41 +257,92 @@ jobs:
# Stage the bundle: bin/darkbloom, bin/darkbloom-enclave, bin/mlx.metallib
# ----------------------------------------------------------------------
+ - name: Embed provisioning profile
+ env:
+ PROVISIONING_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
+ run: |
+ set -euo pipefail
+ if [ -z "${PROVISIONING_PROFILE_BASE64:-}" ]; then
+ echo "::warning::PROVISIONING_PROFILE_BASE64 not set — persistent SE key will fall back to ephemeral"
+ echo "profile_available=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "$PROVISIONING_PROFILE_BASE64" | base64 --decode > /tmp/embedded.provisionprofile
+ security cms -D -i /tmp/embedded.provisionprofile | head -20
+ echo "profile_available=true" >> "$GITHUB_OUTPUT"
+ fi
+ id: profile
+
- name: Stage and sign bundle
id: bundle
run: |
set -euo pipefail
STAGE=/tmp/darkbloom-bundle
rm -rf "$STAGE"
- mkdir -p "$STAGE/bin"
-
- cp "$BIN_DIR/$CLI_NAME" "$STAGE/bin/"
- cp "$BIN_DIR/$ENCLAVE_NAME" "$STAGE/bin/"
- # The MLX C++ runtime checks for a colocated mlx.metallib first;
- # placing it in bin/ next to darkbloom satisfies that lookup.
- cp "${{ steps.metallib.outputs.metallib }}" "$STAGE/bin/mlx.metallib"
+ # Wrap the CLI in a minimal .app bundle so the provisioning profile
+ # authorizes keychain-access-groups for the persistent SE key.
+ APP="$STAGE/Darkbloom.app"
+ mkdir -p "$APP/Contents/MacOS"
+
+ cp "$BIN_DIR/$CLI_NAME" "$APP/Contents/MacOS/"
+ cp "$BIN_DIR/$ENCLAVE_NAME" "$APP/Contents/MacOS/"
+ cp "${{ steps.metallib.outputs.metallib }}" "$APP/Contents/MacOS/mlx.metallib"
+
+ cat > "$APP/Contents/Info.plist" << PLIST
+
+
+
+
+ CFBundleIdentifier
+ io.darkbloom.provider
+ CFBundleExecutable
+ ${CLI_NAME}
+ CFBundleName
+ Darkbloom
+ CFBundleVersion
+ ${VERSION}
+ CFBundleShortVersionString
+ ${VERSION}
+ CFBundlePackageType
+ APPL
+ LSMinimumSystemVersion
+ 14.0
+
+
+ PLIST
+
+ if [ "${{ steps.profile.outputs.profile_available }}" = "true" ]; then
+ cp /tmp/embedded.provisionprofile "$APP/Contents/embedded.provisionprofile"
+ echo "Provisioning profile embedded in app bundle"
+ fi
- # Hardened-runtime sign each Mach-O binary.
+ # Hardened-runtime sign each binary, then the bundle.
+ codesign --force --options runtime --timestamp \
+ --entitlements provider-swift/entitlements.plist \
+ --keychain "$KEYCHAIN_PATH" \
+ --sign "$DEVELOPER_ID" "$APP/Contents/MacOS/$ENCLAVE_NAME"
codesign --force --options runtime --timestamp \
- --entitlements scripts/entitlements.plist \
+ --entitlements provider-swift/entitlements.plist \
--keychain "$KEYCHAIN_PATH" \
- --sign "$DEVELOPER_ID" "$STAGE/bin/$CLI_NAME"
+ --sign "$DEVELOPER_ID" "$APP/Contents/MacOS/$CLI_NAME"
codesign --force --options runtime --timestamp \
- --entitlements scripts/entitlements.plist \
+ --entitlements provider-swift/entitlements.plist \
--keychain "$KEYCHAIN_PATH" \
- --sign "$DEVELOPER_ID" "$STAGE/bin/$ENCLAVE_NAME"
+ --sign "$DEVELOPER_ID" "$APP"
+
+ codesign --verify --verbose=2 "$APP"
+ codesign --verify --verbose=2 "$APP/Contents/MacOS/$CLI_NAME"
- codesign --verify --verbose=2 "$STAGE/bin/$CLI_NAME"
- codesign --verify --verbose=2 "$STAGE/bin/$ENCLAVE_NAME"
+ # Backward-compatible flat layout alongside the .app bundle.
+ mkdir -p "$STAGE/bin"
+ ln -s "../Darkbloom.app/Contents/MacOS/$CLI_NAME" "$STAGE/bin/$CLI_NAME"
+ ln -s "../Darkbloom.app/Contents/MacOS/$ENCLAVE_NAME" "$STAGE/bin/$ENCLAVE_NAME"
+ ln -s "../Darkbloom.app/Contents/MacOS/mlx.metallib" "$STAGE/bin/mlx.metallib"
# Two artifact shapes: zip for notarization, tar.gz for distribution.
ditto -c -k --keepParent "$STAGE" /tmp/darkbloom-notarize.zip
tar czf /tmp/darkbloom-bundle-macos-arm64.tar.gz -C "$STAGE" .
tar tzf /tmp/darkbloom-bundle-macos-arm64.tar.gz | sort | tee /tmp/darkbloom-bundle-files.txt
- grep -qx './bin/darkbloom' /tmp/darkbloom-bundle-files.txt
- grep -qx './bin/darkbloom-enclave' /tmp/darkbloom-bundle-files.txt
- grep -qx './bin/mlx.metallib' /tmp/darkbloom-bundle-files.txt
echo "stage=$STAGE" >> "$GITHUB_OUTPUT"
ls -lh /tmp/darkbloom-bundle-macos-arm64.tar.gz
diff --git a/coordinator/api/install.sh b/coordinator/api/install.sh
index 8a7cafe0..3d726db0 100644
--- a/coordinator/api/install.sh
+++ b/coordinator/api/install.sh
@@ -104,20 +104,32 @@ fi
echo " Bundle hash verified ✓"
echo " Installing into $INSTALL_DIR ..."
-# The bundle ships as bin/{darkbloom,darkbloom-enclave,mlx.metallib}.
-# Older bundles named the helper `eigeninference-enclave`; accept either,
-# install as the canonical `darkbloom-enclave`, and leave a backward-compat
-# symlink for the old name so already-deployed scripts keep working.
+# The bundle ships as Darkbloom.app/ (contains provisioning profile for
+# keychain-access-groups) with bin/ symlinks for backward compatibility.
+# Older flat bundles (bin/darkbloom directly) are also handled.
tar xzf "$TARBALL" -C "$INSTALL_DIR"
-[ -f "$INSTALL_DIR/darkbloom" ] && mv -f "$INSTALL_DIR/darkbloom" "$BIN_DIR/darkbloom"
-[ -f "$INSTALL_DIR/darkbloom-enclave" ] && mv -f "$INSTALL_DIR/darkbloom-enclave" "$BIN_DIR/darkbloom-enclave"
-if [ -f "$INSTALL_DIR/eigeninference-enclave" ] && [ ! -f "$BIN_DIR/darkbloom-enclave" ]; then
- mv -f "$INSTALL_DIR/eigeninference-enclave" "$BIN_DIR/darkbloom-enclave"
+
+# New .app bundle layout: Darkbloom.app/Contents/MacOS/{darkbloom,darkbloom-enclave,mlx.metallib}
+if [ -d "$INSTALL_DIR/Darkbloom.app" ]; then
+ APP_BIN="$INSTALL_DIR/Darkbloom.app/Contents/MacOS"
+ chmod +x "$APP_BIN/darkbloom" "$APP_BIN/darkbloom-enclave" 2>/dev/null || true
+ # bin/ gets symlinks pointing into the .app bundle
+ mkdir -p "$BIN_DIR"
+ ln -sfn "$APP_BIN/darkbloom" "$BIN_DIR/darkbloom"
+ ln -sfn "$APP_BIN/darkbloom-enclave" "$BIN_DIR/darkbloom-enclave"
+ ln -sfn "$APP_BIN/mlx.metallib" "$BIN_DIR/mlx.metallib" 2>/dev/null || true
+ echo " Installed .app bundle with provisioning profile"
+else
+ # Legacy flat layout fallback
+ [ -f "$INSTALL_DIR/darkbloom" ] && mv -f "$INSTALL_DIR/darkbloom" "$BIN_DIR/darkbloom"
+ [ -f "$INSTALL_DIR/darkbloom-enclave" ] && mv -f "$INSTALL_DIR/darkbloom-enclave" "$BIN_DIR/darkbloom-enclave"
+ if [ -f "$INSTALL_DIR/eigeninference-enclave" ] && [ ! -f "$BIN_DIR/darkbloom-enclave" ]; then
+ mv -f "$INSTALL_DIR/eigeninference-enclave" "$BIN_DIR/darkbloom-enclave"
+ fi
+ [ -f "$INSTALL_DIR/mlx.metallib" ] && mv -f "$INSTALL_DIR/mlx.metallib" "$BIN_DIR/mlx.metallib"
+ chmod +x "$BIN_DIR/darkbloom" "$BIN_DIR/darkbloom-enclave" 2>/dev/null || true
fi
-[ -f "$INSTALL_DIR/mlx.metallib" ] && mv -f "$INSTALL_DIR/mlx.metallib" "$BIN_DIR/mlx.metallib"
-chmod +x "$BIN_DIR/darkbloom" "$BIN_DIR/darkbloom-enclave" 2>/dev/null || true
-# Backward-compat: keep the legacy helper name resolvable.
ln -sfn "$BIN_DIR/darkbloom-enclave" "$BIN_DIR/eigeninference-enclave" 2>/dev/null || true
rm -f "$TARBALL"
diff --git a/provider-swift/Sources/ProviderCore/ProviderLoop.swift b/provider-swift/Sources/ProviderCore/ProviderLoop.swift
index b7d66675..818bc74f 100644
--- a/provider-swift/Sources/ProviderCore/ProviderLoop.swift
+++ b/provider-swift/Sources/ProviderCore/ProviderLoop.swift
@@ -79,7 +79,7 @@ public struct ProviderLoopConfig: Sendable {
public actor ProviderLoop {
private let loopConfig: ProviderLoopConfig
private let keyPair: NodeKeyPair
- private let seIdentity: SecureEnclaveIdentity?
+ private let signer: (any AttestationSigner)?
private let attestationBuilder: AttestationBuilder?
private let scheduler: BatchScheduler
private let stats: AtomicProviderStats
@@ -113,8 +113,8 @@ public actor ProviderLoop {
self.loopConfig = config
NodeKeyPair.purgeLegacyFiles()
self.keyPair = NodeKeyPair.generate()
- self.seIdentity = try SecureEnclaveIdentity.createEphemeral()
- self.attestationBuilder = seIdentity.map { AttestationBuilder(identity: $0) }
+ self.signer = Self.createAttestationSigner()
+ self.attestationBuilder = signer.map { AttestationBuilder(identity: $0) }
self.stats = AtomicProviderStats()
self.state = ProviderState()
self.cancellationRegistry = InferenceCancellationRegistry()
@@ -125,6 +125,28 @@ public actor ProviderLoop {
)
}
+ /// Try persistent keychain-backed SE key first; fall back to ephemeral CryptoKit key.
+ private static func createAttestationSigner() -> (any AttestationSigner)? {
+ let log = ProviderLogger(subsystem: "dev.darkbloom.provider", category: "loop")
+
+ if PersistentEnclaveKey.isAvailable {
+ do {
+ let key = try PersistentEnclaveKey.loadOrCreate()
+ log.info("Using persistent keychain-backed Secure Enclave key for attestation")
+ return key
+ } catch {
+ log.warning("Persistent SE key unavailable (\(error)), falling back to ephemeral")
+ }
+ }
+
+ do {
+ return try SecureEnclaveIdentity.createEphemeral()
+ } catch {
+ log.warning("Ephemeral SE identity also unavailable: \(error)")
+ return nil
+ }
+ }
+
// MARK: - Main Run Loop
public func run() async throws {
@@ -416,7 +438,7 @@ public actor ProviderLoop {
let providerStats = self.stats
let providerState = self.state
let registry = self.cancellationRegistry
- let signingIdentity = self.seIdentity
+ let signingIdentity = self.signer
let log = self.logger
// 7. Spawn inference task
diff --git a/provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift b/provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift
index 5170c4c2..ae40c2fd 100644
--- a/provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift
+++ b/provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift
@@ -144,17 +144,21 @@ public enum StatusCanonical {
// MARK: - Builder
-/// Builds and signs attestation blobs using a Secure Enclave identity.
+/// Builds and signs attestation blobs using a Secure Enclave signing key.
+///
+/// Accepts any `AttestationSigner` -- either the ephemeral
+/// `SecureEnclaveIdentity` (CryptoKit) or the persistent
+/// `PersistentEnclaveKey` (Security framework, keychain-backed).
///
/// Usage:
-/// 1. Create or load a SecureEnclaveIdentity
-/// 2. Create an AttestationBuilder with that identity
+/// 1. Create or load a signing key (ephemeral or persistent)
+/// 2. Create an AttestationBuilder with that signer
/// 3. Call `buildAttestation()` to get a SignedAttestation
/// 4. Serialize to JSON and include in the Register message
public final class AttestationBuilder: @unchecked Sendable {
- private let identity: SecureEnclaveIdentity
+ private let identity: any AttestationSigner
- public init(identity: SecureEnclaveIdentity) {
+ public init(identity: any AttestationSigner) {
self.identity = identity
}
diff --git a/provider-swift/Sources/ProviderCore/Security/AttestationSigner.swift b/provider-swift/Sources/ProviderCore/Security/AttestationSigner.swift
new file mode 100644
index 00000000..bc0a1825
--- /dev/null
+++ b/provider-swift/Sources/ProviderCore/Security/AttestationSigner.swift
@@ -0,0 +1,23 @@
+/// AttestationSigner -- protocol abstracting over ephemeral and persistent
+/// Secure Enclave signing keys for attestation.
+///
+/// Both `SecureEnclaveIdentity` (CryptoKit, ephemeral) and
+/// `PersistentEnclaveKey` (Security framework, keychain-backed) conform.
+/// `AttestationBuilder` and `ProviderLoop` use this protocol to accept
+/// either implementation.
+
+import Foundation
+
+public protocol AttestationSigner: Sendable {
+ /// Sign arbitrary data, returning a DER-encoded ECDSA signature.
+ func sign(_ data: Data) throws -> Data
+
+ /// Base64-encoded P-256 public key (raw 64 bytes: X || Y).
+ var publicKeyBase64: String { get }
+}
+
+// MARK: - Conformances
+
+extension SecureEnclaveIdentity: AttestationSigner {}
+
+extension PersistentEnclaveKey: AttestationSigner {}
diff --git a/provider-swift/Sources/ProviderCore/Security/PersistentEnclaveKey.swift b/provider-swift/Sources/ProviderCore/Security/PersistentEnclaveKey.swift
new file mode 100644
index 00000000..2ac144ee
--- /dev/null
+++ b/provider-swift/Sources/ProviderCore/Security/PersistentEnclaveKey.swift
@@ -0,0 +1,315 @@
+/// PersistentEnclaveKey -- persistent Secure Enclave P-256 signing key
+/// backed by the macOS data protection keychain.
+///
+/// Unlike `SecureEnclaveIdentity` (ephemeral, CryptoKit), this key persists
+/// across launches and is bound to the signing team's keychain access group.
+/// Only binaries signed by the same Developer ID team can access it --
+/// enforced by securityd at the kernel level. A patched binary re-signed
+/// with `codesign -s -` gets `errSecMissingEntitlement`.
+
+import CryptoKit
+import Foundation
+import Security
+import os
+
+private let logger = Logger(subsystem: "dev.darkbloom.provider", category: "persistent-enclave-key")
+
+// MARK: - Errors
+
+public enum PersistentEnclaveKeyError: Error, CustomStringConvertible {
+ case secureEnclaveUnavailable
+ case accessControlCreationFailed(status: OSStatus)
+ case keyCreationFailed(status: OSStatus)
+ case keyLookupFailed(status: OSStatus)
+ case deletionFailed(status: OSStatus)
+ case signingFailed(status: OSStatus, message: String)
+ case publicKeyExtractionFailed
+ case publicKeySerializationFailed(status: OSStatus)
+ case missingEntitlement
+
+ public var description: String {
+ switch self {
+ case .secureEnclaveUnavailable:
+ return "Secure Enclave is not available on this device"
+ case .accessControlCreationFailed(let status):
+ return "Failed to create access control: OSStatus \(status)"
+ case .keyCreationFailed(let status):
+ if status == -34018 {
+ return "Key creation failed: missing keychain-access-groups entitlement (OSStatus -34018)"
+ }
+ return "Key creation failed: OSStatus \(status)"
+ case .keyLookupFailed(let status):
+ return "Key lookup failed: OSStatus \(status)"
+ case .deletionFailed(let status):
+ return "Key deletion failed: OSStatus \(status)"
+ case .signingFailed(let status, let message):
+ return "Signing failed (OSStatus \(status)): \(message)"
+ case .publicKeyExtractionFailed:
+ return "Failed to extract public key from private key"
+ case .publicKeySerializationFailed(let status):
+ return "Failed to serialize public key: OSStatus \(status)"
+ case .missingEntitlement:
+ return "Binary is missing the keychain-access-groups entitlement for the configured access group"
+ }
+ }
+}
+
+// MARK: - Helpers
+
+/// Extract an OSStatus from a CFError produced by Security framework APIs.
+private func osStatus(from cfError: Unmanaged?) -> OSStatus {
+ guard let cfError else { return errSecInternalError }
+ let nsError = cfError.takeRetainedValue() as Error as NSError
+ return OSStatus(nsError.code)
+}
+
+// MARK: - PersistentEnclaveKey
+
+public final class PersistentEnclaveKey: @unchecked Sendable {
+ private let privateKey: SecKey
+ private let _publicKeyRaw: Data
+
+ /// Default access group. The team ID prefix is hardcoded because codesign
+ /// does NOT expand $(AppIdentifierPrefix) -- that's Xcode-only.
+ public static let defaultAccessGroup = "SLDQ2GJ6TL.io.darkbloom.provider"
+
+ public static let defaultLabel = "io.darkbloom.provider.attestation-signing.v1"
+
+ /// Raw P-256 public key (64 bytes: X || Y, without the 0x04 prefix).
+ public var publicKeyRaw: Data { _publicKeyRaw }
+
+ /// Base64-encoded public key.
+ public var publicKeyBase64: String { _publicKeyRaw.base64EncodedString() }
+
+ // MARK: - Private init
+
+ private init(privateKey: SecKey) throws {
+ self.privateKey = privateKey
+
+ guard let pubKey = SecKeyCopyPublicKey(privateKey) else {
+ throw PersistentEnclaveKeyError.publicKeyExtractionFailed
+ }
+
+ var serError: Unmanaged?
+ guard let pubData = SecKeyCopyExternalRepresentation(pubKey, &serError) as Data? else {
+ throw PersistentEnclaveKeyError.publicKeySerializationFailed(
+ status: osStatus(from: serError)
+ )
+ }
+
+ // X9.62 uncompressed format: 0x04 || X (32 bytes) || Y (32 bytes)
+ guard pubData.count == 65, pubData[0] == 0x04 else {
+ throw PersistentEnclaveKeyError.publicKeyExtractionFailed
+ }
+ self._publicKeyRaw = Data(pubData.dropFirst())
+ }
+
+ // MARK: - Load or Create
+
+ /// Load an existing persistent key from the keychain, or create one if not found.
+ public static func loadOrCreate(
+ accessGroup: String? = nil,
+ label: String? = nil
+ ) throws -> PersistentEnclaveKey {
+ let group = resolveAccessGroup(accessGroup)
+ let keyLabel = label ?? defaultLabel
+
+ // Only fall through to creation on errSecItemNotFound. Auth failures,
+ // locked-keychain errors, and missing-entitlement must surface so the
+ // caller can fall back instead of racing with createNew.
+ do {
+ let existing = try findExisting(accessGroup: group, label: keyLabel)
+ logger.info("Loaded existing persistent Secure Enclave key")
+ return existing
+ } catch PersistentEnclaveKeyError.keyLookupFailed(status: errSecItemNotFound) {
+ // No existing key — proceed to creation.
+ }
+
+ return try createNew(accessGroup: group, label: keyLabel)
+ }
+
+ // MARK: - Find Existing
+
+ private static func findExisting(
+ accessGroup: String,
+ label: String
+ ) throws -> PersistentEnclaveKey {
+ // kSecUseDataProtectionKeychain forces the iOS-style keychain on macOS,
+ // which is the only one that enforces kSecAttrAccessGroup membership.
+ // Without it, the query may hit the legacy file-based keychain where
+ // the access-group constraint is silently ignored.
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassKey,
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
+ kSecAttrKeySizeInBits as String: 256,
+ kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
+ kSecAttrLabel as String: label,
+ kSecAttrAccessGroup as String: accessGroup,
+ kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
+ kSecUseDataProtectionKeychain as String: true,
+ kSecReturnRef as String: true,
+ ]
+
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ switch status {
+ case errSecSuccess:
+ // Force-unwrap safe: errSecSuccess guarantees a result.
+ let key = result as! SecKey
+ return try PersistentEnclaveKey(privateKey: key)
+ case errSecItemNotFound:
+ throw PersistentEnclaveKeyError.keyLookupFailed(status: errSecItemNotFound)
+ case -34018:
+ throw PersistentEnclaveKeyError.missingEntitlement
+ default:
+ throw PersistentEnclaveKeyError.keyLookupFailed(status: status)
+ }
+ }
+
+ // MARK: - Create New
+
+ private static func createNew(
+ accessGroup: String,
+ label: String
+ ) throws -> PersistentEnclaveKey {
+ guard isAvailable else {
+ throw PersistentEnclaveKeyError.secureEnclaveUnavailable
+ }
+
+ var acError: Unmanaged?
+ guard let accessControl = SecAccessControlCreateWithFlags(
+ kCFAllocatorDefault,
+ kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
+ .privateKeyUsage,
+ &acError
+ ) else {
+ throw PersistentEnclaveKeyError.accessControlCreationFailed(
+ status: osStatus(from: acError)
+ )
+ }
+
+ let privateKeyAttrs: [String: Any] = [
+ kSecAttrIsPermanent as String: true,
+ kSecAttrAccessControl as String: accessControl,
+ kSecAttrLabel as String: label,
+ kSecAttrAccessGroup as String: accessGroup,
+ ]
+
+ let attributes: [String: Any] = [
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
+ kSecAttrKeySizeInBits as String: 256,
+ kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
+ kSecUseDataProtectionKeychain as String: true,
+ kSecPrivateKeyAttrs as String: privateKeyAttrs,
+ ]
+
+ var createError: Unmanaged?
+ guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &createError) else {
+ let status = osStatus(from: createError)
+
+ if status == -34018 {
+ throw PersistentEnclaveKeyError.missingEntitlement
+ }
+ // -25299 = errSecDuplicateItem: race between check and create
+ if status == errSecDuplicateItem {
+ logger.info("Key already exists (race condition), loading existing")
+ return try findExisting(accessGroup: accessGroup, label: label)
+ }
+
+ throw PersistentEnclaveKeyError.keyCreationFailed(status: status)
+ }
+
+ logger.info("Created new persistent Secure Enclave key (access group: \(accessGroup))")
+ return try PersistentEnclaveKey(privateKey: privateKey)
+ }
+
+ // MARK: - Sign
+
+ /// Sign data using the Secure Enclave private key.
+ ///
+ /// Returns a DER-encoded ECDSA signature (ASN.1 SEQUENCE of two INTEGERs),
+ /// compatible with Go's crypto/ecdsa and the coordinator's verification.
+ public func sign(_ data: Data) throws -> Data {
+ var signError: Unmanaged?
+ guard let signature = SecKeyCreateSignature(
+ privateKey,
+ .ecdsaSignatureMessageX962SHA256,
+ data as CFData,
+ &signError
+ ) else {
+ if let cfErr = signError {
+ let nsErr = cfErr.takeRetainedValue() as Error as NSError
+ throw PersistentEnclaveKeyError.signingFailed(
+ status: OSStatus(nsErr.code),
+ message: nsErr.localizedDescription
+ )
+ }
+ throw PersistentEnclaveKeyError.signingFailed(
+ status: errSecInternalError,
+ message: "unknown error"
+ )
+ }
+ return signature as Data
+ }
+
+ // MARK: - Delete
+
+ /// Remove the persistent key from the keychain.
+ public static func delete(
+ accessGroup: String? = nil,
+ label: String? = nil
+ ) throws {
+ let group = resolveAccessGroup(accessGroup)
+ let keyLabel = label ?? defaultLabel
+
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassKey,
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
+ kSecAttrKeySizeInBits as String: 256,
+ kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
+ kSecAttrLabel as String: keyLabel,
+ kSecAttrAccessGroup as String: group,
+ kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
+ kSecUseDataProtectionKeychain as String: true,
+ ]
+
+ let status = SecItemDelete(query as CFDictionary)
+ switch status {
+ case errSecSuccess, errSecItemNotFound:
+ return
+ case -34018:
+ // No entitlement = no key could have been created by this binary.
+ throw PersistentEnclaveKeyError.missingEntitlement
+ default:
+ throw PersistentEnclaveKeyError.deletionFailed(status: status)
+ }
+ }
+
+ // MARK: - Availability
+
+ /// Whether the Secure Enclave is available on this device.
+ ///
+ /// Probes actual hardware capability via CryptoKit. Returns false on Intel
+ /// Macs without T2, macOS VMs without virtualized SE, and the iOS Simulator.
+ ///
+ /// - Note: This does NOT check whether the binary has the
+ /// `keychain-access-groups` entitlement. Even when `isAvailable` returns
+ /// true, `loadOrCreate()` can still throw `.missingEntitlement` on
+ /// unsigned debug builds. The entitlement is gated by the provisioning
+ /// profile embedded in the signed app bundle.
+ public static var isAvailable: Bool {
+ SecureEnclave.isAvailable
+ }
+
+ // MARK: - Access Group Resolution
+
+ private static func resolveAccessGroup(_ override: String?) -> String {
+ if let override { return override }
+ if let envGroup = ProcessInfo.processInfo.environment["DARKBLOOM_KEYCHAIN_ACCESS_GROUP"],
+ !envGroup.isEmpty {
+ return envGroup
+ }
+ return defaultAccessGroup
+ }
+}
diff --git a/provider-swift/Sources/ProviderCore/Security/SecurityHardening.swift b/provider-swift/Sources/ProviderCore/Security/SecurityHardening.swift
index cefdebb0..80dc90ec 100644
--- a/provider-swift/Sources/ProviderCore/Security/SecurityHardening.swift
+++ b/provider-swift/Sources/ProviderCore/Security/SecurityHardening.swift
@@ -496,7 +496,7 @@ public func verifySecurityPosture(hypervisorActive _: Bool = false) throws -> Se
/// The hash covers `requestId:completionTokens:responseBody` -- identical
/// to the Rust provider's `compute_response_attestation`.
public func computeResponseAttestation(
- identity: SecureEnclaveIdentity?,
+ identity: (any AttestationSigner)?,
requestId: String,
completionTokens: UInt64,
responseBody: String
diff --git a/provider-swift/Tests/ProviderCoreTests/PersistentEnclaveKeyTests.swift b/provider-swift/Tests/ProviderCoreTests/PersistentEnclaveKeyTests.swift
new file mode 100644
index 00000000..70dd2536
--- /dev/null
+++ b/provider-swift/Tests/ProviderCoreTests/PersistentEnclaveKeyTests.swift
@@ -0,0 +1,216 @@
+import CryptoKit
+import Foundation
+import Testing
+@testable import ProviderCore
+
+// These tests exercise PersistentEnclaveKey on a real Apple Silicon Mac
+// with Secure Enclave. They use a test-specific label so they don't
+// interfere with the production attestation key.
+
+private let testLabel = "io.darkbloom.provider.test-key.\(UUID().uuidString)"
+
+// The access group must match the binary's entitlements. In debug builds
+// without codesign, these tests will get errSecMissingEntitlement and
+// skip gracefully.
+
+@Test func persistentEnclaveKeyAvailabilityReflectsHardware() {
+ // On Apple Silicon this should be true; the test just verifies
+ // the property doesn't crash.
+ let available = PersistentEnclaveKey.isAvailable
+ #expect(type(of: available) == Bool.self)
+}
+
+@Test func persistentEnclaveKeyCreateAndSign() throws {
+ guard PersistentEnclaveKey.isAvailable else {
+ print("Skipping: Secure Enclave not available")
+ return
+ }
+
+ let key: PersistentEnclaveKey
+ do {
+ key = try PersistentEnclaveKey.loadOrCreate(label: testLabel)
+ } catch let error as PersistentEnclaveKeyError {
+ if case .missingEntitlement = error {
+ print("Skipping: missing keychain-access-groups entitlement (expected in unsigned debug builds)")
+ return
+ }
+ throw error
+ }
+
+ defer { try? PersistentEnclaveKey.delete(label: testLabel) }
+
+ // Public key should be 64 bytes (raw P-256: X || Y)
+ #expect(key.publicKeyRaw.count == 64)
+ #expect(!key.publicKeyBase64.isEmpty)
+
+ // Sign some data
+ let testData = Data("test payload for signing".utf8)
+ let signature = try key.sign(testData)
+ #expect(!signature.isEmpty)
+
+ // Verify the signature using CryptoKit
+ let pubKeyCK = try P256.Signing.PublicKey(rawRepresentation: key.publicKeyRaw)
+ let ecdsaSig = try P256.Signing.ECDSASignature(derRepresentation: signature)
+ #expect(pubKeyCK.isValidSignature(ecdsaSig, for: SHA256.hash(data: testData)))
+}
+
+@Test func persistentEnclaveKeyPersistence() throws {
+ guard PersistentEnclaveKey.isAvailable else {
+ print("Skipping: Secure Enclave not available")
+ return
+ }
+
+ let persistLabel = "io.darkbloom.provider.test-persist.\(UUID().uuidString)"
+
+ let firstKey: PersistentEnclaveKey
+ do {
+ firstKey = try PersistentEnclaveKey.loadOrCreate(label: persistLabel)
+ } catch let error as PersistentEnclaveKeyError {
+ if case .missingEntitlement = error {
+ print("Skipping: missing keychain-access-groups entitlement")
+ return
+ }
+ throw error
+ }
+
+ defer { try? PersistentEnclaveKey.delete(label: persistLabel) }
+
+ // Load again -- should return the SAME public key
+ let secondKey = try PersistentEnclaveKey.loadOrCreate(label: persistLabel)
+ #expect(firstKey.publicKeyRaw == secondKey.publicKeyRaw)
+ #expect(firstKey.publicKeyBase64 == secondKey.publicKeyBase64)
+}
+
+@Test func persistentEnclaveKeyDelete() throws {
+ guard PersistentEnclaveKey.isAvailable else {
+ print("Skipping: Secure Enclave not available")
+ return
+ }
+
+ let deleteLabel = "io.darkbloom.provider.test-delete.\(UUID().uuidString)"
+
+ do {
+ _ = try PersistentEnclaveKey.loadOrCreate(label: deleteLabel)
+ } catch let error as PersistentEnclaveKeyError {
+ if case .missingEntitlement = error {
+ print("Skipping: missing keychain-access-groups entitlement")
+ return
+ }
+ throw error
+ }
+
+ // Delete should succeed
+ try PersistentEnclaveKey.delete(label: deleteLabel)
+
+ // After deletion, loadOrCreate should create a NEW key (different pubkey)
+ // But since we can't guarantee entitlements, we just verify delete
+ // doesn't throw. The next loadOrCreate would create a fresh key.
+}
+
+@Test func persistentEnclaveKeyPublicKeyFormat() throws {
+ guard PersistentEnclaveKey.isAvailable else {
+ print("Skipping: Secure Enclave not available")
+ return
+ }
+
+ let formatLabel = "io.darkbloom.provider.test-format.\(UUID().uuidString)"
+
+ let key: PersistentEnclaveKey
+ do {
+ key = try PersistentEnclaveKey.loadOrCreate(label: formatLabel)
+ } catch let error as PersistentEnclaveKeyError {
+ if case .missingEntitlement = error {
+ print("Skipping: missing keychain-access-groups entitlement")
+ return
+ }
+ throw error
+ }
+
+ defer { try? PersistentEnclaveKey.delete(label: formatLabel) }
+
+ // Public key base64 should decode to exactly 64 bytes
+ let decoded = Data(base64Encoded: key.publicKeyBase64)
+ #expect(decoded?.count == 64)
+
+ // Should be parseable by CryptoKit as a raw P-256 public key
+ #expect(throws: Never.self) {
+ _ = try P256.Signing.PublicKey(rawRepresentation: key.publicKeyRaw)
+ }
+}
+
+@Test func persistentEnclaveKeyConformsToAttestationSigner() throws {
+ guard PersistentEnclaveKey.isAvailable else {
+ print("Skipping: Secure Enclave not available")
+ return
+ }
+
+ let protoLabel = "io.darkbloom.provider.test-proto.\(UUID().uuidString)"
+
+ let key: PersistentEnclaveKey
+ do {
+ key = try PersistentEnclaveKey.loadOrCreate(label: protoLabel)
+ } catch let error as PersistentEnclaveKeyError {
+ if case .missingEntitlement = error {
+ print("Skipping: missing keychain-access-groups entitlement")
+ return
+ }
+ throw error
+ }
+
+ defer { try? PersistentEnclaveKey.delete(label: protoLabel) }
+
+ // Should be usable as an AttestationSigner
+ let signer: any AttestationSigner = key
+ #expect(!signer.publicKeyBase64.isEmpty)
+
+ let data = Data("protocol conformance test".utf8)
+ let sig = try signer.sign(data)
+ #expect(!sig.isEmpty)
+}
+
+@Test func attestationBuilderAcceptsBothSignerTypes() throws {
+ guard PersistentEnclaveKey.isAvailable else {
+ print("Skipping: Secure Enclave not available")
+ return
+ }
+
+ // Verify AttestationBuilder works with ephemeral identity
+ if let ephemeral = try SecureEnclaveIdentity.createEphemeral() {
+ let builder = AttestationBuilder(identity: ephemeral)
+ let signed = try builder.buildAttestation()
+ #expect(!signed.signature.isEmpty)
+ #expect(!signed.attestation.publicKey.isEmpty)
+ }
+
+ // Verify AttestationBuilder works with persistent key
+ let persistentLabel = "io.darkbloom.provider.test-builder.\(UUID().uuidString)"
+ let persistent: PersistentEnclaveKey
+ do {
+ persistent = try PersistentEnclaveKey.loadOrCreate(label: persistentLabel)
+ } catch let error as PersistentEnclaveKeyError {
+ if case .missingEntitlement = error {
+ print("Skipping persistent key test: missing entitlement")
+ return
+ }
+ throw error
+ }
+
+ defer { try? PersistentEnclaveKey.delete(label: persistentLabel) }
+
+ let builder = AttestationBuilder(identity: persistent)
+ let signed = try builder.buildAttestation()
+ #expect(!signed.signature.isEmpty)
+ #expect(signed.attestation.publicKey == persistent.publicKeyBase64)
+}
+
+@Test func deleteNonexistentKeyDoesNotThrow() throws {
+ do {
+ try PersistentEnclaveKey.delete(label: "io.darkbloom.provider.nonexistent.\(UUID().uuidString)")
+ } catch let error as PersistentEnclaveKeyError {
+ if case .missingEntitlement = error {
+ print("Skipping: missing keychain-access-groups entitlement")
+ return
+ }
+ throw error
+ }
+}
diff --git a/provider-swift/entitlements.plist b/provider-swift/entitlements.plist
new file mode 100644
index 00000000..eac62c1a
--- /dev/null
+++ b/provider-swift/entitlements.plist
@@ -0,0 +1,18 @@
+
+
+
+
+ com.apple.application-identifier
+ SLDQ2GJ6TL.io.darkbloom.provider
+ com.apple.security.hypervisor
+
+ com.apple.security.network.client
+
+ com.apple.security.network.server
+
+ keychain-access-groups
+
+ SLDQ2GJ6TL.io.darkbloom.provider
+
+
+
diff --git a/scripts/install.sh b/scripts/install.sh
index da5ad156..28876f47 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -104,20 +104,32 @@ fi
echo " Bundle hash verified ✓"
echo " Installing into $INSTALL_DIR ..."
-# The bundle ships as bin/{darkbloom,darkbloom-enclave,mlx.metallib}.
-# Older bundles named the helper `eigeninference-enclave`; accept either,
-# install as the canonical `darkbloom-enclave`, and leave a backward-compat
-# symlink for the old name so already-deployed scripts keep working.
+# The bundle ships as Darkbloom.app/ (contains provisioning profile for
+# keychain-access-groups) with bin/ symlinks for backward compatibility.
+# Older flat bundles (bin/darkbloom directly) are also handled.
tar xzf "$TARBALL" -C "$INSTALL_DIR"
-[ -f "$INSTALL_DIR/darkbloom" ] && mv -f "$INSTALL_DIR/darkbloom" "$BIN_DIR/darkbloom"
-[ -f "$INSTALL_DIR/darkbloom-enclave" ] && mv -f "$INSTALL_DIR/darkbloom-enclave" "$BIN_DIR/darkbloom-enclave"
-if [ -f "$INSTALL_DIR/eigeninference-enclave" ] && [ ! -f "$BIN_DIR/darkbloom-enclave" ]; then
- mv -f "$INSTALL_DIR/eigeninference-enclave" "$BIN_DIR/darkbloom-enclave"
+
+# New .app bundle layout: Darkbloom.app/Contents/MacOS/{darkbloom,darkbloom-enclave,mlx.metallib}
+if [ -d "$INSTALL_DIR/Darkbloom.app" ]; then
+ APP_BIN="$INSTALL_DIR/Darkbloom.app/Contents/MacOS"
+ chmod +x "$APP_BIN/darkbloom" "$APP_BIN/darkbloom-enclave" 2>/dev/null || true
+ # bin/ gets symlinks pointing into the .app bundle
+ mkdir -p "$BIN_DIR"
+ ln -sfn "$APP_BIN/darkbloom" "$BIN_DIR/darkbloom"
+ ln -sfn "$APP_BIN/darkbloom-enclave" "$BIN_DIR/darkbloom-enclave"
+ ln -sfn "$APP_BIN/mlx.metallib" "$BIN_DIR/mlx.metallib" 2>/dev/null || true
+ echo " Installed .app bundle with provisioning profile"
+else
+ # Legacy flat layout fallback
+ [ -f "$INSTALL_DIR/darkbloom" ] && mv -f "$INSTALL_DIR/darkbloom" "$BIN_DIR/darkbloom"
+ [ -f "$INSTALL_DIR/darkbloom-enclave" ] && mv -f "$INSTALL_DIR/darkbloom-enclave" "$BIN_DIR/darkbloom-enclave"
+ if [ -f "$INSTALL_DIR/eigeninference-enclave" ] && [ ! -f "$BIN_DIR/darkbloom-enclave" ]; then
+ mv -f "$INSTALL_DIR/eigeninference-enclave" "$BIN_DIR/darkbloom-enclave"
+ fi
+ [ -f "$INSTALL_DIR/mlx.metallib" ] && mv -f "$INSTALL_DIR/mlx.metallib" "$BIN_DIR/mlx.metallib"
+ chmod +x "$BIN_DIR/darkbloom" "$BIN_DIR/darkbloom-enclave" 2>/dev/null || true
fi
-[ -f "$INSTALL_DIR/mlx.metallib" ] && mv -f "$INSTALL_DIR/mlx.metallib" "$BIN_DIR/mlx.metallib"
-chmod +x "$BIN_DIR/darkbloom" "$BIN_DIR/darkbloom-enclave" 2>/dev/null || true
-# Backward-compat: keep the legacy helper name resolvable.
ln -sfn "$BIN_DIR/darkbloom-enclave" "$BIN_DIR/eigeninference-enclave" 2>/dev/null || true
rm -f "$TARBALL"