diff --git a/.github/scripts/verify_asset_links.py b/.github/scripts/verify_asset_links.py new file mode 100755 index 0000000..3367c77 --- /dev/null +++ b/.github/scripts/verify_asset_links.py @@ -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() diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 2f75805..f80df59 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -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: diff --git a/android/app/src/main/java/com/ethosprotocol/services/PasskeyRequestBuilder.kt b/android/app/src/main/java/com/ethosprotocol/services/PasskeyRequestBuilder.kt new file mode 100644 index 0000000..34e1cf1 --- /dev/null +++ b/android/app/src/main/java/com/ethosprotocol/services/PasskeyRequestBuilder.kt @@ -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") + } +} 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 fd9b427..d31d3d0 100644 --- a/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt +++ b/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt @@ -12,7 +12,6 @@ import com.ethosprotocol.models.PasskeyVerifyRequest import kotlinx.coroutines.CancellationException import org.json.JSONArray import org.json.JSONObject -import java.util.Base64 import javax.inject.Inject import javax.inject.Singleton @@ -34,19 +33,10 @@ class PasskeyService @Inject constructor( private val credentialManagerFactory: CredentialManagerFactory ) { suspend fun register(activity: Activity, username: String): Result = 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 = credentialManagerFactory.create(activity) val resp = credManager.createCredential(activity, CreatePublicKeyCredentialRequest(requestJson)) diff --git a/android/app/src/main/java/com/ethosprotocol/services/UsernameValidator.kt b/android/app/src/main/java/com/ethosprotocol/services/UsernameValidator.kt new file mode 100644 index 0000000..35b4518 --- /dev/null +++ b/android/app/src/main/java/com/ethosprotocol/services/UsernameValidator.kt @@ -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)) +} 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 da3eeee..6f31719 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -26,6 +26,8 @@ import com.ethosprotocol.services.PendingActionSyncWorker import com.ethosprotocol.services.PendingActionType 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 @@ -83,6 +85,7 @@ class AuthViewModel @Inject constructor( } 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) } } 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 5f76deb..33f0511 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 @@ -25,6 +25,7 @@ import com.ethosprotocol.models.Enable2FARequest import com.ethosprotocol.models.Verify2FARequest import com.ethosprotocol.models.StellarAddress import com.ethosprotocol.services.BiometricHelper +import com.ethosprotocol.services.UsernameValidator import com.ethosprotocol.services.VaultDeepLinkAction import com.ethosprotocol.ui.AcceptanceViewModel import com.ethosprotocol.ui.AuthUiState @@ -84,6 +85,14 @@ fun AuthScreenContent( 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 = onSignIn, @@ -101,15 +110,28 @@ fun AuthScreenContent( @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") } } ) diff --git a/android/app/src/test/java/com/ethosprotocol/PasskeyRequestBuilderTest.kt b/android/app/src/test/java/com/ethosprotocol/PasskeyRequestBuilderTest.kt new file mode 100644 index 0000000..4510746 --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/PasskeyRequestBuilderTest.kt @@ -0,0 +1,88 @@ +package com.ethosprotocol + +import com.ethosprotocol.services.PasskeyRequestBuilder +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Base64 + +class PasskeyRequestBuilderTest { + + @Test + fun registrationRequest_hasExactTopLevelKeys() { + val request = PasskeyRequestBuilder.registrationRequest("chal-123", "alice") + assertEquals( + setOf("challenge", "rp", "user", "pubKeyCredParams", "authenticatorSelection"), + request.keys + ) + } + + @Test + fun registrationRequest_setsChallengeVerbatim() { + val request = PasskeyRequestBuilder.registrationRequest("chal-123", "alice") + assertEquals("chal-123", request["challenge"]?.jsonPrimitive?.contentOrNull) + } + + @Test + fun registrationRequest_rpIsEthosProtocolDomain() { + val rp = PasskeyRequestBuilder.registrationRequest("chal-123", "alice")["rp"]!!.jsonObject + assertEquals(setOf("id", "name"), rp.keys) + assertEquals("ethos-protocol.app", rp["id"]?.jsonPrimitive?.contentOrNull) + assertEquals("Ethos-Protocol", rp["name"]?.jsonPrimitive?.contentOrNull) + } + + @Test + fun registrationRequest_userIdIsBase64UrlOfUsername() { + val user = PasskeyRequestBuilder.registrationRequest("chal-123", "alice")["user"]!!.jsonObject + assertEquals(setOf("id", "name", "displayName"), user.keys) + val expectedId = Base64.getUrlEncoder().withoutPadding().encodeToString("alice".toByteArray()) + assertEquals(expectedId, user["id"]?.jsonPrimitive?.contentOrNull) + assertEquals("alice", user["name"]?.jsonPrimitive?.contentOrNull) + assertEquals("alice", user["displayName"]?.jsonPrimitive?.contentOrNull) + } + + @Test + fun registrationRequest_pubKeyCredParamsUsesEs256() { + val params = PasskeyRequestBuilder.registrationRequest("chal-123", "alice")["pubKeyCredParams"]!!.jsonArray + assertEquals(1, params.size) + val param = params[0].jsonObject + assertEquals("public-key", param["type"]?.jsonPrimitive?.contentOrNull) + assertEquals(-7, param["alg"]?.jsonPrimitive?.int) + } + + @Test + fun registrationRequest_authenticatorSelectionRequiresPlatformResidentKeyAndUserVerification() { + val selection = PasskeyRequestBuilder.registrationRequest("chal-123", "alice")["authenticatorSelection"]!!.jsonObject + assertEquals(setOf("authenticatorAttachment", "requireResidentKey", "userVerification"), selection.keys) + assertEquals("platform", selection["authenticatorAttachment"]?.jsonPrimitive?.contentOrNull) + assertTrue(selection["requireResidentKey"]?.jsonPrimitive?.boolean == true) + assertEquals("required", selection["userVerification"]?.jsonPrimitive?.contentOrNull) + } + + @Test + fun authenticationRequest_hasExactShape() { + val request = PasskeyRequestBuilder.authenticationRequest("chal-456") + assertEquals(setOf("challenge", "rpId", "userVerification"), request.keys) + assertEquals("chal-456", request["challenge"]?.jsonPrimitive?.contentOrNull) + assertEquals("ethos-protocol.app", request["rpId"]?.jsonPrimitive?.contentOrNull) + assertEquals("required", request["userVerification"]?.jsonPrimitive?.contentOrNull) + } + + @Test + fun registrationRequestJson_isValidJsonMatchingRegistrationRequest() { + val json = PasskeyRequestBuilder.registrationRequestJson("chal-123", "alice") + assertEquals(PasskeyRequestBuilder.registrationRequest("chal-123", "alice").toString(), json) + } + + @Test + fun authenticationRequestJson_isValidJsonMatchingAuthenticationRequest() { + val json = PasskeyRequestBuilder.authenticationRequestJson("chal-456") + assertEquals(PasskeyRequestBuilder.authenticationRequest("chal-456").toString(), json) + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/UsernameValidatorTest.kt b/android/app/src/test/java/com/ethosprotocol/UsernameValidatorTest.kt new file mode 100644 index 0000000..d67853e --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/UsernameValidatorTest.kt @@ -0,0 +1,65 @@ +package com.ethosprotocol + +import com.ethosprotocol.services.UsernameValidator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UsernameValidatorTest { + + @Test + fun isValid_acceptsSimpleAlphanumeric() { + assertTrue(UsernameValidator.isValid("alice123")) + } + + @Test + fun isValid_acceptsInteriorDotsUnderscoresHyphens() { + assertTrue(UsernameValidator.isValid("alice.b_c-d")) + } + + @Test + fun isValid_rejectsBlank() { + assertFalse(UsernameValidator.isValid("")) + assertFalse(UsernameValidator.isValid(" ")) + } + + @Test + fun isValid_rejectsTooShort() { + assertFalse(UsernameValidator.isValid("ab")) + } + + @Test + fun isValid_rejectsTooLong() { + assertFalse(UsernameValidator.isValid("a".repeat(UsernameValidator.MAX_LENGTH + 1))) + } + + @Test + fun isValid_acceptsMaxLength() { + assertTrue(UsernameValidator.isValid("a".repeat(UsernameValidator.MAX_LENGTH))) + } + + @Test + fun isValid_rejectsLeadingOrTrailingSpecialCharacter() { + assertFalse(UsernameValidator.isValid("-alice")) + assertFalse(UsernameValidator.isValid("alice-")) + assertFalse(UsernameValidator.isValid(".alice")) + } + + @Test + fun isValid_rejectsDisallowedCharacters() { + assertFalse(UsernameValidator.isValid("alice bob")) + assertFalse(UsernameValidator.isValid("alice@bob")) + assertFalse(UsernameValidator.isValid("alice/bob")) + } + + @Test + fun isValid_treatsSurroundingWhitespaceAsTrimmed() { + assertTrue(UsernameValidator.isValid(" alice123 ")) + } + + @Test + fun sanitize_trimsWhitespace() { + assertEquals("alice", UsernameValidator.sanitize(" alice ")) + } +}