Skip to content

Commit d8b24ef

Browse files
Pulkit Pareekpulkitpareek18
authored andcommitted
add three-QR signup ceremony to the Android app (ADR 0023 phone-side)
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=<pair|enroll|verify> &session=<uuid>&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:<appId>: <ANDROID_ID>:<install_uuid>; 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
1 parent 57c2d4a commit d8b24ef

11 files changed

Lines changed: 1076 additions & 21 deletions

File tree

android/README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,27 @@ real and demoable. The snarkjs prover, the Retrofit `/v1/proof-pairing`
1111
client, the Keystore-bound credential, the Biometric prompt — those
1212
all land in the follow-on prover-glue sprint task.
1313

14+
**ADR 0023 three-QR end-user signup ceremony** lives alongside the W3
15+
QR-sign-in flow. The Splash screen has a second CTA ("Create a new
16+
account (3-QR signup)") that routes to `RegistrationScreen`
17+
paste-deeplink only for V1 (the camera scan path reuses the existing
18+
ScanScreen's ML Kit pipeline and gets wired in Phase 1 Sprint 4
19+
alongside the real FaceEmbedder pipeline from `mobile/biometric/`).
20+
The phone-side endpoints (`POST /v1/registrations/{pair-device,
21+
submit-commitment, complete}`) are bound via `net/RegistrationApi.kt`;
22+
the deeplink parser is `util/RegQrPayload.kt`; the orchestrator is
23+
`ui/reg/RegistrationViewModel.kt`. See
24+
[ADR 0023](../adr/0023-three-qr-signup-ceremony.md) for the wire
25+
protocol + state machine.
26+
1427
See:
1528

1629
- [ADR-0009 — QR proof-pairing protocol](../adr/0009-qr-proof-pairing-protocol.md)
1730
- [ADR-0010 — Android WebView snarkjs bundling](../adr/0010-android-webview-snarkjs-bundling.md)
31+
- [ADR 0023 — Three-QR end-user signup ceremony](../adr/0023-three-qr-signup-ceremony.md)
1832
- [`docs/api_contract.md`](../docs/api_contract.md) — the four
19-
`/v1/proof-pairing/*` endpoints.
33+
`/v1/proof-pairing/*` endpoints + the six `/v1/registrations/*`
34+
endpoints.
2035

2136
## Prerequisites
2237

android/app/src/main/java/dev/zeroauth/android/nav/Nav.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.navigation.navArgument
1010
import dev.zeroauth.android.ui.DoneScreen
1111
import dev.zeroauth.android.ui.EnrollScreen
1212
import dev.zeroauth.android.ui.SplashScreen
13+
import dev.zeroauth.android.ui.reg.RegistrationScreen
1314
import dev.zeroauth.android.ui.scan.ScanScreen
1415

1516
/**
@@ -28,6 +29,8 @@ sealed class Screen(val route: String) {
2829
data object Splash : Screen("splash")
2930
data object Enroll : Screen("enroll")
3031
data object Scan : Screen("scan")
32+
/** ADR 0023 three-QR end-user signup ceremony. */
33+
data object Registration : Screen("registration")
3134

3235
data object Done : Screen("done?payload={payload}") {
3336
const val ARG_PAYLOAD = "payload"
@@ -56,6 +59,19 @@ fun ZeroAuthNavHost() {
5659
popUpTo(Screen.Splash.route) { inclusive = true }
5760
}
5861
},
62+
onCreateAccount = {
63+
navController.navigate(Screen.Registration.route)
64+
},
65+
)
66+
}
67+
68+
composable(Screen.Registration.route) {
69+
RegistrationScreen(
70+
onDone = {
71+
navController.navigate(Screen.Splash.route) {
72+
popUpTo(0) { inclusive = true }
73+
}
74+
},
5975
)
6076
}
6177

android/app/src/main/java/dev/zeroauth/android/net/Api.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,20 @@ object ApiFactory {
124124
explicitNulls = false
125125
}
126126

