fix(apple): persist Keychain trust — sign macOS + data-protection keychain
ci / web (push) Failing after 34s
ci / docs-site (push) Failing after 40s
apple / swift (push) Successful in 1m17s
ci / rust (push) Successful in 1m8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / deploy-docs (push) Successful in 19s
deb / build-publish (push) Failing after 2m19s
ci / web (push) Failing after 34s
ci / docs-site (push) Failing after 40s
apple / swift (push) Successful in 1m17s
ci / rust (push) Successful in 1m8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / deploy-docs (push) Successful in 19s
deb / build-publish (push) Failing after 2m19s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Keychain Sharing: a team-scoped access group so the punktfunk/1 client identity in
|
||||||
|
the data-protection keychain is gated by the app's entitlement (team + bundle id),
|
||||||
|
not a per-binary ACL. That is what makes Keychain access persist across rebuilds
|
||||||
|
with NO prompt — see ClientIdentityStore. $(AppIdentifierPrefix) expands to the
|
||||||
|
team prefix at signing time. -->
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -353,8 +353,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_IDENTITY = "-";
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -387,8 +387,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_IDENTITY = "-";
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -421,6 +421,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
@@ -459,6 +460,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
@@ -496,6 +498,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
@@ -525,6 +528,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM),
|
// 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
|
// 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
|
// 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
|
// 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
|
break // genuine first run — mint below
|
||||||
case .corrupt:
|
case .corrupt:
|
||||||
// Our own item, undecodable: the pairings it backed are unusable either
|
// Our own item, undecodable: the pairings it backed are unusable either
|
||||||
// way, so deliberately self-heal by replacing it.
|
// way, so deliberately self-heal by replacing it (both keychains, best-effort).
|
||||||
SecItemDelete(Self.query as CFDictionary)
|
SecItemDelete(Self.query(dataProtection: true) as CFDictionary)
|
||||||
|
SecItemDelete(Self.query(dataProtection: false) as CFDictionary)
|
||||||
case .denied(let status):
|
case .denied(let status):
|
||||||
throw IdentityError.keychain(status)
|
throw IdentityError.keychain(status)
|
||||||
}
|
}
|
||||||
@@ -89,14 +91,35 @@ final class ClientIdentityStore: @unchecked Sendable {
|
|||||||
case denied(OSStatus)
|
case denied(OSStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let query: [String: Any] = [
|
/// Item coordinates. We prefer the DATA-PROTECTION keychain: with the app's
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
/// `keychain-access-groups` entitlement, items there are gated by the app's identity
|
||||||
kSecAttrService as String: "io.unom.punktfunk",
|
/// (team + bundle id) instead of a per-binary ACL — so a SIGNED build reads them across
|
||||||
kSecAttrAccount as String: "client-identity",
|
/// 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 {
|
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
|
query[kSecReturnData as String] = true
|
||||||
var out: CFTypeRef?
|
var out: CFTypeRef?
|
||||||
switch SecItemCopyMatching(query as CFDictionary, &out) {
|
switch SecItemCopyMatching(query as CFDictionary, &out) {
|
||||||
@@ -116,8 +139,16 @@ final class ClientIdentityStore: @unchecked Sendable {
|
|||||||
guard let data = try? JSONEncoder().encode(
|
guard let data = try? JSONEncoder().encode(
|
||||||
Stored(certPEM: identity.certPEM, keyPEM: identity.keyPEM))
|
Stored(certPEM: identity.certPEM, keyPEM: identity.keyPEM))
|
||||||
else { return errSecParam }
|
else { return errSecParam }
|
||||||
var add = Self.query
|
var add = Self.query(dataProtection: true)
|
||||||
add[kSecValueData as String] = data
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user