diff --git a/android/app/src/main/java/dev/zeroauth/android/prover/ProverIpc.kt b/android/app/src/main/java/dev/zeroauth/android/prover/ProverIpc.kt index 514faeb..0cbccd5 100644 --- a/android/app/src/main/java/dev/zeroauth/android/prover/ProverIpc.kt +++ b/android/app/src/main/java/dev/zeroauth/android/prover/ProverIpc.kt @@ -1,5 +1,6 @@ package dev.zeroauth.android.prover +import android.annotation.SuppressLint import android.os.Parcel import android.os.Parcelable @@ -121,6 +122,15 @@ class ProverRequest( // because String is immutable in the JVM. } + // Lint's `ParcelCreator` check prefers the + // `@JvmField val CREATOR = object : Parcelable.Creator` form so + // the CREATOR is exported as a Java static field. The + // `companion object CREATOR` shorthand below works at runtime + // (Parcel marshalling traverses companion metadata) but lint + // flags it. Refactoring would ripple into the IPC fixtures; for + // V1 we suppress and the Phase 1 Sprint 4 cleanup task swaps to + // @JvmField alongside the prover-witness refactor. + @SuppressLint("ParcelCreator") companion object CREATOR : Parcelable.Creator { override fun createFromParcel(parcel: Parcel): ProverRequest = ProverRequest( @@ -234,6 +244,7 @@ sealed class ProverResponse : Parcelable { fun toException(): ProverException = ProverException(code, errorMessage) } + @SuppressLint("ParcelCreator") companion object CREATOR : Parcelable.Creator { private const val KIND_PROGRESS = 1 diff --git a/android/app/src/main/java/dev/zeroauth/android/ui/reg/RealRegistrationProver.kt b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RealRegistrationProver.kt new file mode 100644 index 0000000..de03c15 --- /dev/null +++ b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RealRegistrationProver.kt @@ -0,0 +1,139 @@ +package dev.zeroauth.android.ui.reg + +import android.content.Context +import dev.zeroauth.android.prover.Groth16Proof +import dev.zeroauth.android.prover.GenerateInput +import dev.zeroauth.android.prover.IsolatedMobileProver +import dev.zeroauth.android.sec.Poseidon +import dev.zeroauth.android.sec.UnlockedCredential +import dev.zeroauth.android.ui.reg.RegistrationViewModel.ProofGenerator +import java.math.BigInteger + +/** + * Real Groth16 prover wiring for the ADR 0023 verify step. + * + * Builds a [RegistrationUnlockedCredential] from the per-install + * 32-byte secret + zero salt + commitment + DID, then hands it to + * the existing [IsolatedMobileProver] which runs snarkjs inside the + * isolated `:prover` process per ADR 0010. + * + * The witness shape matches what the W3 proof-pairing flow uses, so + * the same `identity_proof.circom` v1.2 circuit verifies registration + * proofs without any circuit changes: + * + * biometricSecret = secret as field element + * salt = 0 (we don't use a salt in V1; the slot is + * reserved for a future StrongBox-backed salt + * per ADR 0018) + * commitment = Poseidon(secret, salt) + * didHash = Poseidon(commitment) — single-arg Poseidon + * so the DID is bound to the commitment in the + * proof's hash chain + * sessionNonceHex = challenge_nonce from QR3 + * + * The prover computes: + * didHashSession = Poseidon(didHash, sessionNonce) + * identityBinding = Poseidon(secret, didHashSession) + * publicSignals = [commitment, didHashSession, identityBinding] + * + * The server's verifyProofOffChain runs snarkjs.groth16.verify + * against the boot-pinned vkey; on success the row flips to + * `completed`. ADR 0023 §"V1 limitation" notes that publicSignals[1] + * is NOT validated against the issued challenge_nonce — replay + * defence comes from the single-use verify_code chain + 15-min TTL + * + per-IP rate-limit (already in place). Circuit v1.3 in Phase 1 + * Sprint 4 will move the challenge into a circuit-bound public + * signal. + */ +class RealRegistrationProver( + private val context: Context, + private val secretSource: RegistrationViewModel.BiometricSecretSource, + private val proverFactory: () -> IsolatedMobileProver = { IsolatedMobileProver(context.applicationContext) }, +) : ProofGenerator { + + override suspend fun generate( + secret: ByteArray, + commitmentHex: String, + challengeNonceHex: String, + ): Groth16Proof { + require(secret.size == 32) { "Secret must be 32 bytes; got ${secret.size}" } + + val credential = buildCredential(secret, commitmentHex) + val prover = proverFactory() + try { + val output = prover.generate( + GenerateInput(unlocked = credential, sessionNonceHex = challengeNonceHex), + ) + return output.proof + } finally { + credential.close() + prover.release() + } + } + + /** + * Build the UnlockedCredential the prover expects. All five fields + * become decimal-string field elements snarkjs can ingest. + * + * `did` carries the human-form DID for logging; the field that the + * prover actually consumes is `didHash` (the field-element form). + */ + private fun buildCredential(secret: ByteArray, commitmentHex: String): UnlockedCredential { + val secretField = BigInteger(1, secret) + val saltField = BigInteger.ZERO + val commitmentField = BigInteger(commitmentHex.removePrefix("0x"), 16) + // Single-arg Poseidon of the commitment — binds the DID to the + // commitment in the proof's hash chain. Cheap and stable; the + // circuit doesn't constrain *how* didHash was derived, only + // that the prover and verifier agree on the same value. + val didHashField = Poseidon.hash1(commitmentField) + val (did, _) = DeriveDidAndCommitment.from(secret) + + return RegistrationUnlockedCredential( + biometricSecretStr = secretField.toString(10), + saltStr = saltField.toString(10), + commitmentStr = commitmentField.toString(10), + didHashStr = didHashField.toString(10), + didStr = did, + secretBuffer = secret.copyOf(), + ) + } +} + +/** + * UnlockedCredential subclass for the registration flow. Holds the + * five decimal-string field elements the prover consumes. close() zeros + * the backing byte buffer so the secret doesn't outlive the call. + */ +private class RegistrationUnlockedCredential( + private val biometricSecretStr: String, + private val saltStr: String, + private val commitmentStr: String, + private val didHashStr: String, + private val didStr: String, + private val secretBuffer: ByteArray, +) : UnlockedCredential() { + + @Volatile private var closed = false + + override val biometricSecret: String get() { ensureOpen(); return biometricSecretStr } + override val salt: String get() { ensureOpen(); return saltStr } + override val commitment: String get() { ensureOpen(); return commitmentStr } + override val didHash: String get() { ensureOpen(); return didHashStr } + override val did: String get() = didStr // safe even after close — non-secret + + private fun ensureOpen() { + check(!closed) { "UnlockedCredential is closed" } + } + + override fun close() { + if (closed) return + closed = true + // Zero the buffer so the secret doesn't sit in heap longer than + // necessary. The decimal strings are unfortunately retained by + // the prover until its WebView call returns; the production + // path would also clear them but that's a String-immutability + // concession we can't fix in Kotlin without unsafe access. + secretBuffer.fill(0) + } +} diff --git a/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationQrCamera.kt b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationQrCamera.kt new file mode 100644 index 0000000..cb9906b --- /dev/null +++ b/android/app/src/main/java/dev/zeroauth/android/ui/reg/RegistrationQrCamera.kt @@ -0,0 +1,237 @@ +package dev.zeroauth.android.ui.reg + +import android.Manifest +import android.content.pm.PackageManager +import android.util.Size +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import java.util.concurrent.Executors + +/** + * Camera-driven QR scanner specifically for ADR 0023 registration + * deeplinks (`zeroauth://reg?...`). Lean focused alternative to the + * full `ui/scan/ScanScreen.kt` pipeline — that one carries the W3 + * proof-pairing state machine and isn't trivially reusable. + * + * Three-state UI: + * - permission_pending : on first mount, asks for CAMERA + * - permission_denied : shows the user-facing rationale + retry CTA + * - scanning : live PreviewView, ML Kit barcode analyser + * fires onResult on the first valid scan + * + * The composable does NOT itself parse the QR — it returns the raw + * scanned text via [onResult] and lets the caller route through + * [dev.zeroauth.android.util.RegQrPayload.parse]. This keeps the + * scanner reusable for any QR format the registration flow grows + * into. + * + * Threading model: the ImageAnalysis runs on a single-thread executor. + * ML Kit's `barcodeScanner.process(image)` is async — we wait via + * `addOnSuccessListener`. On the first non-empty result we call + * `onResult` from the analyser thread, but the call site immediately + * dispatches into a Compose-side state update on the main thread + * (via `viewModel` semantics). + */ +@Composable +fun RegistrationQrCamera( + onResult: (String) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED, + ) + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> hasPermission = granted } + + LaunchedEffect(Unit) { + if (!hasPermission) launcher.launch(Manifest.permission.CAMERA) + } + + if (!hasPermission) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Camera access is required to scan the QR codes the platform shows. Grant the permission to continue, or use the paste field instead.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Button(onClick = { launcher.launch(Manifest.permission.CAMERA) }) { + Text("Grant camera permission") + } + OutlinedButton(onClick = onCancel) { Text("Use paste instead") } + } + return + } + + Box(modifier = modifier.fillMaxWidth().height(360.dp).background(Color.Black)) { + CameraScanLayer(onResult = onResult) + // Cancel CTA in the bottom-right corner so the operator can + // bail back to paste-mode without backing out of the screen. + Column( + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.End, + ) { + OutlinedButton(onClick = onCancel) { Text("Cancel") } + } + } +} + +@Composable +private fun CameraScanLayer(onResult: (String) -> Unit) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val executor = remember { Executors.newSingleThreadExecutor() } + + val scanner: BarcodeScanner = remember { + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build(), + ) + } + + DisposableEffect(scanner, executor) { + onDispose { + scanner.close() + executor.shutdown() + } + } + + // Capture-once latch — the analyser fires for every frame the QR is + // visible; we only want to call onResult once and then stop. + val resultLatched = remember { mutableStateOf(false) } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + val previewView = PreviewView(ctx).apply { + implementationMode = PreviewView.ImplementationMode.PERFORMANCE + scaleType = PreviewView.ScaleType.FILL_CENTER + } + val providerFuture = ProcessCameraProvider.getInstance(ctx) + providerFuture.addListener({ + val cameraProvider = providerFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val resolutionSelector = ResolutionSelector.Builder() + .setResolutionStrategy( + ResolutionStrategy( + Size(1280, 720), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER, + ), + ).build() + val analysis = ImageAnalysis.Builder() + .setResolutionSelector(resolutionSelector) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + analysis.setAnalyzer( + executor, + RegistrationQrAnalyzer(scanner) { text -> + if (!resultLatched.value) { + resultLatched.value = true + previewView.post { onResult(text) } + } + }, + ) + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + }, ContextCompat.getMainExecutor(ctx)) + previewView + }, + ) +} + +/** + * Class form (not a free function) so the `@ExperimentalGetImage` + * annotation lands directly on the `analyze` method that uses + * `proxy.image`. Mirrors `QrPayloadAnalyzer` in + * `ui/scan/ScanScreen.kt`: the annotation goes on the override, not + * the class, so the constructor call site doesn't itself need to + * opt in to the experimental API. + */ +private class RegistrationQrAnalyzer( + private val scanner: BarcodeScanner, + private val onText: (String) -> Unit, +) : ImageAnalysis.Analyzer { + @ExperimentalGetImage + override fun analyze(proxy: ImageProxy) { + val media = proxy.image + if (media == null) { + proxy.close() + return + } + val image = InputImage.fromMediaImage(media, proxy.imageInfo.rotationDegrees) + scanner.process(image) + .addOnSuccessListener { barcodes -> + // Pick the first QR with a non-empty raw value. The + // caller routes by RegQrPayload.parse — we don't filter + // by shape here. + val text = barcodes.firstOrNull()?.rawValue + if (!text.isNullOrBlank()) onText(text) + } + .addOnCompleteListener { proxy.close() } + } +} 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 ecf3da9..2fa45d7 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 @@ -11,6 +11,7 @@ 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.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -54,11 +55,24 @@ fun RegistrationScreen( val context = LocalContext.current val vm: RegistrationViewModel = viewModel( factory = viewModelFactory { - initializer { RegistrationViewModel(context.applicationContext) } + initializer { + val appCtx = context.applicationContext + val secret = PerInstallStableSecret(appCtx) + RegistrationViewModel( + context = appCtx, + secretSource = secret, + // Real Groth16 prover wired via IsolatedMobileProver + // (ADR 0010 sandboxed :prover process). Replaces the + // StubProofGenerator default; the demo-grade stub + // can still be passed in tests. + proofGenerator = RealRegistrationProver(appCtx, secret), + ) + } }, ) val state by vm.state.collectAsState() var pasted by rememberSaveable { mutableStateOf("") } + var scannerOpen by rememberSaveable { mutableStateOf(false) } Column( modifier = Modifier @@ -78,24 +92,43 @@ fun RegistrationScreen( 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, - ) + if (scannerOpen) { + RegistrationQrCamera( + onResult = { scanned -> + scannerOpen = false + vm.onQrText(scanned) + pasted = "" + }, + onCancel = { scannerOpen = false }, + ) + } else { + 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") + } - Button( - onClick = { - vm.onQrText(pasted.trim()) - pasted = "" - }, - enabled = pasted.isNotBlank() && !state.isInFlight(), - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = "Submit step") + OutlinedButton( + onClick = { scannerOpen = true }, + enabled = !state.isInFlight(), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Scan with camera") + } } Spacer(Modifier.height(8.dp)) diff --git a/docs/operations/three-qr-signup-deployment.md b/docs/operations/three-qr-signup-deployment.md new file mode 100644 index 0000000..3791171 --- /dev/null +++ b/docs/operations/three-qr-signup-deployment.md @@ -0,0 +1,200 @@ +# Three-QR signup ceremony — deployment + smoke runbook + +End-to-end procedure for getting the ADR 0023 ceremony into a state a friendly pilot tenant can actually use. Walks through every layer (backend → dashboard → Android APK) and provides a smoke-test script the operator runs against the deployed stack to confirm green. + +**Scope:** pilot-grade deployment. Not production-grade — see [`docs/security/audit-findings.md`](../security/audit-findings.md) §"Pilot-ready vs production-ready gap" for what's deliberately deferred. + +## Prerequisites + +- An operator with: + - `ssh zeroauth-deploy@104.207.143.14` access (production VPS) + - GitHub admin on `zeroauth-dev/ZeroAuth` + - Android Studio Iguana+ on a laptop, JDK 17+, ADB, an Android 11+ device +- A tenant API key for the pilot tenant (mint one via the dashboard's **API keys** page → "Generate live key") +- 30 minutes of focused time for the first run; subsequent runs take ~10 min + +## Architecture (one paragraph for context) + +The signup ceremony is three HTTP round-trips between the phone and the server, plus three render→scan handshakes between the dashboard and the phone. The tenant's signup page (or the dashboard's `/demo/registration`) calls `POST /v1/registrations` to open a session and gets back a one-time `pair_code`. The phone scans QR1 (encoding `zeroauth://reg?step=pair&session=…&code=ZA-XXXX-XXXX`), POSTs to `/v1/registrations/pair-device` with the code + a hardware fingerprint, and the server flips the row to `awaiting_commitment`. The phone captures the biometric locally, computes a Poseidon commitment, scans QR2, and POSTs to `/v1/registrations/submit-commitment` with the (did, commitment) pair — the biometric never leaves the phone. The server then mints `verify_code` + a 128-bit challenge nonce baked into QR3. The phone re-captures the biometric, produces a Groth16 proof using `IsolatedMobileProver` (the snarkjs WebView per ADR 0010), and POSTs to `/v1/registrations/complete`. The server runs `verifyProofOffChain`, asserts `publicSignals[0]` equals the stored commitment, creates a `tenant_user` row, and the ceremony terminates `completed`. Every state transition writes a hash-chained audit row. + +## Step 1 — Backend (already deployed) + +Already live at `https://zeroauth.dev`. To confirm: + +```bash +curl -fsS https://zeroauth.dev/api/health +# expect: { "status": "ok", ... } + +curl -fsS -X POST https://zeroauth.dev/v1/registrations \ + -H "Authorization: Bearer za_live_" \ + -H "Content-Type: application/json" \ + -d '{"profile":{"name":"Smoke Test","email":"smoke@example.com"}}' \ + | jq . +# expect: { "session": { "id": "...", "state": "awaiting_device", ... }, +# "pair": { "code": "ZA-XXXX-XXXX", "expires_at": "...", "deeplink": "zeroauth://reg?..." } } +``` + +If the POST returns a 401 the tenant API key is wrong. If it returns a 500 read `/opt/zeroauth/logs/server.log` on the VPS for the actual error. + +To deploy a fresh backend build after a commit to `main`: + +```bash +# CI handles this automatically; manual fallback: +ssh zeroauth-deploy@104.207.143.14 +cd /opt/zeroauth +git pull && docker compose --profile prod up -d --build +docker compose --profile prod logs -f --tail=80 zeroauth-api +# verify the new commit hash in the log header +``` + +## Step 2 — Dashboard demo (already deployed) + +Open : + +1. Sign in with a console JWT (any registered console account works — the dashboard JWT identifies the tenant; the demo uses the tenant's `live` environment by default). +2. Fill name + email (defaults work for testing). +3. Click **Open session & mint QR1** → three QR codes appear in sequence as the phone hits each endpoint. +4. The right-column **Simulate phone** panel drives the flow from the same browser if you don't have a real phone — useful for sales demos. + +## Step 3 — Build and sideload the Android APK + +```bash +git clone git@github.com:zeroauth-dev/ZeroAuth.git +cd ZeroAuth/android +gradle wrapper --gradle-version 8.7 # one-time, regenerates the gitignored wrapper jar +./gradlew :app:assembleDebug # builds app/build/outputs/apk/debug/app-debug.apk +./gradlew :app:installDebug # ADB-attached device, or follow `adb pair` then re-run +``` + +Open the app on the phone → tap **Create a new account (3-QR signup)** on Splash → either tap **Scan with camera** (preferred) or paste the deeplink. + +For a release-signed APK ready for Play Internal Test, see [`android/RELEASE.md`](../../android/RELEASE.md). Requires the four keystore secrets in the GitHub Actions secret store; tag-pushes then produce a signed AAB + APK in the workflow's artefacts. + +## Step 4 — Smoke test + +Drive the script below against the deployed stack to confirm the ceremony works end-to-end without a real phone. The script uses `curl` + `jq` and exits non-zero on any failure. + +```bash +# scripts/smoke-registration.sh — runs in <10s against a healthy prod +set -euo pipefail + +: "${TENANT_API_KEY:?set TENANT_API_KEY=za_live_...}" +: "${SERVER:=https://zeroauth.dev}" + +echo "▶ Open registration session" +START=$(curl -fsS -X POST "$SERVER/v1/registrations" \ + -H "Authorization: Bearer $TENANT_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"profile":{"name":"Smoke","email":"smoke@example.com"}}') +SESSION_ID=$(echo "$START" | jq -r .session.id) +PAIR_CODE=$(echo "$START" | jq -r .pair.code) +echo " session=$SESSION_ID pair_code=$PAIR_CODE" + +echo "▶ Step 1 — pair device" +PAIR=$(curl -fsS -X POST "$SERVER/v1/registrations/pair-device" \ + -H "Content-Type: application/json" \ + -d "{\"pair_code\":\"$PAIR_CODE\",\"fingerprint\":\"smoke:$(date +%s):0123456789abcdef\"}") +ENROLL_CODE=$(echo "$PAIR" | jq -r .next.code) +echo " device_id=$(echo "$PAIR" | jq -r .device_id) enroll_code=$ENROLL_CODE" + +echo "▶ Step 2 — submit commitment" +COMMIT=$(curl -fsS -X POST "$SERVER/v1/registrations/submit-commitment" \ + -H "Content-Type: application/json" \ + -d "{\"enroll_code\":\"$ENROLL_CODE\",\"did\":\"did:zeroauth:face:abcdef1234567890\",\"commitment\":\"0x$(printf 'a%.0s' {1..64})\"}") +VERIFY_CODE=$(echo "$COMMIT" | jq -r .next.code) +CHALLENGE=$(echo "$COMMIT" | jq -r .next.challenge_nonce) +echo " verify_code=$VERIFY_CODE challenge=$CHALLENGE" + +echo "▶ Step 3 — complete (stub proof — expected to surface verify_failed)" +VERIFY=$(curl -sS -o /dev/null -w "%{http_code}" -X POST "$SERVER/v1/registrations/complete" \ + -H "Content-Type: application/json" \ + -d "{\"verify_code\":\"$VERIFY_CODE\",\"challenge_nonce\":\"$CHALLENGE\",\"proof\":{\"pi_a\":[\"1\",\"2\",\"1\"],\"pi_b\":[[\"3\",\"4\"],[\"5\",\"6\"],[\"1\",\"0\"]],\"pi_c\":[\"7\",\"8\",\"1\"]},\"public_signals\":[\"0x$(printf 'a%.0s' {1..64})\"]}") +if [ "$VERIFY" = "404" ]; then + echo " ✓ stub proof correctly rejected (HTTP 404 verify_failed)" +else + echo " ✗ expected 404 verify_failed but got $VERIFY" >&2 + exit 1 +fi + +echo +echo "✓ Smoke OK. Real-proof verify with a real phone:" +echo " 1. Build + install the APK per android/README.md." +echo " 2. On the dashboard demo, open a fresh session." +echo " 3. On the phone, scan each QR with the camera scanner." +echo " 4. The third scan should land the dashboard in the 'Account created' state." +``` + +Save as `scripts/smoke-registration.sh`, `chmod +x`, and: + +```bash +TENANT_API_KEY=za_live_xxx ./scripts/smoke-registration.sh +``` + +## Step 5 — End-to-end against a real phone + +After the smoke passes: + +1. Open `https://zeroauth.dev/dashboard/demo/registration` on a laptop. +2. Click **Open session & mint QR1**. +3. On the phone, open the ZeroAuth app → **Create a new account (3-QR signup)** → **Scan with camera** → point at QR1. +4. The dashboard's live polling will surface "Phone paired ✓ — waiting for biometric commitment". QR2 appears. +5. Phone scans QR2 → dashboard shows "Commitment received ✓ — waiting for verification proof". QR3 appears (carries the challenge nonce). +6. Phone scans QR3 → produces a real Groth16 proof via `IsolatedMobileProver` → POSTs to `/complete`. +7. Dashboard shows "Account created ✓". Open the **Users** page (under the same environment) and confirm the new `tenant_user` row appears. + +If step 6 surfaces `verify_failed`: +- Check the device's logcat: `adb logcat | grep -E '(ZeroAuth|MobileProver)'` — the prover writes timing + protocol events with the `MobileProver` tag. +- Confirm the server's vkey matches the circuit the prover loaded: `curl https://zeroauth.dev/v1/auth/zkp/circuit-info` and compare the `expectedVkeySha256` against the assets pinned in `android/prover-assets.sha256`. +- If both match and verify still fails, the witness shape is the issue — V1 uses single-arg `Poseidon(commitment)` for `didHash` (per `RealRegistrationProver.kt`), which the circuit allows but Phase 1 Sprint 4's v1.3 circuit will bind explicitly. + +## Step 6 — Audit log inspection + +Confirm the ceremony wrote the expected hash-chained audit rows: + +```bash +curl -fsS "https://zeroauth.dev/api/admin/audit-integrity?tenantId=&environment=live&limit=10" \ + -H "x-api-key: $ADMIN_API_KEY" | jq . +``` + +Expect five new actions for each completed ceremony: +1. `registration.started` +2. `registration.device_paired` +3. `registration.commitment_submitted` +4. `device.enrolled` +5. `registration.completed` + +The chain integrity field should be `ok: true`. Any `false` means a tamper-detect alarm — page security immediately per [`docs/shared/incident-response.md`](https://github.com/zeroauth-dev/ZeroAuth-Governance/blob/main/docs/shared/incident-response.md). + +## What still doesn't work end-to-end + +Documented for honesty: + +| Item | Status | Tracked | +|---|---|---| +| Real face capture | Not wired into the registration flow yet. The phone uses a per-install deterministic 32-byte secret stored in SharedPreferences so the demo can run on devices without a working face sensor. | Phase 1 Sprint 4. See `mobile/biometric/` for the vendored pipeline. | +| Circuit-bound challenge nonce | V1 binds the nonce to the request, not to the circuit's public inputs. Replay is blocked by the single-use verify_code chain + 15-min TTL + rate-limit. | Phase 1 Sprint 4 (circuit v1.3). | +| External cryptographer sign-off | Engagement scoped, not yet started. | Phase 1 week 10. | +| Play Store distribution | Sideload only. Internal Test track ready as soon as the four keystore secrets are loaded into GitHub Actions. | [`android/RELEASE.md`](../../android/RELEASE.md). | +| Branch protection on `main` | Settings flag, no code change required. | Audit finding C-16. | + +## Rollback + +If a deploy turns red, revert the merge commit on `main` and redeploy: + +```bash +ssh zeroauth-deploy@104.207.143.14 +cd /opt/zeroauth +git log -5 --oneline # find the last known-good commit +git checkout +docker compose --profile prod up -d --build +``` + +Then on a laptop, revert the merge commit on `main`: + +```bash +git checkout main +git revert -m 1 +git push origin main +``` + +CI will redeploy automatically once the revert lands. diff --git a/scripts/smoke-registration.sh b/scripts/smoke-registration.sh new file mode 100755 index 0000000..0fbe24b --- /dev/null +++ b/scripts/smoke-registration.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# +# Three-QR signup ceremony smoke test (ADR 0023). +# +# Drives the four documented HTTP round-trips against the deployed +# backend using `curl` + `jq`. Validates that: +# +# 1. POST /v1/registrations issues a pair_code +# 2. POST /v1/registrations/pair-device claims a device row +# 3. POST /v1/registrations/submit-commitment stores (did, commitment) +# and mints a challenge nonce +# 4. POST /v1/registrations/complete with a STUB proof correctly +# surfaces 404 verify_failed (the server's verifier rejected it, +# proving the route plumbing reaches the verifier) +# +# Real-proof verify is a separate manual step that requires the Android +# APK + a real device — see docs/operations/three-qr-signup-deployment.md +# §"Step 5 — End-to-end against a real phone". +# +# Exit codes: +# 0 — all four round-trips landed as expected +# 1 — any round-trip failed or the verify step didn't surface 404 +# +# Usage: +# TENANT_API_KEY=za_live_xxx ./scripts/smoke-registration.sh +# TENANT_API_KEY=za_live_xxx SERVER=https://staging.zeroauth.dev ./scripts/smoke-registration.sh + +set -euo pipefail + +: "${TENANT_API_KEY:?set TENANT_API_KEY=za_live_... or za_test_...}" +: "${SERVER:=https://zeroauth.dev}" + +# Make sure jq is available — every assertion below needs it. +if ! command -v jq >/dev/null 2>&1; then + echo "✗ jq is required (brew install jq | apt install jq)" >&2 + exit 1 +fi + +GREEN=$'\e[32m' +RED=$'\e[31m' +DIM=$'\e[2m' +RESET=$'\e[0m' + +fail() { + echo "${RED}✗ $*${RESET}" >&2 + exit 1 +} + +step() { + echo "${DIM}▶${RESET} $*" +} + +ok() { + echo " ${GREEN}✓${RESET} $*" +} + +# ─── Step 0: open session ───────────────────────────────────────── + +step "Open registration session against $SERVER" +START_RES=$(curl -fsS -X POST "$SERVER/v1/registrations" \ + -H "Authorization: Bearer $TENANT_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"profile":{"name":"Smoke Test","email":"smoke@example.com"}}') + +SESSION_ID=$(echo "$START_RES" | jq -r .session.id) +SESSION_STATE=$(echo "$START_RES" | jq -r .session.state) +PAIR_CODE=$(echo "$START_RES" | jq -r .pair.code) +PAIR_DEEPLINK=$(echo "$START_RES" | jq -r .pair.deeplink) + +[ "$SESSION_STATE" = "awaiting_device" ] \ + || fail "expected state=awaiting_device, got '$SESSION_STATE'" +[[ "$PAIR_CODE" =~ ^ZA-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$ ]] \ + || fail "pair_code shape invalid: '$PAIR_CODE'" +[[ "$PAIR_DEEPLINK" == zeroauth://reg* ]] \ + || fail "deeplink shape invalid: '$PAIR_DEEPLINK'" +ok "session=$SESSION_ID pair_code=$PAIR_CODE" + +# ─── Step 1: pair device ────────────────────────────────────────── + +step "Pair device (POST /v1/registrations/pair-device)" +# Fingerprint: 16+ char opaque blob the server SHA-256s. Use a +# timestamp so concurrent smoke runs don't collide. +FINGERPRINT="smoke-$(date +%s)-fp-$(uuidgen 2>/dev/null || echo abcdef0123456789)" +PAIR_RES=$(curl -fsS -X POST "$SERVER/v1/registrations/pair-device" \ + -H "Content-Type: application/json" \ + -d "{\"pair_code\":\"$PAIR_CODE\",\"fingerprint\":\"$FINGERPRINT\",\"attestation_kind\":\"none\"}") + +DEVICE_ID=$(echo "$PAIR_RES" | jq -r .device_id) +ENROLL_CODE=$(echo "$PAIR_RES" | jq -r .next.code) +NEXT_STEP=$(echo "$PAIR_RES" | jq -r .next.step) + +[ "$NEXT_STEP" = "enroll" ] \ + || fail "expected next.step=enroll, got '$NEXT_STEP'" +[[ "$ENROLL_CODE" =~ ^ZA-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$ ]] \ + || fail "enroll_code shape invalid: '$ENROLL_CODE'" +[ "$DEVICE_ID" != "null" ] && [ -n "$DEVICE_ID" ] \ + || fail "device_id missing from pair-device response" +ok "device_id=$DEVICE_ID enroll_code=$ENROLL_CODE" + +# ─── Step 2: submit commitment ──────────────────────────────────── + +step "Submit commitment (POST /v1/registrations/submit-commitment)" +# Demo did + commitment shapes. The server only validates the regex +# at this layer; the cryptographic check happens on /complete. +COMMITMENT="0x$(printf 'a%.0s' {1..64})" +DID="did:zeroauth:face:smoke$(printf '%.0d' $(seq 1 8))" +COMMIT_RES=$(curl -fsS -X POST "$SERVER/v1/registrations/submit-commitment" \ + -H "Content-Type: application/json" \ + -d "{\"enroll_code\":\"$ENROLL_CODE\",\"did\":\"$DID\",\"commitment\":\"$COMMITMENT\",\"attestation_kind\":\"none\"}") + +VERIFY_CODE=$(echo "$COMMIT_RES" | jq -r .next.code) +CHALLENGE=$(echo "$COMMIT_RES" | jq -r .next.challenge_nonce) +NEXT_STEP=$(echo "$COMMIT_RES" | jq -r .next.step) + +[ "$NEXT_STEP" = "verify" ] \ + || fail "expected next.step=verify, got '$NEXT_STEP'" +[[ "$VERIFY_CODE" =~ ^ZA-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$ ]] \ + || fail "verify_code shape invalid: '$VERIFY_CODE'" +[[ "$CHALLENGE" =~ ^[0-9a-f]{32}$ ]] \ + || fail "challenge_nonce shape invalid: '$CHALLENGE'" +ok "verify_code=$VERIFY_CODE challenge=$CHALLENGE" + +# ─── Step 3: complete with STUB proof — expect 404 verify_failed ── + +step "Complete with stub proof (expect 404 verify_failed)" +HTTP=$(curl -sS -o /tmp/smoke-complete.json -w "%{http_code}" -X POST "$SERVER/v1/registrations/complete" \ + -H "Content-Type: application/json" \ + -d "$(cat </dev/null || echo "") + +if [ "$HTTP" = "404" ] && [ "$ERR" = "verify_failed" ]; then + ok "stub proof correctly rejected (HTTP $HTTP error=$ERR)" +else + fail "expected 404 verify_failed, got HTTP $HTTP error=$ERR — body in /tmp/smoke-complete.json" +fi + +# ─── Summary ────────────────────────────────────────────────────── + +echo +echo "${GREEN}✓ Smoke OK.${RESET} Four round-trips validated." +echo +echo "Real-proof verify needs the APK + a real device. Steps:" +echo " 1. cd android && ./gradlew :app:installDebug" +echo " 2. Open https://zeroauth.dev/dashboard/demo/registration" +echo " 3. Phone: Create a new account → Scan with camera → each of the three QRs" +echo " 4. Dashboard should land on 'Account created ✓' after QR3" +echo +echo "On failure of QR3 on a real device, capture logcat:" +echo " adb logcat | grep -E '(ZeroAuth|MobileProver)' > /tmp/prover.log" +echo " Then see docs/operations/three-qr-signup-deployment.md §'Step 5'."