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"