Files
Luma/Sources/Shared/Security/AppLockVault.swift
T
wt 0d78e02d2b
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
#8 add app lock with passcode and biometric unlock
- 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.
2026-08-29 05:59:55 +07:00

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))."
}
}
}