Skip to content

Commit 4c65642

Browse files
Report keychain failures during AppKeychain and SharedKeychain access (#25702)
* Report keychain failures during AppKeychain and SharedKeychain access Both keychain wrappers now route every read, write, and delete failure through a shared reporter. The expected not-found stays silent, other real failures are logged via swift-log together with the failing call site, and an entitlement mismatch crashes because it means the access group is unreachable for the entire build rather than a recoverable runtime condition. This adds a swift-log dependency to the WordPressShared target. * Force-unwrap main bundle id Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> * NSError code to OSStatus Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> --------- Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
1 parent df4a181 commit 4c65642

5 files changed

Lines changed: 129 additions & 40 deletions

File tree

Modules/Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ let package = Package(
230230
name: "WordPressShared",
231231
dependencies: [
232232
"BuildSettingsKit",
233+
.product(name: "Logging", package: "swift-log"),
233234
.product(name: "SwiftSoup", package: "SwiftSoup"),
234235
.target(name: "SFHFKeychainUtils"),
235236
.target(name: "WordPressSharedObjC")

Modules/Sources/WordPressShared/Keychain/AppKeychain.swift

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -46,27 +46,38 @@ public final class AppKeychain: KeychainAccessible {
4646
accessGroup: privateGroup
4747
)
4848
} catch {
49+
reportKeychainFailureIfNeeded(error, serviceName: serviceName, accessGroup: privateGroup)
4950
// A real failure (for example errSecInteractionNotAllowed while the
5051
// device is locked) must surface: the fallback is long-lived now,
5152
// so masking it as not-found would be permanent. Fall back only on
5253
// a genuine not-found of the private read, and only when a shared
5354
// group exists.
5455
guard !isRealKeychainFailure(error), let sharedGroup else { throw error }
55-
let value = try keychainUtils.getPasswordForUsername(
56-
username,
57-
andServiceName: serviceName,
58-
accessGroup: sharedGroup
59-
)
56+
let value: String
57+
do {
58+
value = try keychainUtils.getPasswordForUsername(
59+
username,
60+
andServiceName: serviceName,
61+
accessGroup: sharedGroup
62+
)
63+
} catch {
64+
reportKeychainFailureIfNeeded(error, serviceName: serviceName, accessGroup: sharedGroup)
65+
throw error
66+
}
6067
// Read-repair: migrate the item into the private group so future
6168
// reads stop depending on the shared-group fallback. Best-effort,
6269
// the read already succeeded; the next read retries the repair.
63-
try? keychainUtils.storeUsername(
64-
username,
65-
andPassword: value,
66-
forServiceName: serviceName,
67-
accessGroup: privateGroup,
68-
updateExisting: true
69-
)
70+
do {
71+
try keychainUtils.storeUsername(
72+
username,
73+
andPassword: value,
74+
forServiceName: serviceName,
75+
accessGroup: privateGroup,
76+
updateExisting: true
77+
)
78+
} catch {
79+
reportKeychainFailureIfNeeded(error, serviceName: serviceName, accessGroup: privateGroup)
80+
}
7081
return value
7182
}
7283
}
@@ -99,13 +110,18 @@ public final class AppKeychain: KeychainAccessible {
99110
}
100111
return
101112
}
102-
try keychainUtils.storeUsername(
103-
username,
104-
andPassword: newValue,
105-
forServiceName: serviceName,
106-
accessGroup: privateGroup,
107-
updateExisting: true
108-
)
113+
do {
114+
try keychainUtils.storeUsername(
115+
username,
116+
andPassword: newValue,
117+
forServiceName: serviceName,
118+
accessGroup: privateGroup,
119+
updateExisting: true
120+
)
121+
} catch {
122+
reportKeychainFailureIfNeeded(error, serviceName: serviceName, accessGroup: privateGroup)
123+
throw error
124+
}
109125
}
110126

