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
5 changes: 4 additions & 1 deletion android/app/src/main/java/com/ethosprotocol/models/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ data class PasskeyVerifyRequest(
@Serializable
data class PasskeyRegisterRequest(
@SerialName("credential_id") val credentialId: String,
@SerialName("public_key") val publicKey: String,
// Field name is `attestation_object` per shared/api-contract.md — the backend
// extracts the COSE-encoded public key from this object. The legacy `public_key`
// field is not accepted by the server. (#108)
@SerialName("attestation_object") val attestationObject: String,
@SerialName("client_data_json") val clientDataJson: String
)

Expand Down
3 changes: 2 additions & 1 deletion android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,8 @@ data class VaultUiState(
val vaults: List<Vault> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val isOffline: Boolean = false
val isOffline: Boolean = false,
val beneficiaryUpdated: Boolean = false
)

@HiltViewModel
Expand Down
109 changes: 109 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 @@ -474,6 +474,115 @@ fun BeneficiaryAcceptanceScreenContent(
}
}

// MARK: - Manage Beneficiary Screen

@Composable
fun ManageBeneficiaryScreen(
vault: com.ethosprotocol.models.Vault,
onDone: () -> Unit,
vm: VaultViewModel = hiltViewModel()
) {
val state by vm.state.collectAsStateWithLifecycle()
var newBeneficiary by remember { mutableStateOf("") }
var showConfirmation by remember { mutableStateOf(false) }

// The server rejects an address that is empty or unchanged. Mirror the same
// validation used by iOS BeneficiaryUpdate.isValidNewBeneficiary().
val isAddressValid = newBeneficiary.trim().isNotEmpty() && newBeneficiary.trim() != vault.beneficiary

LaunchedEffect(state.beneficiaryUpdated) {
if (state.beneficiaryUpdated) {
vm.clearBeneficiaryUpdated()
onDone()
}
}

Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Person, contentDescription = null,
modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(16.dp))
Text("Manage Beneficiary", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp))
Text("Current beneficiary:", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(vault.beneficiary,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace)
Spacer(Modifier.height(16.dp))

if (showConfirmation) {
// Confirmation step — mirrors iOS ManageBeneficiaryView.confirmationContent
Text("Confirm Change", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text("From:", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(vault.beneficiary,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace)
Spacer(Modifier.height(4.dp))
Text("To:", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(newBeneficiary.trim(),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace)
state.error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(24.dp))
Button(
onClick = { vm.updateBeneficiary(vault.id, newBeneficiary.trim()) },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading
) {
if (state.isLoading) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
else Text("Confirm Change")
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { showConfirmation = false },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading
) { Text("Back") }
} else {
// Input step
OutlinedTextField(
value = newBeneficiary,
onValueChange = { newBeneficiary = it },
label = { Text("New Beneficiary (Stellar address)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
isError = newBeneficiary.isNotEmpty() && !isAddressValid,
supportingText = if (newBeneficiary.isNotEmpty() && !isAddressValid) {
{ Text("Enter a non-empty address that differs from the current beneficiary.") }
} else null
)
state.error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(24.dp))
Button(
onClick = { showConfirmation = true },
modifier = Modifier.fillMaxWidth(),
enabled = isAddressValid
) { Text("Continue") }
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = onDone,
modifier = Modifier.fillMaxWidth()
) { Text("Cancel") }
}
}
}

// MARK: - Vault Deep Link Screen

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.work.*
import com.ethosprotocol.R
import com.ethosprotocol.api.ApiClient
import com.ethosprotocol.api.ApiResult
import com.ethosprotocol.models.VaultStatus
import com.ethosprotocol.ui.MainActivity
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
Expand Down Expand Up @@ -105,7 +106,14 @@ class VaultWidgetUpdateWorker @AssistedInject constructor(
override suspend fun doWork(): Result {
val result = apiClient.listVaults()
if (result is ApiResult.Success) {
val vault = result.data.firstOrNull() ?: return Result.success()
// Pick the active vault with the lowest ttlRemaining — the same
// "most urgent vault" selection that iOS TTLTimelineProvider uses
// (min(by:) on ttlRemaining). Using firstOrNull() would show a
// different vault than iOS for accounts with multiple vaults.
val vault = result.data
.filter { it.status == VaultStatus.active }
.minByOrNull { it.ttlRemaining ?: Long.MAX_VALUE }
?: return Result.success()
val ttl = formatTtl(vault.ttlRemaining)
VaultStatusWidget.saveVaultData(
applicationContext,
Expand Down
66 changes: 35 additions & 31 deletions docs/mobile-passkey-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ The registration process establishes a new passkey credential for vault owner au
└────────────┬─────────────────────┘
┌────────────▼──────────────────────┐
│ Send attestation + public key │
│ to Ethos-Protocol backend │
│ Send attestation_object │
│ + credential_id + client_data_json│
│ to Ethos-Protocol backend │
└────────────┬─────────────────────┘
┌────────────▼──────────────────────┐
│ Backend stores public key │
│ Backend extracts public key from │
│ attestation_object and stores it │
│ for vault owner │
└────────────▼──────────────────────┘
Expand Down Expand Up @@ -144,20 +146,18 @@ func authorizationController(
didCompleteWithAuthorization authorization: ASAuthorization
) {
if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
// 1. Extract public key from credential
let publicKeyDer = credential.credentialPublicKey // DER-encoded public key

// 2. Extract attestation object (proof of credential creation)
let attestationObject = credential.attestationObject

// 3. Extract client data (challenge proof)
// 1. Extract the raw WebAuthn attestation object (CBOR-encoded COSE key + proof).
// The backend parses the public key out of this object — do NOT try to extract
// the public key client-side and send it separately.
let attestationObject = credential.rawAttestationObject // base64url-encode before sending

// 2. Extract client data (challenge proof)
let clientDataJSON = credential.rawClientDataJSON
// 4. Send to backend for verification and storage

// 3. Send to backend. Field name MUST be `attestation_object` (not `public_key`).
try await registerCredentialWithBackend(
vaultId: vaultId,
publicKey: publicKeyDer,
attestation: attestationObject,
attestationObject: attestationObject,
clientData: clientDataJSON
)
}
Expand Down Expand Up @@ -264,12 +264,17 @@ suspend fun registerPasskey(vaultId: String) {
activity = this
)

// 5. Extract registration response
// 5. Extract registration response and build request
val credentialResponse = result.credential as PublicKeyCredential
val registrationResponse = credentialResponse.registrationResponseJson

// 6. Send to backend
registerCredentialWithBackend(vaultId, registrationResponse)
val json = JSONObject(credentialResponse.registrationResponseJson)
// Send `attestation_object` (not `public_key`) — the backend extracts the
// COSE public key from the attestation object server-side. (#108)
val attestationObject = json.getJSONObject("response").getString("attestationObject")
val clientDataJson = json.getJSONObject("response").getString("clientDataJSON")
val credentialId = json.getString("id")

// 6. Send to backend with correct field names
registerCredentialWithBackend(vaultId, credentialId, attestationObject, clientDataJson)
} catch (e: CreateCredentialException) {
handleError(e)
}
Expand Down Expand Up @@ -385,22 +390,21 @@ pub async fn verify_registration(
) -> Result<PublicKey, Error> {
// 1. Verify attestation challenge matches what was sent
verify_challenge(&registration_response.client_data_json)?;

// 2. Verify origin matches expected domain
verify_origin(&registration_response.client_data_json, "ethos-protocol.app")?;

// 3. For optional attestation verification:
// Verify the credential is from a trusted authenticator (e.g., Apple/Google)
if let Some(attestation_object) = registration_response.attestation_object {
verify_attestation(&attestation_object)?;
}

// 4. Extract and validate public key
let public_key = extract_public_key(&registration_response)?;


// 3. Parse and verify the attestation object (always required — both clients
// send it as `attestation_object`; the legacy `public_key` field is gone).
// The COSE-encoded public key is extracted from inside the attestation object.
let public_key = extract_public_key_from_attestation(&registration_response.attestation_object)?;

// 4. Optionally verify authenticator provenance (Apple/Google hardware)
verify_attestation(&registration_response.attestation_object)?;

// 5. Store public key associated with vault owner
store_passkey(vault_id, public_key.clone()).await?;

Ok(public_key)
}
```
Expand Down
1 change: 1 addition & 0 deletions ios/EthosProtocol/Sources/App/EthosProtocolApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct EthosProtocolApp: App {

init() {
BackgroundRefreshService.shared.registerBackgroundTask()
CheckInSyncTask.shared.registerBackgroundTask()
ICloudSyncService.shared.restoreFromICloud()

NotificationCenter.default.addObserver(
Expand Down
7 changes: 5 additions & 2 deletions ios/EthosProtocol/Sources/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,12 @@ public final class APIClient {
return try await post(path: "/auth/verify", body: body)
}

func registerPasskey(credentialID: String, publicKey: String, clientDataJSON: String) async throws {
func registerPasskey(credentialID: String, attestationObject: String, clientDataJSON: String) async throws {
// Field name is `attestation_object` per shared/api-contract.md — the backend
// parses the COSE public key out of the attestation object itself.
// (Legacy field name `public_key` is not accepted by the server.)
let body = ["credential_id": credentialID,
"public_key": publicKey,
"attestation_object": attestationObject,
"client_data_json": clientDataJSON]
let _: EmptyBody = try await post(path: "/auth/register", body: body)
}
Expand Down
Loading