From e2257a6158e98161f174144a7934210e1dfa8e3e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 12 Jun 2026 23:25:36 +0200 Subject: [PATCH] =?UTF-8?q?fix(apple):=20persist=20Keychain=20trust=20?= =?UTF-8?q?=E2=80=94=20sign=20macOS=20+=20data-protection=20keychain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client identity prompted for Keychain access on every launch/rebuild. Root cause: the macOS app target was ad-hoc signed (CODE_SIGN_IDENTITY = "-"), and the identity lived in the file keychain whose "Always Allow" ACL is bound to the app's exact code signature (cdhash for ad-hoc). Every rebuild changed the binary -> changed the cdhash -> the ACL no longer matched -> re-prompt. - Sign the macOS target with Apple Development (team already set) instead of ad-hoc, so the designated requirement is identity-based and stable across rebuilds. - Move the identity to the data-protection keychain (kSecUseDataProtectionKeychain) gated by a team-scoped keychain-access-group entitlement — access is granted by the app's entitlement, not a per-binary ACL, so it's prompt-free and survives rebuilds. Add Config/Punktfunk.entitlements and wire CODE_SIGN_ENTITLEMENTS into all six app configs (macOS/iOS/tvOS). - Unsigned / ad-hoc builds (e.g. `swift run`) lack the entitlement (errSecMissingEntitlement) — fall back to the legacy file keychain so they still work (with the old prompt), no hard failure. macOS re-mints the identity on first run (the old file-keychain copy isn't in the data-protection keychain) -> one re-pair, which is acceptable. iOS keeps its identity (the explicit access group equals the prior default). Validated: swift build; swift test (39 passed, 0 failures); xcodebuild -showBuildSettings confirms Apple Development + Config/Punktfunk.entitlements. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/apple/Config/Punktfunk.entitlements | 15 ++++++ .../apple/Punktfunk.xcodeproj/project.pbxproj | 12 +++-- .../PunktfunkClient/ClientIdentityStore.swift | 53 +++++++++++++++---- 3 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 clients/apple/Config/Punktfunk.entitlements diff --git a/clients/apple/Config/Punktfunk.entitlements b/clients/apple/Config/Punktfunk.entitlements new file mode 100644 index 0000000..a934dc3 --- /dev/null +++ b/clients/apple/Config/Punktfunk.entitlements @@ -0,0 +1,15 @@ + + + + + + keychain-access-groups + + $(AppIdentifierPrefix)io.unom.punktfunk + + + diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index 83cdd4a..2e0d28b 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -353,8 +353,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_IDENTITY = "-"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -387,8 +387,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_IDENTITY = "-"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -421,6 +421,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F4H37KF6WC; @@ -459,6 +460,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F4H37KF6WC; @@ -496,6 +498,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F4H37KF6WC; @@ -525,6 +528,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F4H37KF6WC; diff --git a/clients/apple/Sources/PunktfunkClient/ClientIdentityStore.swift b/clients/apple/Sources/PunktfunkClient/ClientIdentityStore.swift index 60a5f23..097b865 100644 --- a/clients/apple/Sources/PunktfunkClient/ClientIdentityStore.swift +++ b/clients/apple/Sources/PunktfunkClient/ClientIdentityStore.swift @@ -1,5 +1,6 @@ // This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM), -// generated once and stored in the login Keychain. The certificate's fingerprint is how +// generated once and stored in the data-protection Keychain (with a legacy file-keychain +// fallback for unsigned builds — see `query(dataProtection:)`). The certificate's fingerprint is how // hosts recognize this client after PIN pairing — losing the key un-pairs this Mac from // every host, so the pair is presented on every connect but never regenerated once // stored. That invariant drives the error handling below: a Keychain that *refuses @@ -42,8 +43,9 @@ final class ClientIdentityStore: @unchecked Sendable { break // genuine first run — mint below case .corrupt: // Our own item, undecodable: the pairings it backed are unusable either - // way, so deliberately self-heal by replacing it. - SecItemDelete(Self.query as CFDictionary) + // way, so deliberately self-heal by replacing it (both keychains, best-effort). + SecItemDelete(Self.query(dataProtection: true) as CFDictionary) + SecItemDelete(Self.query(dataProtection: false) as CFDictionary) case .denied(let status): throw IdentityError.keychain(status) } @@ -89,14 +91,35 @@ final class ClientIdentityStore: @unchecked Sendable { case denied(OSStatus) } - private static let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "io.unom.punktfunk", - kSecAttrAccount as String: "client-identity", - ] + /// Item coordinates. We prefer the DATA-PROTECTION keychain: with the app's + /// `keychain-access-groups` entitlement, items there are gated by the app's identity + /// (team + bundle id) instead of a per-binary ACL — so a SIGNED build reads them across + /// rebuilds with NO Keychain prompt (a per-binary ACL re-prompts on every resign, which + /// is why an ad-hoc-signed app asked every launch). An ad-hoc / unsigned build (e.g. + /// `swift run`) has no such entitlement — `SecItem*` returns `errSecMissingEntitlement` + /// there, and we fall back to the legacy file keychain (still works, with the old prompt). + private static func query(dataProtection: Bool) -> [String: Any] { + var q: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "io.unom.punktfunk", + kSecAttrAccount as String: "client-identity", + ] + if dataProtection { q[kSecUseDataProtectionKeychain as String] = true } + return q + } private func copyStored() -> ReadResult { - var query = Self.query + let result = read(dataProtection: true) + // No entitlement (ad-hoc / unsigned build): the data-protection keychain is + // unavailable — read the legacy file keychain instead. + if case .denied(errSecMissingEntitlement) = result { + return read(dataProtection: false) + } + return result + } + + private func read(dataProtection: Bool) -> ReadResult { + var query = Self.query(dataProtection: dataProtection) query[kSecReturnData as String] = true var out: CFTypeRef? switch SecItemCopyMatching(query as CFDictionary, &out) { @@ -116,8 +139,16 @@ final class ClientIdentityStore: @unchecked Sendable { guard let data = try? JSONEncoder().encode( Stored(certPEM: identity.certPEM, keyPEM: identity.keyPEM)) else { return errSecParam } - var add = Self.query + var add = Self.query(dataProtection: true) add[kSecValueData as String] = data - return SecItemAdd(add as CFDictionary, nil) + // After-first-unlock so a background reconnect can still read it; the access-group + // entitlement (not a per-binary ACL) gates it, so it survives rebuilds prompt-free. + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let status = SecItemAdd(add as CFDictionary, nil) + guard status == errSecMissingEntitlement else { return status } + // Ad-hoc / unsigned build: persist to the legacy file keychain instead. + var legacy = Self.query(dataProtection: false) + legacy[kSecValueData as String] = data + return SecItemAdd(legacy as CFDictionary, nil) } }