Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions android/app/src/main/java/dev/zeroauth/android/nav/Nav.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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"
Expand Down Expand Up @@ -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 }
}
},
)
}

Expand Down
16 changes: 14 additions & 2 deletions android/app/src/main/java/dev/zeroauth/android/net/Api.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -144,7 +157,6 @@ object ApiFactory {
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(ZeroAuthApi::class.java)
}

/**
Expand Down
122 changes: 122 additions & 0 deletions android/app/src/main/java/dev/zeroauth/android/net/RegistrationApi.kt
Original file line number Diff line number Diff line change
@@ -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:<method>:<hex>` — 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<String>,
)

// ─── 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,
)
64 changes: 46 additions & 18 deletions android/app/src/main/java/dev/zeroauth/android/ui/SplashScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
}
}
}
}
Expand Down
Loading
Loading