- Gate every layer behind AppLockView when the lock is enabled and lock on launch and on backgrounding; the passcode lives only in the Keychain (AppLockVault) and the enable state defaults to off. - Add the lock section to Settings: enable/setup, change passcode and disable all require the current passcode via a shared passcode sheet; Face ID / Touch ID unlock prompts automatically and can be toggled with passcode confirmation. - Add NSFaceIDUsageDescription to both app targets, cover the passcode policy with tests, guard the feature in verify.sh and document it in SECURITY.md.
104 lines
3.3 KiB
Swift
104 lines
3.3 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
/// Stores the app-lock passcode in the Keychain and the lock preferences in
|
|
/// UserDefaults. The lock is a UI-level gate: the passcode is kept only in
|
|
/// the Keychain and is never mirrored into memory beyond verification.
|
|
final class AppLockVault {
|
|
private let service = "app.luma.chat.applock"
|
|
private let account = "applock-passcode"
|
|
private let enabledKey = "appLockEnabled"
|
|
private let biometricKey = "appLockBiometricUnlock"
|
|
private let defaults: UserDefaults
|
|
|
|
init(defaults: UserDefaults = .standard) {
|
|
self.defaults = defaults
|
|
}
|
|
|
|
var isEnabled: Bool {
|
|
get { defaults.bool(forKey: enabledKey) }
|
|
set { defaults.set(newValue, forKey: enabledKey) }
|
|
}
|
|
|
|
var biometricUnlockEnabled: Bool {
|
|
get { defaults.bool(forKey: biometricKey) }
|
|
set { defaults.set(newValue, forKey: biometricKey) }
|
|
}
|
|
|
|
func save(passcode: String) throws {
|
|
let data = Data(passcode.utf8)
|
|
let baseQuery: [CFString: Any] = [
|
|
kSecClass: kSecClassGenericPassword,
|
|
kSecAttrService: service,
|
|
kSecAttrAccount: account,
|
|
]
|
|
let updateStatus = SecItemUpdate(
|
|
baseQuery as CFDictionary,
|
|
[kSecValueData: data] as CFDictionary
|
|
)
|
|
if updateStatus == errSecSuccess {
|
|
return
|
|
}
|
|
guard updateStatus == errSecItemNotFound else {
|
|
throw AppLockVaultError.unhandled(updateStatus)
|
|
}
|
|
var insertion = baseQuery
|
|
insertion[kSecValueData] = data
|
|
#if !os(macOS)
|
|
insertion[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
#endif
|
|
let addStatus = SecItemAdd(insertion as CFDictionary, nil)
|
|
guard addStatus == errSecSuccess else {
|
|
throw AppLockVaultError.unhandled(addStatus)
|
|
}
|
|
}
|
|
|
|
func deletePasscode() throws {
|
|
let query: [CFString: Any] = [
|
|
kSecClass: kSecClassGenericPassword,
|
|
kSecAttrService: service,
|
|
kSecAttrAccount: account,
|
|
]
|
|
let status = SecItemDelete(query as CFDictionary)
|
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
throw AppLockVaultError.unhandled(status)
|
|
}
|
|
}
|
|
|
|
func verify(passcode: String) -> Bool {
|
|
guard let stored = storedPasscode() else { return false }
|
|
return passcode == stored
|
|
}
|
|
|
|
private func storedPasscode() -> String? {
|
|
let query: [CFString: Any] = [
|
|
kSecClass: kSecClassGenericPassword,
|
|
kSecAttrService: service,
|
|
kSecAttrAccount: account,
|
|
kSecReturnData: true,
|
|
kSecMatchLimit: kSecMatchLimitOne,
|
|
]
|
|
var item: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
guard status == errSecSuccess,
|
|
let data = item as? Data,
|
|
let value = String(data: data, encoding: .utf8) else {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
}
|
|
|
|
enum AppLockVaultError: LocalizedError {
|
|
case unhandled(OSStatus)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .unhandled(let status):
|
|
let message = SecCopyErrorMessageString(status, nil) as String?
|
|
return message ?? "Не удалось обратиться к Keychain (\(status))."
|
|
}
|
|
}
|
|
}
|
|
|