Skip to content
Open
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
118 changes: 118 additions & 0 deletions .github/scripts/verify_asset_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Fail the build if the live assetlinks.json drifts from AndroidManifest's asset_statements.

Compares the Digital Asset Links statements served from
https://ethos-protocol.app/.well-known/assetlinks.json against the
`asset_statements` string resource that AndroidManifest.xml points platform
passkey verification at. See android/app/src/main/res/values/strings.xml.

Note: strings.xml intentionally commits a placeholder sha256_cert_fingerprints
value (the real release cert fingerprint is never checked into git, mirroring
how release signing credentials are injected via CI secrets rather than
hardcoded). So this script cannot assert literal fingerprint equality until
that placeholder is resolved out-of-band; it instead validates that the
served fingerprints are well-formed and, once the placeholder has been
replaced with a real value, that they match exactly.
"""
import json
import re
import sys
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET

STRINGS_XML = "android/app/src/main/res/values/strings.xml"
ASSET_LINKS_URL = "https://ethos-protocol.app/.well-known/assetlinks.json"
FINGERPRINT_RE = re.compile(r"^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){31}$")
PLACEHOLDER = "REPLACE_WITH_RELEASE_CERT_SHA256_FINGERPRINT"


def fail(message):
print(f"::error::{message}")
sys.exit(1)


def load_manifest_statements():
try:
tree = ET.parse(STRINGS_XML)
except (OSError, ET.ParseError) as e:
fail(f"could not read/parse {STRINGS_XML}: {e}")

for el in tree.getroot().findall("string"):
if el.get("name") == "asset_statements":
raw = (el.text or "").strip()
try:
return json.loads(raw.replace('\\"', '"'))
except json.JSONDecodeError as e:
fail(f"asset_statements in {STRINGS_XML} is not valid JSON: {e}")

fail(f"no asset_statements string resource found in {STRINGS_XML}")


def fetch_served_statements():
try:
with urllib.request.urlopen(ASSET_LINKS_URL, timeout=15) as resp:
status = resp.status
body = resp.read().decode("utf-8")
except (urllib.error.URLError, TimeoutError) as e:
fail(f"failed to fetch {ASSET_LINKS_URL}: {e}")

if status != 200:
fail(f"{ASSET_LINKS_URL} returned HTTP {status}")

try:
return json.loads(body)
except json.JSONDecodeError as e:
fail(f"{ASSET_LINKS_URL} did not return valid JSON: {e}")


def statement_key(stmt):
target = stmt.get("target", {})
return (target.get("namespace"), target.get("package_name"))


def main():
manifest_statements = load_manifest_statements()
served_statements = fetch_served_statements()

manifest_by_key = {statement_key(s): s for s in manifest_statements}
served_by_key = {statement_key(s): s for s in served_statements}

errors = []
for key, manifest_stmt in manifest_by_key.items():
served_stmt = served_by_key.get(key)
if served_stmt is None:
errors.append(f"{ASSET_LINKS_URL} is missing a statement for {key}")
continue

manifest_relations = sorted(manifest_stmt.get("relation", []))
served_relations = sorted(served_stmt.get("relation", []))
if manifest_relations != served_relations:
errors.append(
f"relation mismatch for {key}: manifest={manifest_relations} served={served_relations}"
)

served_fingerprints = served_stmt.get("target", {}).get("sha256_cert_fingerprints", [])
if not served_fingerprints:
errors.append(f"{ASSET_LINKS_URL} has no sha256_cert_fingerprints for {key}")
for fp in served_fingerprints:
if not FINGERPRINT_RE.match(fp):
errors.append(f"served fingerprint '{fp}' for {key} is not a well-formed SHA-256 fingerprint")

manifest_fingerprints = manifest_stmt.get("target", {}).get("sha256_cert_fingerprints", [])
if manifest_fingerprints != [PLACEHOLDER]:
if sorted(manifest_fingerprints) != sorted(served_fingerprints):
errors.append(
f"fingerprint mismatch for {key}: manifest={manifest_fingerprints} served={served_fingerprints}"
)

if errors:
for e in errors:
print(f"::error::{e}")
sys.exit(1)

print(f"OK: {ASSET_LINKS_URL} matches {STRINGS_XML}'s asset_statements")


if __name__ == "__main__":
main()
4 changes: 4 additions & 0 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Verify Digital Asset Links (assetlinks.json)
working-directory: ${{ github.workspace }}
run: python3 .github/scripts/verify_asset_links.py

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.ethosprotocol.services

import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import java.util.Base64

/**
* Pure WebAuthn requestJson construction for [PasskeyService], kept separate so its
* exact shape (key names, rp/user nesting, authenticatorSelection flags) can be
* unit-tested without a live CredentialManager.
*/
internal object PasskeyRequestBuilder {
private const val RP_ID = "ethos-protocol.app"
private const val RP_NAME = "Ethos-Protocol"

fun registrationRequestJson(challenge: String, username: String): String =
registrationRequest(challenge, username).toString()

fun authenticationRequestJson(challenge: String): String =
authenticationRequest(challenge).toString()

internal fun registrationRequest(challenge: String, username: String): JsonObject = buildJsonObject {
put("challenge", challenge)
putJsonObject("rp") {
put("id", RP_ID)
put("name", RP_NAME)
}
putJsonObject("user") {
put("id", Base64.getUrlEncoder().withoutPadding().encodeToString(username.toByteArray()))
put("name", username)
put("displayName", username)
}
putJsonArray("pubKeyCredParams") {
add(buildJsonObject {
put("type", "public-key")
put("alg", -7)
})
}
putJsonObject("authenticatorSelection") {
put("authenticatorAttachment", "platform")
put("requireResidentKey", true)
put("userVerification", "required")
}
}

internal fun authenticationRequest(challenge: String): JsonObject = buildJsonObject {
put("challenge", challenge)
put("rpId", RP_ID)
put("userVerification", "required")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ import com.ethosprotocol.api.ApiResult
import com.ethosprotocol.api.TokenProvider
import com.ethosprotocol.models.PasskeyRegisterRequest
import com.ethosprotocol.models.PasskeyVerifyRequest
import org.json.JSONArray
import org.json.JSONObject
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -19,19 +17,10 @@ class PasskeyService @Inject constructor(
private val tokenProvider: TokenProvider
) {
suspend fun register(activity: Activity, username: String): Result<Unit> = runCatching {
val normalizedUsername = UsernameValidator.sanitize(username)
require(UsernameValidator.isValid(normalizedUsername)) { "Invalid username" }
val challenge = requireSuccess(apiClient.getChallenge()).challenge
val requestJson = JSONObject().apply {
put("challenge", challenge)
put("rp", JSONObject().put("id", "ethos-protocol.app").put("name", "Ethos-Protocol"))
put("user", JSONObject()
.put("id", Base64.getUrlEncoder().withoutPadding().encodeToString(username.toByteArray()))
.put("name", username).put("displayName", username))
put("pubKeyCredParams", JSONArray().put(JSONObject().put("type", "public-key").put("alg", -7)))
put("authenticatorSelection", JSONObject()
.put("authenticatorAttachment", "platform")
.put("requireResidentKey", true)
.put("userVerification", "required"))
}.toString()
val requestJson = PasskeyRequestBuilder.registrationRequestJson(challenge, normalizedUsername)

val credManager = CredentialManager.create(activity)
val resp = credManager.createCredential(activity, CreatePublicKeyCredentialRequest(requestJson))
Expand All @@ -47,9 +36,7 @@ class PasskeyService @Inject constructor(

suspend fun authenticate(activity: Activity): Result<Unit> = runCatching {
val challenge = requireSuccess(apiClient.getChallenge()).challenge
val requestJson = JSONObject()
.put("challenge", challenge).put("rpId", "ethos-protocol.app")
.put("userVerification", "required").toString()
val requestJson = PasskeyRequestBuilder.authenticationRequestJson(challenge)

val credManager = CredentialManager.create(activity)
val request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson)))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.ethosprotocol.services

/**
* Provisional rule pending confirmation against the backend's own validation
* and iOS issue #11 — this must stay identical across both clients since the
* value is used verbatim to build the WebAuthn user.id/user.name fields.
*/
object UsernameValidator {
const val MIN_LENGTH = 3
const val MAX_LENGTH = 32

private val PATTERN = Regex("^[A-Za-z0-9][A-Za-z0-9._-]{${MIN_LENGTH - 2},${MAX_LENGTH - 2}}[A-Za-z0-9]$")

fun sanitize(raw: String): String = raw.trim()

fun isValid(raw: String): Boolean = PATTERN.matches(sanitize(raw))
}
47 changes: 43 additions & 4 deletions android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import com.ethosprotocol.services.PendingCheckIn
import com.ethosprotocol.services.PendingCheckInDao
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
Expand All @@ -33,7 +35,8 @@ import javax.inject.Inject
data class AuthUiState(
val isAuthenticated: Boolean = false,
val isLoading: Boolean = false,
val error: String? = null
val error: String? = null,
val cooldownRemainingSeconds: Int = 0
)

@HiltViewModel
Expand All @@ -45,11 +48,39 @@ class AuthViewModel @Inject constructor(
private val _state = MutableStateFlow(AuthUiState(isAuthenticated = tokenProvider.token != null))
val state = _state.asStateFlow()

private var consecutiveFailures = 0
private var cooldownJob: Job? = null

fun signIn(activity: Activity) = viewModelScope.launch {
if (_state.value.cooldownRemainingSeconds > 0) return@launch
_state.update { it.copy(isLoading = true, error = null) }
passkeyService.authenticate(activity)
.onSuccess { _state.update { it.copy(isAuthenticated = true, isLoading = false) } }
.onFailure { e -> _state.update { it.copy(isLoading = false, error = e.message) } }
.onSuccess {
consecutiveFailures = 0
cooldownJob?.cancel()
_state.update { it.copy(isAuthenticated = true, isLoading = false, cooldownRemainingSeconds = 0) }
}
.onFailure { e ->
consecutiveFailures++
_state.update { it.copy(isLoading = false, error = e.message) }
startCooldownIfNeeded()
}
}

private fun startCooldownIfNeeded() {
if (consecutiveFailures < COOLDOWN_FAILURE_THRESHOLD) return
// Cap the exponent well below where `shl` would overflow Int — the clamp to
// COOLDOWN_MAX_SECONDS below makes any exponent past this point equivalent anyway.
val exponent = (consecutiveFailures - COOLDOWN_FAILURE_THRESHOLD).coerceAtMost(10)
val seconds = (COOLDOWN_BASE_SECONDS shl exponent).coerceAtMost(COOLDOWN_MAX_SECONDS)
cooldownJob?.cancel()
cooldownJob = viewModelScope.launch {
for (remaining in seconds downTo 1) {
_state.update { it.copy(cooldownRemainingSeconds = remaining) }
delay(1_000)
}
_state.update { it.copy(cooldownRemainingSeconds = 0) }
}
}

fun register(activity: Activity, username: String) = viewModelScope.launch {
Expand All @@ -61,7 +92,15 @@ class AuthViewModel @Inject constructor(

fun signOut() {
tokenProvider.clear()
_state.update { it.copy(isAuthenticated = false) }
consecutiveFailures = 0
cooldownJob?.cancel()
_state.update { it.copy(isAuthenticated = false, cooldownRemainingSeconds = 0) }
}

private companion object {
const val COOLDOWN_FAILURE_THRESHOLD = 3
const val COOLDOWN_BASE_SECONDS = 2
const val COOLDOWN_MAX_SECONDS = 60
}
}

Expand Down
30 changes: 26 additions & 4 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 com.ethosprotocol.models.TwoFactorStatus
import com.ethosprotocol.models.Enable2FARequest
import com.ethosprotocol.models.Verify2FARequest
import com.ethosprotocol.services.BiometricHelper
import com.ethosprotocol.services.UsernameValidator
import com.ethosprotocol.services.VaultDeepLinkAction
import com.ethosprotocol.ui.AcceptanceViewModel
import com.ethosprotocol.ui.AuthViewModel
Expand Down Expand Up @@ -58,11 +59,19 @@ fun AuthScreen(vm: AuthViewModel = hiltViewModel()) {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
}
if (state.cooldownRemainingSeconds > 0) {
Text(
"Too many failed attempts. Try again in ${state.cooldownRemainingSeconds}s.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
Spacer(Modifier.height(8.dp))
}

Button(
onClick = { vm.signIn(activity) },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading
enabled = !state.isLoading && state.cooldownRemainingSeconds == 0
) {
if (state.isLoading) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
else { Icon(Icons.Default.Key, null); Spacer(Modifier.width(8.dp)); Text("Sign in with Passkey") }
Expand All @@ -75,15 +84,28 @@ fun AuthScreen(vm: AuthViewModel = hiltViewModel()) {
@Composable
private fun RegisterSheet(onRegister: (String) -> Unit, onDismiss: () -> Unit) {
var username by remember { mutableStateOf("") }
val trimmedUsername = username.trim()
val isValid = UsernameValidator.isValid(trimmedUsername)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create Account") },
text = {
OutlinedTextField(value = username, onValueChange = { username = it },
label = { Text("Username") }, singleLine = true)
Column {
OutlinedTextField(value = username, onValueChange = { username = it },
label = { Text("Username") }, singleLine = true)
if (username.isNotBlank() && !isValid) {
Spacer(Modifier.height(4.dp))
Text(
"${UsernameValidator.MIN_LENGTH}-${UsernameValidator.MAX_LENGTH} characters: " +
"letters, numbers, '.', '_', '-' (must start/end with a letter or number)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
},
confirmButton = {
TextButton(onClick = { onRegister(username) }, enabled = username.isNotBlank()) { Text("Register") }
TextButton(onClick = { onRegister(trimmedUsername) }, enabled = isValid) { Text("Register") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
Expand Down
Loading