ios_client

This commit is contained in:
Filip
2026-03-14 12:43:56 +01:00
parent 5fd80e6dd6
commit 214da18779
74 changed files with 13136 additions and 284 deletions

View File

@@ -0,0 +1,132 @@
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"
}
}
}
}