Require Face ID to open the app and to send
This commit is contained in:
@@ -15,6 +15,18 @@ final class AppModel: ObservableObject {
|
|||||||
@Published var moneroReady = false
|
@Published var moneroReady = false
|
||||||
@Published var moneroAddress: String?
|
@Published var moneroAddress: String?
|
||||||
@Published var moneroAccounts: [MoneroAccount] = []
|
@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.
|
/// Rough time remaining for the Monero sync, derived from observed block rate.
|
||||||
@Published var syncEta: String?
|
@Published var syncEta: String?
|
||||||
|
|
||||||
@@ -41,6 +53,22 @@ final class AppModel: ObservableObject {
|
|||||||
|
|
||||||
var symbol: String { chain.symbol }
|
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
|
// MARK: - Cache
|
||||||
|
|
||||||
/// Restores the last-known state for the chain on screen instantly; the
|
/// Restores the last-known state for the chain on screen instantly; the
|
||||||
@@ -78,6 +106,9 @@ final class AppModel: ObservableObject {
|
|||||||
if xrpWallet != nil || moneroReady {
|
if xrpWallet != nil || moneroReady {
|
||||||
chain = xrpWallet != nil ? .xrp : .xmr
|
chain = xrpWallet != nil ? .xrp : .xmr
|
||||||
}
|
}
|
||||||
|
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
|
||||||
|
locked = true
|
||||||
|
}
|
||||||
showCached(chain)
|
showCached(chain)
|
||||||
await refresh(force: true)
|
await refresh(force: true)
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
@@ -334,6 +365,7 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func revealSeed() async throws -> String {
|
func revealSeed() async throws -> String {
|
||||||
|
try await requireAuth(reason: "Reveal your recovery phrase")
|
||||||
if chain == .xrp {
|
if chain == .xrp {
|
||||||
guard let seed = WalletStore.secret(.xrp) else {
|
guard let seed = WalletStore.secret(.xrp) else {
|
||||||
throw XRPError.rpc("No stored secret for this wallet")
|
throw XRPError.rpc("No stored secret for this wallet")
|
||||||
@@ -347,6 +379,7 @@ final class AppModel: ObservableObject {
|
|||||||
// MARK: - Sends
|
// MARK: - Sends
|
||||||
|
|
||||||
func send(to: String, amount: String, tag: String, priority: TransactionPriority) async throws {
|
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))
|
let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces))
|
||||||
if chain == .xrp {
|
if chain == .xrp {
|
||||||
guard let seed = WalletStore.secret(.xrp) else {
|
guard let seed = WalletStore.secret(.xrp) else {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Scan a QR code to fill in a payment address.</string>
|
<string>Scan a QR code to fill in a payment address.</string>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Unlock Koins and approve payments.</string>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
|||||||
@@ -290,6 +290,8 @@ enum SelfTest {
|
|||||||
expectTrue("qr not monero", !AddressFormat.isMonero(xrpAddress))
|
expectTrue("qr not monero", !AddressFormat.isMonero(xrpAddress))
|
||||||
expectTrue("qr not xrp", !AddressFormat.isXRP(xmrAddress))
|
expectTrue("qr not xrp", !AddressFormat.isXRP(xmrAddress))
|
||||||
|
|
||||||
|
emit("biometrics available=\(Biometrics.isAvailable) label=\(Biometrics.label)")
|
||||||
|
|
||||||
// secp256k1 signing must produce a low-S DER signature.
|
// secp256k1 signing must produce a low-S DER signature.
|
||||||
if let keypair = try? XRPKey.keypair(fromSeed: secpSeed),
|
if let keypair = try? XRPKey.keypair(fromSeed: secpSeed),
|
||||||
let message = Hex.decode(signingHex),
|
let message = Hex.decode(signingHex),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,13 @@ struct RootView: View {
|
|||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
|
.overlay {
|
||||||
|
if model.locked, model.faceIDEnabled {
|
||||||
|
LockScreen {
|
||||||
|
Task { await model.authenticate() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.task { await model.bootstrap() }
|
.task { await model.bootstrap() }
|
||||||
.sheet(item: $seedBox) { box in
|
.sheet(item: $seedBox) { box in
|
||||||
SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil }
|
SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil }
|
||||||
@@ -67,6 +74,9 @@ struct RootView: View {
|
|||||||
if route == .dashboard { route = .home }
|
if route == .dashboard { route = .home }
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { phase in
|
||||||
|
if phase == .background, model.faceIDEnabled, Biometrics.isAvailable {
|
||||||
|
model.locked = true
|
||||||
|
}
|
||||||
if phase != .active {
|
if phase != .active {
|
||||||
Task { await model.persistWallet() }
|
Task { await model.persistWallet() }
|
||||||
}
|
}
|
||||||
@@ -79,6 +89,7 @@ struct HomeView: View {
|
|||||||
@Binding var route: Route
|
@Binding var route: Route
|
||||||
@State private var showDeleteConfirm = false
|
@State private var showDeleteConfirm = false
|
||||||
@State private var selectedTransaction: TransactionRecord?
|
@State private var selectedTransaction: TransactionRecord?
|
||||||
|
@State private var showSettings = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 18) {
|
VStack(spacing: 18) {
|
||||||
@@ -104,6 +115,10 @@ struct HomeView: View {
|
|||||||
symbol: model.symbol,
|
symbol: model.symbol,
|
||||||
chain: model.chain) { selectedTransaction = nil }
|
chain: model.chain) { selectedTransaction = nil }
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showSettings) {
|
||||||
|
SettingsView()
|
||||||
|
.environmentObject(model)
|
||||||
|
}
|
||||||
.confirmationDialog("Delete this wallet?",
|
.confirmationDialog("Delete this wallet?",
|
||||||
isPresented: $showDeleteConfirm,
|
isPresented: $showDeleteConfirm,
|
||||||
titleVisibility: .visible) {
|
titleVisibility: .visible) {
|
||||||
@@ -120,13 +135,26 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
Text("Koins")
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.font(.system(size: 30, weight: .heavy))
|
Text("Koins")
|
||||||
.foregroundStyle(Theme.text)
|
.font(.system(size: 30, weight: .heavy))
|
||||||
Text("Monero and XRP wallet")
|
.foregroundStyle(Theme.text)
|
||||||
.font(.system(size: 13))
|
Text("Monero and XRP wallet")
|
||||||
.foregroundStyle(Theme.muted)
|
.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)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ targets:
|
|||||||
- UIInterfaceOrientationPortrait
|
- UIInterfaceOrientationPortrait
|
||||||
UIUserInterfaceStyle: Dark
|
UIUserInterfaceStyle: Dark
|
||||||
NSCameraUsageDescription: Scan a QR code to fill in a payment address.
|
NSCameraUsageDescription: Scan a QR code to fill in a payment address.
|
||||||
|
NSFaceIDUsageDescription: Unlock Koins and approve payments.
|
||||||
NSAppTransportSecurity:
|
NSAppTransportSecurity:
|
||||||
NSAllowsArbitraryLoads: true
|
NSAllowsArbitraryLoads: true
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user