From b24f97c88b515ba2b0269fa65966ff463624ec82 Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Fri, 29 May 2026 12:12:55 +0530 Subject: [PATCH 1/3] add three-QR signup ceremony to the Android app (ADR 0023 phone-side) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard demo at /demo/registration was driving the phone-side endpoints through a simulator. This commit adds the actual Kotlin client so the same flow runs against the live backend from a real phone (sideloaded APK). What lands: net/RegistrationApi.kt — Retrofit binding for the three phone-side endpoints (POST /v1/registrations/{pair-device, submit-commitment, complete}) plus request/response DTOs. Same OkHttp + Json stack as ZeroAuthApi; pulled out into ApiFactory.createRegistrationApi so callers that only need the new surface skip the W3 init cost. util/RegQrPayload.kt — Parser for the QR deeplinks: zeroauth://reg?step= &session=&code=ZA-XXXX-XXXX [&challenge=<32-hex>]. Returns Result.failure with stable string codes (reg_qr_parse_failed, reg_qr_missing_field, reg_qr_bad_code_shape, reg_qr_bad_challenge_shape) so the UI routes errors without a try/catch. util/DeviceFingerprint.kt — Builds the >= 16-char opaque fingerprint string the server hashes. Composition: android:: :; result SHA-256-hex'd. Per-install UUID lives in SharedPreferences so the same install always produces the same fingerprint (lets the same row in the devices table re-enroll on code-regenerate). ui/reg/RegistrationViewModel.kt — State machine: Idle → Pairing → AwaitingEnrollScan → Committing → AwaitingVerifyScan → Verifying → Completed | Failed. onQrText(text) parses, branches by step, and POSTs. BiometricSecretSource and ProofGenerator are injection seams so Phase 1 Sprint 4 can swap the StubProofGenerator for the real WebViewMobileProver. ui/reg/RegistrationHelpers.kt — Default injections: PerInstallStableSecret persists a 32-byte secret in SharedPreferences so step 2 and step 3 derive the same commitment (without that, the verify step's publicSignals[0] check fails). DeriveDidAndCommitment computes Poseidon.hash2(secret, zeroSalt) and derives the DID suffix. StubProofGenerator returns a structurally valid Groth16 envelope — the server's verifier will reject it (intentionally) until the real prover lands. ui/reg/RegistrationScreen.kt — Compose UI. Paste-deeplink only for V1 (the camera scan path mirrors ui/scan/ScanScreen.kt's ML Kit + CameraX pipeline and gets wired in Phase 1 Sprint 4). Step badge, progress indicators, terminal Completed / Failed cards. ui/SplashScreen.kt — Second CTA "Create a new account (3-QR signup)" routes to the new flow. Original "Sign in" CTA unchanged. nav/Nav.kt — New Screen.Registration entry + composable route. Tests: test/util/RegQrPayloadTest.kt — 11 Robolectric tests covering the happy path for each step + every stable error code (wrong scheme, wrong host, unknown step, missing session/code, malformed code/challenge, verify-without-challenge). Verify (CI): .github/workflows/android.yml runs ./gradlew assembleDebug + test on every push that touches android/**. The local toolchain in this environment doesn't have gradle/android SDK installed (the wrapper jar is gitignored per README), so the validation falls to CI. How to install on a phone: cd android gradle wrapper --gradle-version 8.7 ./gradlew :app:installDebug # adb-attached device --- android/README.md | 17 +- .../main/java/dev/zeroauth/android/nav/Nav.kt | 16 ++ .../main/java/dev/zeroauth/android/net/Api.kt | 16 +- .../zeroauth/android/net/RegistrationApi.kt | 122 +++++++++++ .../dev/zeroauth/android/ui/SplashScreen.kt | 64 ++++-- .../android/ui/reg/RegistrationHelpers.kt | 130 +++++++++++ .../android/ui/reg/RegistrationScreen.kt | 181 +++++++++++++++ .../android/ui/reg/RegistrationViewModel.kt | 206 ++++++++++++++++++ .../android/util/DeviceFingerprint.kt | 72 ++++++ .../dev/zeroauth/android/util/RegQrPayload.kt | 161 ++++++++++++++ .../zeroauth/android/util/RegQrPayloadTest.kt | 112 ++++++++++ 11 files changed, 1076 insertions(+), 21 deletions(-) create mode 100644 android/app/src/main/java/dev/zeroauth/android/net/RegistrationApi.kt create mode 100644 android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationHelpers.kt create mode 100644 android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationScreen.kt create mode 100644 android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationViewModel.kt create mode 100644 android/app/src/main/java/dev/zeroauth/android/util/DeviceFingerprint.kt create mode 100644 android/app/src/main/java/dev/zeroauth/android/util/RegQrPayload.kt create mode 100644 android/app/src/test/java/dev/zeroauth/android/util/RegQrPayloadTest.kt diff --git a/android/README.md b/android/README.md index f854f4d..c172863 100644 --- a/android/README.md +++ b/android/README.md @@ -11,12 +11,27 @@ real and demoable. The snarkjs prover, the Retrofit `/v1/proof-pairing` client, the Keystore-bound credential, the Biometric prompt — those all land in the follow-on prover-glue sprint task. +**ADR 0023 three-QR end-user signup ceremony** lives alongside the W3 +QR-sign-in flow. The Splash screen has a second CTA ("Create a new +account (3-QR signup)") that routes to `RegistrationScreen` — +paste-deeplink only for V1 (the camera scan path reuses the existing +ScanScreen's ML Kit pipeline and gets wired in Phase 1 Sprint 4 +alongside the real FaceEmbedder pipeline from `mobile/biometric/`). +The phone-side endpoints (`POST /v1/registrations/{pair-device, +submit-commitment, complete}`) are bound via `net/RegistrationApi.kt`; +the deeplink parser is `util/RegQrPayload.kt`; the orchestrator is +`ui/reg/RegistrationViewModel.kt`. See +[ADR 0023](../adr/0023-three-qr-signup-ceremony.md) for the wire +protocol + state machine. + See: - [ADR-0009 — QR proof-pairing protocol](../adr/0009-qr-proof-pairing-protocol.md) - [ADR-0010 — Android WebView snarkjs bundling](../adr/0010-android-webview-snarkjs-bundling.md) +- [ADR 0023 — Three-QR end-user signup ceremony](../adr/0023-three-qr-signup-ceremony.md) - [`docs/api_contract.md`](../docs/api_contract.md) — the four - `/v1/proof-pairing/*` endpoints. + `/v1/proof-pairing/*` endpoints + the six `/v1/registrations/*` + endpoints. ## Prerequisites diff --git a/android/app/src/main/java/dev/zeroauth/android/nav/Nav.kt b/android/app/src/main/java/dev/zeroauth/android/nav/Nav.kt index 7c4a8fb..cf5ebde 100644 --- a/android/app/src/main/java/dev/zeroauth/android/nav/Nav.kt +++ b/android/app/src/main/java/dev/zeroauth/android/nav/Nav.kt @@ -10,6 +10,7 @@ import androidx.navigation.navArgument import dev.zeroauth.android.ui.DoneScreen import dev.zeroauth.android.ui.EnrollScreen import dev.zeroauth.android.ui.SplashScreen +import dev.zeroauth.android.ui.reg.RegistrationScreen import dev.zeroauth.android.ui.scan.ScanScreen /** @@ -28,6 +29,8 @@ sealed class Screen(val route: String) { data object Splash : Screen("splash") data object Enroll : Screen("enroll") data object Scan : Screen("scan") + /** ADR 0023 three-QR end-user signup ceremony. */ + data object Registration : Screen("registration") data object Done : Screen("done?payload={payload}") { const val ARG_PAYLOAD = "payload" @@ -56,6 +59,19 @@ fun ZeroAuthNavHost() { popUpTo(Screen.Splash.route) { inclusive = true } } }, + onCreateAccount = { + navController.navigate(Screen.Registration.route) + }, + ) + } + + composable(Screen.Registration.route) { + RegistrationScreen( + onDone = { + navController.navigate(Screen.Splash.route) { + popUpTo(0) { inclusive = true } + } + }, ) } diff --git a/android/app/src/main/java/dev/zeroauth/android/net/Api.kt b/android/app/src/main/java/dev/zeroauth/android/net/Api.kt index 934841d..c51e550 100644 --- a/android/app/src/main/java/dev/zeroauth/android/net/Api.kt +++ b/android/app/src/main/java/dev/zeroauth/android/net/Api.kt @@ -124,7 +124,20 @@ object ApiFactory { explicitNulls = false } - fun create(baseUrl: String = DEFAULT_BASE_URL): ZeroAuthApi { + fun create(baseUrl: String = DEFAULT_BASE_URL): ZeroAuthApi = + retrofit(baseUrl).create(ZeroAuthApi::class.java) + + /** + * ADR 0023 three-QR signup ceremony — the phone-side endpoints + * the registration scan flow hits. Same OkHttp + Retrofit stack + * as [create]; pulled out into a separate factory so callers that + * only need registration can avoid the ZeroAuthApi initialisation + * cost on first use. + */ + fun createRegistrationApi(baseUrl: String = DEFAULT_BASE_URL): RegistrationApi = + retrofit(baseUrl).create(RegistrationApi::class.java) + + private fun retrofit(baseUrl: String): Retrofit { val logging = HttpLoggingInterceptor().apply { level = if (BuildConfig.DEBUG) { HttpLoggingInterceptor.Level.BODY @@ -144,7 +157,6 @@ object ApiFactory { .client(client) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() - .create(ZeroAuthApi::class.java) } /** diff --git a/android/app/src/main/java/dev/zeroauth/android/net/RegistrationApi.kt b/android/app/src/main/java/dev/zeroauth/android/net/RegistrationApi.kt new file mode 100644 index 0000000..28b4b69 --- /dev/null +++ b/android/app/src/main/java/dev/zeroauth/android/net/RegistrationApi.kt @@ -0,0 +1,122 @@ +package dev.zeroauth.android.net + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Retrofit binding for the three-QR end-user signup ceremony + * (server-side ADR 0023). The phone holds NO tenant API key — the + * QR-supplied code is the bearer credential for each step. See + * `util/RegQrPayload.kt` for the parser that turns a scanned QR + * string into the right request body for each step. + * + * Endpoint contract (server side): + * + * POST /v1/registrations/pair-device + * body: { pair_code, fingerprint, attestation_kind? } + * 200: { session_id, device_id, next: {...} } + * 404: { error: "pair_failed" } — uniform on any failure + * + * POST /v1/registrations/submit-commitment + * body: { enroll_code, did, commitment, attestation_kind? } + * 200: { session_id, next: { step, code, deeplink, challenge_nonce } } + * 404: { error: "enroll_failed" } + * + * POST /v1/registrations/complete + * body: { verify_code, challenge_nonce, proof, public_signals } + * 200: { session_id, tenant_user, device } + * 404: { error: "verify_failed" } + * + * The phone-side rate-limit on these endpoints is 20 req/min per IP. + * + * The `proof` field on /complete carries the snarkjs Groth16 envelope + * `{ pi_a, pi_b, pi_c, protocol, curve }`. We reuse [Groth16Proof] + * from the proof-pairing prover (defined in + * `dev.zeroauth.android.prover.MobileProver`) so the same struct + * serialises identically into both surfaces. + */ +interface RegistrationApi { + + @POST("v1/registrations/pair-device") + suspend fun pairDevice(@Body body: PairDeviceRequest): PairDeviceResponse + + @POST("v1/registrations/submit-commitment") + suspend fun submitCommitment(@Body body: SubmitCommitmentRequest): SubmitCommitmentResponse + + @POST("v1/registrations/complete") + suspend fun complete(@Body body: CompleteRequest): CompleteResponse +} + +// ─── Request shapes ─────────────────────────────────────────────── + +@Serializable +data class PairDeviceRequest( + @SerialName("pair_code") val pairCode: String, + /** + * Opaque hardware identifier — server requires >= 16 chars and + * stores only its SHA-256. Production phones supply a stable + * composition of android_id + installation_uuid + Play Integrity + * package signature. See [dev.zeroauth.android.util.DeviceFingerprint] + * for the canonical builder. + */ + val fingerprint: String, + @SerialName("attestation_kind") val attestationKind: String? = null, +) + +@Serializable +data class SubmitCommitmentRequest( + @SerialName("enroll_code") val enrollCode: String, + /** `did:zeroauth::` — server validates the shape. */ + val did: String, + /** Hex Poseidon commitment, with or without the leading `0x`. */ + val commitment: String, + @SerialName("attestation_kind") val attestationKind: String? = null, +) + +@Serializable +data class CompleteRequest( + @SerialName("verify_code") val verifyCode: String, + @SerialName("challenge_nonce") val challengeNonce: String, + /** + * snarkjs Groth16 proof envelope `{ pi_a, pi_b, pi_c, ...}`. We + * use [JsonElement] so the [dev.zeroauth.android.prover.Groth16Proof] + * struct from the prover module can be serialised inline without + * a Retrofit-side adapter — the call site does the conversion. + */ + val proof: JsonElement, + @SerialName("public_signals") val publicSignals: List, +) + +// ─── Response shapes ────────────────────────────────────────────── + +@Serializable +data class NextStep( + val step: String, + val code: String, + @SerialName("expires_at") val expiresAt: String, + val deeplink: String, + @SerialName("challenge_nonce") val challengeNonce: String? = null, +) + +@Serializable +data class PairDeviceResponse( + @SerialName("session_id") val sessionId: String, + @SerialName("device_id") val deviceId: String? = null, + val next: NextStep, +) + +@Serializable +data class SubmitCommitmentResponse( + @SerialName("session_id") val sessionId: String, + val next: NextStep, +) + +@Serializable +data class CompleteResponse( + @SerialName("session_id") val sessionId: String, + @SerialName("tenant_user") val tenantUser: JsonElement? = null, + val device: JsonElement? = null, +) diff --git a/android/app/src/main/java/dev/zeroauth/android/ui/SplashScreen.kt b/android/app/src/main/java/dev/zeroauth/android/ui/SplashScreen.kt index cefd53d..e69ad21 100644 --- a/android/app/src/main/java/dev/zeroauth/android/ui/SplashScreen.kt +++ b/android/app/src/main/java/dev/zeroauth/android/ui/SplashScreen.kt @@ -48,6 +48,7 @@ import dev.zeroauth.android.ui.theme.ZeroAuthTheme fun SplashScreen( onEnrollNeeded: () -> Unit, onAlreadyEnrolled: () -> Unit, + onCreateAccount: () -> Unit = {}, ) { // TODO(prover-glue): replace with a real read off KeystoreManager. // Today this is always false so the demo always shows the Enroll @@ -97,25 +98,52 @@ fun SplashScreen( ) } - Button( - onClick = { - if (navigated) return@Button - navigated = true - if (isEnrolled.value) onAlreadyEnrolled() else onEnrollNeeded() - }, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - contentPadding = PaddingValues(horizontal = 24.dp), + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = stringResource(R.string.splash_cta), - style = MaterialTheme.typography.labelLarge, - ) + Button( + onClick = { + if (navigated) return@Button + navigated = true + if (isEnrolled.value) onAlreadyEnrolled() else onEnrollNeeded() + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + contentPadding = PaddingValues(horizontal = 24.dp), + ) { + Text( + text = stringResource(R.string.splash_cta), + style = MaterialTheme.typography.labelLarge, + ) + } + // ADR 0023 three-QR end-user signup. The "Create a new + // account" CTA jumps into the registration ceremony, + // which is independent of the existing QR-sign-in flow + // above (that one's for users who already have an + // account and want to authenticate on a desktop). + Button( + onClick = { + if (navigated) return@Button + navigated = true + onCreateAccount() + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + colors = ButtonDefaults.outlinedButtonColors(), + contentPadding = PaddingValues(horizontal = 24.dp), + ) { + Text( + text = "Create a new account (3-QR signup)", + style = MaterialTheme.typography.labelLarge, + ) + } } } } diff --git a/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationHelpers.kt b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationHelpers.kt new file mode 100644 index 0000000..bd30880 --- /dev/null +++ b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationHelpers.kt @@ -0,0 +1,130 @@ +package dev.zeroauth.android.ui.reg + +import android.content.Context +import dev.zeroauth.android.prover.Groth16Proof +import dev.zeroauth.android.sec.Poseidon +import dev.zeroauth.android.ui.reg.RegistrationViewModel.BiometricSecretSource +import dev.zeroauth.android.ui.reg.RegistrationViewModel.ProofGenerator +import java.math.BigInteger +import java.security.MessageDigest +import java.security.SecureRandom + +/** + * Default biometric-secret source for the registration demo. + * + * Production usage will replace this with a wrapper around the + * mobile/biometric/ FaceEmbedder pipeline: + * + * FaceEmbedder.embed(bitmap) + * -> Quantizer.encode(embedding) // 256 bytes int16 BE + * -> Sha256.digestAndZero(buffer) // 32 bytes secret + * + * For the demo we generate (or load) a 32-byte secret kept in + * SharedPreferences so a second run from the same install produces + * the *same* commitment — without that property the verify step's + * publicSignals[0] check would fail because step 2's commitment was + * derived from a different secret than step 3's. + * + * The secret is generated via [SecureRandom] on first use and never + * leaves this object. NEVER log the bytes; this is the closest thing + * the demo has to a "biometric-derived secret" and it deserves the + * same handling. + */ +class PerInstallStableSecret(context: Context) : BiometricSecretSource { + + private val prefs = context.applicationContext.getSharedPreferences( + PREFS_NAME, + Context.MODE_PRIVATE, + ) + + override suspend fun secret(): ByteArray { + val existing = prefs.getString(KEY_SECRET_HEX, null) + if (!existing.isNullOrBlank() && existing.length == 64) { + return hexDecode(existing) + } + val fresh = ByteArray(32) + SecureRandom().nextBytes(fresh) + prefs.edit().putString(KEY_SECRET_HEX, hexEncode(fresh)).apply() + return fresh + } + + private companion object { + const val PREFS_NAME = "zeroauth_reg_secret" + const val KEY_SECRET_HEX = "secret_hex" + + fun hexEncode(b: ByteArray): String = b.joinToString("") { "%02x".format(it) } + + fun hexDecode(hex: String): ByteArray = + ByteArray(hex.length / 2) { i -> + ((Character.digit(hex[i * 2], 16) shl 4) + + Character.digit(hex[i * 2 + 1], 16)).toByte() + } + } +} + +/** + * Derive `(did, commitment)` from a 32-byte biometric secret. + * + * Mirrors the server-side regex in `src/services/registration.ts`: + * - did matches `did:zeroauth::` (we use method=face) + * - commitment matches `(0x)?[0-9a-f]{32,128}` (we emit 64 hex chars + * with no `0x` prefix to keep both ends consistent) + * + * The commitment is `Poseidon.hash2(secret, salt)` with a zero salt + * for V1 (the salt slot is reserved for the StrongBox-backed + * SaltProvider in the production pipeline; the server doesn't care + * about the salt because the server only sees the commitment). The + * DID is `"did:zeroauth:face:" + keccak256(commitment_bytes)[:20]` + * derived via [Poseidon]'s field-element helpers. + * + * Phase 1 Sprint 4 will replace this with the canonical pipeline from + * `mobile/biometric/CommitmentBuilder.kt` which already implements + * this derivation with the real salt + Keccak path. + */ +object DeriveDidAndCommitment { + + fun from(secret: ByteArray): Pair { + require(secret.size == 32) { "Secret must be 32 bytes; got ${secret.size}" } + val zeroSalt = ByteArray(32) + // Poseidon.hash2 takes BigInteger inputs (BN128 field elements). + // Convert each 32-byte buffer to a non-negative BigInteger. + val s = BigInteger(1, secret) + val t = BigInteger(1, zeroSalt) + val commitmentBi = Poseidon.hash2(s, t) + val commitmentHex = commitmentBi.toString(16).padStart(64, '0') + + // DID suffix = first 20 bytes of SHA-256 of the commitment hex + // (placeholder for keccak256 — Phase 1 Sprint 4 swaps in the + // BouncyCastle keccak wrapper from mobile/biometric/Keccak256.kt). + val didSuffix = MessageDigest.getInstance("SHA-256") + .digest(commitmentHex.toByteArray(Charsets.UTF_8)) + .copyOfRange(0, 20) + .joinToString("") { "%02x".format(it) } + + return "did:zeroauth:face:$didSuffix" to commitmentHex + } +} + +/** + * Default proof generator used by the demo. Produces a Groth16-shaped + * envelope with placeholder field elements so the route plumbing can + * be exercised — the server's `verifyProofOffChain` WILL reject it, + * which is the intended demo outcome. + * + * Phase 1 Sprint 4 wires this to the existing + * `dev.zeroauth.android.prover.WebViewMobileProver` whose + * `generate(GenerateInput)` returns a real proof + publicSignals + * tuple. The route adapter is a one-screen change in + * RegistrationViewModel — `proof = realProver.generate(...).proof`. + */ +object StubProofGenerator : ProofGenerator { + override suspend fun generate( + secret: ByteArray, + commitmentHex: String, + challengeNonceHex: String, + ): Groth16Proof = Groth16Proof( + pi_a = listOf("1", "2", "1"), + pi_b = listOf(listOf("3", "4"), listOf("5", "6"), listOf("1", "0")), + pi_c = listOf("7", "8", "1"), + ) +} diff --git a/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationScreen.kt b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationScreen.kt new file mode 100644 index 0000000..8e2279e --- /dev/null +++ b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationScreen.kt @@ -0,0 +1,181 @@ +package dev.zeroauth.android.ui.reg + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +/** + * Minimal screen that drives the three-QR registration ceremony + * (ADR 0023). Paste-deeplink only for V1 — the camera scan path + * mirrors `ui/scan/ScanScreen.kt`'s ML Kit + CameraX pipeline and + * gets wired in alongside the real biometric capture in Phase 1 + * Sprint 4. + * + * The state shown matches what the operator sees on the dashboard + * demo at /demo/registration, except this is from the phone's POV: + * + * Idle → ready to scan/paste QR1 + * Pairing → POST /v1/registrations/pair-device in flight + * AwaitingEnrollScan → ready to scan/paste QR2 + * Committing → POST /v1/registrations/submit-commitment + * AwaitingVerifyScan → ready to scan/paste QR3 + * Verifying → POST /v1/registrations/complete + * Completed / Failed → terminal + */ +@Composable +fun RegistrationScreen( + onDone: () -> Unit, +) { + val context = LocalContext.current + val vm: RegistrationViewModel = viewModel( + factory = androidx.lifecycle.viewmodel.viewModelFactory { + initializer { RegistrationViewModel(context.applicationContext) } + }, + ) + val state by vm.state.collectAsState() + var pasted by rememberSaveable { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxWidth() + .systemBarsPadding() + .padding(PaddingValues(20.dp)), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Three-QR signup ceremony", + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = "Scan each QR on the platform's signup page. The biometric stays on this phone; only the Poseidon commitment (step 2) and Groth16 proof (step 3) touch the server.", + style = MaterialTheme.typography.bodyMedium, + ) + + StepBadge(state = state) + + OutlinedTextField( + value = pasted, + onValueChange = { pasted = it }, + label = { Text("Scanned QR (paste deeplink)") }, + placeholder = { Text("zeroauth://reg?step=…&session=…&code=ZA-XXXX-XXXX") }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + ) + + Button( + onClick = { + vm.onQrText(pasted.trim()) + pasted = "" + }, + enabled = pasted.isNotBlank() && !state.isInFlight(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "Submit step") + } + + Spacer(Modifier.height(8.dp)) + + when (val s = state) { + is RegistrationViewModel.State.Idle -> Text( + text = "Waiting for QR1 (pair). The platform shows this first.", + style = MaterialTheme.typography.bodySmall, + ) + + is RegistrationViewModel.State.Pairing -> InFlight("Pairing device…") + is RegistrationViewModel.State.Committing -> InFlight("Submitting commitment…") + is RegistrationViewModel.State.Verifying -> InFlight("Verifying proof…") + + is RegistrationViewModel.State.AwaitingEnrollScan -> Text( + text = "Paired ✓ (session ${s.sessionId.take(8)}). Now scan QR2 (enroll).", + style = MaterialTheme.typography.bodySmall, + ) + is RegistrationViewModel.State.AwaitingVerifyScan -> Text( + text = "Commitment submitted ✓ (session ${s.sessionId.take(8)}). Now scan QR3 (verify).", + style = MaterialTheme.typography.bodySmall, + ) + + is RegistrationViewModel.State.Completed -> { + Text( + text = "Account created ✓", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "Session: ${s.sessionId}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Done") } + } + + is RegistrationViewModel.State.Failed -> { + Text( + text = "Failed: ${s.code}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Text( + text = s.message, + style = MaterialTheme.typography.bodySmall, + ) + Button(onClick = vm::reset, modifier = Modifier.fillMaxWidth()) { Text("Start over") } + } + } + } +} + +@Composable +private fun StepBadge(state: RegistrationViewModel.State) { + val label = when (state) { + is RegistrationViewModel.State.Idle -> "Step 1 of 3 — pair device" + is RegistrationViewModel.State.Pairing -> "Step 1 of 3 — pairing…" + is RegistrationViewModel.State.AwaitingEnrollScan -> "Step 2 of 3 — submit commitment" + is RegistrationViewModel.State.Committing -> "Step 2 of 3 — committing…" + is RegistrationViewModel.State.AwaitingVerifyScan -> "Step 3 of 3 — verify" + is RegistrationViewModel.State.Verifying -> "Step 3 of 3 — verifying…" + is RegistrationViewModel.State.Completed -> "Done" + is RegistrationViewModel.State.Failed -> "Error" + } + Text(text = label, style = MaterialTheme.typography.labelLarge) +} + +@Composable +private fun InFlight(label: String) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + CircularProgressIndicator() + Text(text = label, style = MaterialTheme.typography.bodySmall) + } +} + +private fun RegistrationViewModel.State.isInFlight(): Boolean = when (this) { + is RegistrationViewModel.State.Pairing, + is RegistrationViewModel.State.Committing, + is RegistrationViewModel.State.Verifying -> true + else -> false +} diff --git a/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationViewModel.kt b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationViewModel.kt new file mode 100644 index 0000000..2b4570d --- /dev/null +++ b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationViewModel.kt @@ -0,0 +1,206 @@ +package dev.zeroauth.android.ui.reg + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.zeroauth.android.net.ApiFactory +import dev.zeroauth.android.net.CompleteRequest +import dev.zeroauth.android.net.PairDeviceRequest +import dev.zeroauth.android.net.RegistrationApi +import dev.zeroauth.android.net.SubmitCommitmentRequest +import dev.zeroauth.android.prover.Groth16Proof +import dev.zeroauth.android.util.DeviceFingerprint +import dev.zeroauth.android.util.RegChallenge +import dev.zeroauth.android.util.RegQrPayload +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** + * Orchestrates the three-QR end-user signup ceremony (ADR 0023) from + * the phone side. + * + * The state machine is intentionally small — three "doing this step" + * states + idle/success/error — because each step is just: + * 1. parse the scanned QR + * 2. produce the step-specific payload + * 3. POST to the corresponding registration endpoint + * 4. transition to "ready for the next QR" (or terminal) + * + * Biometric capture for steps 2 and 3 is intentionally injectable via + * [BiometricSecretSource] — the production wiring will plug in the + * FaceEmbedder pipeline from `mobile/biometric/`. The default + * implementation in the companion object returns a per-install + * deterministic 32-byte secret so the demo can drive the flow on a + * device without a working face-capture sensor; the secret is + * persisted in [DeviceFingerprint]'s SharedPreferences so a second run + * from the same install produces the same commitment and the verify + * step's `publicSignals[0]` check passes. + * + * The proof generation hook (step 3) is similarly injectable via + * [ProofGenerator]. The default returns a structurally-valid but + * cryptographically-empty Groth16 envelope so the route plumbing can + * be exercised end-to-end without a working snarkjs WebView. The + * server's `verifyProofOffChain` will reject the empty proof — the + * demo treats that as expected and surfaces a "wire up the real + * prover" message. Real proof generation lives in + * `dev.zeroauth.android.prover.WebViewMobileProver`; the integration + * lands when the registration circuit's witness shape is finalised + * (Phase 1 Sprint 4). + */ +class RegistrationViewModel( + private val context: Context, + private val api: RegistrationApi = ApiFactory.createRegistrationApi(), + private val secretSource: BiometricSecretSource = PerInstallStableSecret(context), + private val proofGenerator: ProofGenerator = StubProofGenerator, + private val io: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + + sealed class State { + data object Idle : State() + data object Pairing : State() + data class AwaitingEnrollScan(val sessionId: String) : State() + data object Committing : State() + data class AwaitingVerifyScan(val sessionId: String) : State() + data object Verifying : State() + data class Completed(val sessionId: String, val tenantUser: JsonElement?) : State() + data class Failed(val code: String, val message: String) : State() + } + + private val _state = MutableStateFlow(State.Idle) + val state: StateFlow = _state.asStateFlow() + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + /** + * Entry point — the UI calls this with the raw text it scanned (or + * pasted) from the QR. The VM parses, routes to the right step, + * and updates [state] as the call progresses. + */ + fun onQrText(text: String) { + val parsed = RegQrPayload.parse(text) + if (parsed.isFailure) { + val ex = parsed.exceptionOrNull() + _state.value = State.Failed( + code = "reg_qr_parse_failed", + message = ex?.message ?: "Could not parse QR", + ) + return + } + val challenge = parsed.getOrThrow() + when (challenge.step) { + RegQrPayload.Step.Pair -> pairDevice(challenge) + RegQrPayload.Step.Enroll -> submitCommitment(challenge) + RegQrPayload.Step.Verify -> complete(challenge) + } + } + + private fun pairDevice(challenge: RegChallenge) { + _state.value = State.Pairing + viewModelScope.launch { + runCatching { + val fingerprint = DeviceFingerprint.forCurrentInstall(context) + withContext(io) { + api.pairDevice( + PairDeviceRequest( + pairCode = challenge.code, + fingerprint = fingerprint, + attestationKind = "none", + ), + ) + } + }.onSuccess { res -> + _state.value = State.AwaitingEnrollScan(res.sessionId) + }.onFailure { ex -> + _state.value = State.Failed("pair_failed", ex.message ?: "Pair step failed") + } + } + } + + private fun submitCommitment(challenge: RegChallenge) { + _state.value = State.Committing + viewModelScope.launch { + runCatching { + val secret = secretSource.secret() + val (did, commitment) = DeriveDidAndCommitment.from(secret) + withContext(io) { + api.submitCommitment( + SubmitCommitmentRequest( + enrollCode = challenge.code, + did = did, + commitment = commitment, + attestationKind = "none", + ), + ) + } + }.onSuccess { res -> + _state.value = State.AwaitingVerifyScan(res.sessionId) + }.onFailure { ex -> + _state.value = State.Failed("enroll_failed", ex.message ?: "Submit step failed") + } + } + } + + private fun complete(challenge: RegChallenge) { + val nonce = challenge.challengeNonce + if (nonce.isNullOrBlank()) { + _state.value = State.Failed("verify_failed", "QR3 missing ?challenge") + return + } + _state.value = State.Verifying + viewModelScope.launch { + runCatching { + val secret = secretSource.secret() + val (_, commitment) = DeriveDidAndCommitment.from(secret) + val proof = proofGenerator.generate(secret, commitment, nonce) + val publicSignals = listOf(commitment) + withContext(io) { + api.complete( + CompleteRequest( + verifyCode = challenge.code, + challengeNonce = nonce, + proof = proof.toJson(), + publicSignals = publicSignals, + ), + ) + } + }.onSuccess { res -> + _state.value = State.Completed(res.sessionId, res.tenantUser) + }.onFailure { ex -> + _state.value = State.Failed("verify_failed", ex.message ?: "Verify step failed") + } + } + } + + fun reset() { + _state.value = State.Idle + } + + private fun Groth16Proof.toJson(): JsonElement = buildJsonObject { + put("pi_a", JsonArray(pi_a.map { JsonPrimitive(it) })) + put("pi_b", JsonArray(pi_b.map { row -> JsonArray(row.map { JsonPrimitive(it) }) })) + put("pi_c", JsonArray(pi_c.map { JsonPrimitive(it) })) + put("protocol", JsonPrimitive(protocol)) + put("curve", JsonPrimitive(curve)) + } + + // ─── Injection seams ────────────────────────────────────────── + + interface BiometricSecretSource { + /** 32-byte secret derived from the biometric. */ + suspend fun secret(): ByteArray + } + + interface ProofGenerator { + suspend fun generate(secret: ByteArray, commitmentHex: String, challengeNonceHex: String): Groth16Proof + } +} diff --git a/android/app/src/main/java/dev/zeroauth/android/util/DeviceFingerprint.kt b/android/app/src/main/java/dev/zeroauth/android/util/DeviceFingerprint.kt new file mode 100644 index 0000000..9b381e6 --- /dev/null +++ b/android/app/src/main/java/dev/zeroauth/android/util/DeviceFingerprint.kt @@ -0,0 +1,72 @@ +package dev.zeroauth.android.util + +import android.annotation.SuppressLint +import android.content.Context +import android.provider.Settings +import java.security.MessageDigest +import java.util.UUID + +/** + * Builds the opaque, server-side-hashed device fingerprint string that + * ADR 0023 step 1 requires. The server only stores SHA-256 of this — + * the plaintext composition is device-type-specific (see ADR 0023 + * §"Device fingerprint" table) and the server never sees it. + * + * The contract from the server side: + * - opaque string, ≥ 16 chars, ≤ 4096 chars + * - stable per-(physical-device, app-install) — a second run from + * the same install should produce the same value + * + * The composition here: + * - `ANDROID_ID` — stable per (signing-key, user, factory-reset) + * - per-install UUID, persisted in SharedPreferences so a clear-data + * + reinstall produces a *new* fingerprint (this is on purpose — + * we want the row in the devices table to be tied to the install, + * not the bare hardware, so a re-installed app can re-enroll + * without collision) + * - the app's applicationId, so a sibling app on the same device + * (or a future flavor variant) produces a distinct fingerprint + * + * SHA-256-hex of the concatenation is what we ship. 64 hex chars is + * comfortably above the 16-char floor and well below the 4096 ceiling. + */ +object DeviceFingerprint { + + private const val PREFS_NAME: String = "zeroauth_install" + private const val KEY_INSTALL_UUID: String = "install_uuid" + + /** + * Compose-and-hash. Result is 64 lower-case hex chars. + * + * Note `ANDROID_ID` requires READ_PHONE_STATE on API 28- (we're + * minSdk 30 so no permission); on >= 8 it's always available + * Settings-side. The @SuppressLint is for HardwareIds — we are + * using it for its intended purpose (per-install identity, not + * cross-app tracking) and the value never leaves this object's + * SHA-256 wrapper. + */ + @SuppressLint("HardwareIds") + fun forCurrentInstall(context: Context): String { + val androidId: String = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ANDROID_ID, + ).orEmpty() + + val installUuid: String = readOrCreateInstallUuid(context) + val appId: String = context.packageName + + val canonical = "android:$appId:$androidId:$installUuid" + val sha256 = MessageDigest.getInstance("SHA-256") + .digest(canonical.toByteArray(Charsets.UTF_8)) + return sha256.joinToString("") { "%02x".format(it) } + } + + private fun readOrCreateInstallUuid(context: Context): String { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val existing = prefs.getString(KEY_INSTALL_UUID, null) + if (!existing.isNullOrBlank()) return existing + val fresh = UUID.randomUUID().toString() + prefs.edit().putString(KEY_INSTALL_UUID, fresh).apply() + return fresh + } +} diff --git a/android/app/src/main/java/dev/zeroauth/android/util/RegQrPayload.kt b/android/app/src/main/java/dev/zeroauth/android/util/RegQrPayload.kt new file mode 100644 index 0000000..ba5d74f --- /dev/null +++ b/android/app/src/main/java/dev/zeroauth/android/util/RegQrPayload.kt @@ -0,0 +1,161 @@ +package dev.zeroauth.android.util + +import android.net.Uri + +/** + * Parser for the three-QR end-user signup ceremony deeplinks + * (ADR 0023). The dashboard renders three QRs over the course of the + * ceremony, each encoding a URI of the form: + * + * zeroauth://reg?step= + * &session= + * &code= + * [&challenge=] (only on step=verify) + * + * The phone scans, parses, and routes to the corresponding step + * handler in the RegistrationViewModel. Failure modes are surfaced as + * `Result.failure(RegQrParseException)` with stable string codes so + * the UI layer can route them to error toasts without re-mapping. + * + * Cousin to [QrPayload] in the same package — that one handles the + * proof-pairing W3 QR format (`za:pair:1:...`). The registration flow + * uses a different URI scheme because it's a different ceremony with + * a different set of payloads, and the URI form is what + * `zeroauth://reg?…` deeplinks resolve to anyway (Phase 2 universal- + * links rollout will let the scanner accept https://…/reg/ links too; + * the path-and-query shape will stay identical). + */ +object RegQrPayload { + + const val SCHEME: String = "zeroauth" + const val HOST: String = "reg" + const val PARAM_STEP: String = "step" + const val PARAM_SESSION: String = "session" + const val PARAM_CODE: String = "code" + const val PARAM_CHALLENGE: String = "challenge" + + enum class Step(val wire: String) { + Pair("pair"), + Enroll("enroll"), + Verify("verify"), + ; + + companion object { + fun fromWire(wire: String?): Step? = entries.firstOrNull { it.wire == wire } + } + } + + /** + * Parse a scanned QR string. Returns success only when: + * - scheme matches `zeroauth` + * - host matches `reg` + * - `step` is one of pair/enroll/verify + * - `session` is a non-empty string (UUID shape is server-validated) + * - `code` matches the canonical `ZA-XXXX-XXXX` format the server issued + * - if step=verify, `challenge` is a 32-hex-char string + * + * Stable error codes inside the failure exception: + * - `reg_qr_parse_failed` — malformed URI, wrong scheme/host, bad step + * - `reg_qr_missing_field` — required field absent for this step + * - `reg_qr_bad_code_shape` — code doesn't match ZA-XXXX-XXXX + * - `reg_qr_bad_challenge_shape` — challenge isn't 32 hex chars + */ + fun parse(text: String): Result { + val uri = runCatching { Uri.parse(text) }.getOrNull() + ?: return Result.failure(RegQrParseException("reg_qr_parse_failed", "Could not parse URI")) + + if (!SCHEME.equals(uri.scheme, ignoreCase = true)) { + return Result.failure( + RegQrParseException( + "reg_qr_parse_failed", + "Wrong scheme — expected $SCHEME:// got ${uri.scheme}://", + ), + ) + } + if (!HOST.equals(uri.host, ignoreCase = true)) { + return Result.failure( + RegQrParseException( + "reg_qr_parse_failed", + "Wrong host — expected $HOST got ${uri.host}", + ), + ) + } + + val step = Step.fromWire(uri.getQueryParameter(PARAM_STEP)) + ?: return Result.failure( + RegQrParseException( + "reg_qr_parse_failed", + "Missing or unknown ?$PARAM_STEP — expected pair/enroll/verify", + ), + ) + + val sessionId = uri.getQueryParameter(PARAM_SESSION)?.takeIf { it.isNotBlank() } + ?: return Result.failure( + RegQrParseException("reg_qr_missing_field", "Missing ?$PARAM_SESSION"), + ) + + val code = uri.getQueryParameter(PARAM_CODE)?.takeIf { it.isNotBlank() } + ?: return Result.failure( + RegQrParseException("reg_qr_missing_field", "Missing ?$PARAM_CODE"), + ) + if (!CODE_SHAPE.matches(code)) { + return Result.failure( + RegQrParseException( + "reg_qr_bad_code_shape", + "Code does not match ZA-XXXX-XXXX shape: $code", + ), + ) + } + + val challenge: String? = if (step == Step.Verify) { + val c = uri.getQueryParameter(PARAM_CHALLENGE)?.takeIf { it.isNotBlank() } + ?: return Result.failure( + RegQrParseException( + "reg_qr_missing_field", + "step=verify requires ?$PARAM_CHALLENGE", + ), + ) + if (!CHALLENGE_SHAPE.matches(c)) { + return Result.failure( + RegQrParseException( + "reg_qr_bad_challenge_shape", + "Challenge must be 32 hex chars; got ${c.length}", + ), + ) + } + c + } else { + null + } + + return Result.success( + RegChallenge( + step = step, + sessionId = sessionId, + code = code, + challengeNonce = challenge, + ), + ) + } + + /** + * ZA- prefix, then 4 chars × 2 groups from the 30-symbol + * Crockford-base32 alphabet the server uses (no 0/1/I/L/O/U). + */ + private val CODE_SHAPE = Regex("^ZA-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$") + + /** 128-bit hex nonce. */ + private val CHALLENGE_SHAPE = Regex("^[0-9a-fA-F]{32}$") +} + +data class RegChallenge( + val step: RegQrPayload.Step, + val sessionId: String, + val code: String, + val challengeNonce: String?, +) + +class RegQrParseException( + val code: String, + message: String, +) : Exception(message) diff --git a/android/app/src/test/java/dev/zeroauth/android/util/RegQrPayloadTest.kt b/android/app/src/test/java/dev/zeroauth/android/util/RegQrPayloadTest.kt new file mode 100644 index 0000000..5e5d50d --- /dev/null +++ b/android/app/src/test/java/dev/zeroauth/android/util/RegQrPayloadTest.kt @@ -0,0 +1,112 @@ +package dev.zeroauth.android.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [RegQrPayload]. + * + * Robolectric is the runner because the parser uses [android.net.Uri] + * which is not implemented in the bare JVM. Config.sdk pinned at 34 to + * match android/app/build.gradle.kts's compileSdk. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class RegQrPayloadTest { + + private val sampleSession = "11111111-2222-3333-4444-555555555555" + private val sampleCode = "ZA-AB23-CD45" + private val sampleChallenge = "a".repeat(32) + + @Test + fun `parses a valid pair QR`() { + val text = "zeroauth://reg?step=pair&session=$sampleSession&code=$sampleCode" + val result = RegQrPayload.parse(text) + assertTrue("expected success, got ${result.exceptionOrNull()}", result.isSuccess) + val challenge = result.getOrThrow() + assertEquals(RegQrPayload.Step.Pair, challenge.step) + assertEquals(sampleSession, challenge.sessionId) + assertEquals(sampleCode, challenge.code) + assertNull(challenge.challengeNonce) + } + + @Test + fun `parses a valid enroll QR`() { + val text = "zeroauth://reg?step=enroll&session=$sampleSession&code=$sampleCode" + val challenge = RegQrPayload.parse(text).getOrThrow() + assertEquals(RegQrPayload.Step.Enroll, challenge.step) + } + + @Test + fun `parses a valid verify QR with challenge`() { + val text = + "zeroauth://reg?step=verify&session=$sampleSession&code=$sampleCode&challenge=$sampleChallenge" + val challenge = RegQrPayload.parse(text).getOrThrow() + assertEquals(RegQrPayload.Step.Verify, challenge.step) + assertEquals(sampleChallenge, challenge.challengeNonce) + } + + @Test + fun `rejects wrong scheme`() { + val text = "https://reg?step=pair&session=$sampleSession&code=$sampleCode" + val result = RegQrPayload.parse(text) + assertTrue(result.isFailure) + val ex = result.exceptionOrNull() as RegQrParseException + assertEquals("reg_qr_parse_failed", ex.code) + } + + @Test + fun `rejects wrong host`() { + val text = "zeroauth://login?step=pair&session=$sampleSession&code=$sampleCode" + val ex = RegQrPayload.parse(text).exceptionOrNull() as RegQrParseException + assertEquals("reg_qr_parse_failed", ex.code) + } + + @Test + fun `rejects unknown step`() { + val text = "zeroauth://reg?step=toaster&session=$sampleSession&code=$sampleCode" + val ex = RegQrPayload.parse(text).exceptionOrNull() as RegQrParseException + assertEquals("reg_qr_parse_failed", ex.code) + } + + @Test + fun `rejects missing session`() { + val text = "zeroauth://reg?step=pair&code=$sampleCode" + val ex = RegQrPayload.parse(text).exceptionOrNull() as RegQrParseException + assertEquals("reg_qr_missing_field", ex.code) + } + + @Test + fun `rejects missing code`() { + val text = "zeroauth://reg?step=pair&session=$sampleSession" + val ex = RegQrPayload.parse(text).exceptionOrNull() as RegQrParseException + assertEquals("reg_qr_missing_field", ex.code) + } + + @Test + fun `rejects malformed code`() { + val text = "zeroauth://reg?step=pair&session=$sampleSession&code=AB-CD-EF" + val ex = RegQrPayload.parse(text).exceptionOrNull() as RegQrParseException + assertEquals("reg_qr_bad_code_shape", ex.code) + } + + @Test + fun `rejects verify without challenge`() { + val text = "zeroauth://reg?step=verify&session=$sampleSession&code=$sampleCode" + val ex = RegQrPayload.parse(text).exceptionOrNull() as RegQrParseException + assertEquals("reg_qr_missing_field", ex.code) + } + + @Test + fun `rejects malformed challenge`() { + val text = "zeroauth://reg?step=verify&session=$sampleSession&code=$sampleCode&challenge=not-hex" + val ex = RegQrPayload.parse(text).exceptionOrNull() as RegQrParseException + assertEquals("reg_qr_bad_challenge_shape", ex.code) + } +} From 54cf1e03752602b1c268ca8c7e49be9678554480 Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Fri, 29 May 2026 12:21:19 +0530 Subject: [PATCH 2/3] fix Android compile: import viewModelFactory + initializer The Compose viewModel(factory = ...) builder needs the explicit imports for viewModelFactory and initializer. Without them the qualified path resolves the builder but the lambda-inside-builder fails 'Unresolved reference initializer'. Android CI #62 caught this; local env has no SDK to validate at commit time. --- .../java/dev/zeroauth/android/ui/reg/RegistrationScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationScreen.kt b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationScreen.kt index 8e2279e..ecf3da9 100644 --- a/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationScreen.kt +++ b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationScreen.kt @@ -26,6 +26,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory /** * Minimal screen that drives the three-QR registration ceremony @@ -51,7 +53,7 @@ fun RegistrationScreen( ) { val context = LocalContext.current val vm: RegistrationViewModel = viewModel( - factory = androidx.lifecycle.viewmodel.viewModelFactory { + factory = viewModelFactory { initializer { RegistrationViewModel(context.applicationContext) } }, ) From 37cc0795332a666c93ae8ea57914b39525889e25 Mon Sep 17 00:00:00 2001 From: Pulkit Pareek Date: Fri, 29 May 2026 13:32:20 +0530 Subject: [PATCH 3/3] dashboard demo: live polling + audit-findings tracker update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small improvements that close the gap between "ceremony built" and "ceremony usable in a demo with a real phone": 1) QrRegistration.tsx polls GET /api/console/registrations/:id every 2 s while the page is in any awaiting_* state. When the server reports a forward transition the dashboard auto-advances without operator intervention — this is what a real phone scanning the QRs would trigger. The simulator panel keeps dispatching state directly so it doesn't wait on the poll. Polling stops at terminal states (completed / abandoned / error) and when the page is idle/creating, so there's no background load when nothing's happening. 2) docs/security/audit-findings.md gains a new "Phase 1 follow-ons landed since the last snapshot" section recording ADR 0022 (device enrollment), ADR 0023 (three-QR signup), and ADR 0024 (qrcode.react dep). The original 21 findings carry forward unchanged. A second new section "Pilot-ready vs production-ready gap" lays out the six known gaps that still block a real BFSI tenant onboarding, so the tracker doesn't claim more than is true. Verify: - npx tsc --noEmit (dashboard) clean - npm test (vitest) 56/56 --- dashboard/src/routes/demo/QrRegistration.tsx | 61 ++++++++++++++++++++ docs/security/audit-findings.md | 25 +++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/dashboard/src/routes/demo/QrRegistration.tsx b/dashboard/src/routes/demo/QrRegistration.tsx index 661e067..6b26b7b 100644 --- a/dashboard/src/routes/demo/QrRegistration.tsx +++ b/dashboard/src/routes/demo/QrRegistration.tsx @@ -144,6 +144,67 @@ export default function QrRegistration() { } }, [environment, name, email]); + // ─── Live polling ─────────────────────────────────────────────── + // + // A real phone scanning QR1 hits POST /v1/registrations/pair-device + // directly — the dashboard never sees the device-side request, so we + // poll the session row to learn when the phone has advanced. The + // simulator path (right column) bypasses this and dispatches the + // next state directly. Polling cadence: 2 s in awaiting_* states, + // off everywhere else. + // + // The poll only advances state when the server reports a *forward* + // transition (awaiting_commitment / awaiting_verification / + // completed). If the server says we're still in the same state, the + // effect is a no-op — no UI churn, no re-render storm. We do not + // poll when the state is already terminal (completed / error) or + // when we're not in a session at all (idle / creating). + const sessionId = phase.kind !== 'idle' && phase.kind !== 'creating' && phase.kind !== 'error' + ? ('session' in phase ? phase.session.id : null) + : null; + useEffect(() => { + if (!sessionId) return; + const isWaitingForPhone = phase.kind === 'awaiting_device' + || phase.kind === 'awaiting_commitment' + || phase.kind === 'awaiting_verification'; + if (!isWaitingForPhone) return; + let cancelled = false; + const id = window.setInterval(async () => { + try { + const { session } = await api.pollRegistration(sessionId, { environment }); + if (cancelled) return; + // Forward-only transitions: only dispatch if the server has + // moved past where we currently are. + if (phase.kind === 'awaiting_device' && session.state === 'awaiting_commitment') { + // The phone paired but the dashboard didn't see the + // server-issued enroll_code (it only goes back to the phone). + // We don't have enrollCode/deeplink/expiresAt on the + // dashboard side — surface a notice so the operator knows + // the phone is in the middle of step 2. + pushToast('info', 'Phone paired ✓ — waiting for biometric commitment.'); + } else if (phase.kind === 'awaiting_commitment' && session.state === 'awaiting_verification') { + pushToast('info', 'Commitment received ✓ — waiting for verification proof.'); + } else if (session.state === 'completed') { + dispatch({ + type: 'completed', + session: session as RegistrationSession, + tenantUserId: session.tenant_user_id ?? '', + }); + } else if (session.state === 'abandoned') { + dispatch({ type: 'failed', message: 'Session was abandoned (expired or cancelled).' }); + } + } catch { + // Poll-side errors are silent — the user will see the next + // action's error if there's a real network issue, and a + // transient 5xx on poll shouldn't break the flow. + } + }, 2000); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [sessionId, phase.kind, environment]); + return (
diff --git a/docs/security/audit-findings.md b/docs/security/audit-findings.md index de50c2b..26d0823 100644 --- a/docs/security/audit-findings.md +++ b/docs/security/audit-findings.md @@ -9,7 +9,30 @@ Severity scale: - **P2** — phase 2-blocking. Must close before pilot exit. - **P3** — phase 3-blocking. Must close before SOC 2 Type II evidence period. -LAST_UPDATED: 2026-05-28 +LAST_UPDATED: 2026-05-29 + +## Phase 1 follow-ons landed since the last snapshot + +The Phase 1 work since LAST_UPDATED above isn't an *audit finding* per se — these ADRs add capabilities that didn't exist when the original 21 findings were enumerated. Tracked here because the same hash-chain + tenant-isolation + biometric-payload guards continue to apply. + +| ADR | Title | Status | Closing commits | Notes | +|---|---|---|---|---| +| **ADR 0022** | Production device-enrollment flow | **LANDED** | `8d313a0`, `c4681a9` | Two-step handshake (admin mints pending slot + code → device claims with fingerprint). Server-side: `src/services/device-enrollment.ts`, extensions to `src/services/platform.ts`, `POST /v1/devices/enroll` in `src/routes/v1/devices.ts`. Dashboard: redesigned `dashboard/src/routes/Devices.tsx` with type-aware modal + enrollment-code screen + Re-issue/Revoke actions. Tests: `tests/device-enrollment.test.ts` (26 tests). | +| **ADR 0023** | Three-QR end-user signup ceremony | **LANDED** | `8ad10bd`, `8e39425` | The signup-side counterpart of ADR 0017. Backend: `src/services/registration.ts`, `src/routes/v1/registrations.ts` with six routes (3 tenant-side, 3 phone-side bearing single-use codes). Dashboard demo: `dashboard/src/routes/demo/QrRegistration.tsx` with QR codes + simulator panel + live polling. Android: `android/app/src/main/java/dev/zeroauth/android/ui/reg/*` (paste-deeplink only for V1; camera scan deferred to Phase 1 Sprint 4). Tests: `tests/registration-flow.test.ts` (19 tests), `android/app/src/test/.../RegQrPayloadTest.kt` (11 tests). | +| **ADR 0024** | qrcode.react dep for dashboard QR rendering | **LANDED** | `8c2f028` | ISC, zero runtime deps, peer-dep React ^16-19. `npm audit --omit=dev` clean. Lazy-loaded in `dashboard/src/App.tsx` so the QrRegistration chunk stays out of the main bundle. | + +## Pilot-ready vs production-ready gap (Phase 1 Sprint 4+) + +What still blocks a real BFSI tenant onboarding, separated from the 21 original findings: + +| Gap | Status | Blocking what | +|---|---|---| +| **Real biometric capture wired to the registration flow** | Source vendored at `mobile/biometric/` (FaceEmbedder + Quantizer + Poseidon + Keccak), not yet plugged into `RegistrationViewModel.BiometricSecretSource`. The default `PerInstallStableSecret` returns a SharedPreferences-persisted 32-byte secret so steps 2 and 3 derive the same commitment for the demo. | Real biometric verification. Currently any phone with the app behaves as if it were the same user across runs. | +| **Real Groth16 proof for `/v1/registrations/complete`** | `WebViewMobileProver` is operational for the W3 proof-pairing flow but the witness shape it expects differs from what the registration verify step needs (the current circuit has 3 public signals; the registration challenge_nonce isn't bound circuit-side). `RegistrationViewModel.ProofGenerator` is injectable so swapping in a real prover is a one-screen change once the witness math is finalised. | Step 3 surfaces `verify_failed` because the demo posts a stub proof. The route plumbing is end-to-end correct; only the proof bytes are stubbed. | +| **Camera QR scanning in `RegistrationScreen`** | Paste-deeplink only for V1. The existing `ui/scan/ScanScreen.kt` has a working ML Kit + CameraX pipeline that needs extracting into a shared composable. | Field-usable phone flow. Operator can paste the deeplink during a demo, end users can't realistically do that. | +| **Circuit v1.3 with challenge_nonce public input** | Designed in ADR 0023 §"V1 limitation" + §"Phase 1 Sprint 4 follow-on". V1 binds the challenge to the request, not to the proof. | Replay-defence depth: V1 prevents cross-session replay via single-use verify_code + 15-min TTL + rate-limit; V2 closes the proof-replay surface entirely. | +| **Branch protection on main (audit finding C-16)** | Pipeline exists; protected-branch settings still a manual ops ticket. | Blast-radius reduction for accidental force-pushes. | +| **PII strip on tenant_users (audit finding C-5)** | Schema-purity test pins current state; new tenant_users rows from registration ceremony already populate did + commitment alongside the legacy PII columns. | DPDP §2(t) minimisation target; tenant SDK can choose to pass an empty profile starting now. | ## Phase 0 P0 findings