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..26a22d1 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,18 @@ 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) + + // 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/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..529d63c 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt @@ -29,7 +29,9 @@ 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.PasskeyManagementScreen import com.ethosprotocol.ui.screens.BeneficiaryAcceptanceScreen import com.ethosprotocol.ui.screens.DepositScreen import com.ethosprotocol.ui.screens.VaultDeepLinkScreen @@ -220,7 +222,19 @@ 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 */ }, + onManagePasskeysClick = { navController.navigate("passkeys") } + ) + } + composable("passkeys") { + PasskeyManagementScreen( + onBack = { navController.popBackStack() }, + 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..8893632 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 @@ -343,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 d9cba64..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,6 +201,7 @@ private fun RecoverySheet( @Composable fun VaultListScreen( onVaultClick: (String) -> Unit, + onManagePasskeysClick: () -> Unit = {}, vm: VaultViewModel = hiltViewModel() ) { val state by vm.state.collectAsStateWithLifecycle() @@ -261,6 +264,8 @@ fun VaultListScreen( Scaffold( topBar = { TopAppBar(title = { Text("My Vaults") }, actions = { + // #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") } }) } @@ -1495,3 +1500,164 @@ 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 + +/** + * #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/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/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/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/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..1911370 100644 --- a/ios/EthosProtocol/Sources/Services/APIClient.swift +++ b/ios/EthosProtocol/Sources/Services/APIClient.swift @@ -149,6 +149,35 @@ public final class APIClient { let _: EmptyBody = try await post(path: "/auth/recover/link", body: body) } + // 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`, + /// 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) + } + + /// 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/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() 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/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..2ea38e3 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 @@ -461,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/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/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 b4e32c4..fbdb889 100644 --- a/ios/EthosProtocol/Sources/Views/SettingsView.swift +++ b/ios/EthosProtocol/Sources/Views/SettingsView.swift @@ -3,9 +3,22 @@ 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 { + // #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. + 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 +48,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/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") + } } 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) + } +} 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" }