Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ final class BackgroundRefreshService {
try? BGTaskScheduler.shared.submit(request)
}

/// Cancels the pending BGAppRefreshTaskRequest (used on sign-out, so a stale
/// background refresh doesn't run — and re-schedule itself — for a signed-out user).
func cancelScheduledRefresh() {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
}

func handleRefresh(task: BackgroundRefreshTask) {
scheduleAppRefresh()

Expand Down
8 changes: 8 additions & 0 deletions ios/EthosProtocol/Sources/Services/ICloudSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ final class ICloudSyncService {
return loadLocalAssociations()[vaultID]?.credentialID
}

/// Clears this device's local vault-to-credential associations (used on sign-out).
/// Deliberately leaves the iCloud key-value store itself untouched — wiping that would
/// also erase the association for any other device still signed in; restoreFromICloud()
/// repopulates local storage from iCloud on the next sign-in if sync is re-enabled.
func clearLocalAssociations() {
UserDefaults.standard.removeObject(forKey: associationsKey)
}

/// Pull associations from iCloud and merge into local storage using last-write-wins strategy.
func restoreFromICloud() {
guard isSyncEnabled else { return }
Expand Down
7 changes: 7 additions & 0 deletions ios/EthosProtocol/Sources/Services/OfflineSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ final class OfflineCache {
totalSize -= entry.size
}
}

/// Removes every cached response (used on sign-out, so no cached vault data
/// survives for whoever opens the app next on this device).
func clearAll() {
try? FileManager.default.removeItem(at: dir)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
}

import CryptoKit
Expand Down
18 changes: 14 additions & 4 deletions ios/EthosProtocol/Sources/Services/PasskeyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,22 +112,32 @@ final class PasskeyService: NSObject {
private func performRequest(_ request: ASAuthorizationRequest) async throws -> ASAuthorizationCredential {
try await withCheckedThrowingContinuation { continuation in
let controller = ASAuthorizationController(authorizationRequests: [request])
let delegate = PasskeyDelegate(continuation: continuation)
let delegate = makeRetainedDelegate(for: controller, continuation: continuation)
controller.delegate = delegate
controller.performRequests()
objc_setAssociatedObject(controller, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN)
}
}
}

private class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate {
// `internal` (not `private`): tests construct/inspect this via `@testable import` to
// verify the delegate-retention behavior above without invoking real system UI.
class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate {
let continuation: CheckedContinuation<ASAuthorizationCredential, Error>
init(continuation: CheckedContinuation<ASAuthorizationCredential, Error>) { self.continuation = continuation }
private let onComplete: () -> Void

init(continuation: CheckedContinuation<ASAuthorizationCredential, Error>, onComplete: @escaping () -> Void) {
self.continuation = continuation
self.onComplete = onComplete
}

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
continuation.resume(returning: authorization.credential)
onComplete()
}

func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
continuation.resume(throwing: error)
onComplete()
}
}

Expand Down
65 changes: 65 additions & 0 deletions ios/EthosProtocol/Sources/ViewModels/Stores.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Combine
import SwiftUI

// Runs `mutation` only if the current Task hasn't been cancelled. Guards
// @Published/@State writes that happen after an `await` — if whatever launched
Expand Down Expand Up @@ -67,6 +68,70 @@ final class AuthStore: ObservableObject {
// the previous user's cached vault data while offline.
OfflineCache.shared.clearAll()
isAuthenticated = false
isLocked = false
backgroundedAt = nil
}

/// Called from RootView's `.onChange(of: scenePhase)`. Records when the app leaves the
/// foreground and, once it returns, re-locks the vault behind a fresh biometric check if
/// it was backgrounded for at least the configured re-lock timeout. `now:` is injectable
/// so tests can simulate elapsed time without real waits.
func handleScenePhaseChange(_ phase: ScenePhase, now: Date = Date()) {
switch phase {
case .background:
backgroundedAt = now
case .active:
if let backgroundedAt, isAuthenticated,
now.timeIntervalSince(backgroundedAt) >= ReLockTimeoutOption.current.seconds {
isLocked = true
}
backgroundedAt = nil
case .inactive:
break
@unknown default:
break
}
}
}

/// How long the app can sit in the background before `AuthStore` requires biometrics again.
/// Persisted so the choice survives relaunch; configurable from SettingsView.
enum ReLockTimeoutOption: Int, CaseIterable, Identifiable {
case immediately = 0
case thirtySeconds = 30
case oneMinute = 60
case fiveMinutes = 300
case fifteenMinutes = 900
case never = -1

var id: Int { rawValue }

var seconds: TimeInterval {
self == .never ? .infinity : TimeInterval(rawValue)
}

var label: String {
switch self {
case .immediately: return "Immediately"
case .thirtySeconds: return "30 Seconds"
case .oneMinute: return "1 Minute"
case .fiveMinutes: return "5 Minutes"
case .fifteenMinutes: return "15 Minutes"
case .never: return "Never"
}
}

private static let userDefaultsKey = "com.ethosprotocol.relock_timeout"

static var current: ReLockTimeoutOption {
get {
guard let stored = UserDefaults.standard.object(forKey: userDefaultsKey) as? Int,
let option = ReLockTimeoutOption(rawValue: stored) else {
return .oneMinute
}
return option
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: userDefaultsKey) }
}
}

