Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions OpenASO.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1611,7 +1611,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 85BF4D5D6B;
GENERATE_INFOPLIST_FILE = NO;
Expand All @@ -1630,7 +1630,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.4.3;
MARKETING_VERSION = 0.4.4;
POSTHOG_HOST = "";
POSTHOG_PROJECT_TOKEN = "";
PRODUCT_BUNDLE_IDENTIFIER = com.thirdtech.openaso.dev;
Expand Down Expand Up @@ -1840,7 +1840,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 85BF4D5D6B;
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -1860,7 +1860,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.4.3;
MARKETING_VERSION = 0.4.4;
POSTHOG_HOST = "";
POSTHOG_PROJECT_TOKEN = "";
PRODUCT_BUNDLE_IDENTIFIER = com.thirdtech.openaso;
Expand Down
74 changes: 46 additions & 28 deletions OpenASO/Services/Credentials/KeychainService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,40 @@ extension KeychainService {

struct SystemKeychainService: KeychainService {
typealias CopyMatching = (CFDictionary, UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus
typealias Update = (CFDictionary, CFDictionary) -> OSStatus
typealias Add = (CFDictionary, UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus
typealias Delete = (CFDictionary) -> OSStatus
typealias ReadFailureReporter = (KeychainReadFailure) -> Void

private static let logger = Logger(subsystem: OpenASOLog.subsystem, category: "keychain")

private let copyMatching: CopyMatching
private let update: Update
private let add: Add
private let delete: Delete
private let reportReadFailure: ReadFailureReporter

init(
copyMatching: @escaping CopyMatching = { query, result in
SecItemCopyMatching(query, result)
},
update: @escaping Update = { query, attributes in
SecItemUpdate(query, attributes)
},
add: @escaping Add = { query, result in
SecItemAdd(query, result)
},
delete: @escaping Delete = { query in
SecItemDelete(query)
},
reportReadFailure: @escaping ReadFailureReporter = { failure in
SystemKeychainService.logReadFailure(failure)
}
) {
self.copyMatching = copyMatching
self.update = update
self.add = add
self.delete = delete
self.reportReadFailure = reportReadFailure
}

Expand All @@ -80,25 +98,17 @@ struct SystemKeychainService: KeychainService {
usesDataProtectionKeychain: false
)
)
#if DEBUG
// A debug build that had to use the legacy writer cannot prove that `save` migrated the
// item to the protected store, because `save` may itself have taken the debug fallback.
// Keep the source item so credentials and sessions survive the next development launch.
reportFailureIfNeeded(legacyResult)
return legacyResult
#else
if case .success(let data) = legacyResult,
(try? save(data, service: service, account: account)) != nil
(try? saveToDataProtectionKeychain(data, service: service, account: account)) == true
{
SecItemDelete(keychainQuery(
_ = delete(keychainQuery(
service: service,
account: account,
usesDataProtectionKeychain: false
) as CFDictionary)
}
reportFailureIfNeeded(legacyResult)
return legacyResult
#endif
}

private func readData(query baseQuery: [String: Any]) -> KeychainReadResult {
Expand Down Expand Up @@ -133,21 +143,33 @@ struct SystemKeychainService: KeychainService {
}

func save(_ data: Data, service: String, account: String) throws {
guard try saveToDataProtectionKeychain(data, service: service, account: account) else {
try saveToLegacyKeychain(data, service: service, account: account)
return
}
}

/// Returns `false` when this signed process cannot use the Data Protection Keychain and the
/// caller should use the encrypted login Keychain instead.
private func saveToDataProtectionKeychain(
_ data: Data,
service: String,
account: String
) throws -> Bool {
let query = keychainQuery(
service: service,
account: account,
usesDataProtectionKeychain: true
)
let attributes: [String: Any] = [kSecValueData as String: data]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
let status = update(query as CFDictionary, attributes as CFDictionary)

if status == errSecSuccess {
return
return true
}

if Self.shouldUseLegacyWriteFallback(for: status) {
try saveToLegacyKeychain(data, service: service, account: account)
return
return false
}

guard status == errSecItemNotFound else {
Expand All @@ -158,25 +180,21 @@ struct SystemKeychainService: KeychainService {
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock

let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
let addStatus = add(addQuery as CFDictionary, nil)
if Self.shouldUseLegacyWriteFallback(for: addStatus) {
try saveToLegacyKeychain(data, service: service, account: account)
return
return false
}
guard addStatus == errSecSuccess else {
throw OpenASOError.providerUnavailable("Could not save item to Keychain.")
}
return true
}

/// Local ad-hoc/debug builds can lack the application identifier entitlement required by the
/// Data Protection Keychain. Keep Release on the protected store while allowing development
/// builds to use the encrypted macOS login Keychain that `readData` already migrates from.
/// Directly distributed and local builds can lack the application identifier entitlement
/// required by the Data Protection Keychain. In that case, use the encrypted macOS login
/// Keychain that `readData` already supports.
nonisolated static func shouldUseLegacyWriteFallback(for status: OSStatus) -> Bool {
#if DEBUG
status == errSecMissingEntitlement
#else
false
#endif
}

private func saveToLegacyKeychain(_ data: Data, service: String, account: String) throws {
Expand All @@ -186,7 +204,7 @@ struct SystemKeychainService: KeychainService {
usesDataProtectionKeychain: false
)
let attributes: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
let updateStatus = update(query as CFDictionary, attributes as CFDictionary)
if updateStatus == errSecSuccess {
return
}
Expand All @@ -198,18 +216,18 @@ struct SystemKeychainService: KeychainService {
var addQuery = query
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
guard SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess else {
guard add(addQuery as CFDictionary, nil) == errSecSuccess else {
throw OpenASOError.providerUnavailable("Could not save item to Keychain.")
}
}

func delete(service: String, account: String) {
SecItemDelete(keychainQuery(
_ = delete(keychainQuery(
service: service,
account: account,
usesDataProtectionKeychain: true
) as CFDictionary)
SecItemDelete(keychainQuery(
_ = delete(keychainQuery(
service: service,
account: account,
usesDataProtectionKeychain: false
Expand Down
54 changes: 51 additions & 3 deletions OpenASOTests/AppServicesDependencyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -818,14 +818,62 @@ struct AppServicesDependencyTests {
}

@Test
func debugBuildFallsBackToLoginKeychainOnlyForMissingEntitlement() {
#if DEBUG
func systemKeychainFallsBackToLoginKeychainOnlyForMissingEntitlement() {
#expect(SystemKeychainService.shouldUseLegacyWriteFallback(for: errSecMissingEntitlement))
#endif
#expect(!SystemKeychainService.shouldUseLegacyWriteFallback(for: errSecAuthFailed))
#expect(!SystemKeychainService.shouldUseLegacyWriteFallback(for: errSecInteractionNotAllowed))
}

@Test
func systemKeychainUsesLoginKeychainWhenDataProtectionEntitlementIsMissing() throws {
var updatedQueries: [[String: Any]] = []
var addedQueries: [[String: Any]] = []
let keychain = SystemKeychainService(
update: { query, _ in
let query = query as NSDictionary as! [String: Any]
updatedQueries.append(query)
return query[kSecUseDataProtectionKeychain as String] as? Bool == true
? errSecMissingEntitlement
: errSecItemNotFound
},
add: { query, _ in
addedQueries.append(query as NSDictionary as! [String: Any])
return errSecSuccess
}
)

try keychain.save(Data("secret".utf8), service: "service", account: "account")

#expect(updatedQueries.count == 2)
#expect(updatedQueries.first?[kSecUseDataProtectionKeychain as String] as? Bool == true)
#expect(updatedQueries.last?[kSecUseDataProtectionKeychain as String] == nil)
#expect(addedQueries.count == 1)
#expect(addedQueries.first?[kSecUseDataProtectionKeychain as String] == nil)
}

@Test
func systemKeychainPreservesLegacyItemWhenProtectedMigrationLacksEntitlement() {
let data = Data("secret".utf8)
var readCount = 0
var deletedQueries: [[String: Any]] = []
let keychain = SystemKeychainService(
copyMatching: { _, result in
readCount += 1
guard readCount == 2 else { return errSecItemNotFound }
result?.pointee = data as CFData
return errSecSuccess
},
update: { _, _ in errSecMissingEntitlement },
delete: { query in
deletedQueries.append(query as NSDictionary as! [String: Any])
return errSecSuccess
}
)

#expect(keychain.readData(service: "service", account: "account") == .success(data))
#expect(deletedQueries.isEmpty)
}

@Test
func systemKeychainTreatsMissingItemAsExpectedAbsence() {
var reportedFailures: [KeychainReadFailure] = []
Expand Down
Loading