|
| 1 | +# Passkey Registration Security (IOTP Bootstrap Flow) |
| 2 | + |
| 3 | +During first-time setup, opencode-cloud requires the user to verify an **Initial One-Time Password (IOTP)** printed in the container logs and then enroll a **WebAuthn passkey** before any protected functionality is accessible. This document explains the security mechanisms that gate passkey registration and how they interact. |
| 4 | + |
| 5 | +## Flow Overview |
| 6 | + |
| 7 | +``` |
| 8 | +User reads IOTP from container logs |
| 9 | + │ |
| 10 | + ▼ |
| 11 | +┌─────────────────────────────────────┐ |
| 12 | +│ POST /bootstrap/verify │ IOTP validated by privileged helper |
| 13 | +│ ─ HTTPS enforced │ Session created with: |
| 14 | +│ ─ CSRF header required │ bootstrapPending = true |
| 15 | +│ ─ Rate limited (3 / 15 min) │ bootstrapOtp = <verified OTP> |
| 16 | +└──────────────┬──────────────────────┘ |
| 17 | + │ redirect |
| 18 | + ▼ |
| 19 | +┌─────────────────────────────────────┐ |
| 20 | +│ GET /auth/passkey/setup │ Passkey enrollment UI |
| 21 | +│ ─ ?required=1 hides "Skip" button │ (cosmetic only; real gate is |
| 22 | +│ ─ If bootstrapPending && creds > 0 │ middleware, not query param) |
| 23 | +│ → clears pending, redirects │ |
| 24 | +└──────────────┬──────────────────────┘ |
| 25 | + │ user initiates enrollment |
| 26 | + ▼ |
| 27 | +┌─────────────────────────────────────┐ |
| 28 | +│ POST /passkey/register/options │ Server generates WebAuthn challenge |
| 29 | +│ ─ Session required │ Returns: |
| 30 | +│ ─ CSRF header required │ - WebAuthn registration options |
| 31 | +│ ─ HTTPS enforced │ - Signed JWT challenge token |
| 32 | +└──────────────┬──────────────────────┘ |
| 33 | + │ browser calls navigator.credentials.create() |
| 34 | + ▼ |
| 35 | +┌─────────────────────────────────────┐ |
| 36 | +│ POST /passkey/register/verify │ Server verifies attestation |
| 37 | +│ ─ Session required │ 1. Consume challenge token (single-use) |
| 38 | +│ ─ CSRF header required │ 2. Verify WebAuthn response |
| 39 | +│ ─ HTTPS enforced │ 3. Store credential |
| 40 | +│ │ 4. If bootstrapPending: |
| 41 | +│ │ ─ Retrieve session.bootstrapOtp |
| 42 | +│ │ ─ Call completeBootstrapOtp(otp) |
| 43 | +│ │ ─ Clear bootstrapPending on success |
| 44 | +└──────────────┬──────────────────────┘ |
| 45 | + │ |
| 46 | + ▼ |
| 47 | + Bootstrap complete |
| 48 | + Protected routes accessible |
| 49 | +``` |
| 50 | + |
| 51 | +## Security Layers |
| 52 | + |
| 53 | +| Layer | What it prevents | Source | |
| 54 | +|-------|-----------------|--------| |
| 55 | +| HTTPS enforcement | Credential interception, MitM attacks | `fork-auth/src/security/https-detection.ts` | |
| 56 | +| CSRF (`X-Requested-With` header) | Cross-site request forgery on all state-changing endpoints | `fork-auth/src/security/csrf.ts` | |
| 57 | +| Rate limiting | Brute-force IOTP guessing (3 attempts per 15 minutes) | `fork-auth/src/security/rate-limit.ts` | |
| 58 | +| Session-bound state | Ensures IOTP verification and passkey registration are tied to the same session | `opencode/src/session/user-session.ts` | |
| 59 | +| Challenge token replay prevention | Reusing a WebAuthn challenge for multiple registrations | `fork-auth/src/auth/passkey-challenge.ts` | |
| 60 | +| Middleware blocking | Accessing protected routes before passkey enrollment completes | `fork-auth/src/middleware/auth.ts` | |
| 61 | +| Bootstrap completion validation | Marking bootstrap as done without a valid IOTP + enrolled passkey | `fork-auth/src/auth/bootstrap.ts` | |
| 62 | + |
| 63 | +## Detailed Walkthrough |
| 64 | + |
| 65 | +### Step 1: IOTP Verification (`POST /bootstrap/verify`) |
| 66 | + |
| 67 | +**Source:** `fork-auth/src/routes/auth.ts` (the `/bootstrap/verify` handler) |
| 68 | + |
| 69 | +Before anything else, the endpoint enforces: |
| 70 | +- **HTTPS** (or localhost) via `shouldBlockInsecureLogin()`. |
| 71 | +- **Rate limiting** via `bootstrapRateLimiter()` (3 failed attempts per 15 minutes). |
| 72 | +- **CSRF** via `X-Requested-With` header check. |
| 73 | + |
| 74 | +The OTP is validated by calling `verifyBootstrapOtp(otp)`, which invokes the privileged `opencode-cloud-bootstrap` helper binary via `sudo -n`. The helper runs outside the web process and is the sole authority on whether the IOTP is valid and the bootstrap flow is active. |
| 75 | + |
| 76 | +On success: |
| 77 | +1. A session is created for the `opencoder` user via `UserSession.create()`. |
| 78 | +2. `UserSession.setBootstrapPending(session.id, otp)` sets two flags: |
| 79 | + - `bootstrapPending = true` — blocks all protected routes. |
| 80 | + - `bootstrapOtp = <the verified OTP>` — stored in session for later completion. |
| 81 | +3. A session cookie and CSRF cookie are set. |
| 82 | +4. The response includes `redirectTo: /auth/passkey/setup?required=1`. |
| 83 | + |
| 84 | +On failure the helper returns specific error codes (`otp_invalid`, `inactive`, `helper_error`) and the rate limiter records the failure. |
| 85 | + |
| 86 | +**Source:** `fork-auth/src/auth/bootstrap.ts` (`verifyBootstrapOtp`) |
| 87 | +**Source:** `opencode/src/session/user-session.ts` (`setBootstrapPending`) |
| 88 | + |
| 89 | +### Step 2: Passkey Setup Page (`GET /auth/passkey/setup`) |
| 90 | + |
| 91 | +**Source:** `fork-auth/src/routes/auth.ts` (the `/passkey/setup` handler) |
| 92 | + |
| 93 | +This endpoint serves the passkey enrollment UI. The `required` query parameter controls only whether a "Skip" button is shown in the UI: |
| 94 | + |
| 95 | +```typescript |
| 96 | +const required = session.bootstrapPending === true || c.req.query("required") === "1" |
| 97 | +// ... |
| 98 | +canSkip: !required, |
| 99 | +``` |
| 100 | + |
| 101 | +**Removing `?required=1` from the URL has no security impact.** If the user clicks "Skip", they are redirected to the app — but the auth middleware immediately redirects them back to `/auth/passkey/setup?required=1` because `session.bootstrapPending` is still `true` (see [Middleware Enforcement](#middleware-enforcement)). The user is stuck in a loop until they register a passkey. |
| 102 | + |
| 103 | +There is also an early-exit optimization: if `bootstrapPending` is true but the user already has registered credentials (e.g., from a retry), it clears the pending state and redirects to the app. |
| 104 | + |
| 105 | +### Step 3: WebAuthn Challenge Generation (`POST /passkey/register/options`) |
| 106 | + |
| 107 | +**Source:** `fork-auth/src/routes/auth.ts` (the `/passkey/register/options` handler) |
| 108 | +**Source:** `fork-auth/src/auth/passkey-challenge.ts` |
| 109 | + |
| 110 | +Requires an authenticated session, CSRF header, and HTTPS. Generates WebAuthn registration options via the `simplewebauthn` library and creates a signed challenge token. |
| 111 | + |
| 112 | +The challenge token is a JWT (HS256) containing: |
| 113 | +- `typ: "passkey_register"` — purpose binding. |
| 114 | +- `challenge` — the WebAuthn challenge bytes. |
| 115 | +- `rpID` / `origins` — relying party identity. |
| 116 | +- `username` / `ip` — optional bindings. |
| 117 | +- `jti` — unique token ID (UUID) for replay prevention. |
| 118 | +- `exp` — expiration (default 5 minutes). |
| 119 | + |
| 120 | +The token is signed with a server-held secret and returned to the client alongside the WebAuthn options. |
| 121 | + |
| 122 | +### Step 4: Passkey Registration Verification (`POST /passkey/register/verify`) |
| 123 | + |
| 124 | +**Source:** `fork-auth/src/routes/auth.ts` (the `/passkey/register/verify` handler) |
| 125 | + |
| 126 | +This is the critical security gate. The flow is: |
| 127 | + |
| 128 | +1. **Consume the challenge token** via `consumePasskeyChallengeToken()`: |
| 129 | + - Verifies JWT signature and expiration. |
| 130 | + - Checks the `jti` against an in-memory `used` map — if already consumed, returns `null`. |
| 131 | + - Optionally validates that the request IP matches the token's IP. |
| 132 | + - Marks the `jti` as used (preventing replay). |
| 133 | + |
| 134 | +2. **Verify the WebAuthn attestation** via `verifyRegistrationResponse()`: |
| 135 | + - Validates the authenticator's attestation against the original challenge. |
| 136 | + - Extracts the public key and credential metadata. |
| 137 | + |
| 138 | +3. **Store the credential** in persistent storage (survives server restarts). |
| 139 | + |
| 140 | +4. **If `bootstrapPending` is true** (the IOTP bootstrap flow): |
| 141 | + - Retrieve `session.bootstrapOtp`. If missing, return 401 (`bootstrap_state_invalid`). |
| 142 | + - Call `completeBootstrapOtp(otp)` to finalize with the privileged helper. |
| 143 | + - If the helper reports failure (and the code is not `inactive`), return 500 — the passkey is stored but bootstrap is not marked complete. |
| 144 | + - On success, call `UserSession.clearBootstrapPending(session.id)` to clear both `bootstrapPending` and `bootstrapOtp`. |
| 145 | + |
| 146 | +**Source:** `fork-auth/src/auth/bootstrap.ts` (`completeBootstrapOtp`) |
| 147 | + |
| 148 | +## Challenge Token Security |
| 149 | + |
| 150 | +**Source:** `fork-auth/src/auth/passkey-challenge.ts` |
| 151 | + |
| 152 | +Challenge tokens prevent several attacks: |
| 153 | + |
| 154 | +| Property | Mechanism | |
| 155 | +|----------|-----------| |
| 156 | +| **Signed** | HS256 JWT — cannot be forged without the server secret | |
| 157 | +| **Time-limited** | `exp` claim, default 5 minutes | |
| 158 | +| **Single-use** | `jti` tracked in an in-memory `Map`; second consumption returns `null` | |
| 159 | +| **Purpose-bound** | `typ` field distinguishes `passkey_auth` from `passkey_register` | |
| 160 | +| **IP-bound** (optional) | If `ip` is set in the token and the verifying request has a different IP, consumption fails | |
| 161 | +| **Pruned** | Expired JTIs are pruned on each `consume` call to prevent memory growth | |
| 162 | + |
| 163 | +## Middleware Enforcement |
| 164 | + |
| 165 | +**Source:** `fork-auth/src/middleware/auth.ts` |
| 166 | + |
| 167 | +The auth middleware runs on **all protected routes** and checks `session.bootstrapPending`: |
| 168 | + |
| 169 | +```typescript |
| 170 | +if (session.bootstrapPending) { |
| 171 | + if (isApiCall()) { |
| 172 | + return c.json({ error: "passkey_setup_required", message: "Passkey setup is required" }, 403) |
| 173 | + } |
| 174 | + return c.redirect("/auth/passkey/setup?required=1") |
| 175 | +} |
| 176 | +``` |
| 177 | + |
| 178 | +This means: |
| 179 | +- No protected API endpoint can be called while bootstrap is pending (403). |
| 180 | +- Browser requests are redirected to passkey setup. |
| 181 | +- The only way to clear `bootstrapPending` is to complete the full passkey registration flow, which requires `completeBootstrapOtp()` to succeed. |
| 182 | +- URL manipulation (removing `?required=1`, navigating directly to protected routes) does not bypass this check. |
| 183 | + |
| 184 | +## Edge Cases |
| 185 | + |
| 186 | +### Session expires during passkey setup |
| 187 | + |
| 188 | +Sessions are in-memory and have a sliding expiration window. If the session expires mid-flow, the user must restart from IOTP verification. The IOTP helper tracks completion state independently, so a stale session cannot be used to skip verification. |
| 189 | + |
| 190 | +### `completeBootstrapOtp()` fails after passkey is stored |
| 191 | + |
| 192 | +The passkey credential is persisted before bootstrap completion is attempted. If the helper fails, the endpoint returns a 500 error, and `bootstrapPending` remains `true`. The user can retry passkey registration. On the next visit to `/auth/passkey/setup`, the early-exit check detects existing credentials and clears the pending state (since the passkey already exists). |
| 193 | + |
| 194 | +### Missing `bootstrapOtp` in session |
| 195 | + |
| 196 | +If `session.bootstrapOtp` is somehow unset while `bootstrapPending` is true, the endpoint returns 401 (`bootstrap_state_invalid`). The user must restart from the login page. |
| 197 | + |
| 198 | +### Server restart during bootstrap |
| 199 | + |
| 200 | +Sessions are in-memory and lost on restart. The user must re-verify the IOTP. The helper's state is independent — if the IOTP was already completed, the helper returns `inactive` and the user needs `occ reset iotp` to generate a fresh one. |
| 201 | + |
| 202 | +### Concurrent bootstrap attempts |
| 203 | + |
| 204 | +Rate limiting (3 attempts / 15 min) constrains parallel IOTP guessing from the same IP. Each successful verification creates an independent session, but `completeBootstrapOtp()` is idempotent at the helper level — only the first completion succeeds; subsequent calls return `inactive`. |
| 205 | + |
| 206 | +## Key Source Files |
| 207 | + |
| 208 | +| File | Role | |
| 209 | +|------|------| |
| 210 | +| `packages/opencode/packages/fork-auth/src/routes/auth.ts` | Auth route handlers (bootstrap verify, passkey setup, register options/verify) | |
| 211 | +| `packages/opencode/packages/fork-auth/src/middleware/auth.ts` | `bootstrapPending` enforcement on all protected routes | |
| 212 | +| `packages/opencode/packages/fork-auth/src/auth/passkey-challenge.ts` | JWT challenge token creation and single-use consumption | |
| 213 | +| `packages/opencode/packages/fork-auth/src/auth/passkey.ts` | WebAuthn registration and authentication logic | |
| 214 | +| `packages/opencode/packages/fork-auth/src/auth/passkey-storage.ts` | Credential persistence | |
| 215 | +| `packages/opencode/packages/fork-auth/src/auth/bootstrap.ts` | IOTP helper communication (verify, complete, status) | |
| 216 | +| `packages/opencode/packages/opencode/src/session/user-session.ts` | Session state management (`bootstrapPending`, `bootstrapOtp`) | |
| 217 | +| `packages/opencode/packages/fork-auth/src/security/rate-limit.ts` | Rate limiting | |
| 218 | +| `packages/opencode/packages/fork-auth/src/security/csrf.ts` | CSRF protection | |
| 219 | +| `packages/opencode/packages/fork-auth/src/security/https-detection.ts` | HTTPS enforcement | |
0 commit comments