From ee0f9f8b4c1d00c6e0aaa8223ee8fdec2bb89117 Mon Sep 17 00:00:00 2001 From: iam-mercy Date: Mon, 27 Jul 2026 15:19:34 +0000 Subject: [PATCH] Harden passkey delegate retention, sign-out cleanup, input validation, and app re-lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PasskeyService: replace the objc_setAssociatedObject delegate-retention hack with instance-level, controller-keyed storage so concurrent performRequest calls (e.g. register() and authenticate() in flight together) each retain their own delegate without clobbering the other's. - RegisterView: validate username length/character-set and trim whitespace client-side before registration, with an inline validation message instead of a generic backend error. - AuthStore.signOut(): clear the stored credential ID, pending local notifications, the scheduled background refresh task, the OfflineCache directory, and local iCloud vault associations — not just the auth token — so no vault data survives sign-out on a shared/resold device. - Add a ScenePhase-driven re-lock timer with a configurable timeout (SettingsView) and a privacy overlay shown whenever the app isn't in the foreground, so balances/beneficiary details require a fresh Face ID check after backgrounding and never appear in the app switcher. Closes #9, #10, #11, #12 --- ios/EthosProtocol/Sources/Models/Models.swift | 37 +++ .../Services/BackgroundRefreshService.swift | 6 + .../Sources/Services/ICloudSyncService.swift | 8 + .../Sources/Services/KeychainService.swift | 4 + .../Services/NotificationService.swift | 6 + .../Sources/Services/OfflineSupport.swift | 7 + .../Sources/Services/PasskeyService.swift | 46 +++- .../Sources/ViewModels/Stores.swift | 79 ++++++ .../Sources/Views/SettingsView.swift | 17 ++ ios/EthosProtocol/Sources/Views/Views.swift | 103 +++++++- .../Tests/EthosProtocolTests.swift | 244 ++++++++++++++++++ .../KeychainAndNotificationTests.swift | 66 +++++ .../Tests/ICloudSyncServiceTests.swift | 11 + 13 files changed, 624 insertions(+), 10 deletions(-) diff --git a/ios/EthosProtocol/Sources/Models/Models.swift b/ios/EthosProtocol/Sources/Models/Models.swift index 4c21ac3..ff299cd 100644 --- a/ios/EthosProtocol/Sources/Models/Models.swift +++ b/ios/EthosProtocol/Sources/Models/Models.swift @@ -50,6 +50,43 @@ enum VaultAmount { } } +// Client-side mirror of the backend's username policy for passkey registration, so +// malformed input is caught before it's sent as the WebAuthn userID/display name instead +// of surfacing as a generic backend rejection. +enum UsernameValidation { + static let minLength = 3 + static let maxLength = 30 + private static let allowedCharacters = CharacterSet(charactersIn: + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-") + + enum ValidationError: LocalizedError, Equatable { + case tooShort + case tooLong + case invalidCharacters + + var errorDescription: String? { + switch self { + case .tooShort: + return "Username must be at least \(UsernameValidation.minLength) characters." + case .tooLong: + return "Username must be \(UsernameValidation.maxLength) characters or fewer." + case .invalidCharacters: + return "Username can only contain letters, numbers, underscores, and hyphens." + } + } + } + + /// Trims leading/trailing whitespace, then validates length and character set. + /// Returns the trimmed username on success, or the first validation failure found. + static func validate(_ input: String) -> Result { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= minLength else { return .failure(.tooShort) } + guard trimmed.count <= maxLength else { return .failure(.tooLong) } + guard trimmed.unicodeScalars.allSatisfy(allowedCharacters.contains) else { return .failure(.invalidCharacters) } + return .success(trimmed) + } +} + enum BeneficiaryUpdate { /// A new beneficiary address is only valid if it's non-empty (after trimming) /// and actually differs from the vault's current beneficiary. diff --git a/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift b/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift index b6499da..7c149f7 100644 --- a/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift +++ b/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift @@ -38,6 +38,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/KeychainService.swift b/ios/EthosProtocol/Sources/Services/KeychainService.swift index 3538bc8..cdc5004 100644 --- a/ios/EthosProtocol/Sources/Services/KeychainService.swift +++ b/ios/EthosProtocol/Sources/Services/KeychainService.swift @@ -33,6 +33,10 @@ final class KeychainService { load(forKey: credentialKey) } + func deleteCredentialID() { + delete(forKey: credentialKey) + } + private func save(_ value: String, forKey key: String, accessible: CFString = kSecAttrAccessibleWhenUnlockedThisDeviceOnly) { let data = Data(value.utf8) let query: [CFString: Any] = [ diff --git a/ios/EthosProtocol/Sources/Services/NotificationService.swift b/ios/EthosProtocol/Sources/Services/NotificationService.swift index 66bebbc..63271d6 100644 --- a/ios/EthosProtocol/Sources/Services/NotificationService.swift +++ b/ios/EthosProtocol/Sources/Services/NotificationService.swift @@ -99,6 +99,12 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate { center.add(request) } + /// Removes every pending local notification (used on sign-out, so a previously + /// signed-in user's check-in/TTL reminders don't fire for whoever uses the app next). + func removeAllPendingNotifications() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + } + func registerNotificationCategories() { // .authenticationRequired ensures iOS forces the device to be unlocked before this // action fires — otherwise anyone with the phone in hand could trigger a check-in diff --git a/ios/EthosProtocol/Sources/Services/OfflineSupport.swift b/ios/EthosProtocol/Sources/Services/OfflineSupport.swift index 594baef..3da80b9 100644 --- a/ios/EthosProtocol/Sources/Services/OfflineSupport.swift +++ b/ios/EthosProtocol/Sources/Services/OfflineSupport.swift @@ -39,6 +39,13 @@ final class OfflineCache { let file = dir.appendingPathComponent(key.sha256Hex) try? FileManager.default.removeItem(at: file) } + + /// 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 d39fd91..10eac4f 100644 --- a/ios/EthosProtocol/Sources/Services/PasskeyService.swift +++ b/ios/EthosProtocol/Sources/Services/PasskeyService.swift @@ -44,25 +44,63 @@ final class PasskeyService: NSObject { return try await APIClient.shared.verifyPasskey(credentialID: credID, clientDataJSON: clientData, signature: signature) } + // ASAuthorizationController.delegate is a weak reference, so whatever creates the + // delegate has to keep it alive itself until the request completes. This used to rely + // on objc_setAssociatedObject hung off the controller instance — fragile, since it'd + // silently break if performRequest were ever refactored to reuse a controller instead + // of creating a fresh one per call. Keyed by controller identity (rather than a single + // property) so two concurrent performRequest calls — e.g. a register() and an + // authenticate() in flight at once — each retain their own delegate without clobbering + // the other's reference. + // + // `internal` (not `private`): lets tests exercise the retention/release bookkeeping + // directly via `@testable import`, without needing to drive a real system passkey + // prompt through `performRequests()`. + var activeDelegates: [ObjectIdentifier: PasskeyDelegate] = [:] + + var activeDelegateCount: Int { activeDelegates.count } + + func makeRetainedDelegate( + for controller: ASAuthorizationController, + continuation: CheckedContinuation + ) -> PasskeyDelegate { + let key = ObjectIdentifier(controller) + let delegate = PasskeyDelegate(continuation: continuation) { [weak self] in + self?.activeDelegates[key] = nil + } + activeDelegates[key] = delegate + return delegate + } + 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 0ca10b2..9e527cb 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 @@ -17,6 +18,12 @@ final class AuthStore: ObservableObject { @Published var isAuthenticated = false @Published var isLoading = false @Published var error: String? + // Re-lock gate, separate from isAuthenticated: the passkey session stays signed in, + // but the vault contents are hidden behind a fresh biometric check after the app has + // spent long enough in the background. See handleScenePhaseChange(_:now:). + @Published var isLocked = false + + private var backgroundedAt: Date? init() { isAuthenticated = KeychainService.shared.loadToken() != nil @@ -50,8 +57,80 @@ final class AuthStore: ObservableObject { } func signOut() { + // Clears every piece of local state that could leak the previous user's vault + // data to whoever uses the app next on this device (shared/resold-device threat + // model) — not just the auth token. KeychainService.shared.deleteToken() + KeychainService.shared.deleteCredentialID() + NotificationService.shared.removeAllPendingNotifications() + BackgroundRefreshService.shared.cancelScheduledRefresh() + OfflineCache.shared.clearAll() + ICloudSyncService.shared.clearLocalAssociations() 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 c3344c5..2f054f0 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 } } } @@ -93,6 +169,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 { @@ -100,6 +180,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 { Text(error).foregroundStyle(.red).font(.caption) } @@ -109,9 +194,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() } @@ -119,6 +205,11 @@ struct RegisterView: View { } } } + + private var isValid: Bool { + if case .success = validationResult { return true } + return false + } } // MARK: - Vault List diff --git a/ios/EthosProtocol/Tests/EthosProtocolTests.swift b/ios/EthosProtocol/Tests/EthosProtocolTests.swift index 4b7976e..f96b4b5 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 { @@ -492,6 +575,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 @@ -992,3 +1082,157 @@ final class BeneficiaryUpdateTests: XCTestCase { XCTAssertTrue(BeneficiaryUpdate.isValidNewBeneficiary(" GNEW123 ", currentBeneficiary: "GOLD456")) } } + +// MARK: - #11 Username Validation Tests + +final class UsernameValidationTests: XCTestCase { + + func test_validate_wellFormedUsername_succeedsAndReturnsTrimmed() { + switch UsernameValidation.validate("alice_92") { + case .success(let value): XCTAssertEqual(value, "alice_92") + case .failure(let error): XCTFail("Expected success, got \(error)") + } + } + + func test_validate_trimsLeadingAndTrailingWhitespace() { + switch UsernameValidation.validate(" alice ") { + case .success(let value): XCTAssertEqual(value, "alice") + case .failure(let error): XCTFail("Expected success, got \(error)") + } + } + + func test_validate_tooShort_fails() { + XCTAssertEqual(UsernameValidation.validate("ab"), .failure(.tooShort)) + } + + func test_validate_whitespaceOnly_failsAsTooShort() { + XCTAssertEqual(UsernameValidation.validate(" "), .failure(.tooShort)) + } + + func test_validate_tooLong_fails() { + let tooLong = String(repeating: "a", count: UsernameValidation.maxLength + 1) + XCTAssertEqual(UsernameValidation.validate(tooLong), .failure(.tooLong)) + } + + func test_validate_atMaxLength_succeeds() { + let atMax = String(repeating: "a", count: UsernameValidation.maxLength) + XCTAssertEqual(UsernameValidation.validate(atMax), .success(atMax)) + } + + func test_validate_invalidCharacters_fails() { + for invalid in ["alice smith", "alice@site.com", "alice!", "alice/bob"] { + XCTAssertEqual(UsernameValidation.validate(invalid), .failure(.invalidCharacters), + "Expected \(invalid) to be rejected") + } + } + + func test_validate_allowsHyphenAndUnderscore() { + XCTAssertEqual(UsernameValidation.validate("alice-bob_92"), .success("alice-bob_92")) + } +} + +// MARK: - #12 Re-Lock on Background/Foreground Tests + +@MainActor +final class AuthStoreReLockTests: XCTestCase { + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: "com.ethosprotocol.relock_timeout") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "com.ethosprotocol.relock_timeout") + super.tearDown() + } + + func test_handleScenePhaseChange_locksAfterTimeoutElapses() { + ReLockTimeoutOption.current = .oneMinute + let store = AuthStore() + store.isAuthenticated = true + let backgroundedTime = Date() + + store.handleScenePhaseChange(.background, now: backgroundedTime) + store.handleScenePhaseChange(.active, now: backgroundedTime.addingTimeInterval(61)) + + XCTAssertTrue(store.isLocked) + } + + func test_handleScenePhaseChange_doesNotLock_beforeTimeoutElapses() { + ReLockTimeoutOption.current = .oneMinute + let store = AuthStore() + store.isAuthenticated = true + let backgroundedTime = Date() + + store.handleScenePhaseChange(.background, now: backgroundedTime) + store.handleScenePhaseChange(.active, now: backgroundedTime.addingTimeInterval(30)) + + XCTAssertFalse(store.isLocked) + } + + func test_handleScenePhaseChange_neverOption_neverLocksRegardlessOfElapsedTime() { + ReLockTimeoutOption.current = .never + let store = AuthStore() + store.isAuthenticated = true + let backgroundedTime = Date() + + store.handleScenePhaseChange(.background, now: backgroundedTime) + store.handleScenePhaseChange(.active, now: backgroundedTime.addingTimeInterval(999_999)) + + XCTAssertFalse(store.isLocked) + } + + func test_handleScenePhaseChange_whenNotAuthenticated_doesNotLock() { + ReLockTimeoutOption.current = .immediately + let store = AuthStore() + store.isAuthenticated = false + let backgroundedTime = Date() + + store.handleScenePhaseChange(.background, now: backgroundedTime) + store.handleScenePhaseChange(.active, now: backgroundedTime.addingTimeInterval(5)) + + XCTAssertFalse(store.isLocked) + } + + func test_handleScenePhaseChange_immediately_locksAsSoonAsForegrounded() { + ReLockTimeoutOption.current = .immediately + let store = AuthStore() + store.isAuthenticated = true + let backgroundedTime = Date() + + store.handleScenePhaseChange(.background, now: backgroundedTime) + store.handleScenePhaseChange(.active, now: backgroundedTime.addingTimeInterval(0.001)) + + XCTAssertTrue(store.isLocked) + } +} + +final class ReLockTimeoutOptionTests: XCTestCase { + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: "com.ethosprotocol.relock_timeout") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "com.ethosprotocol.relock_timeout") + super.tearDown() + } + + func test_current_defaultsToOneMinute_whenUnset() { + XCTAssertEqual(ReLockTimeoutOption.current, .oneMinute) + } + + func test_current_persistsAcrossReads() { + ReLockTimeoutOption.current = .fiveMinutes + XCTAssertEqual(ReLockTimeoutOption.current, .fiveMinutes) + } + + func test_never_hasInfiniteSeconds() { + XCTAssertEqual(ReLockTimeoutOption.never.seconds, .infinity) + } + + func test_immediately_hasZeroSeconds() { + XCTAssertEqual(ReLockTimeoutOption.immediately.seconds, 0) + } +} diff --git a/ios/EthosProtocol/Tests/HostedTests/KeychainAndNotificationTests.swift b/ios/EthosProtocol/Tests/HostedTests/KeychainAndNotificationTests.swift index d32cfd9..7c7ed09 100644 --- a/ios/EthosProtocol/Tests/HostedTests/KeychainAndNotificationTests.swift +++ b/ios/EthosProtocol/Tests/HostedTests/KeychainAndNotificationTests.swift @@ -76,4 +76,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")