#8 add app lock with passcode and biometric unlock
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

- 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.
This commit is contained in:
wt committed 2026-08-29 05:59:55 +07:00
1 parent b0eb44d7a7
commit 0d78e02d2b
13 files changed
+663 -21

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
import Foundation
/// Pure policy for the app-lock passcode: minimum length and validation.
/// Kept separate from the Keychain storage so it is unit-testable.
enum AppLockPolicy {
static let minimumLength = 4
static func isValid(_ passcode: String) -> Bool {
passcode.count >= minimumLength
}
}
+89
View File
@@ -1,6 +1,7 @@
import AVFoundation
import Combine
import Foundation
import LocalAuthentication
import SwiftData
import UniformTypeIdentifiers
import WebRTC
@@ -56,6 +57,9 @@ final class AppModel: ObservableObject {
@Published var errorMessage: String?
@Published var informationalMessage: String?
@Published var previewURL: URL?
@Published private(set) var isAppLocked = false
@Published private(set) var appLockIsEnabled = false
@Published private(set) var appLockBiometricIsEnabled = false
@Published private(set) var mediaViewerItem: MediaViewerItem?
@Published private(set) var mediaPreviewURLs: [String: URL] = [:]
@Published private(set) var mediaThumbnailData: [String: Data] = [:]
@@ -68,6 +72,7 @@ final class AppModel: ObservableObject {
private let xmpp: XMPPService
private let credentials: CredentialVault
private let preferences: AccountPreferences
private let appLock = AppLockVault()
private let notifications: NotificationCoordinator
private let watchBridge: PhoneWatchBridge
private let avatarCache: AvatarCache
@@ -129,6 +134,13 @@ final class AppModel: ObservableObject {
self.watchBridge = watchBridge
self.avatarCache = avatarCache
// The app lock must be initialised before any view reads it; the
// locked state persists only in memory and is re-applied from the
// stored preferences on launch.
appLockIsEnabled = appLock.isEnabled
appLockBiometricIsEnabled = appLock.biometricUnlockEnabled
isAppLocked = appLock.isEnabled && !RuntimeEnvironment.isRunningTests
xmpp.eventHandler = { [weak self] event in
self?.consume(event)
}
@@ -454,12 +466,89 @@ final class AppModel: ObservableObject {
if !active {
resetTypingState()
syncWatch(immediate: true)
if appLockIsEnabled {
isAppLocked = true
}
}
if active, case .disconnected(_) = connectionStatus {
Task { await reconnect() }
}
}
// MARK: - App lock
func unlockWithPasscode(_ passcode: String) -> Bool {
guard appLock.verify(passcode: passcode) else { return false }
isAppLocked = false
return true
}
func unlockWithBiometrics() async -> Bool {
guard appLockIsEnabled, appLockBiometricIsEnabled else { return false }
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
else {
return false
}
do {
let granted = try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Разблокируйте Luma"
)
guard granted else { return false }
isAppLocked = false
return true
} catch {
return false
}
}
func enableAppLock(passcode: String) -> Bool {
guard AppLockPolicy.isValid(passcode) else { return false }
do {
try appLock.save(passcode: passcode)
appLock.isEnabled = true
appLockIsEnabled = true
isAppLocked = false
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
func disableAppLock(passcode: String) -> Bool {
guard appLock.verify(passcode: passcode) else { return false }
try? appLock.deletePasscode()
appLock.isEnabled = false
appLock.biometricUnlockEnabled = false
appLockIsEnabled = false
appLockBiometricIsEnabled = false
isAppLocked = false
return true
}
func setAppLockBiometricUnlock(_ enabled: Bool, passcode: String) -> Bool {
guard appLock.verify(passcode: passcode) else { return false }
appLock.biometricUnlockEnabled = enabled
appLockBiometricIsEnabled = enabled
return true
}
func changeAppLockPasscode(from oldPasscode: String, to newPasscode: String) -> Bool {
guard AppLockPolicy.isValid(newPasscode), appLock.verify(passcode: oldPasscode) else {
return false
}
do {
try appLock.save(passcode: newPasscode)
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
func setVideoNoteCaptureActive(_ active: Bool) {
videoNoteCaptureIsActive = active
updateArchiveSyncSuspensionForMedia()
+103
View File
@@ -0,0 +1,103 @@
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))."
}
}
}
+314
View File
@@ -0,0 +1,314 @@
import LocalAuthentication
import SwiftUI
@MainActor
struct AppLockView: View {
@ObservedObject var model: AppModel
@State private var passcode = ""
@State private var attemptFailed = false
@FocusState private var isFocused: Bool
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.08, green: 0.38, blue: 0.78),
Color(red: 0.12, green: 0.62, blue: 0.96),
Color(red: 0.44, green: 0.84, blue: 0.96),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(spacing: 22) {
Image(systemName: "lock.fill")
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(.white)
Text("Luma заблокирован")
.font(.title2.weight(.bold))
.foregroundStyle(.white)
SecureField("Пароль", text: $passcode)
.focused($isFocused)
.textContentType(.password)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.center)
.frame(maxWidth: 260)
.onSubmit(submit)
if attemptFailed {
Text("Неверный пароль")
.font(.caption.weight(.medium))
.foregroundStyle(.white.opacity(0.95))
}
Button(action: submit) {
Text("Разблокировать")
.fontWeight(.semibold)
.frame(maxWidth: 260)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.disabled(passcode.isEmpty)
if biometricAvailable, model.appLockBiometricIsEnabled {
Button {
Task { await biometricUnlock() }
} label: {
Label("Войти по \(biometricName)", systemImage: biometricIcon)
.foregroundStyle(.white)
}
}
}
.padding(28)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
.padding(24)
}
.onAppear {
isFocused = true
}
.task {
// Auto-prompt biometrics once when the lock screen appears.
guard biometricAvailable, model.appLockBiometricIsEnabled else { return }
await biometricUnlock()
}
}
private var biometricContext: LAContext {
LAContext()
}
private var biometricAvailable: Bool {
var error: NSError?
return biometricContext.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
)
}
private var biometryType: LABiometryType {
biometricContext.biometryType
}
private var biometricIcon: String {
biometryType == .faceID ? "faceid" : "touchid"
}
private var biometricName: String {
biometryType == .faceID ? "Face ID" : "Touch ID"
}
private func submit() {
guard !passcode.isEmpty else { return }
if model.unlockWithPasscode(passcode) {
attemptFailed = false
passcode = ""
} else {
attemptFailed = true
passcode = ""
}
}
private func biometricUnlock() async {
_ = await model.unlockWithBiometrics()
}
}
/// Configurable passcode sheet: setting up a new passcode, changing it, or
/// verifying the current one to disable the lock / toggle biometrics.
@MainActor
struct AppLockPasscodeSheet: View {
enum Mode: Identifiable {
case setup
case change
case verify(VerificationAction)
enum VerificationAction: String {
case disableLock
case enableBiometrics
case disableBiometrics
}
var id: String {
switch self {
case .setup: return "setup"
case .change: return "change"
case .verify(let action): return "verify-\(action.rawValue)"
}
}
}
let model: AppModel
let mode: Mode
@Environment(\.dismiss) private var dismiss
@State private var oldPasscode = ""
@State private var passcode = ""
@State private var repeatedPasscode = ""
@State private var errorText: String?
var body: some View {
NavigationStack {
Form {
if needsOldPasscode {
Section("Текущий пароль") {
SecureField("Текущий пароль", text: $oldPasscode)
.textContentType(.password)
}
}
Section(title) {
SecureField(fieldLabel, text: $passcode)
.textContentType(.password)
if needsRepeatedPasscode {
SecureField("Повторите пароль", text: $repeatedPasscode)
.textContentType(.password)
}
}
if let errorText {
Section {
Text(errorText)
.font(.caption)
.foregroundStyle(.red)
}
}
}
.navigationTitle(navigationTitle)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Готово", action: submit)
.disabled(!canSubmit)
}
}
}
#if os(macOS)
.frame(minWidth: 420, minHeight: 320)
#endif
}
private var needsOldPasscode: Bool {
switch mode {
case .change, .verify:
return true
case .setup:
return false
}
}
private var needsRepeatedPasscode: Bool {
switch mode {
case .setup, .change:
return true
case .verify:
return false
}
}
private var title: String {
switch mode {
case .setup: return "Новый пароль"
case .change: return "Новый пароль"
case .verify: return "Пароль"
}
}
private var navigationTitle: String {
switch mode {
case .setup: return "Включить блокировку"
case .change: return "Сменить пароль"
case .verify(let action):
switch action {
case .disableLock: return "Выключить блокировку"
case .enableBiometrics, .disableBiometrics: return "Подтвердите пароль"
}
}
}
private var fieldLabel: String {
switch mode {
case .setup, .change: return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)"
case .verify: return "Пароль"
}
}
private var canSubmit: Bool {
switch mode {
case .setup:
return !passcode.isEmpty && !repeatedPasscode.isEmpty
case .change:
return !oldPasscode.isEmpty && !passcode.isEmpty && !repeatedPasscode.isEmpty
case .verify:
return !oldPasscode.isEmpty
}
}
private func submit() {
errorText = nil
switch mode {
case .setup:
guard AppLockPolicy.isValid(passcode) else {
errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
return
}
guard passcode == repeatedPasscode else {
errorText = "Пароли не совпадают."
repeatedPasscode = ""
return
}
if model.enableAppLock(passcode: passcode) {
dismiss()
} else {
errorText = model.errorMessage ?? "Не удалось включить блокировку."
}
case .change:
guard AppLockPolicy.isValid(passcode) else {
errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
return
}
guard passcode == repeatedPasscode else {
errorText = "Пароли не совпадают."
repeatedPasscode = ""
return
}
if model.changeAppLockPasscode(from: oldPasscode, to: passcode) {
dismiss()
} else {
errorText = "Неверный текущий пароль."
oldPasscode = ""
}
case .verify(let action):
switch action {
case .disableLock:
if model.disableAppLock(passcode: oldPasscode) {
dismiss()
} else {
errorText = "Неверный пароль."
oldPasscode = ""
}
case .enableBiometrics:
if model.setAppLockBiometricUnlock(true, passcode: oldPasscode) {
dismiss()
} else {
errorText = "Неверный пароль."
oldPasscode = ""
}
case .disableBiometrics:
if model.setAppLockBiometricUnlock(false, passcode: oldPasscode) {
dismiss()
} else {
errorText = "Неверный пароль."
oldPasscode = ""
}
}
}
}
}
+22 -16
View File
@@ -7,25 +7,31 @@ struct RootView: View {
var body: some View {
ZStack {
Group {
if model.account == nil {
LoginView(model: model)
} else {
MainChatView(model: model)
.environment(\.modelContext, model.modelContext)
if model.isAppLocked {
// The lock covers every layer: chat list, media viewer and
// calls stay hidden until the passcode or biometrics succeed.
AppLockView(model: model)
} else {
Group {
if model.account == nil {
LoginView(model: model)
} else {
MainChatView(model: model)
.environment(\.modelContext, model.modelContext)
}
}
}
if let item = model.mediaViewerItem {
MediaViewer(item: item, onClose: model.closeMediaViewer)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
.zIndex(100)
}
if let item = model.mediaViewerItem {
MediaViewer(item: item, onClose: model.closeMediaViewer)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
.zIndex(100)
}
if let call = model.activeCall {
CallView(model: model, call: call)
.transition(.opacity.combined(with: .scale(scale: 1.015)))
.zIndex(200)
if let call = model.activeCall {
CallView(model: model, call: call)
.transition(.opacity.combined(with: .scale(scale: 1.015)))
.zIndex(200)
}
}
}
.animation(.easeInOut(duration: 0.22), value: model.account?.id)
+51
View File
@@ -1,3 +1,4 @@
import LocalAuthentication
import PhotosUI
import SwiftUI
@@ -7,6 +8,7 @@ struct SettingsView: View {
@State private var showingSignOutConfirmation = false
@State private var forgetHistory = false
@State private var avatarItem: PhotosPickerItem?
@State private var passcodeSheetMode: AppLockPasscodeSheet.Mode?
var body: some View {
NavigationStack {
@@ -98,6 +100,36 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}
Section("Блокировка приложения") {
Toggle("Заблокировать приложение", isOn: Binding(
get: { model.appLockIsEnabled },
set: { enabled in
if enabled {
passcodeSheetMode = .setup
} else {
passcodeSheetMode = .verify(.disableLock)
}
}
))
if model.appLockIsEnabled {
Toggle("Вход по \(biometricName)", isOn: Binding(
get: { model.appLockBiometricIsEnabled },
set: { enabled in
passcodeSheetMode = .verify(
enabled ? .enableBiometrics : .disableBiometrics
)
}
))
.disabled(!biometricAvailable)
Button("Сменить пароль") {
passcodeSheetMode = .change
}
}
Text("При включённой блокировке Luma запрашивает пароль при запуске и после сворачивания. Пароль хранится в Keychain.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Приватность чата") {
Toggle("Показывать статус набора", isOn: Binding(
get: { model.typingIndicatorsEnabled },
@@ -153,12 +185,31 @@ struct SettingsView: View {
}
}
}
.sheet(item: $passcodeSheetMode) { mode in
AppLockPasscodeSheet(model: model, mode: mode)
}
}
#if os(macOS)
.frame(minWidth: 500, minHeight: 560)
#endif
}
private var biometricContext: LAContext {
LAContext()
}
private var biometricAvailable: Bool {
var error: NSError?
return biometricContext.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
)
}
private var biometricName: String {
biometricContext.biometryType == .faceID ? "Face ID" : "Touch ID"
}
private var connectionTitle: String {
switch model.connectionStatus {
case .connected: return "Подключено"