Expand Down
17 changes: 17 additions & 0 deletions ios/EthosProtocol/Sources/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import SwiftUI

struct SettingsView: View {
@State private var iCloudSyncEnabled = ICloudSyncService.shared.isSyncEnabled
@State private var reLockTimeout = ReLockTimeoutOption.current

var body: some View {
Form {
Expand All @@ -16,6 +17,22 @@ struct SettingsView: View {
} header: {
Text("iCloud Backup")
}

Section {
Picker("Re-lock After", selection: $reLockTimeout) {
ForEach(ReLockTimeoutOption.allCases) { option in
Text(option.label).tag(option)
}
}
.onChange(of: reLockTimeout) { _, newValue in
ReLockTimeoutOption.current = newValue
}
Text("Require Face ID again after the app has been in the background for this long.")
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Privacy")
}
}
.navigationTitle("Settings")
}
Expand Down
103 changes: 97 additions & 6 deletions ios/EthosProtocol/Sources/Views/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,88 @@ import SwiftUI

struct RootView: View {
@EnvironmentObject var authStore: AuthStore
@Environment(\.scenePhase) private var scenePhase

var body: some View {
if authStore.isAuthenticated {
VaultListView()
} else {
AuthView()
ZStack {
if authStore.isAuthenticated {
VaultListView()
} else {
AuthView()
}

// Re-lock gate: shown atop the vault list after the app has spent long enough
// in the background (AuthStore.handleScenePhaseChange), independent of privacy
// overlay below which covers *every* backgrounding regardless of duration.
if authStore.isAuthenticated && authStore.isLocked {
LockScreenView()
}

// Covers the vault list/balances the instant the app stops being active, so
// the system's app-switcher snapshot never captures sensitive content.
if authStore.isAuthenticated && scenePhase != .active {
PrivacyOverlayView()
}
}
.onChange(of: scenePhase) { _, newPhase in
authStore.handleScenePhaseChange(newPhase)
}
}
}

private struct PrivacyOverlayView: View {
var body: some View {
ZStack {
Color(.systemBackground)
Image(systemName: "lock.shield.fill")
.font(.system(size: 48))
.foregroundStyle(.blue)
}
.ignoresSafeArea()
.transition(.opacity)
}
}

private struct LockScreenView: View {
@EnvironmentObject var authStore: AuthStore
@State private var error: String?
@State private var isUnlocking = false

var body: some View {
ZStack {
Color(.systemBackground).ignoresSafeArea()
VStack(spacing: 24) {
Image(systemName: "faceid")
.font(.system(size: 64))
.foregroundStyle(.blue)
Text("Ethos-Protocol Locked").font(.title.bold())
if let error {
Text(error).foregroundStyle(.red).font(.caption).multilineTextAlignment(.center)
}
Button(action: unlock) {
Label(isUnlocking ? "Unlocking…" : "Unlock", systemImage: "faceid")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(isUnlocking)
}
.padding(32)
}
.onAppear(perform: unlock)
}

private func unlock() {
guard !isUnlocking else { return }
isUnlocking = true
error = nil
Task {
do {
try await BiometricService.shared.authenticate(reason: "Unlock Ethos-Protocol")
authStore.isLocked = false
} catch {
self.error = error.localizedDescription
}
isUnlocking = false
}
}
}
Expand Down Expand Up @@ -146,13 +222,22 @@ struct RegisterView: View {
@Environment(\.dismiss) var dismiss
@State private var username = ""

private var validationResult: Result<String, UsernameValidation.ValidationError> {
UsernameValidation.validate(username)
}

var body: some View {
NavigationStack {
Form {
Section("Account") {
TextField("Username", text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if case .failure(let validationError) = validationResult, !username.isEmpty {
Text(validationError.errorDescription ?? "Invalid username")
.font(.caption)
.foregroundStyle(.red)
}
}
if let error = authStore.error {
Section { ErrorActionView(error: error, retry: { Task { await authStore.register(username: username) } }) }
Expand All @@ -162,16 +247,22 @@ struct RegisterView: View {
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Register") {
Task { await authStore.register(username: username); dismiss() }
guard case .success(let validUsername) = validationResult else { return }
Task { await authStore.register(username: validUsername); dismiss() }
}
.disabled(username.isEmpty || authStore.isLoading)
.disabled(!isValid || authStore.isLoading)
}
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}

private var isValid: Bool {
if case .success = validationResult { return true }
return false
}
}

struct RecoverAccessView: View {
Expand Down
Loading