From 78e1cefad44090badf4aaafa2bd5ba6e3652d82b Mon Sep 17 00:00:00 2001 From: TZGyn Date: Sun, 27 Sep 2026 23:35:26 +0800 Subject: [PATCH] Require Face ID to open the app and to send --- apps/ios/Koins/AppModel.swift | 33 +++++++++++++++++++ apps/ios/Koins/Info.plist | 2 ++ apps/ios/Koins/SelfTest.swift | 2 ++ apps/ios/Koins/Support/Biometrics.swift | 25 ++++++++++++++ apps/ios/Koins/Views/LockScreen.swift | 30 +++++++++++++++++ apps/ios/Koins/Views/RootView.swift | 42 +++++++++++++++++++---- apps/ios/Koins/Views/SettingsView.swift | 44 +++++++++++++++++++++++++ apps/ios/project.yml | 1 + 8 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 apps/ios/Koins/Support/Biometrics.swift create mode 100644 apps/ios/Koins/Views/LockScreen.swift create mode 100644 apps/ios/Koins/Views/SettingsView.swift diff --git a/apps/ios/Koins/AppModel.swift b/apps/ios/Koins/AppModel.swift index 49078e5..96bb202 100644 --- a/apps/ios/Koins/AppModel.swift +++ b/apps/ios/Koins/AppModel.swift @@ -15,6 +15,18 @@ final class AppModel: ObservableObject { @Published var moneroReady = false @Published var moneroAddress: String? @Published var moneroAccounts: [MoneroAccount] = [] + /// Whether the app is behind the biometric lock screen right now. + @Published var locked = false + + /// Face ID/Touch ID lock. On by default when the device has biometrics; + /// persisted across launches. + var faceIDEnabled: Bool { + get { UserDefaults.standard.object(forKey: "koins.faceID") as? Bool ?? true } + set { + UserDefaults.standard.set(newValue, forKey: "koins.faceID") + objectWillChange.send() + } + } /// Rough time remaining for the Monero sync, derived from observed block rate. @Published var syncEta: String? @@ -41,6 +53,22 @@ final class AppModel: ObservableObject { var symbol: String { chain.symbol } + // MARK: - Biometric lock + + func authenticate() async { + if await Biometrics.authenticate(reason: "Unlock Koins") { + locked = false + } + } + + /// Requires biometrics for sensitive actions when the lock is enabled; a + /// device without biometrics is not asked (the wallet is still usable). + func requireAuth(reason: String) async throws { + guard faceIDEnabled, Biometrics.isAvailable else { return } + let ok = await Biometrics.authenticate(reason: reason) + if !ok { throw XRPError.rpc("Authentication failed") } + } + // MARK: - Cache /// Restores the last-known state for the chain on screen instantly; the @@ -78,6 +106,9 @@ final class AppModel: ObservableObject { if xrpWallet != nil || moneroReady { chain = xrpWallet != nil ? .xrp : .xmr } + if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable { + locked = true + } showCached(chain) await refresh(force: true) startAutoRefresh() @@ -334,6 +365,7 @@ final class AppModel: ObservableObject { } func revealSeed() async throws -> String { + try await requireAuth(reason: "Reveal your recovery phrase") if chain == .xrp { guard let seed = WalletStore.secret(.xrp) else { throw XRPError.rpc("No stored secret for this wallet") @@ -347,6 +379,7 @@ final class AppModel: ObservableObject { // MARK: - Sends func send(to: String, amount: String, tag: String, priority: TransactionPriority) async throws { + try await requireAuth(reason: "Authorize this payment") let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces)) if chain == .xrp { guard let seed = WalletStore.secret(.xrp) else { diff --git a/apps/ios/Koins/Info.plist b/apps/ios/Koins/Info.plist index f94fc76..e435c73 100644 --- a/apps/ios/Koins/Info.plist +++ b/apps/ios/Koins/Info.plist @@ -25,6 +25,8 @@ NSCameraUsageDescription Scan a QR code to fill in a payment address. + NSFaceIDUsageDescription + Unlock Koins and approve payments. UILaunchScreen UISupportedInterfaceOrientations diff --git a/apps/ios/Koins/SelfTest.swift b/apps/ios/Koins/SelfTest.swift index a669647..5ffccf0 100644 --- a/apps/ios/Koins/SelfTest.swift +++ b/apps/ios/Koins/SelfTest.swift @@ -290,6 +290,8 @@ enum SelfTest { expectTrue("qr not monero", !AddressFormat.isMonero(xrpAddress)) expectTrue("qr not xrp", !AddressFormat.isXRP(xmrAddress)) + emit("biometrics available=\(Biometrics.isAvailable) label=\(Biometrics.label)") + // secp256k1 signing must produce a low-S DER signature. if let keypair = try? XRPKey.keypair(fromSeed: secpSeed), let message = Hex.decode(signingHex), diff --git a/apps/ios/Koins/Support/Biometrics.swift b/apps/ios/Koins/Support/Biometrics.swift new file mode 100644 index 0000000..1462f12 --- /dev/null +++ b/apps/ios/Koins/Support/Biometrics.swift @@ -0,0 +1,25 @@ +import Foundation +import LocalAuthentication + +/// Face ID / Touch ID gating for the app lock and for sensitive actions. +enum Biometrics { + static var isAvailable: Bool { + LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + } + + static var label: String { + let context = LAContext() + _ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + return context.biometryType == .faceID ? "Face ID" : "Touch ID" + } + + /// Biometrics with the system passcode fallback (the alert offers "Use + /// Passcode" after a failed face scan). + static func authenticate(reason: String) async -> Bool { + guard isAvailable else { return false } + let context = LAContext() + context.localizedCancelTitle = "Cancel" + return (try? await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, + localizedReason: reason)) ?? false + } +} \ No newline at end of file diff --git a/apps/ios/Koins/Views/LockScreen.swift b/apps/ios/Koins/Views/LockScreen.swift new file mode 100644 index 0000000..dab9cd7 --- /dev/null +++ b/apps/ios/Koins/Views/LockScreen.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// Full-screen biometric lock, overlaid on the app whenever `model.locked`. +struct LockScreen: View { + var unlock: () -> Void + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + VStack(spacing: 16) { + Spacer() + Image(systemName: "lock.shield") + .font(.system(size: 44, weight: .light)) + .foregroundStyle(Theme.accent) + Text("Koins is locked") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(Theme.text) + Text("Authenticate to continue") + .font(.system(size: 13)) + .foregroundStyle(Theme.muted) + Spacer() + PrimaryButton(title: "Unlock with \(Biometrics.label)", action: unlock) + Spacer() + } + .padding(24) + } + .preferredColorScheme(.dark) + .task { unlock() } + } +} \ No newline at end of file diff --git a/apps/ios/Koins/Views/RootView.swift b/apps/ios/Koins/Views/RootView.swift index 1c1e450..8cf8b87 100644 --- a/apps/ios/Koins/Views/RootView.swift +++ b/apps/ios/Koins/Views/RootView.swift @@ -59,6 +59,13 @@ struct RootView: View { .scrollDismissesKeyboard(.interactively) } .preferredColorScheme(.dark) + .overlay { + if model.locked, model.faceIDEnabled { + LockScreen { + Task { await model.authenticate() } + } + } + } .task { await model.bootstrap() } .sheet(item: $seedBox) { box in SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil } @@ -67,6 +74,9 @@ struct RootView: View { if route == .dashboard { route = .home } } .onChange(of: scenePhase) { phase in + if phase == .background, model.faceIDEnabled, Biometrics.isAvailable { + model.locked = true + } if phase != .active { Task { await model.persistWallet() } } @@ -79,6 +89,7 @@ struct HomeView: View { @Binding var route: Route @State private var showDeleteConfirm = false @State private var selectedTransaction: TransactionRecord? + @State private var showSettings = false var body: some View { VStack(spacing: 18) { @@ -104,6 +115,10 @@ struct HomeView: View { symbol: model.symbol, chain: model.chain) { selectedTransaction = nil } } + .sheet(isPresented: $showSettings) { + SettingsView() + .environmentObject(model) + } .confirmationDialog("Delete this wallet?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { @@ -120,13 +135,26 @@ struct HomeView: View { } private var header: some View { - VStack(alignment: .leading, spacing: 4) { - Text("Koins") - .font(.system(size: 30, weight: .heavy)) - .foregroundStyle(Theme.text) - Text("Monero and XRP wallet") - .font(.system(size: 13)) - .foregroundStyle(Theme.muted) + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text("Koins") + .font(.system(size: 30, weight: .heavy)) + .foregroundStyle(Theme.text) + Text("Monero and XRP wallet") + .font(.system(size: 13)) + .foregroundStyle(Theme.muted) + } + Spacer() + Button { + showSettings = true + } label: { + Image(systemName: "gearshape") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(Theme.muted) + .frame(width: 36, height: 36) + .background(Theme.surface) + .clipShape(Circle()) + } } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/ios/Koins/Views/SettingsView.swift b/apps/ios/Koins/Views/SettingsView.swift new file mode 100644 index 0000000..0da3367 --- /dev/null +++ b/apps/ios/Koins/Views/SettingsView.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + VStack(alignment: .leading, spacing: 18) { + ScreenHeader(title: "Settings", subtitle: "Security and device options", onBack: { dismiss() }) + + Card { + Toggle(isOn: Binding(get: { model.faceIDEnabled }, + set: { model.faceIDEnabled = $0 })) { + VStack(alignment: .leading, spacing: 3) { + Text("Unlock with \(Biometrics.label)") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.text) + Text("Require \(Biometrics.label) to open the app, reveal the recovery phrase, and send payments.") + .font(.system(size: 11)) + .foregroundStyle(Theme.muted) + } + } + .tint(Theme.accent) + .disabled(!Biometrics.isAvailable) + + if !Biometrics.isAvailable { + Text("This device has no biometric sensor configured.") + .font(.system(size: 11)) + .foregroundStyle(Theme.muted) + } + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 20) + .padding(.top, 12) + } + .preferredColorScheme(.dark) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } +} \ No newline at end of file diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 7e68967..3d7e7a7 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -67,6 +67,7 @@ targets: - UIInterfaceOrientationPortrait UIUserInterfaceStyle: Dark NSCameraUsageDescription: Scan a QR code to fill in a payment address. + NSFaceIDUsageDescription: Unlock Koins and approve payments. NSAppTransportSecurity: NSAllowsArbitraryLoads: true