diff --git a/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift b/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift index e534f0b..2c48025 100644 --- a/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift +++ b/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift @@ -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() diff --git a/ios/EthosProtocol/Sources/Services/ICloudSyncService.swift b/ios/EthosProtocol/Sources/Services/ICloudSyncService.swift index be2bef5..cbc48f9 100644 --- a/ios/EthosProtocol/Sources/Services/ICloudSyncService.swift +++ b/ios/EthosProtocol/Sources/Services/ICloudSyncService.swift @@ -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 } diff --git a/ios/EthosProtocol/Sources/Services/OfflineSupport.swift b/ios/EthosProtocol/Sources/Services/OfflineSupport.swift index 8ee1667..354eaa2 100644 --- a/ios/EthosProtocol/Sources/Services/OfflineSupport.swift +++ b/ios/EthosProtocol/Sources/Services/OfflineSupport.swift @@ -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 diff --git a/ios/EthosProtocol/Sources/Services/PasskeyService.swift b/ios/EthosProtocol/Sources/Services/PasskeyService.swift index dbe94a5..a11e7f8 100644 --- a/ios/EthosProtocol/Sources/Services/PasskeyService.swift +++ b/ios/EthosProtocol/Sources/Services/PasskeyService.swift @@ -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 - init(continuation: CheckedContinuation) { self.continuation = continuation } + private let onComplete: () -> Void + + init(continuation: CheckedContinuation, 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() } } diff --git a/ios/EthosProtocol/Sources/ViewModels/Stores.swift b/ios/EthosProtocol/Sources/ViewModels/Stores.swift index 86861e3..d2ef9ae 100644 --- a/ios/EthosProtocol/Sources/ViewModels/Stores.swift +++ b/ios/EthosProtocol/Sources/ViewModels/Stores.swift @@ -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 @@ -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) } } } diff --git a/ios/EthosProtocol/Sources/Views/SettingsView.swift b/ios/EthosProtocol/Sources/Views/SettingsView.swift index 44f2a1a..b4e32c4 100644 --- a/ios/EthosProtocol/Sources/Views/SettingsView.swift +++ b/ios/EthosProtocol/Sources/Views/SettingsView.swift @@ -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 { @@ -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") } diff --git a/ios/EthosProtocol/Sources/Views/Views.swift b/ios/EthosProtocol/Sources/Views/Views.swift index e30a141..a1521f7 100644 --- a/ios/EthosProtocol/Sources/Views/Views.swift +++ b/ios/EthosProtocol/Sources/Views/Views.swift @@ -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 } } } @@ -146,6 +222,10 @@ struct RegisterView: View { @Environment(\.dismiss) var dismiss @State private var username = "" + private var validationResult: Result { + UsernameValidation.validate(username) + } + var body: some View { NavigationStack { Form { @@ -153,6 +233,11 @@ struct RegisterView: View { 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) } }) } @@ -162,9 +247,10 @@ 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() } @@ -172,6 +258,11 @@ struct RegisterView: View { } } } + + private var isValid: Bool { + if case .success = validationResult { return true } + return false + } } struct RecoverAccessView: View { diff --git a/ios/EthosProtocol/Tests/EthosProtocolTests.swift b/ios/EthosProtocol/Tests/EthosProtocolTests.swift index 2ab466a..b8a4b18 100644 --- a/ios/EthosProtocol/Tests/EthosProtocolTests.swift +++ b/ios/EthosProtocol/Tests/EthosProtocolTests.swift @@ -1,4 +1,6 @@ import XCTest +import AuthenticationServices +import SwiftUI @testable import EthosProtocol @testable import TTLWidget @@ -72,6 +74,12 @@ final class KeychainServiceTests: XCTestCase { KeychainService.shared.deleteToken() XCTAssertNil(KeychainService.shared.loadToken()) } + + func test_deleteCredentialID_returnsNil() { + KeychainService.shared.saveCredentialID("cred-to-delete") + KeychainService.shared.deleteCredentialID() + XCTAssertNil(KeychainService.shared.loadCredentialID()) + } } final class OfflineCacheTests: XCTestCase { @@ -85,6 +93,23 @@ final class OfflineCacheTests: XCTestCase { func test_load_missingKey_returnsNil() { XCTAssertNil(OfflineCache.shared.load(for: "nonexistent-key-\(UUID())")) } + + func test_clearAll_removesPreviouslyCachedData() { + let data = Data("residual-vault-data".utf8) + OfflineCache.shared.save(data, for: "clear-all-test-key") + XCTAssertNotNil(OfflineCache.shared.load(for: "clear-all-test-key")) + + OfflineCache.shared.clearAll() + + XCTAssertNil(OfflineCache.shared.load(for: "clear-all-test-key")) + } + + func test_clearAll_allowsSavingAgainAfterwards() { + OfflineCache.shared.clearAll() + let data = Data("post-clear-data".utf8) + OfflineCache.shared.save(data, for: "post-clear-key") + XCTAssertEqual(OfflineCache.shared.load(for: "post-clear-key"), data) + } } final class Base64URLTests: XCTestCase { @@ -100,6 +125,64 @@ final class Base64URLTests: XCTestCase { } } +// MARK: - #9 Passkey Delegate Retention Tests + +final class PasskeyDelegateRetentionTests: XCTestCase { + + private enum DummyError: Error { case simulatedFailure } + + private func makeAssertionController(challengeByte: UInt8) -> ASAuthorizationController { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "ethos-protocol.app") + let request = provider.createCredentialAssertionRequest(challenge: Data([challengeByte])) + return ASAuthorizationController(authorizationRequests: [request]) + } + + // Regression test for the objc_setAssociatedObject retention hack: two requests in + // flight at once must each keep their own delegate alive and release only their own + // entry when they complete, never the other's. + func test_concurrentPerformRequests_dontClobberEachOthersDelegate() async throws { + let service = PasskeyService.shared + let controllerA = makeAssertionController(challengeByte: 0x01) + let controllerB = makeAssertionController(challengeByte: 0x02) + + let taskA = Task { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let delegate = service.makeRetainedDelegate(for: controllerA, continuation: continuation) + controllerA.delegate = delegate + } + } + let taskB = Task { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let delegate = service.makeRetainedDelegate(for: controllerB, continuation: continuation) + controllerB.delegate = delegate + } + } + + // Give both tasks a chance to register their delegate before either completes. + try await Task.sleep(nanoseconds: 20_000_000) + XCTAssertEqual(service.activeDelegateCount, 2, "Both concurrent requests should retain their own delegate") + + // Simulate the system callback for A only — B's delegate/continuation must survive. + (controllerA.delegate as? PasskeyDelegate)?.authorizationController(controller: controllerA, didCompleteWithError: DummyError.simulatedFailure) + do { + try await taskA.value + XCTFail("Expected taskA to throw") + } catch is DummyError { + // expected + } + XCTAssertEqual(service.activeDelegateCount, 1, "Completing A's request must release only A's delegate") + + (controllerB.delegate as? PasskeyDelegate)?.authorizationController(controller: controllerB, didCompleteWithError: DummyError.simulatedFailure) + do { + try await taskB.value + XCTFail("Expected taskB to throw") + } catch is DummyError { + // expected + } + XCTAssertEqual(service.activeDelegateCount, 0, "Completing B's request should release its delegate") + } +} + // MARK: - #841 Biometric Authentication Tests final class BiometricServiceTests: XCTestCase { @@ -615,6 +698,13 @@ final class BackgroundRefreshServiceTests: XCTestCase { XCTAssertTrue(a === b) } + // MARK: - #10 Clear Local State on Sign-Out + + func test_cancelScheduledRefresh_doesNotThrow() { + BackgroundRefreshService.shared.scheduleAppRefresh() + XCTAssertNoThrow(BackgroundRefreshService.shared.cancelScheduledRefresh()) + } + func test_scheduleTTLWarning_doesNotThrow_forActiveVault() throws { // UNUserNotificationCenter.current() traps with "bundleProxyForCurrentProcess // is nil" when called from a bare, hostless SPM test bundle (no real app diff --git a/ios/EthosProtocol/Tests/HostedTests/KeychainAndNotificationTests.swift b/ios/EthosProtocol/Tests/HostedTests/KeychainAndNotificationTests.swift index 1f7e6ea..3b6c8d4 100644 --- a/ios/EthosProtocol/Tests/HostedTests/KeychainAndNotificationTests.swift +++ b/ios/EthosProtocol/Tests/HostedTests/KeychainAndNotificationTests.swift @@ -101,4 +101,70 @@ final class NotificationServiceHostedTests: XCTestCase { XCTAssertFalse(trigger.repeats, "TTL warning should not repeat") } } + + func test_removeAllPendingNotifications_clearsScheduledRequests() async { + NotificationService.shared.scheduleTTLWarning(vaultID: "vault-to-clear-\(UUID().uuidString)", ttlRemaining: 3_600) + + NotificationService.shared.removeAllPendingNotifications() + + let pendingRequests = await UNUserNotificationCenter.current().pendingNotificationRequests() + XCTAssertTrue(pendingRequests.isEmpty, "No notifications should remain pending after removeAllPendingNotifications()") + } +} + +// MARK: - #10 Sign-Out Local State Clearing Tests (Hosted) +// +// AuthStore.signOut() touches Keychain and UNUserNotificationCenter, both of which need a +// real app host process (see the notes above) — so these run here rather than in the bare +// SPM bundle in Tests/EthosProtocolTests.swift. + +@MainActor +final class SignOutClearsLocalStateTests: XCTestCase { + + override func setUp() { + super.setUp() + ICloudSyncService.shared.isSyncEnabled = false + } + + func test_signOut_clearsCredentialID() { + KeychainService.shared.saveCredentialID("cred-to-clear") + AuthStore().signOut() + XCTAssertNil(KeychainService.shared.loadCredentialID()) + } + + func test_signOut_clearsOfflineCache_noResidualVaultDataReadable() { + let cacheKey = "https://api.ethos-protocol.app/v1/vaults" + OfflineCache.shared.save(Data(#"[{"id":"vault-1","balance":50000000}]"#.utf8), for: cacheKey) + XCTAssertNotNil(OfflineCache.shared.load(for: cacheKey), "Precondition: cached vault data should be present before sign-out") + + AuthStore().signOut() + + XCTAssertNil(OfflineCache.shared.load(for: cacheKey), "Vault data cached before sign-out must not be readable afterward") + } + + func test_signOut_clearsICloudLocalAssociation() { + ICloudSyncService.shared.save(vaultID: "vault-to-forget", credentialID: "cred-to-forget") + AuthStore().signOut() + XCTAssertNil(ICloudSyncService.shared.credentialID(for: "vault-to-forget")) + } + + func test_signOut_clearsPendingNotifications() async { + NotificationService.shared.scheduleTTLWarning(vaultID: "vault-signout-\(UUID().uuidString)", ttlRemaining: 3_600) + + AuthStore().signOut() + + let pendingRequests = await UNUserNotificationCenter.current().pendingNotificationRequests() + XCTAssertTrue(pendingRequests.isEmpty, "No notifications from the signed-out session should remain pending") + } + + func test_signOut_resetsLockState() { + let store = AuthStore() + store.isAuthenticated = true + store.isLocked = true + + store.signOut() + + XCTAssertFalse(store.isLocked) + XCTAssertFalse(store.isAuthenticated) + } } diff --git a/ios/EthosProtocol/Tests/ICloudSyncServiceTests.swift b/ios/EthosProtocol/Tests/ICloudSyncServiceTests.swift index 2061214..d88d0d4 100644 --- a/ios/EthosProtocol/Tests/ICloudSyncServiceTests.swift +++ b/ios/EthosProtocol/Tests/ICloudSyncServiceTests.swift @@ -52,6 +52,17 @@ final class ICloudSyncServiceTests: XCTestCase { XCTAssertNil(ICloudSyncService.shared.credentialID(for: "nonexistent-\(UUID())")) } + // MARK: - #10 Clear Local State on Sign-Out + + func test_clearLocalAssociations_removesLocallySavedAssociation() { + ICloudSyncService.shared.save(vaultID: "vault-to-clear", credentialID: "cred-to-clear") + XCTAssertEqual(ICloudSyncService.shared.credentialID(for: "vault-to-clear"), "cred-to-clear") + + ICloudSyncService.shared.clearLocalAssociations() + + XCTAssertNil(ICloudSyncService.shared.credentialID(for: "vault-to-clear")) + } + func test_restoreFromICloud_mergesIntoLocal() { // Pre-populate local storage ICloudSyncService.shared.save(vaultID: "local-vault", credentialID: "local-cred")