111127
private func deleteIgnoringNotFound(_ username: String, serviceName: String, accessGroup: String) throws {
@@ -116,6 +132,7 @@ public final class AppKeychain: KeychainAccessible {
116132
accessGroup: accessGroup
117133
)
118134
} catch {
135+
reportKeychainFailureIfNeeded(error, serviceName: serviceName, accessGroup: accessGroup)
119136
// Deleting a missing item is expected: the item usually exists
120137
// in only one of the two groups. Anything else (for example
121138
// errSecInteractionNotAllowed while the device is locked) must
Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,64 @@
11
import Foundation
2+
import Logging
23
import Security
34

45
/// The error domain `SFHFKeychainUtils` uses for its own failures. Shared so
56
/// the classifier and its tests cannot drift to different spellings.
67
let sfhfKeychainErrorDomain = "SFHFKeychainUtilsErrorDomain"
78

8-
/// Classifies errors thrown by `SFHFKeychainUtils`.
9+
/// The underlying Keychain `OSStatus` for an error produced by
10+
/// `SFHFKeychainUtils`, or `nil` when the error is not from that domain.
911
///
10-
/// `SFHFKeychainUtils` populates an `NSError` in its own domain (with the raw
11-
/// `OSStatus` as the code) only for real failures. A not-found surfaces either
12-
/// as a nil result that Swift bridges to a generic non-SFHF error (reads), or
13-
/// as an SFHF error whose code is `errSecItemNotFound` (deletes). So a real
14-
/// failure is exactly an SFHF-domain error whose code is not `errSecItemNotFound`.
15-
public func isRealKeychainFailure(_ error: Error) -> Bool {
12+
/// `SFHFKeychainUtils` reports real failures as an `NSError` in its own domain,
13+
/// carrying the raw Keychain `OSStatus` as the code. A not-found read instead
14+
/// bridges to a generic error in another domain. Centralizing the domain check
15+
/// and the `Int` -> `OSStatus` conversion here lets callers compare directly
16+
/// against the `errSec*` constants.
17+
func keychainErrorCode(error: Error) -> OSStatus? {
1618
let nsError = error as NSError
17-
return nsError.domain == sfhfKeychainErrorDomain
18-
&& nsError.code != Int(errSecItemNotFound)
19+
guard nsError.domain == sfhfKeychainErrorDomain else { return nil }
20+
return OSStatus(truncatingIfNeeded: nsError.code)
21+
}
22+
23+
/// Whether an error from `SFHFKeychainUtils` is a real failure rather than the
24+
/// expected not-found. A not-found surfaces either as a non-SFHF error (reads)
25+
/// or as an SFHF error whose code is `errSecItemNotFound` (deletes).
26+
public func isRealKeychainFailure(_ error: Error) -> Bool {
27+
guard let code = keychainErrorCode(error: error) else { return false }
28+
return code != errSecItemNotFound
29+
}
30+
31+
private let keychainLogger = Logger(label: (Bundle.main.bundleIdentifier!) + ".keychain")
32+
33+
/// Logs a real keychain failure. An entitlement mismatch is fatal: it means
34+
/// the access group is unreachable for the entire build (a provisioning or
35+
/// build-settings defect, not a runtime condition), so there is nothing to
36+
/// recover to and we crash to surface it immediately. Does nothing for the
37+
/// expected not-found.
38+
///
39+
/// For every other failure this only emits telemetry and does not change
40+
/// control flow: callers still throw, fall back, or swallow as before. The
41+
/// source location identifies which call site (read, write, or delete) failed.
42+
func reportKeychainFailureIfNeeded(
43+
_ error: Error,
44+
serviceName: String,
45+
accessGroup: String,
46+
file: StaticString = #fileID,
47+
line: UInt = #line
48+
) {
49+
guard let status = keychainErrorCode(error: error), status != errSecItemNotFound else {
50+
return
51+
}
52+
53+
if status == errSecMissingEntitlement {
54+
fatalError(
55+
"Keychain entitlement mismatch (service: \(serviceName), group: \(accessGroup))",
56+
file: file,
57+
line: line
58+
)
59+
}
60+
61+
keychainLogger.error(
62+
"Keychain failure (service: \(serviceName), group: \(accessGroup), status: \(status)) at \(file):\(line)"
63+
)
1964
}

Modules/Sources/WordPressShared/Keychain/SharedKeychain.swift

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,32 @@ public final class SharedKeychain: KeychainAccessible {
2828
}
2929

3030
public func getPassword(for username: String, serviceName: String) throws -> String {
31-
try keychainUtils.getPasswordForUsername(
32-
username,
33-
andServiceName: serviceName,
34-
accessGroup: group
35-
)
31+
do {
32+
return try keychainUtils.getPasswordForUsername(
33+
username,
34+
andServiceName: serviceName,
35+
accessGroup: group
36+
)
37+
} catch {
38+
reportKeychainFailureIfNeeded(error, serviceName: serviceName, accessGroup: group)
39+
throw error
40+
}
3641
}
3742

3843
public func setPassword(for username: String, to newValue: String?, serviceName: String) throws {
3944
if let newValue {
40-
try keychainUtils.storeUsername(
41-
username,
42-
andPassword: newValue,
43-
forServiceName: serviceName,
44-
accessGroup: group,
45-
updateExisting: true
46-
)
45+
do {
46+
try keychainUtils.storeUsername(
47+
username,
48+
andPassword: newValue,
49+
forServiceName: serviceName,
50+
accessGroup: group,
51+
updateExisting: true
52+
)
53+
} catch {
54+
reportKeychainFailureIfNeeded(error, serviceName: serviceName, accessGroup: group)
55+
throw error
56+
}
4757
} else {
4858
do {
4959
try keychainUtils.deleteItem(
@@ -52,6 +62,7 @@ public final class SharedKeychain: KeychainAccessible {
5262
accessGroup: group
5363
)
5464
} catch {
65+
reportKeychainFailureIfNeeded(error, serviceName: serviceName, accessGroup: group)
5566
// Deleting an already-absent item is success: the migration
5667
// cleanup path removes a token that may legitimately be gone.
5768
// Real failures must surface, same as AppKeychain.

Modules/Tests/WordPressSharedTests/KeychainErrorClassificationTests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,19 @@ struct KeychainErrorClassificationTests {
1919
enum ReadError: Error { case notFound }
2020
#expect(!isRealKeychainFailure(ReadError.notFound))
2121
}
22+
23+
@Test func entitlementMismatchIsRealFailure() {
24+
let error = NSError(domain: sfhfKeychainErrorDomain, code: Int(errSecMissingEntitlement))
25+
#expect(isRealKeychainFailure(error))
26+
}
27+
28+
@Test func keychainErrorCodeReturnsStatusForSFHFError() {
29+
let error = NSError(domain: sfhfKeychainErrorDomain, code: Int(errSecMissingEntitlement))
30+
#expect(keychainErrorCode(error: error) == errSecMissingEntitlement)
31+
}
32+
33+
@Test func keychainErrorCodeIsNilForForeignDomain() {
34+
let error = NSError(domain: NSCocoaErrorDomain, code: Int(errSecMissingEntitlement))
35+
#expect(keychainErrorCode(error: error) == nil)
36+
}
2237
}

0 commit comments

Comments
 (0)