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