From 136e39e478e380757eddd127915bdb2d9a21bee4 Mon Sep 17 00:00:00 2001 From: Phanuella Date: Fri, 28 Aug 2026 18:54:21 +0000 Subject: [PATCH 1/4] fix(ios): add background task scheduling audit trail and budget docs (#204) Add os_log signposts around BackgroundRefreshService and CheckInSyncTask's schedule/invoke points so requested vs. observed real-device cadence can be compared from a Console.app trace, and document the current scheduling design and budget considerations in docs/. Consolidating the two tasks is deferred until real-device data confirms they're actually competing for budget. Closes #204 --- docs/background-task-scheduling.md | 49 +++++++++++++++++++ .../Services/BackgroundRefreshService.swift | 8 +++ .../Sources/Services/CheckInSyncTask.swift | 7 +++ 3 files changed, 64 insertions(+) create mode 100644 docs/background-task-scheduling.md diff --git a/docs/background-task-scheduling.md b/docs/background-task-scheduling.md new file mode 100644 index 0000000..0d59278 --- /dev/null +++ b/docs/background-task-scheduling.md @@ -0,0 +1,49 @@ +# iOS Background Task Scheduling Budget (#204) + +`BackgroundRefreshService` and `CheckInSyncTask` each register a distinct +`BGTaskScheduler` identifier: + +| Task | Identifier | Request type | Requested cadence | +|---|---|---|---| +| TTL polling | `app.ethos-protocol.vault-ttl-refresh` | `BGAppRefreshTaskRequest` | every 3,600s (`earliestBeginDate`) | +| Check-in sync | `app.ethos-protocol.checkin-sync` | `BGProcessingTaskRequest`, `requiresNetworkConnectivity = true` | resubmitted immediately after every run, and whenever `PendingCheckInStore` gains an item | + +Both are declared in `BGTaskSchedulerPermittedIdentifiers` (Info.plist) and +registered independently in `EthosProtocolApp`/`AppDelegate`. + +## Budget considerations + +`BGAppRefreshTaskRequest` and `BGProcessingTaskRequest` draw from separate +scheduling pools — `BGAppRefreshTask` budget is governed by app usage +patterns (roughly one opportunity per app-usage session), while +`BGProcessingTask` budget is longer-running but only granted opportunistically +(charging/idle, or `requiresNetworkConnectivity` conditions being met). They +are not competing for the exact same allowance, but both still count against +the device-wide ceiling iOS applies across *all* background work for the app, +so a device running many other background-heavy apps can still starve either +task. + +`earliestBeginDate` on both requests is a lower bound, not a guarantee — the +actual cadence a device delivers can run well behind the requested interval, +especially for `BGAppRefreshTask` on a rarely-foregrounded app. + +## Auditing real-world cadence + +Neither task previously logged when it was scheduled or actually invoked, +which made it impossible to compare requested vs. observed cadence without +attaching a debugger. Both now emit an `os_log` (subsystem +`app.ethos-protocol`, category `background-scheduling`) signpost on +`scheduleAppRefresh()`/`scheduleSync()` and on task invocation +(`handleRefresh`/`handleSync`), so a multi-day trace can be pulled from +Console.app (device logs, filtered to that subsystem) to compare the +requested cadence above against what the OS actually delivers. + +This is a prerequisite for the follow-up decision called for in #204 — +consolidating both tasks into a single dispatch point — which should only be +done once real-device data confirms the two tasks are actually competing for +budget rather than running independently at their requested cadence. No +consolidation has been made yet: the two tasks currently have different +scheduling requirements (`BGAppRefreshTaskRequest` with no network +requirement vs. `BGProcessingTaskRequest` requiring connectivity) that a +merged task would need to reconcile, and that reconciliation isn't warranted +without evidence of real starvation. diff --git a/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift b/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift index fdd6069..9ecf588 100644 --- a/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift +++ b/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift @@ -1,5 +1,6 @@ import BackgroundTasks import Foundation +import os.log // Protocol for testable refresh task lifecycle protocol BackgroundRefreshTask { @@ -17,6 +18,10 @@ final class BackgroundRefreshService { static let shared = BackgroundRefreshService() static let taskIdentifier = "app.ethos-protocol.vault-ttl-refresh" + // #204: see docs/background-task-scheduling.md — used to compare requested vs. + // real-world observed cadence against CheckInSyncTask's separate scheduling pool. + private static let log = OSLog(subsystem: "app.ethos-protocol", category: "background-scheduling") + // Injected dependency for testing; defaults to APIClient.shared.listAllVaults() // — every page, not just the first (#21), so a TTL warning isn't missed for // an account with more vaults than fit on one page. @@ -51,6 +56,8 @@ final class BackgroundRefreshService { let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) request.earliestBeginDate = Date(timeIntervalSinceNow: 3_600) // poll every hour try? BGTaskScheduler.shared.submit(request) + // #204: lets a multi-day Console.app trace compare requested vs. observed cadence. + Self.log.log("scheduled: earliestBeginDate=+3600s") } /// Cancels the pending BGAppRefreshTaskRequest (used on sign-out, so a stale @@ -60,6 +67,7 @@ final class BackgroundRefreshService { } func handleRefresh(task: BackgroundRefreshTask) { + Self.log.log("invoked") scheduleAppRefresh() // Register the expiration handler before kicking off the async work so there's no diff --git a/ios/EthosProtocol/Sources/Services/CheckInSyncTask.swift b/ios/EthosProtocol/Sources/Services/CheckInSyncTask.swift index ee11d55..61105eb 100644 --- a/ios/EthosProtocol/Sources/Services/CheckInSyncTask.swift +++ b/ios/EthosProtocol/Sources/Services/CheckInSyncTask.swift @@ -1,5 +1,6 @@ import BackgroundTasks import Foundation +import os.log // MARK: - CheckInSyncTask @@ -22,6 +23,10 @@ final class CheckInSyncTask { static let shared = CheckInSyncTask() static let taskIdentifier = "app.ethos-protocol.checkin-sync" + // #204: see docs/background-task-scheduling.md — used to compare requested vs. + // real-world observed cadence against BackgroundRefreshService's separate scheduling pool. + private static let log = OSLog(subsystem: "app.ethos-protocol", category: "background-scheduling") + // Error codes where the server has definitively rejected the check-in. Matches // PendingActionSyncWorker.NON_RETRYABLE_ERROR_CODES on Android exactly. static let nonRetryableErrorCodes: Set = [400, 404, 410] @@ -53,6 +58,7 @@ final class CheckInSyncTask { request.requiresExternalPower = false // Submit best-effort; ignore if background tasks are disabled (simulator, low power mode). try? BGTaskScheduler.shared.submit(request) + Self.log.log("scheduled: requiresNetworkConnectivity=true") } // MARK: - Sync logic @@ -93,6 +99,7 @@ final class CheckInSyncTask { // MARK: - BGProcessingTask handler private func handleSync(task: BGProcessingTask) { + Self.log.log("invoked") // Re-schedule before doing the work so a gap never opens up. scheduleSync() From f27dffee20d76a095b5b5f66957320d0137edbfa Mon Sep 17 00:00:00 2001 From: Phanuella Date: Fri, 28 Aug 2026 18:55:12 +0000 Subject: [PATCH 2/4] fix(ios): merge instead of overwrite on iCloud KV push to fix multi-device conflicts (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define the vault-association conflict rule as last-write-wins by persist timestamp (a proxy for server-accepted order), and apply it symmetrically: pushAssociations() now merges against the current remote state before writing, instead of overwriting the whole remote blob with local state. This fixes the case where two devices each queue an offline check-in/association for different vaults and then sync — previously, whichever device pushed last would silently wipe the other's association instead of both surviving. Closes #205 --- .../Sources/Services/ICloudSyncService.swift | 36 ++++++++++--- .../Tests/ICloudSyncServiceTests.swift | 53 +++++++++++++++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/ios/EthosProtocol/Sources/Services/ICloudSyncService.swift b/ios/EthosProtocol/Sources/Services/ICloudSyncService.swift index cbc48f9..276c423 100644 --- a/ios/EthosProtocol/Sources/Services/ICloudSyncService.swift +++ b/ios/EthosProtocol/Sources/Services/ICloudSyncService.swift @@ -31,6 +31,14 @@ final class ICloudSyncService { // MARK: - Associations /// Save a vault-to-credential association locally; push to iCloud if sync is on. + /// + /// Conflict rule (#205): last-write-wins per vault ID, compared by `timestamp` — the + /// moment this device durably persisted the association, immediately following the + /// server-accepted passkey registration or check-in it records. `pushAssociations` + /// merges against the current remote state rather than overwriting it outright, so two + /// devices racing to sync after each queued an offline check-in for the same vault + /// converge on the newer association instead of one device's push silently discarding + /// the other's. func save(vaultID: String, credentialID: String) { var assoc = loadLocalAssociations() assoc[vaultID] = VaultAssociation(credentialID: credentialID, timestamp: Date().timeIntervalSince1970) @@ -55,16 +63,24 @@ final class ICloudSyncService { /// Pull associations from iCloud and merge into local storage using last-write-wins strategy. func restoreFromICloud() { guard isSyncEnabled else { return } - let remote = remoteAssociations() - var local = loadLocalAssociations() - for (k, remoteValue) in remote { - if let localValue = local[k] { - local[k] = remoteValue.timestamp > localValue.timestamp ? remoteValue : localValue + persist(Self.merge(local: loadLocalAssociations(), remote: remoteAssociations())) + } + + /// Merges two association maps using the last-write-wins conflict rule (#205): for a + /// vault ID present on both sides, the entry with the newer `timestamp` wins; a vault ID + /// present on only one side is kept as-is. `internal` (not `private`) so + /// ICloudSyncServiceTests can exercise the merge/conflict rule directly, without needing + /// the iCloud entitlement NSUbiquitousKeyValueStore requires (unavailable in CI). + static func merge(local: [String: VaultAssociation], remote: [String: VaultAssociation]) -> [String: VaultAssociation] { + var merged = local + for (vaultID, remoteValue) in remote { + if let localValue = merged[vaultID] { + if remoteValue.timestamp > localValue.timestamp { merged[vaultID] = remoteValue } } else { - local[k] = remoteValue + merged[vaultID] = remoteValue } } - persist(local) + return merged } // MARK: - Private helpers @@ -81,7 +97,11 @@ final class ICloudSyncService { } private func pushAssociations(_ associations: [String: VaultAssociation]) { - guard let data = try? JSONEncoder().encode(associations) else { return } + // #205: merge against the current remote state instead of overwriting it wholesale — + // otherwise a second device's push, racing this one, could clobber an association the + // first device just wrote for a vault the second device never touched locally. + let merged = Self.merge(local: associations, remote: remoteAssociations()) + guard let data = try? JSONEncoder().encode(merged) else { return } store.set(data, forKey: associationsKey) store.synchronize() } diff --git a/ios/EthosProtocol/Tests/ICloudSyncServiceTests.swift b/ios/EthosProtocol/Tests/ICloudSyncServiceTests.swift index c600463..941fdb7 100644 --- a/ios/EthosProtocol/Tests/ICloudSyncServiceTests.swift +++ b/ios/EthosProtocol/Tests/ICloudSyncServiceTests.swift @@ -171,4 +171,57 @@ final class ICloudSyncServiceTests: XCTestCase { // Newer local value should win XCTAssertEqual(ICloudSyncService.shared.credentialID(for: "vault-conflict-2"), "new-local-cred") } + + // MARK: - #205 Simultaneous Multi-Device Check-In Conflict Resolution + // + // Exercises `ICloudSyncService.merge(local:remote:)` directly, rather than through + // `NSUbiquitousKeyValueStore`, so these run reliably in CI (no iCloud entitlement needed). + + func test_merge_simultaneousDevices_disjointVaults_keepsBoth() { + // Device A queued an offline check-in/association for vault-1 while Device B, + // also offline, queued one for vault-2. Neither should be dropped once both sync. + let deviceA: [String: ICloudSyncService.VaultAssociation] = [ + "vault-1": .init(credentialID: "cred-a", timestamp: 100) + ] + let deviceB: [String: ICloudSyncService.VaultAssociation] = [ + "vault-2": .init(credentialID: "cred-b", timestamp: 101) + ] + let merged = ICloudSyncService.merge(local: deviceA, remote: deviceB) + XCTAssertEqual(merged["vault-1"]?.credentialID, "cred-a") + XCTAssertEqual(merged["vault-2"]?.credentialID, "cred-b") + } + + func test_merge_simultaneousDevices_sameVault_newerTimestampWins() { + // Both devices raced to check in on the same vault; the server-accepted-order + // proxy (persist timestamp) must decide deterministically, not "whichever device + // pushed to iCloud last". + let older: [String: ICloudSyncService.VaultAssociation] = [ + "vault-shared": .init(credentialID: "device-a-cred", timestamp: 200) + ] + let newer: [String: ICloudSyncService.VaultAssociation] = [ + "vault-shared": .init(credentialID: "device-b-cred", timestamp: 205) + ] + XCTAssertEqual(ICloudSyncService.merge(local: older, remote: newer)["vault-shared"]?.credentialID, "device-b-cred") + // Order-independent: the newer entry wins regardless of which side is "local" vs "remote". + XCTAssertEqual(ICloudSyncService.merge(local: newer, remote: older)["vault-shared"]?.credentialID, "device-b-cred") + } + + func test_pushAssociations_doesNotClobberNewerRemoteEntryForUntouchedVault() { + // Simulates the double check-in race end-to-end: Device A pushes its local state + // (which knows nothing about vault-2) after Device B already pushed a newer entry + // for vault-2. Device A's push must not delete vault-2's association from iCloud. + try? XCTSkipIf(ProcessInfo.processInfo.environment["CI"] != nil, + "NSUbiquitousKeyValueStore requires an iCloud entitlement + signed-in account, unavailable in CI") + let deviceBAssoc = ICloudSyncService.VaultAssociation(credentialID: "device-b-cred", timestamp: Date().timeIntervalSince1970) + if let data = try? JSONEncoder().encode(["vault-2": deviceBAssoc]) { + NSUbiquitousKeyValueStore.default.set(data, forKey: "com.ethosprotocol.vault_associations") + NSUbiquitousKeyValueStore.default.synchronize() + } + + ICloudSyncService.shared.isSyncEnabled = true + ICloudSyncService.shared.save(vaultID: "vault-1", credentialID: "device-a-cred") + + XCTAssertEqual(ICloudSyncService.shared.credentialID(for: "vault-1"), "device-a-cred") + XCTAssertEqual(ICloudSyncService.shared.credentialID(for: "vault-2"), "device-b-cred") + } } From 2f13439b42a50069c5368a203744ac2f913fa0ef Mon Sep 17 00:00:00 2001 From: Phanuella Date: Fri, 28 Aug 2026 19:02:06 +0000 Subject: [PATCH 3/4] feat: support multiple passkeys per account (#207) Add a PasskeyCredential model to Models.swift/Models.kt so an account's credentials are represented as a list rather than a single value, and add an authenticated "add another passkey" entry point (POST /auth/credentials) distinct from the initial registration flow and from account recovery: - iOS: PasskeyService.addPasskey / AuthStore.addPasskey, AddPasskeyView (surfaced from Settings), without disturbing the current session. - Android: PasskeyService.addPasskey / AuthViewModel.addPasskey, AddPasskeyScreen (surfaced from the vault list toolbar). Closes #207 --- .../java/com/ethosprotocol/api/ApiClient.kt | 5 ++ .../java/com/ethosprotocol/models/Models.kt | 21 ++++++ .../ethosprotocol/services/PasskeyService.kt | 17 +++++ .../java/com/ethosprotocol/ui/MainActivity.kt | 9 ++- .../java/com/ethosprotocol/ui/ViewModels.kt | 14 ++++ .../com/ethosprotocol/ui/screens/Screens.kt | 68 +++++++++++++++++++ .../com/ethosprotocol/PasskeyServiceTest.kt | 62 +++++++++++++++++ ios/EthosProtocol/Sources/Models/Models.swift | 12 ++++ .../Sources/Services/APIClient.swift | 12 ++++ .../Sources/Services/PasskeyService.swift | 15 ++++ .../Sources/ViewModels/Stores.swift | 18 +++++ .../Sources/Views/AddPasskeyView.swift | 63 +++++++++++++++++ .../Sources/Views/SettingsView.swift | 13 ++++ ios/EthosProtocol/Tests/AuthStoreTests.swift | 42 ++++++++++++ shared/api-contract.md | 27 ++++++++ 15 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 ios/EthosProtocol/Sources/Views/AddPasskeyView.swift diff --git a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt index e063aaf..6c0f30d 100644 --- a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt +++ b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt @@ -120,6 +120,11 @@ class ApiClient( suspend fun completeRecovery(req: RecoveryCompleteRequest): ApiResult = post("/auth/recovery/complete", req) + // Adds a passkey to the *currently authenticated* account (#207), distinct from + // registerPasskey (new account) and completeRecovery (recovery for a signed-out user). + suspend fun addPasskey(req: AddPasskeyRequest): ApiResult = + post("/auth/credentials", req) + // Vaults suspend fun listVaults(): ApiResult> = get("/vaults") diff --git a/android/app/src/main/java/com/ethosprotocol/models/Models.kt b/android/app/src/main/java/com/ethosprotocol/models/Models.kt index f04701f..601c5d5 100644 --- a/android/app/src/main/java/com/ethosprotocol/models/Models.kt +++ b/android/app/src/main/java/com/ethosprotocol/models/Models.kt @@ -75,6 +75,27 @@ data class PasskeyRegisterRequest( @SerialName("client_data_json") val clientDataJson: String ) +// One of possibly several passkeys registered to the authenticated account (#206, #207) — +// an account is not limited to a single credential, so this is always modeled as a list +// (`List`), never a lone value. +@Serializable +data class PasskeyCredential( + @SerialName("credential_id") val credentialId: String, + @SerialName("device_label") val deviceLabel: String? = null, + @SerialName("created_at") val createdAt: String, + @SerialName("last_used_at") val lastUsedAt: String? = null +) + +// #207: sent to POST /auth/credentials to add a passkey to the *currently authenticated* +// account — same shape as PasskeyRegisterRequest, but routed through the authenticated +// endpoint rather than /auth/register (new account) or /auth/recovery/complete (recovery). +@Serializable +data class AddPasskeyRequest( + @SerialName("credential_id") val credentialId: String, + @SerialName("public_key") val publicKey: String, + @SerialName("client_data_json") val clientDataJson: String +) + // MARK: - Account Recovery ("lost your device?") // // Shared contract with iOS's #5: initiate() sends a recovery code to the account's diff --git a/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt b/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt index e7c47f8..e6ab261 100644 --- a/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt +++ b/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt @@ -16,7 +16,9 @@ import com.ethosprotocol.api.ApiCallFailedException import com.ethosprotocol.api.ApiClient import com.ethosprotocol.api.ApiResult import com.ethosprotocol.api.TokenProvider +import com.ethosprotocol.models.AddPasskeyRequest import com.ethosprotocol.models.AuthChallenge +import com.ethosprotocol.models.PasskeyCredential import com.ethosprotocol.models.PasskeyRegisterRequest import com.ethosprotocol.models.PasskeyVerifyRequest import com.ethosprotocol.models.RecoveryCompleteRequest @@ -84,6 +86,21 @@ class PasskeyService @Inject constructor( requireSuccess(apiClient.completeRecovery(completeReq)) }.onFailure { if (it is CancellationException) throw it } + // Registers an additional passkey on this device for the *currently authenticated* + // account (#207) — e.g. a user adding a tablet as a second device — distinct from + // recoverAccount, which requires a recovery token for a signed-out user. Relies on the + // existing session's bearer token (ApiClient attaches it automatically) rather than + // recovery proof. + suspend fun addPasskey(activity: Activity, username: String): Result = runCatching { + val json = createPasskeyCredential(activity, username) + val addReq = AddPasskeyRequest( + credentialId = json.getString("id"), + publicKey = extractCosePublicKey(json.getJSONObject("response").getString("attestationObject")), + clientDataJson = json.getJSONObject("response").getString("clientDataJSON") + ) + requireSuccess(apiClient.addPasskey(addReq)) + }.onFailure { if (it is CancellationException) throw it } + private suspend fun createPasskeyCredential(activity: Activity, username: String): JSONObject { val challenge = requireSuccess(apiClient.getChallenge()) val requestJson = buildRegistrationRequestJson(challenge, username) diff --git a/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt b/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt index e806dda..0fb5efa 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt @@ -29,6 +29,7 @@ import androidx.navigation.compose.rememberNavController import com.ethosprotocol.services.BiometricHelper import com.ethosprotocol.services.VaultDeepLink import com.ethosprotocol.services.VaultDeepLinkParser +import com.ethosprotocol.ui.screens.AddPasskeyScreen import com.ethosprotocol.ui.screens.AuthScreen import com.ethosprotocol.ui.screens.BeneficiaryAcceptanceScreen import com.ethosprotocol.ui.screens.DepositScreen @@ -220,7 +221,13 @@ private fun AppNavigation( NavHost(navController, startDestination = if (authState.isAuthenticated) "vaults" else "auth") { composable("auth") { AuthScreen(vm = authVm) } composable("vaults") { - VaultListScreen(onVaultClick = { /* navigate to detail */ }) + VaultListScreen( + onVaultClick = { /* navigate to detail */ }, + onAddPasskeyClick = { navController.navigate("add-passkey") } + ) + } + composable("add-passkey") { + AddPasskeyScreen(onDone = { navController.popBackStack() }) } composable("accept/{vaultId}/{token}") { backStack -> val vaultId = backStack.arguments?.getString("vaultId") ?: return@composable diff --git a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt index 9ef735f..7bb8886 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -127,6 +127,20 @@ class AuthViewModel @Inject constructor( .onFailure { e -> handleAuthFailure(e) } } + // Registers an additional passkey for the currently signed-in account (#207) — e.g. a + // second device — without disturbing the existing session (unlike register(), this + // never flips isAuthenticated). PasskeyService.addPasskey uses the existing session's + // bearer token rather than an account-recovery proof. + fun addPasskey(activity: Activity, username: String) = viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + passkeyService.addPasskey(activity, username) + .onSuccess { _state.update { it.copy(isLoading = false, error = null) } } + .onFailure { e -> + if (BuildConfig.DEBUG) Log.w(TAG, "addPasskey failed", e) + _state.update { it.copy(isLoading = false, error = ApiErrorMapper.friendlyMessage(e)) } + } + } + fun signOut() = viewModelScope.launch { // Unregister before clearing the auth token — ApiClient.bearerAuth() reads // tokenProvider.token when building the request, so clearing first would send diff --git a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt index d9cba64..e659d11 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt @@ -199,6 +199,7 @@ private fun RecoverySheet( @Composable fun VaultListScreen( onVaultClick: (String) -> Unit, + onAddPasskeyClick: () -> Unit = {}, vm: VaultViewModel = hiltViewModel() ) { val state by vm.state.collectAsStateWithLifecycle() @@ -261,6 +262,8 @@ fun VaultListScreen( Scaffold( topBar = { TopAppBar(title = { Text("My Vaults") }, actions = { + // #207: authenticated entry point to add a passkey for a second device. + IconButton(onClick = onAddPasskeyClick) { Icon(Icons.Default.Key, "Add another passkey") } IconButton(onClick = { showCreate = true }) { Icon(Icons.Default.Add, "Create vault") } }) } @@ -1495,3 +1498,68 @@ fun VaultDetailScreen( } } } + +// MARK: - Add Passkey Screen + +/** + * #207: authenticated "Add another passkey" entry point, distinct from the initial + * account-registration flow (AuthScreen's register mode) — lets an already-signed-in user + * register a passkey for a second device without going through account recovery. + */ +@Composable +fun AddPasskeyScreen( + onDone: () -> Unit, + vm: AuthViewModel = hiltViewModel() +) { + val state by vm.state.collectAsStateWithLifecycle() + val activity = LocalContext.current as android.app.Activity + var username by remember { mutableStateOf("") } + var submitted by remember { mutableStateOf(false) } + + // Navigate back once the in-flight add completes successfully — mirrors how + // RegisterView (iOS) dismisses on a successful AuthStore.register() call. + LaunchedEffect(state.isLoading, state.error) { + if (submitted && !state.isLoading && state.error == null) onDone() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Add Another Passkey") }, + navigationIcon = { IconButton(onClick = onDone) { Icon(Icons.Default.ArrowBack, "Back") } } + ) + } + ) { padding -> + Column(modifier = Modifier.padding(padding).padding(16.dp).fillMaxWidth()) { + Text( + "Confirm your username, then use this device's screen lock or biometric to " + + "create a new passkey for it.", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Username") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(16.dp)) + state.error?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(8.dp)) + } + Button( + onClick = { submitted = true; vm.addPasskey(activity, username) }, + enabled = username.isNotBlank() && !state.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Add Passkey") + } + } + } + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt b/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt index fed29ef..73ff0ac 100644 --- a/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt @@ -6,8 +6,10 @@ import com.ethosprotocol.api.ApiCallFailedException import com.ethosprotocol.api.ApiClient import com.ethosprotocol.api.ApiResult import com.ethosprotocol.api.TokenProvider +import com.ethosprotocol.models.AddPasskeyRequest import com.ethosprotocol.models.AuthChallenge import com.ethosprotocol.models.AuthToken +import com.ethosprotocol.models.PasskeyCredential import com.ethosprotocol.models.PasskeyRegisterRequest import com.ethosprotocol.models.PasskeyVerifyRequest import com.ethosprotocol.services.CredentialManagerFactory @@ -165,6 +167,66 @@ class PasskeyServiceTest { assertEquals("Forbidden", result.exceptionOrNull()?.message) } + // ── addPasskey (#207: adding a second passkey while already signed in) ───── + + @Test + fun addPasskey_success_callsAddPasskeyWithCorrectFields() = runTest { + val fakeRegResponse = mockk { + every { registrationResponseJson } returns fakeRegistrationJson + } + val fakeCredential = PasskeyCredential( + credentialId = "credential-id-reg", + deviceLabel = "iPad", + createdAt = "2099-01-01T00:00:00Z" + ) + // Already signed in: tokenProvider already holds a session before this call, and + // addPasskey must not touch it — it authenticates via ApiClient's bearer auth, not + // a fresh sign-in ceremony. + tokenProvider.token = "already-signed-in-token" + coEvery { mockApiClient.getChallenge() } returns ApiResult.Success(fakeChallenge) + coEvery { mockCredentialManager.createCredential(mockActivity, any()) } returns fakeRegResponse + coEvery { mockApiClient.addPasskey(any()) } returns ApiResult.Success(fakeCredential) + + val result = service.addPasskey(mockActivity, "alice") + + assertTrue("Expected success", result.isSuccess) + assertEquals(fakeCredential, result.getOrNull()) + assertEquals("already-signed-in-token", tokenProvider.token) + + val slot = slot() + coVerify { mockApiClient.addPasskey(capture(slot)) } + assertEquals("credential-id-reg", slot.captured.credentialId) + assertEquals(fakeCoseKeyBase64, slot.captured.publicKey) + assertEquals("client-data-base64", slot.captured.clientDataJson) + } + + @Test + fun addPasskey_credentialManagerThrows_returnsFailure_addPasskeyNotCalled() = runTest { + coEvery { mockApiClient.getChallenge() } returns ApiResult.Success(fakeChallenge) + coEvery { mockCredentialManager.createCredential(mockActivity, any()) } throws + RuntimeException("biometric cancelled") + + val result = service.addPasskey(mockActivity, "alice") + + assertTrue("Expected failure", result.isFailure) + coVerify(exactly = 0) { mockApiClient.addPasskey(any()) } + } + + @Test + fun addPasskey_apiError_returnsFailure() = runTest { + val fakeRegResponse = mockk { + every { registrationResponseJson } returns fakeRegistrationJson + } + coEvery { mockApiClient.getChallenge() } returns ApiResult.Success(fakeChallenge) + coEvery { mockCredentialManager.createCredential(mockActivity, any()) } returns fakeRegResponse + coEvery { mockApiClient.addPasskey(any()) } returns ApiResult.Error("Forbidden", 403) + + val result = service.addPasskey(mockActivity, "alice") + + assertTrue("Expected failure", result.isFailure) + assertEquals("Forbidden", result.exceptionOrNull()?.message) + } + // ── authenticate ───────────────────────────────────────────────────────── @Test diff --git a/ios/EthosProtocol/Sources/Models/Models.swift b/ios/EthosProtocol/Sources/Models/Models.swift index 31db604..e536b08 100644 --- a/ios/EthosProtocol/Sources/Models/Models.swift +++ b/ios/EthosProtocol/Sources/Models/Models.swift @@ -162,6 +162,18 @@ struct AccountRecoveryProof: Codable { let backupCode: String } +/// One of possibly several passkeys registered to the authenticated account (#206, #207) — +/// an account is not limited to a single credential, so this is always modeled as a list +/// (`[PasskeyCredential]`), never a lone value. +struct PasskeyCredential: Codable, Identifiable, Equatable { + let credentialId: String + let deviceLabel: String? + let createdAt: Date + let lastUsedAt: Date? + + var id: String { credentialId } +} + struct PushRegistration: Codable { let token: String let platform: String // "ios" | "android" diff --git a/ios/EthosProtocol/Sources/Services/APIClient.swift b/ios/EthosProtocol/Sources/Services/APIClient.swift index 24282f0..10bfa9f 100644 --- a/ios/EthosProtocol/Sources/Services/APIClient.swift +++ b/ios/EthosProtocol/Sources/Services/APIClient.swift @@ -149,6 +149,18 @@ public final class APIClient { let _: EmptyBody = try await post(path: "/auth/recover/link", body: body) } + // MARK: - Passkey Credentials (#207) + + /// Registers an additional passkey to the *currently authenticated* account (#207), + /// using the existing session's Bearer token — distinct from `linkAdditionalPasskey`, + /// which is for a signed-out user proving identity via account recovery instead. + func addPasskey(credentialID: String, publicKey: String, clientDataJSON: String) async throws -> PasskeyCredential { + let body = ["credential_id": credentialID, + "public_key": publicKey, + "client_data_json": clientDataJSON] + return try await post(path: "/auth/credentials", body: body) + } + // MARK: - Vaults /// One page of `GET /vaults`. See shared/api-contract.md's "List Pagination" diff --git a/ios/EthosProtocol/Sources/Services/PasskeyService.swift b/ios/EthosProtocol/Sources/Services/PasskeyService.swift index 988e9e0..7810a64 100644 --- a/ios/EthosProtocol/Sources/Services/PasskeyService.swift +++ b/ios/EthosProtocol/Sources/Services/PasskeyService.swift @@ -89,6 +89,21 @@ final class PasskeyService: NSObject { return credential.credentialID } + /// Registers an additional passkey on this device for the *currently signed-in* account + /// (#207) — e.g. a user adding a tablet as a second device — without going through the + /// account-recovery flow `linkAdditionalPasskey` requires for a signed-out user. Relies + /// on the existing session's Bearer token for authorization instead of a recovery proof. + func addPasskey(username: String) async throws -> PasskeyCredential { + let credential = try await createRegistrationCredential(username: username) + let result = try await APIClient.shared.addPasskey( + credentialID: credential.credentialID, + publicKey: credential.publicKey, + clientDataJSON: credential.clientDataJSON + ) + persistCredentialID(credential.credentialID) + return result + } + private struct RegistrationCredential { let credentialID: String let publicKey: String diff --git a/ios/EthosProtocol/Sources/ViewModels/Stores.swift b/ios/EthosProtocol/Sources/ViewModels/Stores.swift index 75dbb53..c39cb99 100644 --- a/ios/EthosProtocol/Sources/ViewModels/Stores.swift +++ b/ios/EthosProtocol/Sources/ViewModels/Stores.swift @@ -43,6 +43,7 @@ final class AuthStore: ObservableObject { var linkAdditionalPasskey: (String, AccountRecoveryProof) async throws -> String = { username, proof in try await PasskeyService.shared.linkAdditionalPasskey(username: username, existingAccountProof: proof) } + var passkeyAdd: (String) async throws -> PasskeyCredential = { try await PasskeyService.shared.addPasskey(username: $0) } init() { isAuthenticated = KeychainService.shared.loadToken() != nil @@ -109,6 +110,23 @@ final class AuthStore: ObservableObject { isLoading = false } + /// Registers an additional passkey for the currently signed-in account (#207) — e.g. a + /// second device — without disturbing the existing session (unlike `register`/ + /// `recoverAccess`, this never sets `isAuthenticated`). Returns the newly added + /// credential on success so the caller (PasskeyManagementView) can refresh its list. + @discardableResult + func addPasskey(username: String) async -> PasskeyCredential? { + isLoading = true; error = nil + var added: PasskeyCredential? + do { + added = try await passkeyAdd(username) + } catch { + ifNotCancelled { self.error = ErrorPresentation(error) } + } + isLoading = false + return added + } + func signOut() async { refreshTask?.cancel() refreshTask = nil diff --git a/ios/EthosProtocol/Sources/Views/AddPasskeyView.swift b/ios/EthosProtocol/Sources/Views/AddPasskeyView.swift new file mode 100644 index 0000000..cf01fe1 --- /dev/null +++ b/ios/EthosProtocol/Sources/Views/AddPasskeyView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +/// Lets an already-authenticated user register an additional passkey (#207) — e.g. for a +/// second device — without going through the account-recovery flow RecoverAccessView drives +/// for a signed-out user. Presented as a sheet from PasskeyManagementView. +struct AddPasskeyView: View { + @EnvironmentObject var authStore: AuthStore + @Environment(\.dismiss) var dismiss + let onAdded: (PasskeyCredential) -> Void + + @State private var username = "" + + private var validationResult: Result { + UsernameValidation.validate(username) + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Username", text: $username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + if case .failure(let validationError) = validationResult, !username.isEmpty { + Text(validationError.errorDescription ?? "Invalid username") + .font(.caption) + .foregroundStyle(.red) + } + } header: { + Text("Confirm Your Username") + } footer: { + Text("You'll be prompted for Face ID or Touch ID to create the new passkey on this device.") + } + if let error = authStore.error { + Section { Text(error.message).font(.caption).foregroundStyle(.red) } + } + } + .navigationTitle("Add Another Passkey") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Add") { + guard case .success(let validUsername) = validationResult else { return } + Task { + if let credential = await authStore.addPasskey(username: validUsername) { + onAdded(credential) + dismiss() + } + } + } + .disabled(!isValid || authStore.isLoading) + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } + + private var isValid: Bool { + if case .success = validationResult { return true } + return false + } +} diff --git a/ios/EthosProtocol/Sources/Views/SettingsView.swift b/ios/EthosProtocol/Sources/Views/SettingsView.swift index b4e32c4..f9e7078 100644 --- a/ios/EthosProtocol/Sources/Views/SettingsView.swift +++ b/ios/EthosProtocol/Sources/Views/SettingsView.swift @@ -3,9 +3,19 @@ import SwiftUI struct SettingsView: View { @State private var iCloudSyncEnabled = ICloudSyncService.shared.isSyncEnabled @State private var reLockTimeout = ReLockTimeoutOption.current + @State private var showAddPasskey = false var body: some View { Form { + Section { + // #207: authenticated "Add another passkey" entry point — distinct from the + // initial account-registration flow (RegisterView), for a signed-in user + // adding a second device without going through account recovery. + Button("Add Another Passkey") { showAddPasskey = true } + } header: { + Text("Passkeys") + } + Section { Toggle("Sync vault associations to iCloud", isOn: $iCloudSyncEnabled) .onChange(of: iCloudSyncEnabled) { _, newValue in @@ -35,5 +45,8 @@ struct SettingsView: View { } } .navigationTitle("Settings") + .sheet(isPresented: $showAddPasskey) { + AddPasskeyView(onAdded: { _ in }) + } } } diff --git a/ios/EthosProtocol/Tests/AuthStoreTests.swift b/ios/EthosProtocol/Tests/AuthStoreTests.swift index 7c1becf..a33d197 100644 --- a/ios/EthosProtocol/Tests/AuthStoreTests.swift +++ b/ios/EthosProtocol/Tests/AuthStoreTests.swift @@ -48,6 +48,48 @@ final class AuthStoreSingleCeremonyTests: XCTestCase { } } +// MARK: - #207 Add Additional Passkey While Signed In + +@MainActor +final class AuthStoreAddPasskeyTests: XCTestCase { + + func test_addPasskey_whileSignedIn_succeedsWithoutDisturbingSession() async { + let store = AuthStore() + store.passkeyRegister = { _ in AuthToken(token: "tok-register", expiresAt: Date().addingTimeInterval(3_600)) } + await store.register(username: "alice") + XCTAssertTrue(store.isAuthenticated) + + let newCredential = PasskeyCredential(credentialId: "cred-second-device", deviceLabel: "iPad", createdAt: Date(), lastUsedAt: nil) + store.passkeyAdd = { _ in newCredential } + + let result = await store.addPasskey(username: "alice") + + XCTAssertEqual(result, newCredential) + XCTAssertNil(store.error) + // Adding a second passkey must not disturb the existing session (#207) — it's not + // a new sign-in, so isAuthenticated must remain exactly what it already was. + XCTAssertTrue(store.isAuthenticated) + await store.signOut() + } + + func test_addPasskey_failure_setsError_keepsExistingSession() async { + enum FakeError: Error, LocalizedError { case addFailed + var errorDescription: String? { "add failed" } + } + let store = AuthStore() + store.passkeyRegister = { _ in AuthToken(token: "tok-register", expiresAt: Date().addingTimeInterval(3_600)) } + await store.register(username: "alice") + store.passkeyAdd = { _ in throw FakeError.addFailed } + + let result = await store.addPasskey(username: "alice") + + XCTAssertNil(result) + XCTAssertNotNil(store.error) + XCTAssertTrue(store.isAuthenticated, "A failed add-passkey attempt must not sign the user out") + await store.signOut() + } +} + // MARK: - #3 Token Refresh Tests @MainActor diff --git a/shared/api-contract.md b/shared/api-contract.md index d438ce6..58a760f 100644 --- a/shared/api-contract.md +++ b/shared/api-contract.md @@ -52,6 +52,9 @@ The server must: | POST | `/auth/register` | Register new passkey credential, returns `AuthToken` directly (#2) — no separate `/auth/verify` call is needed right after registering | | POST | `/auth/refresh` | Proactively refresh the current session before it expires, returns a new `AuthToken` (#3) | | POST | `/auth/recover/link` | Link a new passkey to an existing account, once identity is proven via email/backup code ("lost your device" recovery) | +| GET | `/auth/credentials` | List the authenticated account's registered passkey credentials (#206) | +| POST | `/auth/credentials` | Register an additional passkey to the *currently authenticated* account (#207) — distinct from `/auth/recover/link`, which is for a signed-out user proving identity via recovery instead | +| DELETE | `/auth/credentials/{credential_id}` | Revoke a registered passkey credential (#206). The server rejects revoking the credential used to authenticate the current session. | ### Vaults | Method | Path | Description | @@ -358,6 +361,30 @@ normal WebAuthn registration ceremony against a `/auth/challenge` obtained for t account) rather than issuing a session directly. Clients call `POST /auth/verify` afterwards to authenticate with the newly linked passkey. +### PasskeyCredential (#206, #207) +```json +{ + "credential_id": "base64url", + "device_label": "string | null", + "created_at": "ISO8601", + "last_used_at": "ISO8601 | null" +} +``` +Returned as a JSON array by `GET /auth/credentials`, and as a single object by +`POST /auth/credentials` (the newly-added credential). `device_label` is server-assigned +(e.g. derived from the WebAuthn ceremony's client platform) and may be `null` if the server +can't determine one. An account can have more than one `PasskeyCredential` — clients must +model this as a list, not a single credential (#207). + +### AddPasskeyRequest (#207) +```json +{ "credential_id": "base64url", "public_key": "base64url", "client_data_json": "base64url" } +``` +Same shape as `PasskeyRegisterRequest`, sent to `POST /auth/credentials` with the current +session's `Authorization: Bearer ` header rather than through `/auth/register` (which +is only for creating a brand-new account) or `/auth/recover/link` (which requires recovery +proof for a signed-out user). Response: `PasskeyCredential`. + ### BeneficiaryUpdateRequest ```json { "beneficiary": "string" } From a1078c3191e49798bd1d8c75fe5789265f26757d Mon Sep 17 00:00:00 2001 From: Phanuella Date: Fri, 28 Aug 2026 19:05:34 +0000 Subject: [PATCH 4/4] feat: add passkey management screen with list/revoke (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /auth/credentials and DELETE /auth/credentials/{id} to both API clients, and a Settings-reachable screen listing an account's registered passkeys (device label, last-used) with a biometric-gated revoke action: - iOS: PasskeyManagementStore + RevokeCredentialCoordinator (mirrors Disable2FACoordinator's biometric-gate pattern), PasskeyManagementView. - Android: PasskeyManagementViewModel, PasskeyManagementScreen — revoke is gated by BiometricHelper in the screen layer, mirroring VaultDetailScreen's disable-2FA flow (#120). Both screens also expose the #207 "add another passkey" entry point. Closes #206 --- .../java/com/ethosprotocol/api/ApiClient.kt | 7 ++ .../java/com/ethosprotocol/ui/MainActivity.kt | 7 ++ .../java/com/ethosprotocol/ui/ViewModels.kt | 38 ++++++ .../com/ethosprotocol/ui/screens/Screens.kt | 104 ++++++++++++++++- .../PasskeyManagementViewModelTest.kt | 97 ++++++++++++++++ .../Sources/Services/APIClient.swift | 19 ++- .../Sources/ViewModels/Stores.swift | 51 +++++++++ .../Sources/Views/PasskeyManagementView.swift | 71 ++++++++++++ .../Sources/Views/SettingsView.swift | 3 + .../Tests/PasskeyManagementTests.swift | 108 ++++++++++++++++++ 10 files changed, 501 insertions(+), 4 deletions(-) create mode 100644 android/app/src/test/java/com/ethosprotocol/PasskeyManagementViewModelTest.kt create mode 100644 ios/EthosProtocol/Sources/Views/PasskeyManagementView.swift create mode 100644 ios/EthosProtocol/Tests/PasskeyManagementTests.swift diff --git a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt index 6c0f30d..26a22d1 100644 --- a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt +++ b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt @@ -125,6 +125,13 @@ class ApiClient( suspend fun addPasskey(req: AddPasskeyRequest): ApiResult = post("/auth/credentials", req) + // Passkey credential management (#206) — an account is not limited to a single passkey, + // so listCredentials() always returns a list. + suspend fun listCredentials(): ApiResult> = get("/auth/credentials") + + suspend fun revokeCredential(credentialId: String): ApiResult = + delete("/auth/credentials/$credentialId", Unit) + // Vaults suspend fun listVaults(): ApiResult> = get("/vaults") diff --git a/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt b/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt index 0fb5efa..529d63c 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt @@ -31,6 +31,7 @@ import com.ethosprotocol.services.VaultDeepLink import com.ethosprotocol.services.VaultDeepLinkParser import com.ethosprotocol.ui.screens.AddPasskeyScreen import com.ethosprotocol.ui.screens.AuthScreen +import com.ethosprotocol.ui.screens.PasskeyManagementScreen import com.ethosprotocol.ui.screens.BeneficiaryAcceptanceScreen import com.ethosprotocol.ui.screens.DepositScreen import com.ethosprotocol.ui.screens.VaultDeepLinkScreen @@ -223,6 +224,12 @@ private fun AppNavigation( composable("vaults") { VaultListScreen( onVaultClick = { /* navigate to detail */ }, + onManagePasskeysClick = { navController.navigate("passkeys") } + ) + } + composable("passkeys") { + PasskeyManagementScreen( + onBack = { navController.popBackStack() }, onAddPasskeyClick = { navController.navigate("add-passkey") } ) } diff --git a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt index 7bb8886..8893632 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -357,6 +357,44 @@ class TwoFactorViewModel @Inject constructor( } } +// --- Passkey Management ViewModel (#206) --- + +data class PasskeyManagementUiState( + val credentials: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null +) + +@HiltViewModel +class PasskeyManagementViewModel @Inject constructor( + private val apiClient: ApiClient +) : ViewModel() { + + private val _state = MutableStateFlow(PasskeyManagementUiState()) + val state = _state.asStateFlow() + + fun load() = viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + when (val result = apiClient.listCredentials()) { + is ApiResult.Success -> _state.update { it.copy(credentials = result.data, isLoading = false) } + is ApiResult.Error -> _state.update { it.copy(isLoading = false, error = result.message) } + ApiResult.NetworkUnavailable -> _state.update { it.copy(isLoading = false, error = "No network") } + } + } + + // Called only after successful BiometricHelper authentication in the screen layer (#206), + // mirroring TwoFactorViewModel.disable2FAAfterBiometric — revoking a passkey is at least + // as security-sensitive as disabling 2FA. + fun revokeCredentialAfterBiometric(credentialId: String) = viewModelScope.launch { + when (val result = apiClient.revokeCredential(credentialId)) { + is ApiResult.Success -> + _state.update { it.copy(credentials = it.credentials.filterNot { c -> c.credentialId == credentialId }) } + is ApiResult.Error -> _state.update { it.copy(error = result.message) } + ApiResult.NetworkUnavailable -> _state.update { it.copy(error = "No network") } + } + } +} + // --- Vault ViewModel --- data class VaultUiState( diff --git a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt index e659d11..d33364d 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ethosprotocol.models.Vault +import com.ethosprotocol.models.PasskeyCredential import com.ethosprotocol.models.TwoFactorMethod import com.ethosprotocol.models.TwoFactorStatus import com.ethosprotocol.models.Enable2FARequest @@ -33,6 +34,7 @@ import com.ethosprotocol.ui.AuthUiState import com.ethosprotocol.ui.AuthViewModel import com.ethosprotocol.ui.VaultViewModel import com.ethosprotocol.ui.TwoFactorViewModel +import com.ethosprotocol.ui.PasskeyManagementViewModel // MARK: - Auth Screen @@ -199,7 +201,7 @@ private fun RecoverySheet( @Composable fun VaultListScreen( onVaultClick: (String) -> Unit, - onAddPasskeyClick: () -> Unit = {}, + onManagePasskeysClick: () -> Unit = {}, vm: VaultViewModel = hiltViewModel() ) { val state by vm.state.collectAsStateWithLifecycle() @@ -262,8 +264,8 @@ fun VaultListScreen( Scaffold( topBar = { TopAppBar(title = { Text("My Vaults") }, actions = { - // #207: authenticated entry point to add a passkey for a second device. - IconButton(onClick = onAddPasskeyClick) { Icon(Icons.Default.Key, "Add another passkey") } + // #206: entry point to the passkey management screen (list/revoke/add). + IconButton(onClick = onManagePasskeysClick) { Icon(Icons.Default.Key, "Manage passkeys") } IconButton(onClick = { showCreate = true }) { Icon(Icons.Default.Add, "Create vault") } }) } @@ -1499,6 +1501,102 @@ fun VaultDetailScreen( } } +// MARK: - Passkey Management Screen + +/** + * Lists the account's registered passkeys and lets the user revoke one (#206) — e.g. after + * losing the device it lives on. Revoke is gated by [BiometricHelper], mirroring how + * [VaultDetailScreen] gates disabling 2FA (#120): the biometric prompt requires a + * [androidx.fragment.app.FragmentActivity] and so lives here in the screen layer, while the + * network call is delegated to the ViewModel. + */ +@Composable +fun PasskeyManagementScreen( + onBack: () -> Unit, + onAddPasskeyClick: () -> Unit, + vm: PasskeyManagementViewModel = hiltViewModel() +) { + val state by vm.state.collectAsStateWithLifecycle() + val context = LocalContext.current + var biometricError by remember { mutableStateOf(null) } + var pendingRevoke by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { vm.load() } + + pendingRevoke?.let { credential -> + AlertDialog( + onDismissRequest = { pendingRevoke = null }, + title = { Text("Revoke this passkey?") }, + text = { Text("Any device using \"${credential.deviceLabel ?: "this passkey"}\" will no longer be able to sign in.") }, + confirmButton = { + TextButton(onClick = { + pendingRevoke = null + biometricError = null + BiometricHelper(context as androidx.fragment.app.FragmentActivity).authenticate( + title = "Confirm Revoke Passkey", + subtitle = "Biometric or PIN required to revoke this passkey", + onSuccess = { vm.revokeCredentialAfterBiometric(credential.credentialId) }, + onError = { err -> biometricError = err } + ) + }) { Text("Revoke") } + }, + dismissButton = { TextButton(onClick = { pendingRevoke = null }) { Text("Cancel") } } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Passkeys") }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } }, + actions = { + IconButton(onClick = onAddPasskeyClick) { Icon(Icons.Default.Add, "Add another passkey") } + } + ) + } + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + biometricError?.let { + Text(it, color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodySmall) + } + when { + state.isLoading && state.credentials.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + } + state.error != null && state.credentials.isEmpty() -> { + Column(Modifier.padding(16.dp)) { + Text(state.error!!, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = { vm.load() }) { Text("Retry") } + } + } + state.credentials.isEmpty() -> { + Text("No passkeys registered.", modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + else -> { + LazyColumn { + items(state.credentials, key = { it.credentialId }) { credential -> + ListItem( + headlineContent = { Text(credential.deviceLabel ?: "Unknown device") }, + supportingContent = { + Text(credential.lastUsedAt?.let { "Last used $it" } ?: "Never used") + }, + trailingContent = { + IconButton(onClick = { pendingRevoke = credential }) { + Icon(Icons.Default.Delete, "Revoke passkey") + } + } + ) + } + } + } + } + } + } +} + // MARK: - Add Passkey Screen /** diff --git a/android/app/src/test/java/com/ethosprotocol/PasskeyManagementViewModelTest.kt b/android/app/src/test/java/com/ethosprotocol/PasskeyManagementViewModelTest.kt new file mode 100644 index 0000000..8315986 --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/PasskeyManagementViewModelTest.kt @@ -0,0 +1,97 @@ +package com.ethosprotocol + +import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.api.ApiResult +import com.ethosprotocol.models.PasskeyCredential +import com.ethosprotocol.ui.PasskeyManagementViewModel +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [PasskeyManagementViewModel] (#206) — listing and revoking passkey + * credentials for the authenticated account. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PasskeyManagementViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val apiClient: ApiClient = mockk() + private lateinit var vm: PasskeyManagementViewModel + + private val credentialA = PasskeyCredential( + credentialId = "cred-a", deviceLabel = "iPhone", createdAt = "2099-01-01T00:00:00Z", lastUsedAt = null + ) + private val credentialB = PasskeyCredential( + credentialId = "cred-b", deviceLabel = "Pixel", createdAt = "2099-01-01T00:00:00Z", lastUsedAt = "2099-01-02T00:00:00Z" + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + vm = PasskeyManagementViewModel(apiClient) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + @Test + fun `load success populates credentials`() = runTest { + coEvery { apiClient.listCredentials() } returns ApiResult.Success(listOf(credentialA, credentialB)) + + vm.load() + + assertEquals(listOf(credentialA, credentialB), vm.state.value.credentials) + assertFalse(vm.state.value.isLoading) + assertNull(vm.state.value.error) + } + + @Test + fun `load error sets error message and clears loading`() = runTest { + coEvery { apiClient.listCredentials() } returns ApiResult.Error("Unauthorized", 401) + + vm.load() + + assertEquals("Unauthorized", vm.state.value.error) + assertFalse(vm.state.value.isLoading) + assertTrue(vm.state.value.credentials.isEmpty()) + } + + @Test + fun `revokeCredentialAfterBiometric success removes only that credential`() = runTest { + coEvery { apiClient.listCredentials() } returns ApiResult.Success(listOf(credentialA, credentialB)) + vm.load() + coEvery { apiClient.revokeCredential("cred-a") } returns ApiResult.Success(Unit) + + vm.revokeCredentialAfterBiometric("cred-a") + + assertEquals(listOf(credentialB), vm.state.value.credentials) + assertNull(vm.state.value.error) + } + + @Test + fun `revokeCredentialAfterBiometric error keeps credential and sets error`() = runTest { + coEvery { apiClient.listCredentials() } returns ApiResult.Success(listOf(credentialA)) + vm.load() + coEvery { apiClient.revokeCredential("cred-a") } returns ApiResult.Error("Cannot revoke current session credential", 409) + + vm.revokeCredentialAfterBiometric("cred-a") + + assertEquals(listOf(credentialA), vm.state.value.credentials) + assertEquals("Cannot revoke current session credential", vm.state.value.error) + } +} diff --git a/ios/EthosProtocol/Sources/Services/APIClient.swift b/ios/EthosProtocol/Sources/Services/APIClient.swift index 10bfa9f..1911370 100644 --- a/ios/EthosProtocol/Sources/Services/APIClient.swift +++ b/ios/EthosProtocol/Sources/Services/APIClient.swift @@ -149,7 +149,7 @@ public final class APIClient { let _: EmptyBody = try await post(path: "/auth/recover/link", body: body) } - // MARK: - Passkey Credentials (#207) + // MARK: - Passkey Credentials (#206, #207) /// Registers an additional passkey to the *currently authenticated* account (#207), /// using the existing session's Bearer token — distinct from `linkAdditionalPasskey`, @@ -161,6 +161,23 @@ public final class APIClient { return try await post(path: "/auth/credentials", body: body) } + /// Lists the authenticated account's registered passkey credentials (#206) — an account + /// is not limited to one, so this always returns a list. + func listCredentials() async throws -> [PasskeyCredential] { + try await get(path: "/auth/credentials") + } + + /// Revokes a registered passkey credential (#206), e.g. after it's lost or compromised. + func revokeCredential(credentialID: String) async throws { + var req = request(path: "/auth/credentials/\(credentialID)") + req.httpMethod = "DELETE" + // Anti-replay: DELETE is a mutation; apply nonce + timestamp (task #121). + for (field, value) in Self.makeAntiReplayHeaders() { + req.setValue(value, forHTTPHeaderField: field) + } + _ = try await execute(req) + } + // MARK: - Vaults /// One page of `GET /vaults`. See shared/api-contract.md's "List Pagination" diff --git a/ios/EthosProtocol/Sources/ViewModels/Stores.swift b/ios/EthosProtocol/Sources/ViewModels/Stores.swift index c39cb99..2ea38e3 100644 --- a/ios/EthosProtocol/Sources/ViewModels/Stores.swift +++ b/ios/EthosProtocol/Sources/ViewModels/Stores.swift @@ -479,3 +479,54 @@ struct Disable2FACoordinator { try await apiDisable(vaultID) } } + +// MARK: - #206 Passkey Management + +@MainActor +final class PasskeyManagementStore: ObservableObject { + @Published var credentials: [PasskeyCredential] = [] + @Published var isLoading = false + @Published var error: ErrorPresentation? + + // Injected for testing; mirrors BackgroundRefreshService.vaultListProvider. + var listCredentials: () async throws -> [PasskeyCredential] = { try await APIClient.shared.listCredentials() } + var revoke = RevokeCredentialCoordinator() + + func load() async { + isLoading = true; error = nil + do { + let result = try await listCredentials() + ifNotCancelled { credentials = result } + } catch { + ifNotCancelled { self.error = ErrorPresentation(error) } + } + isLoading = false + } + + /// Revokes `credential` after a biometric gate (#206) and removes it from the + /// displayed list on success, without a full reload round trip. + func revokeCredential(_ credential: PasskeyCredential) async { + error = nil + do { + try await revoke.run(credentialID: credential.id) + ifNotCancelled { credentials.removeAll { $0.id == credential.id } } + } catch { + ifNotCancelled { self.error = ErrorPresentation(error) } + } + } +} + +/// Encapsulates the "authenticate then revoke" sequence for a passkey credential (#206), +/// mirroring `Disable2FACoordinator`'s biometric-gate pattern — revoking a passkey is at +/// least as security-sensitive as disabling 2FA. +struct RevokeCredentialCoordinator { + var biometric: BiometricAuthenticating = BiometricService.shared + var apiRevoke: (String) async throws -> Void = { credentialID in + try await APIClient.shared.revokeCredential(credentialID: credentialID) + } + + func run(credentialID: String) async throws { + try await biometric.authenticate(reason: "Confirm removing this passkey") + try await apiRevoke(credentialID) + } +} diff --git a/ios/EthosProtocol/Sources/Views/PasskeyManagementView.swift b/ios/EthosProtocol/Sources/Views/PasskeyManagementView.swift new file mode 100644 index 0000000..9cecbe0 --- /dev/null +++ b/ios/EthosProtocol/Sources/Views/PasskeyManagementView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +/// Lists the account's registered passkeys and lets the user revoke one (#206) — e.g. after +/// losing the device it lives on — and add another (#207), reusing `AddPasskeyView`. +struct PasskeyManagementView: View { + @StateObject private var store = PasskeyManagementStore() + @State private var showAddPasskey = false + @State private var pendingRevoke: PasskeyCredential? + + private static let lastUsedFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() + + var body: some View { + List { + if let error = store.error { + Section { Text(error.message).font(.caption).foregroundStyle(.red) } + } + ForEach(store.credentials) { credential in + VStack(alignment: .leading, spacing: 4) { + Text(credential.deviceLabel ?? "Unknown device") + .font(.body) + Text(lastUsedDescription(for: credential)) + .font(.caption) + .foregroundStyle(.secondary) + } + .swipeActions { + Button("Revoke", role: .destructive) { pendingRevoke = credential } + } + } + if store.credentials.isEmpty && !store.isLoading { + Text("No passkeys registered.") + .foregroundStyle(.secondary) + } + } + .overlay { if store.isLoading && store.credentials.isEmpty { ProgressView() } } + .navigationTitle("Passkeys") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { showAddPasskey = true }) { Image(systemName: "plus") } + } + } + .task { await store.load() } + .refreshable { await store.load() } + .sheet(isPresented: $showAddPasskey) { + AddPasskeyView(onAdded: { _ in Task { await store.load() } }) + } + // #206: revoking is security-sensitive (at least as much as disabling 2FA), so it's + // confirmed explicitly before the biometric gate in RevokeCredentialCoordinator runs. + .confirmationDialog( + "Revoke this passkey? Any device using it will no longer be able to sign in.", + isPresented: Binding(get: { pendingRevoke != nil }, set: { if !$0 { pendingRevoke = nil } }), + titleVisibility: .visible + ) { + Button("Revoke", role: .destructive) { + if let credential = pendingRevoke { + Task { await store.revokeCredential(credential) } + } + pendingRevoke = nil + } + Button("Cancel", role: .cancel) { pendingRevoke = nil } + } + } + + private func lastUsedDescription(for credential: PasskeyCredential) -> String { + guard let lastUsedAt = credential.lastUsedAt else { return "Never used" } + return "Last used \(Self.lastUsedFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))" + } +} diff --git a/ios/EthosProtocol/Sources/Views/SettingsView.swift b/ios/EthosProtocol/Sources/Views/SettingsView.swift index f9e7078..fbdb889 100644 --- a/ios/EthosProtocol/Sources/Views/SettingsView.swift +++ b/ios/EthosProtocol/Sources/Views/SettingsView.swift @@ -8,6 +8,9 @@ struct SettingsView: View { var body: some View { Form { Section { + // #206: lists registered credentials and lets the user revoke one, e.g. + // after losing the device it lives on. + NavigationLink("Manage Passkeys") { PasskeyManagementView() } // #207: authenticated "Add another passkey" entry point — distinct from the // initial account-registration flow (RegisterView), for a signed-in user // adding a second device without going through account recovery. diff --git a/ios/EthosProtocol/Tests/PasskeyManagementTests.swift b/ios/EthosProtocol/Tests/PasskeyManagementTests.swift new file mode 100644 index 0000000..6e49b6f --- /dev/null +++ b/ios/EthosProtocol/Tests/PasskeyManagementTests.swift @@ -0,0 +1,108 @@ +import XCTest +@testable import EthosProtocol + +// MARK: - #206 RevokeCredentialCoordinator Tests +// +// Mirrors Disable2FACoordinatorTests: revoking a passkey is at least as security-sensitive +// as disabling 2FA, so the same "biometric gates the API call" invariant applies. + +final class RevokeCredentialCoordinatorTests: XCTestCase { + + func test_biometricSuccess_callsRevokeAPI() async throws { + let biometric = MockBiometricService() + biometric.shouldSucceed = true + var revokedIDs: [String] = [] + + let coordinator = RevokeCredentialCoordinator(biometric: biometric, apiRevoke: { revokedIDs.append($0) }) + + try await coordinator.run(credentialID: "cred-abc") + + XCTAssertEqual(biometric.authenticateCallCount, 1) + XCTAssertEqual(revokedIDs, ["cred-abc"]) + } + + func test_biometricCancelled_doesNotCallRevokeAPI() async { + let biometric = MockBiometricService() + biometric.shouldSucceed = false + var revokeCallCount = 0 + + let coordinator = RevokeCredentialCoordinator(biometric: biometric, apiRevoke: { _ in revokeCallCount += 1 }) + + do { + try await coordinator.run(credentialID: "cred-xyz") + XCTFail("Expected an error when biometric is cancelled") + } catch { + // expected + } + + XCTAssertEqual(revokeCallCount, 0, "Revoking MUST NOT happen when biometric authentication fails") + } +} + +// MARK: - #206 PasskeyManagementStore Tests + +@MainActor +final class PasskeyManagementStoreTests: XCTestCase { + + func test_load_populatesCredentials() async { + let store = PasskeyManagementStore() + let fixture = [ + PasskeyCredential(credentialId: "cred-1", deviceLabel: "iPhone", createdAt: Date(), lastUsedAt: Date()), + PasskeyCredential(credentialId: "cred-2", deviceLabel: "iPad", createdAt: Date(), lastUsedAt: nil) + ] + store.listCredentials = { fixture } + + await store.load() + + XCTAssertEqual(store.credentials, fixture) + XCTAssertNil(store.error) + } + + func test_load_failure_setsError() async { + enum FakeError: Error, LocalizedError { case listFailed + var errorDescription: String? { "list failed" } + } + let store = PasskeyManagementStore() + store.listCredentials = { throw FakeError.listFailed } + + await store.load() + + XCTAssertTrue(store.credentials.isEmpty) + XCTAssertNotNil(store.error) + } + + func test_revokeCredential_success_removesFromList() async { + let store = PasskeyManagementStore() + let toRevoke = PasskeyCredential(credentialId: "cred-revoke", deviceLabel: "Old iPhone", createdAt: Date(), lastUsedAt: nil) + let toKeep = PasskeyCredential(credentialId: "cred-keep", deviceLabel: "iPad", createdAt: Date(), lastUsedAt: nil) + store.listCredentials = { [toRevoke, toKeep] } + await store.load() + + let biometric = MockBiometricService() + biometric.shouldSucceed = true + store.revoke = RevokeCredentialCoordinator(biometric: biometric, apiRevoke: { _ in }) + + await store.revokeCredential(toRevoke) + + XCTAssertEqual(store.credentials, [toKeep]) + XCTAssertNil(store.error) + } + + func test_revokeCredential_biometricCancelled_keepsCredentialInList() async { + let store = PasskeyManagementStore() + let credential = PasskeyCredential(credentialId: "cred-1", deviceLabel: "iPhone", createdAt: Date(), lastUsedAt: nil) + store.listCredentials = { [credential] } + await store.load() + + let biometric = MockBiometricService() + biometric.shouldSucceed = false + var apiCallCount = 0 + store.revoke = RevokeCredentialCoordinator(biometric: biometric, apiRevoke: { _ in apiCallCount += 1 }) + + await store.revokeCredential(credential) + + XCTAssertEqual(apiCallCount, 0) + XCTAssertEqual(store.credentials, [credential], "A cancelled biometric prompt must leave the credential in the list") + XCTAssertNotNil(store.error) + } +}