133 lines
4.8 KiB
Swift
133 lines
4.8 KiB
Swift
import Foundation
|
|
import Security
|
|
import LocalAuthentication
|
|
|
|
enum KeychainService {
|
|
private static let service = "com.encryptedchat.credentials"
|
|
private static let account = "userCredentials"
|
|
|
|
struct Credentials: Codable {
|
|
let email: String
|
|
let password: String
|
|
let host: String
|
|
let port: UInt16
|
|
}
|
|
|
|
/// Check if saved credentials exist without triggering biometric prompt.
|
|
static func hasSavedCredentials() -> Bool {
|
|
let context = LAContext()
|
|
context.interactionNotAllowed = true
|
|
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
kSecReturnAttributes as String: true,
|
|
kSecUseAuthenticationContext as String: context
|
|
]
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
// errSecInteractionNotAllowed means item exists but needs biometric
|
|
return status == errSecSuccess || status == errSecInteractionNotAllowed
|
|
}
|
|
|
|
/// Save credentials to Keychain with biometric protection.
|
|
static func saveCredentials(email: String, password: String, host: String, port: UInt16) throws {
|
|
// Delete any existing entry first
|
|
deleteCredentials()
|
|
|
|
let credentials = Credentials(email: email, password: password, host: host, port: port)
|
|
let data = try JSONEncoder().encode(credentials)
|
|
|
|
var accessError: Unmanaged<CFError>?
|
|
guard let accessControl = SecAccessControlCreateWithFlags(
|
|
kCFAllocatorDefault,
|
|
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
|
|
.biometryAny,
|
|
&accessError
|
|
) else {
|
|
throw KeychainError.accessControlCreationFailed
|
|
}
|
|
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecValueData as String: data,
|
|
kSecAttrAccessControl as String: accessControl
|
|
]
|
|
|
|
let status = SecItemAdd(query as CFDictionary, nil)
|
|
guard status == errSecSuccess else {
|
|
throw KeychainError.saveFailed(status)
|
|
}
|
|
}
|
|
|
|
/// Load credentials from Keychain. Triggers biometric prompt.
|
|
static func loadCredentials() throws -> Credentials {
|
|
let context = LAContext()
|
|
context.localizedReason = "Unlock to log in"
|
|
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
kSecUseAuthenticationContext as String: context
|
|
]
|
|
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
|
|
guard status == errSecSuccess, let data = result as? Data else {
|
|
if status == errSecUserCanceled || status == errSecAuthFailed {
|
|
throw KeychainError.biometricFailed
|
|
}
|
|
throw KeychainError.loadFailed(status)
|
|
}
|
|
|
|
return try JSONDecoder().decode(Credentials.self, from: data)
|
|
}
|
|
|
|
/// Delete stored credentials from Keychain.
|
|
@discardableResult
|
|
static func deleteCredentials() -> Bool {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account
|
|
]
|
|
let status = SecItemDelete(query as CFDictionary)
|
|
return status == errSecSuccess || status == errSecItemNotFound
|
|
}
|
|
|
|
/// Check if biometric authentication is available on this device.
|
|
static func isBiometricAvailable() -> Bool {
|
|
let context = LAContext()
|
|
var error: NSError?
|
|
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
|
}
|
|
|
|
enum KeychainError: LocalizedError {
|
|
case accessControlCreationFailed
|
|
case saveFailed(OSStatus)
|
|
case loadFailed(OSStatus)
|
|
case biometricFailed
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .accessControlCreationFailed:
|
|
return "Failed to create biometric access control"
|
|
case .saveFailed(let status):
|
|
return "Failed to save credentials (error \(status))"
|
|
case .loadFailed(let status):
|
|
return "Failed to load credentials (error \(status))"
|
|
case .biometricFailed:
|
|
return "Biometric authentication failed or was cancelled"
|
|
}
|
|
}
|
|
}
|
|
}
|