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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ class ApiClient(
suspend fun completeRecovery(req: RecoveryCompleteRequest): ApiResult<Unit> =
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<PasskeyCredential> =
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<List<PasskeyCredential>> = get("/auth/credentials")

suspend fun revokeCredential(credentialId: String): ApiResult<Unit> =
delete("/auth/credentials/$credentialId", Unit)

// Vaults
suspend fun listVaults(): ApiResult<List<Vault>> = get("/vaults")

Expand Down
21 changes: 21 additions & 0 deletions android/app/src/main/java/com/ethosprotocol/models/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<PasskeyCredential>`), 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<PasskeyCredential> = 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)
Expand Down
16 changes: 15 additions & 1 deletion android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -343,6 +357,44 @@ class TwoFactorViewModel @Inject constructor(
}
}

// --- Passkey Management ViewModel (#206) ---

data class PasskeyManagementUiState(
val credentials: List<PasskeyCredential> = 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(
Expand Down
166 changes: 166 additions & 0 deletions android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -199,6 +201,7 @@ private fun RecoverySheet(
@Composable
fun VaultListScreen(
onVaultClick: (String) -> Unit,
onManagePasskeysClick: () -> Unit = {},
vm: VaultViewModel = hiltViewModel()
) {
val state by vm.state.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -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") }
})
}
Expand Down Expand Up @@ -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<String?>(null) }
var pendingRevoke by remember { mutableStateOf<PasskeyCredential?>(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")
}
}
}
}
}
Loading