diff --git a/NearbyShare/Util/AppGroup.swift b/NearbyShare/Util/AppGroup.swift index cd2561f..9be117d 100644 --- a/NearbyShare/Util/AppGroup.swift +++ b/NearbyShare/Util/AppGroup.swift @@ -6,6 +6,7 @@ // import Foundation +import Security /// Contains information about the app group used to share data between main app and extensions struct AppGroup { @@ -19,3 +20,90 @@ struct AppGroup { return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupName) } } + + +struct KeychainStore { + private static let service = "com.leonboettger.quickdrop.securestorage" + // Must match the shared keychain access group declared in all Apple entitlements. + private static let sharedAccessGroup = "92SDAWLN76.com.leonboettger.neardrop" + + static func loadData(account: String) throws -> Data? { + var query = baseQuery(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + return result as? Data + case errSecItemNotFound: + return nil + default: + throw KeychainStoreError.unexpectedStatus(status) + } + } + + + static func saveData(_ data: Data, account: String) throws { + var addQuery = baseQuery(account: account) + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus == errSecSuccess { + return + } + if addStatus != errSecDuplicateItem { + throw KeychainStoreError.unexpectedStatus(addStatus) + } + + let attributesToUpdate: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + + let updateStatus = SecItemUpdate(baseQuery(account: account) as CFDictionary, attributesToUpdate as CFDictionary) + guard updateStatus == errSecSuccess else { + throw KeychainStoreError.unexpectedStatus(updateStatus) + } + } + + + static func deleteItem(account: String) throws { + let status = SecItemDelete(baseQuery(account: account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainStoreError.unexpectedStatus(status) + } + } + + + private static func baseQuery(account: String) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true, + ] + + query[kSecAttrAccessGroup as String] = sharedAccessGroup + + return query + } +} + + +enum KeychainStoreError: LocalizedError { + case unexpectedStatus(OSStatus) + + var errorDescription: String? { + switch self { + case let .unexpectedStatus(status): + if let message = SecCopyErrorMessageString(status, nil) as String? { + return "Keychain error \(status): \(message)" + } + return "Keychain error \(status)" + } + } +} diff --git a/NearbyShare/Util/IdentityManager.swift b/NearbyShare/Util/IdentityManager.swift index a912378..d0c63ff 100644 --- a/NearbyShare/Util/IdentityManager.swift +++ b/NearbyShare/Util/IdentityManager.swift @@ -51,17 +51,21 @@ class IdentityManager { private func generateAndStoreKeyPair() -> (ECPrivateKey, ECPublicKey) { let domain = Domain.instance(curve: .EC256r1) let (pubKey, privKey) = domain.makeKeyPair() - - AppGroup.appGroupUD.set(Data(privKey.s.asSignedBytes()), forKey: signingKeyTag) + + let privateKeyData = Data(privKey.s.asSignedBytes()) + do { + try KeychainStore.saveData(privateKeyData, account: signingKeyTag) + AppGroup.appGroupUD.removeObject(forKey: signingKeyTag) + } catch { + log("[IdentityManager] Failed to store private key in keychain: \(error.localizedDescription)") + } return (privKey, pubKey) } private func loadKeys() -> (ECPrivateKey, ECPublicKey)? { - let keyData = AppGroup.appGroupUD.data(forKey: signingKeyTag) - - guard let keyData = keyData else { return nil } + guard let keyData = loadStoredKeyData() else { return nil } let domain = Domain.instance(curve: .EC256r1) guard let privateKey = try? ECPrivateKey(domain: domain, s: .init(signed: Array(keyData))) else { @@ -80,6 +84,32 @@ class IdentityManager { return (privateKey, publicKey) } + + + private func loadStoredKeyData() -> Data? { + do { + if let keyData = try KeychainStore.loadData(account: signingKeyTag) { + AppGroup.appGroupUD.removeObject(forKey: signingKeyTag) + return keyData + } + } catch { + log("[IdentityManager] Failed to load private key from keychain: \(error.localizedDescription)") + } + + guard let legacyKeyData = AppGroup.appGroupUD.data(forKey: signingKeyTag) else { + return nil + } + + do { + try KeychainStore.saveData(legacyKeyData, account: signingKeyTag) + AppGroup.appGroupUD.removeObject(forKey: signingKeyTag) + log("[IdentityManager] Migrated private key from shared defaults to keychain.") + } catch { + log("[IdentityManager] Failed to migrate private key to keychain: \(error.localizedDescription)") + } + + return legacyKeyData + } } diff --git a/NearbyShare/Util/TrustStore.swift b/NearbyShare/Util/TrustStore.swift index 87e5f99..151d96c 100644 --- a/NearbyShare/Util/TrustStore.swift +++ b/NearbyShare/Util/TrustStore.swift @@ -25,29 +25,40 @@ class TrustStore: ObservableObject { // MARK: - Persistence private func loadTrustedCertificates() { - guard let dict = AppGroup.appGroupUD.dictionary(forKey: trustedKeysKey) as? [String: Data] else { + do { + if let data = try KeychainStore.loadData(account: trustedKeysKey) { + trustedCertificates = try JSONDecoder().decode([String: TrustedCertificate].self, from: data) + AppGroup.appGroupUD.removeObject(forKey: trustedKeysKey) + return + } + } catch { + print("[TrustStore] Failed to load trusted certificates from keychain: \(error.localizedDescription)") + } + + guard let dict = legacyTrustedCertificates() else { trustedCertificates = [:] return } - var decoded: [String: TrustedCertificate] = [:] - for (key, data) in dict { - if let cert = try? JSONDecoder().decode(TrustedCertificate.self, from: data) { - decoded[key] = cert - } + trustedCertificates = dict + + do { + try persistTrustedCertificates() + AppGroup.appGroupUD.removeObject(forKey: trustedKeysKey) + print("[TrustStore] Migrated trusted certificates from shared defaults to keychain.") + } catch { + print("[TrustStore] Failed to migrate trusted certificates to keychain: \(error.localizedDescription)") } - trustedCertificates = decoded } private func saveTrustedCertificates() { - var dict: [String: Data] = [:] - for (key, cert) in trustedCertificates { - if let data = try? JSONEncoder().encode(cert) { - dict[key] = data - } + do { + try persistTrustedCertificates() + AppGroup.appGroupUD.removeObject(forKey: trustedKeysKey) + } catch { + print("[TrustStore] Failed to save trusted certificates to keychain: \(error.localizedDescription)") } - AppGroup.appGroupUD.set(dict, forKey: trustedKeysKey) } @@ -84,4 +95,25 @@ class TrustStore: ObservableObject { let creationDate: Date let certificateData: Data } + + + private func persistTrustedCertificates() throws { + let data = try JSONEncoder().encode(trustedCertificates) + try KeychainStore.saveData(data, account: trustedKeysKey) + } + + + private func legacyTrustedCertificates() -> [String: TrustedCertificate]? { + guard let dict = AppGroup.appGroupUD.dictionary(forKey: trustedKeysKey) as? [String: Data] else { + return nil + } + + var decoded: [String: TrustedCertificate] = [:] + for (key, data) in dict { + if let cert = try? JSONDecoder().decode(TrustedCertificate.self, from: data) { + decoded[key] = cert + } + } + return decoded + } } diff --git a/QuickDrop/QuickDrop.entitlements b/QuickDrop/QuickDrop.entitlements index a8302e4..afe12fd 100644 --- a/QuickDrop/QuickDrop.entitlements +++ b/QuickDrop/QuickDrop.entitlements @@ -6,5 +6,9 @@ group.com.leonboettger.neardrop + keychain-access-groups + + $(AppIdentifierPrefix)com.leonboettger.neardrop + diff --git a/QuickDropiOS/QuickDropiOS.entitlements b/QuickDropiOS/QuickDropiOS.entitlements index dfc9800..c2bc796 100644 --- a/QuickDropiOS/QuickDropiOS.entitlements +++ b/QuickDropiOS/QuickDropiOS.entitlements @@ -8,5 +8,9 @@ group.com.leonboettger.neardrop + keychain-access-groups + + $(AppIdentifierPrefix)com.leonboettger.neardrop + diff --git a/ShareExtension/ShareExtension.entitlements b/ShareExtension/ShareExtension.entitlements index a8302e4..afe12fd 100644 --- a/ShareExtension/ShareExtension.entitlements +++ b/ShareExtension/ShareExtension.entitlements @@ -6,5 +6,9 @@ group.com.leonboettger.neardrop + keychain-access-groups + + $(AppIdentifierPrefix)com.leonboettger.neardrop + diff --git a/iOSShareExtension/iOSShareExtension.entitlements b/iOSShareExtension/iOSShareExtension.entitlements index dfc9800..c2bc796 100644 --- a/iOSShareExtension/iOSShareExtension.entitlements +++ b/iOSShareExtension/iOSShareExtension.entitlements @@ -8,5 +8,9 @@ group.com.leonboettger.neardrop + keychain-access-groups + + $(AppIdentifierPrefix)com.leonboettger.neardrop +