Skip to content

Commit 28b83e6

Browse files
pRizzclaude
andcommitted
docs: add passkey registration security documentation
Document the IOTP-gated passkey enrollment flow, including the sequential security layers, challenge token properties, middleware enforcement, and edge cases. Add discoverability link from README. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cb59b62 commit 28b83e6

3 files changed

Lines changed: 223 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,8 @@ occ config show
363363
364364
## Authentication
365365
366+
Security details: `docs/security/passkey-registration.md` (IOTP bootstrap and passkey enrollment flow)
367+
366368
opencode-cloud uses **PAM (Pluggable Authentication Modules)** for authentication.
367369
368370
First boot path:
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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 |

packages/core/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,8 @@ occ config show
363363
364364
## Authentication
365365
366+
Security details: `docs/security/passkey-registration.md` (IOTP bootstrap and passkey enrollment flow)
367+
366368
opencode-cloud uses **PAM (Pluggable Authentication Modules)** for authentication.
367369
368370
First boot path:

0 commit comments

Comments
 (0)