127-
fun create(baseUrl: String = DEFAULT_BASE_URL): ZeroAuthApi {
127+
fun create(baseUrl: String = DEFAULT_BASE_URL): ZeroAuthApi =
128+
retrofit(baseUrl).create(ZeroAuthApi::class.java)
129+
130+
/**
131+
* ADR 0023 three-QR signup ceremony — the phone-side endpoints
132+
* the registration scan flow hits. Same OkHttp + Retrofit stack
133+
* as [create]; pulled out into a separate factory so callers that
134+
* only need registration can avoid the ZeroAuthApi initialisation
135+
* cost on first use.
136+
*/
137+
fun createRegistrationApi(baseUrl: String = DEFAULT_BASE_URL): RegistrationApi =
138+
retrofit(baseUrl).create(RegistrationApi::class.java)
139+
140+
private fun retrofit(baseUrl: String): Retrofit {
128141
val logging = HttpLoggingInterceptor().apply {
129142
level = if (BuildConfig.DEBUG) {
130143
HttpLoggingInterceptor.Level.BODY
@@ -144,7 +157,6 @@ object ApiFactory {
144157
.client(client)
145158
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
146159
.build()
147-
.create(ZeroAuthApi::class.java)
148160
}
149161

150162
/**
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package dev.zeroauth.android.net
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
import kotlinx.serialization.json.JsonElement
6+
import retrofit2.http.Body
7+
import retrofit2.http.POST
8+
9+
/**
10+
* Retrofit binding for the three-QR end-user signup ceremony
11+
* (server-side ADR 0023). The phone holds NO tenant API key — the
12+
* QR-supplied code is the bearer credential for each step. See
13+
* `util/RegQrPayload.kt` for the parser that turns a scanned QR
14+
* string into the right request body for each step.
15+
*
16+
* Endpoint contract (server side):
17+
*
18+
* POST /v1/registrations/pair-device
19+
* body: { pair_code, fingerprint, attestation_kind? }
20+
* 200: { session_id, device_id, next: {...} }
21+
* 404: { error: "pair_failed" } — uniform on any failure
22+
*
23+
* POST /v1/registrations/submit-commitment
24+
* body: { enroll_code, did, commitment, attestation_kind? }
25+
* 200: { session_id, next: { step, code, deeplink, challenge_nonce } }
26+
* 404: { error: "enroll_failed" }
27+
*
28+
* POST /v1/registrations/complete
29+
* body: { verify_code, challenge_nonce, proof, public_signals }
30+
* 200: { session_id, tenant_user, device }
31+
* 404: { error: "verify_failed" }
32+
*
33+
* The phone-side rate-limit on these endpoints is 20 req/min per IP.
34+
*
35+
* The `proof` field on /complete carries the snarkjs Groth16 envelope
36+
* `{ pi_a, pi_b, pi_c, protocol, curve }`. We reuse [Groth16Proof]
37+
* from the proof-pairing prover (defined in
38+
* `dev.zeroauth.android.prover.MobileProver`) so the same struct
39+
* serialises identically into both surfaces.
40+
*/
41+
interface RegistrationApi {
42+
43+
@POST("v1/registrations/pair-device")
44+
suspend fun pairDevice(@Body body: PairDeviceRequest): PairDeviceResponse
45+
46+
@POST("v1/registrations/submit-commitment")
47+
suspend fun submitCommitment(@Body body: SubmitCommitmentRequest): SubmitCommitmentResponse
48+
49+
@POST("v1/registrations/complete")
50+
suspend fun complete(@Body body: CompleteRequest): CompleteResponse
51+
}
52+
53+
// ─── Request shapes ───────────────────────────────────────────────
54+
55+
@Serializable
56+
data class PairDeviceRequest(
57+
@SerialName("pair_code") val pairCode: String,
58+
/**
59+
* Opaque hardware identifier — server requires >= 16 chars and
60+
* stores only its SHA-256. Production phones supply a stable
61+
* composition of android_id + installation_uuid + Play Integrity
62+
* package signature. See [dev.zeroauth.android.util.DeviceFingerprint]
63+
* for the canonical builder.
64+
*/
65+
val fingerprint: String,
66+
@SerialName("attestation_kind") val attestationKind: String? = null,
67+
)
68+
69+
@Serializable
70+
data class SubmitCommitmentRequest(
71+
@SerialName("enroll_code") val enrollCode: String,
72+
/** `did:zeroauth:<method>:<hex>` — server validates the shape. */
73+
val did: String,
74+
/** Hex Poseidon commitment, with or without the leading `0x`. */
75+
val commitment: String,
76+
@SerialName("attestation_kind") val attestationKind: String? = null,
77+
)
78+
79+
@Serializable
80+
data class CompleteRequest(
81+
@SerialName("verify_code") val verifyCode: String,
82+
@SerialName("challenge_nonce") val challengeNonce: String,
83+
/**
84+
* snarkjs Groth16 proof envelope `{ pi_a, pi_b, pi_c, ...}`. We
85+
* use [JsonElement] so the [dev.zeroauth.android.prover.Groth16Proof]
86+
* struct from the prover module can be serialised inline without
87+
* a Retrofit-side adapter — the call site does the conversion.
88+
*/
89+
val proof: JsonElement,
90+
@SerialName("public_signals") val publicSignals: List<String>,
91+
)
92+
93+
// ─── Response shapes ──────────────────────────────────────────────
94+
95+
@Serializable
96+
data class NextStep(
97+
val step: String,
98+
val code: String,
99+
@SerialName("expires_at") val expiresAt: String,
100+
val deeplink: String,
101+
@SerialName("challenge_nonce") val challengeNonce: String? = null,
102+
)
103+
104+
@Serializable
105+
data class PairDeviceResponse(
106+
@SerialName("session_id") val sessionId: String,
107+
@SerialName("device_id") val deviceId: String? = null,
108+
val next: NextStep,
109+
)
110+
111+
@Serializable
112+
data class SubmitCommitmentResponse(
113+
@SerialName("session_id") val sessionId: String,
114+
val next: NextStep,
115+
)
116+
117+
@Serializable
118+
data class CompleteResponse(
119+
@SerialName("session_id") val sessionId: String,
120+
@SerialName("tenant_user") val tenantUser: JsonElement? = null,
121+
val device: JsonElement? = null,
122+
)

android/app/src/main/java/dev/zeroauth/android/ui/SplashScreen.kt

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import dev.zeroauth.android.ui.theme.ZeroAuthTheme
4848
fun SplashScreen(
4949
onEnrollNeeded: () -> Unit,
5050
onAlreadyEnrolled: () -> Unit,
51+
onCreateAccount: () -> Unit = {},
5152
) {
5253
// TODO(prover-glue): replace with a real read off KeystoreManager.
5354
// Today this is always false so the demo always shows the Enroll
@@ -97,25 +98,52 @@ fun SplashScreen(
9798
)
9899
}
99100

100-
Button(
101-
onClick = {
102-
if (navigated) return@Button
103-
navigated = true
104-
if (isEnrolled.value) onAlreadyEnrolled() else onEnrollNeeded()
105-
},
106-
modifier = Modifier
107-
.fillMaxWidth()
108-
.height(56.dp),
109-
colors = ButtonDefaults.buttonColors(
110-
containerColor = MaterialTheme.colorScheme.primary,
111-
contentColor = MaterialTheme.colorScheme.onPrimary,
112-
),
113-
contentPadding = PaddingValues(horizontal = 24.dp),
101+
Column(
102+
modifier = Modifier.fillMaxWidth(),
103+
verticalArrangement = Arrangement.spacedBy(12.dp),
114104
) {
115-
Text(
116-
text = stringResource(R.string.splash_cta),
117-
style = MaterialTheme.typography.labelLarge,
118-
)
105+
Button(
106+
onClick = {
107+
if (navigated) return@Button
108+
navigated = true
109+
if (isEnrolled.value) onAlreadyEnrolled() else onEnrollNeeded()
110+
},
111+
modifier = Modifier
112+
.fillMaxWidth()
113+
.height(56.dp),
114+
colors = ButtonDefaults.buttonColors(
115+
containerColor = MaterialTheme.colorScheme.primary,
116+
contentColor = MaterialTheme.colorScheme.onPrimary,
117+
),
118+
contentPadding = PaddingValues(horizontal = 24.dp),
119+
) {
120+
Text(
121+
text = stringResource(R.string.splash_cta),
122+
style = MaterialTheme.typography.labelLarge,
123+
)
124+
}
125+
// ADR 0023 three-QR end-user signup. The "Create a new
126+
// account" CTA jumps into the registration ceremony,
127+
// which is independent of the existing QR-sign-in flow
128+
// above (that one's for users who already have an
129+
// account and want to authenticate on a desktop).
130+
Button(
131+
onClick = {
132+
if (navigated) return@Button
133+
navigated = true
134+
onCreateAccount()
135+
},
136+
modifier = Modifier
137+
.fillMaxWidth()
138+
.height(56.dp),
139+
colors = ButtonDefaults.outlinedButtonColors(),
140+
contentPadding = PaddingValues(horizontal = 24.dp),
141+
) {
142+
Text(
143+
text = "Create a new account (3-QR signup)",
144+
style = MaterialTheme.typography.labelLarge,
145+
)
146+
}
119147
}
120148
}
121149
}

0 commit comments

Comments
 (0)