From 3e3635753eee93bcd3c712e20cc9c6d1fcc4702f Mon Sep 17 00:00:00 2001 From: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com> Date: Sun, 10 May 2026 09:24:44 -0700 Subject: [PATCH 1/4] Add persistent Secure Enclave attestation key with keychain access group enforcement Replace ephemeral CryptoKit SE keys with persistent Security framework keys stored in the macOS data protection keychain. The key is bound to the signing team's keychain access group (SLDQ2GJ6TL.io.darkbloom.provider), enforced by securityd at the kernel level. A patched binary re-signed with codesign -s - gets errSecMissingEntitlement and cannot access the key. - PersistentEnclaveKey: Security framework SE key with SecKeyCreateRandomKey, kSecAttrIsPermanent, and team-scoped access group - AttestationSigner protocol: abstracts over both ephemeral and persistent keys - ProviderLoop: tries persistent key first, falls back to ephemeral with warning - Entitlements plist with keychain-access-groups for production signing - 8 tests covering creation, persistence, signing, deletion, protocol conformance --- .../Sources/ProviderCore/ProviderLoop.swift | 30 +- .../Security/AttestationBuilder.swift | 14 +- .../Security/AttestationSigner.swift | 23 ++ .../Security/PersistentEnclaveKey.swift | 299 ++++++++++++++++++ .../Security/SecurityHardening.swift | 2 +- .../PersistentEnclaveKeyTests.swift | 216 +++++++++++++ provider-swift/entitlements.plist | 16 + 7 files changed, 590 insertions(+), 10 deletions(-) create mode 100644 provider-swift/Sources/ProviderCore/Security/AttestationSigner.swift create mode 100644 provider-swift/Sources/ProviderCore/Security/PersistentEnclaveKey.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/PersistentEnclaveKeyTests.swift create mode 100644 provider-swift/entitlements.plist 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..f7230a81 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Security/PersistentEnclaveKey.swift @@ -0,0 +1,299 @@ +/// 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 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 + + if let existing = try? findExisting(accessGroup: group, label: keyLabel) { + logger.info("Loaded existing persistent Secure Enclave key") + return existing + } + + return try createNew(accessGroup: group, label: keyLabel) + } + + // MARK: - Find Existing + + private static func findExisting( + accessGroup: String, + label: String + ) throws -> PersistentEnclaveKey { + 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, + 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, + 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, + ] + + 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. + public static var isAvailable: Bool { + #if targetEnvironment(simulator) + return false + #else + if #available(macOS 13.0, *) { + return true + } + return false + #endif + } + + // 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..b4d2a3bb --- /dev/null +++ b/provider-swift/entitlements.plist @@ -0,0 +1,16 @@ + + + + + com.apple.security.hypervisor + + com.apple.security.network.client + + com.apple.security.network.server + + keychain-access-groups + + SLDQ2GJ6TL.io.darkbloom.provider + + + From fdb113fa70ac4eb0a6c4875c14644022ced4d119 Mon Sep 17 00:00:00 2001 From: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com> Date: Sun, 10 May 2026 11:08:19 -0700 Subject: [PATCH 2/4] Embed provisioning profile in .app bundle for persistent SE key The data protection keychain requires a provisioning profile to authorize the keychain-access-groups entitlement. Wrap the CLI binaries in a minimal Darkbloom.app bundle with embedded.provisionprofile so the persistent SE attestation key works on provider machines. - release-swift.yml: new step decodes PROVISIONING_PROFILE_BASE64 secret, builds Darkbloom.app/Contents/ structure, signs bundle + individual binaries - install.sh: detects .app bundle layout, symlinks bin/ into the app bundle - Backward-compatible: falls back gracefully if secret is not set or if provider receives a flat (pre-.app) bundle --- .github/workflows/release-swift.yml | 85 +++++++++++++++++++++++------ coordinator/internal/api/install.sh | 36 ++++++++---- scripts/install.sh | 34 ++++++++---- 3 files changed, 115 insertions(+), 40 deletions(-) diff --git a/.github/workflows/release-swift.yml b/.github/workflows/release-swift.yml index 1c351279..1cbc42db 100644 --- a/.github/workflows/release-swift.yml +++ b/.github/workflows/release-swift.yml @@ -234,41 +234,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/internal/api/install.sh b/coordinator/internal/api/install.sh index 8a7cafe0..28876f47 100644 --- a/coordinator/internal/api/install.sh +++ b/coordinator/internal/api/install.sh @@ -22,7 +22,7 @@ set -euo pipefail # Direct-fetch copy: no serve-time templating applied. Override with # curl ... | COORD_URL=https://api.dev.darkbloom.xyz bash # Or fetch the coordinator-served copy at $COORD_URL/install.sh for templating. -COORD_URL="${COORD_URL:-__DARKBLOOM_COORD_URL__}" +COORD_URL="${COORD_URL:-https://api.darkbloom.dev}" INSTALL_DIR="$HOME/.darkbloom" BIN_DIR="$INSTALL_DIR/bin" @@ -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/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" From 4f1b94f2dbb63ebb6a46fe18eb35b2c3c1ce86ac Mon Sep 17 00:00:00 2001 From: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com> Date: Thu, 14 May 2026 14:07:44 -0700 Subject: [PATCH 3/4] Add com.apple.application-identifier to provider entitlements Required for data protection keychain access. Must match the bundle ID in the provisioning profile (SLDQ2GJ6TL.io.darkbloom.provider). --- provider-swift/entitlements.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/provider-swift/entitlements.plist b/provider-swift/entitlements.plist index b4d2a3bb..eac62c1a 100644 --- a/provider-swift/entitlements.plist +++ b/provider-swift/entitlements.plist @@ -2,6 +2,8 @@ + com.apple.application-identifier + SLDQ2GJ6TL.io.darkbloom.provider com.apple.security.hypervisor com.apple.security.network.client From 84664d04ba4d6977d7b547aad375ffe83e7ee21a Mon Sep 17 00:00:00 2001 From: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com> Date: Thu, 14 May 2026 14:44:23 -0700 Subject: [PATCH 4/4] Address review: data protection keychain flag, tighter error handling, real SE probe Codex P1 / hank P1: - coordinator/api/install.sh: restore __DARKBLOOM_COORD_URL__ placeholder (the coordinator templates this at serve time via server.go; hardcoding the URL broke dev/self-hosted coordinators) - PersistentEnclaveKey: add kSecUseDataProtectionKeychain: true to all Security framework calls. Without it, queries may hit the legacy file-based keychain where access group enforcement is silently ignored. hank P2: - loadOrCreate: catch only errSecItemNotFound before falling through to createNew. Auth failures, locked keychain, and missing entitlement now propagate to the caller instead of racing with key creation. - isAvailable: probe real SE capability via CryptoKit's SecureEnclave.isAvailable instead of just checking macOS version. Now returns false on Intel Macs without T2 and macOS VMs without virtualized SE. Added doc comment noting the entitlement dependency. --- coordinator/api/install.sh | 2 +- .../Security/PersistentEnclaveKey.swift | 34 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/coordinator/api/install.sh b/coordinator/api/install.sh index 28876f47..3d726db0 100644 --- a/coordinator/api/install.sh +++ b/coordinator/api/install.sh @@ -22,7 +22,7 @@ set -euo pipefail # Direct-fetch copy: no serve-time templating applied. Override with # curl ... | COORD_URL=https://api.dev.darkbloom.xyz bash # Or fetch the coordinator-served copy at $COORD_URL/install.sh for templating. -COORD_URL="${COORD_URL:-https://api.darkbloom.dev}" +COORD_URL="${COORD_URL:-__DARKBLOOM_COORD_URL__}" INSTALL_DIR="$HOME/.darkbloom" BIN_DIR="$INSTALL_DIR/bin" diff --git a/provider-swift/Sources/ProviderCore/Security/PersistentEnclaveKey.swift b/provider-swift/Sources/ProviderCore/Security/PersistentEnclaveKey.swift index f7230a81..2ac144ee 100644 --- a/provider-swift/Sources/ProviderCore/Security/PersistentEnclaveKey.swift +++ b/provider-swift/Sources/ProviderCore/Security/PersistentEnclaveKey.swift @@ -7,6 +7,7 @@ /// 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 @@ -113,9 +114,15 @@ public final class PersistentEnclaveKey: @unchecked Sendable { let group = resolveAccessGroup(accessGroup) let keyLabel = label ?? defaultLabel - if let existing = try? findExisting(accessGroup: group, label: keyLabel) { + // 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) @@ -127,6 +134,10 @@ public final class PersistentEnclaveKey: @unchecked Sendable { 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, @@ -135,6 +146,7 @@ public final class PersistentEnclaveKey: @unchecked Sendable { kSecAttrLabel as String: label, kSecAttrAccessGroup as String: accessGroup, kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, + kSecUseDataProtectionKeychain as String: true, kSecReturnRef as String: true, ] @@ -188,6 +200,7 @@ public final class PersistentEnclaveKey: @unchecked Sendable { kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, kSecAttrKeySizeInBits as String: 256, kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, + kSecUseDataProtectionKeychain as String: true, kSecPrivateKeyAttrs as String: privateKeyAttrs, ] @@ -258,6 +271,7 @@ public final class PersistentEnclaveKey: @unchecked Sendable { kSecAttrLabel as String: keyLabel, kSecAttrAccessGroup as String: group, kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, + kSecUseDataProtectionKeychain as String: true, ] let status = SecItemDelete(query as CFDictionary) @@ -275,15 +289,17 @@ public final class PersistentEnclaveKey: @unchecked Sendable { // 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 { - #if targetEnvironment(simulator) - return false - #else - if #available(macOS 13.0, *) { - return true - } - return false - #endif + SecureEnclave.isAvailable } // MARK: - Access Group Resolution