From 746004c46189d399bc55df7900dbc56d0235b934 Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 1 Mar 2026 06:05:26 +0100 Subject: [PATCH] feat: implement QR code authentication for tunnel access Adds ephemeral single-use QR tokens for passwordless tunnel login. Scanning the QR auto-authenticates; bare tunnel URL requires Basic Auth. Backend: - TunnelManager: 60s token rotation, 90s grace, rejection-sampled 6-char base62 short codes, Map-based O(1) lookup, SVG caching, global rate limit - Auth middleware: /q/ bypass, separate qrAuthFailures counter, enhanced AuthSessionRecord with device context (ip, ua, createdAt, method) - Routes: GET /q/:code (consume + cookie + redirect), POST /api/tunnel/qr/ regenerate, POST /api/auth/revoke, updated GET /api/tunnel/qr with cache - SSE: tunnel:qrRotated, tunnel:qrRegenerated, tunnel:qrAuthUsed events - Audit: qr_auth lifecycle log entries Frontend: - Auto-refresh QR via inline SVG in SSE (fallback fetch if absent) - 60s countdown indicator on QR badge - Regenerate QR button - QRLjacking detection toast with [Revoke All] action button (10s duration) - showToast enhanced with optional duration and action button support Fixes: - /api/logout now invalidates server-side session token (was only clearing browser cookie, leaving token valid for replay) Tests: 20 new tests in test/qr-auth.test.ts covering token lifecycle, bias check, rate limiting, SVG caching, and full server integration. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 17 +- docs/qr-auth-plan.md | 723 +++++++++++++++++++++++++++++ src/tunnel-manager.ts | 155 +++++++ src/types/lifecycle.ts | 3 +- src/web/middleware/auth.ts | 26 +- src/web/ports/auth-port.ts | 20 + src/web/ports/index.ts | 1 + src/web/public/api-client.js | 10 + src/web/public/app.js | 325 ++++++------- src/web/public/subagent-windows.js | 87 ++++ src/web/routes/session-routes.ts | 11 +- src/web/routes/system-routes.ts | 101 +++- src/web/server.ts | 36 +- test/qr-auth.test.ts | 345 ++++++++++++++ 14 files changed, 1671 insertions(+), 189 deletions(-) create mode 100644 docs/qr-auth-plan.md create mode 100644 src/web/ports/auth-port.ts create mode 100644 test/qr-auth.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 95de78e8..4bf60523 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,10 +161,10 @@ journalctl --user -u codeman-web -f | `src/prompts/index.ts` | Barrel export for all agent prompts | | `src/prompts/*.ts` | Agent prompts (research-agent, planner) | | `src/templates/claude-md.ts` | CLAUDE.md generation for new cases | -| `src/tunnel-manager.ts` | Manages cloudflared child process for Cloudflare tunnel remote access | +| `src/tunnel-manager.ts` | Manages cloudflared child process for Cloudflare tunnel + QR auth token rotation | | `src/cli.ts` | Command-line interface handlers | | `src/web/server.ts` | Fastify server setup, SSE at `/api/events`, delegates to route modules | -| `src/web/routes/*.ts` | 12 domain route modules (session, respawn, ralph, plan, etc.) — each exports `register*Routes()` | +| `src/web/routes/*.ts` | 13 domain route modules (session, respawn, ralph, plan, etc.) — each exports `register*Routes()` | | `src/web/ports/*.ts` | Port interfaces (SessionPort, EventPort, etc.) — route modules declare dependencies via intersection types | | `src/web/middleware/auth.ts` | Auth middleware: Basic Auth, session cookies, rate limiting, security headers, CORS | | `src/web/route-helpers.ts` | Shared helper utilities for route modules | @@ -244,7 +244,7 @@ Re-exported via `src/utils/index.ts`. Key exports: |------|---------| | `src/web/public/index.html` | HTML entry point with inline critical CSS and async vendor loading | | `src/web/public/constants.js` | Shared constants, timing values, Z-index layers, Web Push utilities | -| `src/web/public/api-client.js` | API fetch wrapper (`_api`, `_apiJson`, `_apiPost`) | +| `src/web/public/api-client.js` | API fetch wrapper (`_api`, `_apiJson`, `_apiPost`, `_apiPut`) | | `src/web/public/mobile-handlers.js` | `MobileDetection`, `KeyboardHandler`, `SwipeHandler` objects | | `src/web/public/voice-input.js` | `DeepgramProvider`, `VoiceInput` objects for speech-to-text | | `src/web/public/notification-manager.js` | `NotificationManager` class (5-layer notification system) | @@ -286,8 +286,10 @@ The frontend is split across multiple vanilla JS modules (extracted from the ori ### Security - **HTTP Basic Auth**: Optional via `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` env vars -- **Session cookies**: After Basic Auth, a 24h session cookie (`codeman_session`) is issued so credentials aren't re-sent on every request. Active sessions auto-extend. SSE works via same-origin cookie (`EventSource` can't send custom headers). -- **Rate limiting**: 10 failed auth attempts per IP triggers 429 rejection (15-minute decay window). Manual `StaleExpirationMap` counter — no `@fastify/rate-limit` needed. +- **QR Auth**: Single-use ephemeral 6-char tokens (60s TTL, 90s grace) for tunnel login without typing passwords. `TunnelManager` rotates tokens, serves cached SVG at `GET /api/tunnel/qr`, validates at `GET /q/:code`. Separate per-IP rate limit (10/15min) + global path limit (30/min). Desktop notification on consumption (QRLjacking detection). Audit logged as `qr_auth` in `session-lifecycle.jsonl`. See `docs/qr-auth-plan.md`. +- **Session cookies**: After Basic Auth or QR Auth, a 24h session cookie (`codeman_session`) is issued so credentials aren't re-sent on every request. Active sessions auto-extend. SSE works via same-origin cookie (`EventSource` can't send custom headers). Sessions store device context (IP + User-Agent) for audit via `AuthSessionRecord`. +- **Session revocation**: `POST /api/auth/revoke` revokes individual sessions or all sessions. +- **Rate limiting**: 10 failed auth attempts per IP triggers 429 rejection (15-minute decay window). Manual `StaleExpirationMap` counter — no `@fastify/rate-limit` needed. QR auth has its own separate rate limiter. - **Hook bypass**: `/api/hook-event` POST is exempt from auth — Claude Code hooks curl this from localhost and can't present credentials. Safe: validated by `HookEventSchema`, only triggers broadcasts. - **CORS**: Restricted to localhost only - **Security headers**: X-Content-Type-Options, X-Frame-Options, CSP; HSTS if HTTPS @@ -309,6 +311,7 @@ The frontend is split across multiple vanilla JS modules (extracted from the ori | Hooks | `hook:{eventName}` (dynamic) | Claude Code hook events | | Plan | `plan:started/progress/completed/cancelled/subagent` | Plan orchestration | | Mux | `mux:created/killed/died/statsUpdated` | tmux process monitor | +| Tunnel | `tunnel:qrRotated/qrRegenerated/qrAuthUsed` | QR token lifecycle | | Image | `image:detected` | Screenshot detection | ### API Route Categories @@ -328,6 +331,7 @@ The frontend is split across multiple vanilla JS modules (extracted from the ori | Mux | `/api/mux-sessions` | 5 | tmux management, stats | | Scheduled | `/api/scheduled` | 4 | CRUD for scheduled runs | | Push | `/api/push` | 4 | VAPID key, subscribe, update prefs, unsubscribe | +| Auth | `/api/auth`, `/q/:code` | 3 | QR validation, session revocation | | Teams | `/api/teams` | 2 | list teams, get team tasks | ## Adding Features @@ -490,6 +494,7 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base | **Voice input** | `docs/voice-input-plan.md` | | **Improvement roadmaps** | `docs/respawn-improvement-plan.md`, `docs/ralph-improvement-plan.md`, `docs/plan-improvement-roadmap.md` | | **Background keystroke forwarding** | `docs/background-keystroke-forwarding-merged-plan.md` | +| **QR auth design** | `docs/qr-auth-plan.md` | | **Run summary** | `docs/run-summary-plan.md` | Additional design docs and investigation reports are in the `docs/` directory. @@ -572,7 +577,7 @@ journalctl --user -u codeman-tunnel -f ### Auth Flow -1. First request → browser shows Basic Auth prompt (username: `admin` or `CODEMAN_USERNAME`) +1. First request → browser shows Basic Auth prompt (username: `admin` or `CODEMAN_USERNAME`), or scan QR code from tunnel settings panel 2. On success → server issues `codeman_session` HttpOnly cookie (24h TTL, auto-extends on activity) 3. Subsequent requests → cookie authenticates silently (no more prompts) 4. SSE works automatically — `EventSource` sends same-origin cookies diff --git a/docs/qr-auth-plan.md b/docs/qr-auth-plan.md new file mode 100644 index 00000000..9e2f419c --- /dev/null +++ b/docs/qr-auth-plan.md @@ -0,0 +1,723 @@ +# QR Code Authentication Plan + +> Ephemeral, single-use auth tokens embedded in the tunnel QR code — scan to auto-authenticate, while the bare tunnel URL stays password-protected. + +## Problem + +When the Cloudflare tunnel is active, anyone who discovers the `*.trycloudflare.com` URL can access Codeman (they just need the Basic Auth password, or if no password is set, full open access). The QR code currently encodes the raw tunnel URL — it provides no additional security. We want: + +1. **Scanning the QR code** → seamless, instant access (no password prompt) +2. **Having only the URL** → blocked by Basic Auth (no access without credentials) + +## Design + +### Core Concept: Ephemeral Single-Use QR Tokens + +The server maintains a rotating pool of short-lived, single-use tokens. The QR code encodes a short URL containing a lookup code that maps to the real token server-side. When scanned, the server validates the token, atomically consumes it, issues a session cookie, and redirects to `/`. The token is **not** the password — it's a separate, independent, ephemeral authentication pathway. + +``` +Desktop → displays QR (auto-refreshes every 60s via SSE) +QR Code → https://abc-xyz.trycloudflare.com/q/Xk9mQ3 +Phone → scans, GET /q/Xk9mQ3 +Server → looks up short code via Map (hash-based, timing-safe) + → finds token record → validates TTL + → atomically consumes token (single-use) + → issues codeman_session cookie + → 302 redirect to / + → SSE push: new QR with embedded SVG for desktop display + → desktop toast: "Device [IP] authenticated via QR" + → audit log entry to session-lifecycle.jsonl +User → lands on app, fully authenticated +``` + +Someone who only has `https://abc-xyz.trycloudflare.com/` gets the standard Basic Auth prompt. + +### Token Properties + +| Property | Value | +|----------|-------| +| Length | 32 bytes (256 bits entropy) | +| Generation | `crypto.randomBytes(32).toString('hex')` | +| Short code | 6 chars base62, rejection-sampled (no modulo bias) | +| Short code derivation | Independent random generation (not derived from token) | +| Storage | In-memory `Map` (no disk persistence) | +| TTL | 60 seconds (auto-rotation via timer), 90s grace for previous token | +| Effective window | Up to 90 seconds for the previous token (documented, not hidden) | +| Usage | **Single-use** — atomically consumed on first valid scan | +| URL format | Short code in path (`/q/Xk9mQ3`), not query params | +| URL length | ~53-56 chars total — targets QR Version 4 (33x33) for fast scanning | +| Scope | Only valid when `CODEMAN_PASSWORD` is set (no point without auth) | +| Lookup | `Map.get()` — hash-based O(1), no timing side-channel | + +### Why This Design? + +**Why not embed the password directly?** +- Password would appear in browser history, Cloudflare edge logs, and URL bars +- Password can't be rotated independently from QR access + +**Why not a long-lived multi-use token? (original design)** +- A static token is functionally a second password — if the QR image leaks (screenshot shared, shoulder surfing, Cloudflare logs), the attacker has permanent access +- The USENIX Security 2025 paper ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) found 47 of the top-100 websites vulnerable due to exactly this pattern — missing single-use enforcement and long-lived tokens were 2 of the 6 critical design flaws identified + +**Why short codes in the URL path instead of query params?** +- Query params (`?t=TOKEN`) leak into browser history, address bar, `Referer` headers, and Cloudflare edge logs +- Path-based short codes (`/q/Xk9mQ3`) are opaque references — the real token never appears in URLs +- Short codes are 6-char base62 (62^6 = 56.8 billion combinations), sufficient for lookup since they're backed by the full 256-bit token for validation and rate-limited to 10 attempts/IP +- The short `/q/` path (vs `/qr-auth/`) saves 7 bytes, helping keep the QR at Version 4 (33x33 modules) instead of Version 5 (37x37) — faster scanning on budget phones + +## Auth Flow Diagram + +``` +┌─────────────┐ scan QR ┌──────────────────────────────────────┐ +│ Mobile │ ────────────→ │ GET /q/Xk9mQ3 │ +│ Device │ │ │ +└─────────────┘ │ 1. Auth middleware sees /q/ │ + │ → skips Basic Auth check │ + │ 2. Route handler: Map.get(shortCode) │ + │ → hash-based lookup (timing-safe) │ + │ 3. Checks TTL (90s grace for prev) │ + │ → token not expired? │ + │ 4. Checks consumed flag │ + │ → not already used? │ + │ 5. Atomically marks token consumed │ + │ 6. Issues codeman_session cookie │ + │ 7. 302 redirect to / │ + │ 8. Audit log → session-lifecycle.jsonl│ + │ 9. SSE push: tunnel:qrRegenerated │ + │ → desktop refreshes QR (SVG inline)│ + │ 10. Desktop toast: "Device auth'd" │ + └──────────────────────────────────────┘ + +┌─────────────┐ replay URL ┌──────────────────────────────────────┐ +│ Attacker │ ────────────→ │ GET /q/Xk9mQ3 │ +│ (stale code) │ │ │ +└─────────────┘ │ 1. Map.get(shortCode) → not found │ + │ OR token consumed OR expired │ + │ 2. Increment QR rate limit counter │ + │ (separate from Basic Auth counter) │ + │ 3. 401 Unauthorized │ + └──────────────────────────────────────┘ + +┌─────────────┐ URL only ┌──────────────────────────────────────┐ +│ Attacker │ ────────────→ │ GET / │ +│ (no token) │ │ │ +└─────────────┘ │ 1. Auth middleware checks cookie │ + │ → no cookie │ + │ 2. Checks Basic Auth header │ + │ → no header │ + │ 3. Returns 401 + WWW-Authenticate │ + │ → Browser shows password popup │ + └──────────────────────────────────────┘ +``` + +## Implementation + +### 1. Token Manager — `src/tunnel-manager.ts` + +Add a `QrTokenRecord` type and token rotation logic to `TunnelManager`. The token rotates every 60 seconds. A consumed token is immediately replaced. Up to 2 tokens can be valid simultaneously (current + previous, to handle the race where someone scans right as rotation happens). The previous token has a 90s grace period (not a full extra 60s — only enough to cover the scan-during-rotation race). + +**Design decisions from security review:** +- **Map-based lookup** (not array scan) — `Map.get()` uses hash-based O(1) lookup, eliminating timing side-channels from string comparison +- **Rejection sampling** for short codes — avoids modulo bias (`256 % 62 != 0` gives 25% overrepresentation for first 6 charset chars) +- **SVG cache** — stores generated QR SVG per rotation cycle to avoid regenerating on every `/api/tunnel/qr` poll +- **Separate rate limit counter** — QR auth failures tracked independently from Basic Auth failures + +```typescript +import { randomBytes } from 'node:crypto'; + +interface QrTokenRecord { + token: string; // 64 hex chars (256 bits) + shortCode: string; // 6 chars base62 (for URL path) + createdAt: number; // Date.now() + consumed: boolean; // single-use flag +} + +const QR_TOKEN_TTL_MS = 60_000; // 60 seconds +const QR_TOKEN_GRACE_MS = 90_000; // 90s grace for previous token (scan-during-rotation) +const SHORT_CODE_LENGTH = 6; +const QR_RATE_LIMIT_MAX = 30; // global rate limit across all IPs +const QR_RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute window + +/** Rejection-sampled short code generation — no modulo bias */ +function generateShortCode(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const maxUnbiased = 248; // largest multiple of 62 that fits in a byte (248 = 62 * 4) + const result: string[] = []; + while (result.length < SHORT_CODE_LENGTH) { + const [byte] = randomBytes(1); + if (byte < maxUnbiased) result.push(chars[byte % 62]); + // else: discard and re-draw (rejection sampling) + } + return result.join(''); +} + +export class TunnelManager extends EventEmitter { + // Map-based lookup: shortCode → QrTokenRecord (timing-safe, no string comparison) + private qrTokensByCode = new Map(); + private currentShortCode: string | null = null; + private rotationTimer: ReturnType | null = null; + + // SVG cache — regenerated only on token rotation, not per request + private cachedQrSvg: { shortCode: string; svg: string } | null = null; + + // Global rate limit counter (separate from Basic Auth rate limiting) + private qrAttemptCount = 0; + private qrRateLimitResetTimer: ReturnType | null = null; + + constructor() { + super(); + this.rotateToken(); + this.rotationTimer = setInterval(() => this.rotateToken(), QR_TOKEN_TTL_MS); + this.qrRateLimitResetTimer = setInterval(() => { this.qrAttemptCount = 0; }, QR_RATE_LIMIT_WINDOW_MS); + } + + private rotateToken(): void { + const record: QrTokenRecord = { + token: randomBytes(32).toString('hex'), + shortCode: generateShortCode(), + createdAt: Date.now(), + consumed: false, + }; + + // Evict expired tokens from the Map + const now = Date.now(); + for (const [code, rec] of this.qrTokensByCode) { + if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) { + this.qrTokensByCode.delete(code); + } + } + + this.qrTokensByCode.set(record.shortCode, record); + this.currentShortCode = record.shortCode; + this.cachedQrSvg = null; // invalidate SVG cache + this.emit('qrTokenRotated'); + } + + /** Get the current (newest) token's short code for QR URL */ + getCurrentShortCode(): string | undefined { + return this.currentShortCode ?? undefined; + } + + /** Get cached QR SVG, regenerating only if the short code changed */ + async getQrSvg(tunnelUrl: string): Promise { + const code = this.currentShortCode; + if (!code) throw new Error('No QR token available'); + if (this.cachedQrSvg?.shortCode === code) return this.cachedQrSvg.svg; + const QRCode = require('qrcode'); + const svg = await QRCode.toString(`${tunnelUrl}/q/${code}`, { type: 'svg', margin: 2, width: 256 }); + this.cachedQrSvg = { shortCode: code, svg }; + return svg; + } + + /** + * Validate and atomically consume a token by short code. + * Returns { success, ip?, ua? } for audit logging on success. + * Map.get() is hash-based — no timing side-channel from string comparison. + */ + consumeToken(shortCode: string): boolean { + // Global rate limit (across all IPs) + if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false; + this.qrAttemptCount++; + + const record = this.qrTokensByCode.get(shortCode); + if (!record) return false; + if (record.consumed) return false; + + const now = Date.now(); + if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false; + + // Atomic consume (single-threaded JS = no race) + record.consumed = true; + // Immediately rotate so desktop gets a fresh QR + this.rotateToken(); + this.emit('qrTokenRegenerated'); + return true; + } + + /** Force-regenerate (manual revocation via API) */ + regenerateQrToken(): void { + // Invalidate all existing tokens + this.qrTokensByCode.clear(); + this.currentShortCode = null; + this.rotateToken(); + this.emit('qrTokenRegenerated'); + } + + stopRotation(): void { + if (this.rotationTimer) { + clearInterval(this.rotationTimer); + this.rotationTimer = null; + } + if (this.qrRateLimitResetTimer) { + clearInterval(this.qrRateLimitResetTimer); + this.qrRateLimitResetTimer = null; + } + } +} +``` + +### 2. Auth Middleware Bypass — `src/web/middleware/auth.ts` + +Add `/q/` to the bypass list (same pattern as `/api/hook-event`). The route handler itself handles token validation and rate limiting. + +```typescript +// In the onRequest hook, add before Basic Auth check: +if (req.url.startsWith('/q/')) { + done(); // Let the route handler deal with token validation + return; +} +``` + +**Important**: Unlike `/api/hook-event` (localhost-only), `/q/` must be reachable from any IP (remote devices scan the QR). Rate limiting is handled by two independent mechanisms: +1. **Per-IP rate limit** — reuses the `authFailures` StaleExpirationMap (10 attempts/IP/15min), but tracked via a **separate counter** from Basic Auth failures (so a user who fat-fingers their password doesn't burn their QR attempts) +2. **Global path rate limit** — `TunnelManager.qrAttemptCount` caps total QR attempts to 30/minute across all IPs, defending against distributed brute force + +### 3. Auto-Auth Route — `src/web/routes/system-routes.ts` + +Add `GET /q/:code` as a top-level route (not under `/api/`): + +```typescript +app.get('/q/:code', async (req, reply) => { + const shortCode = (req.params as { code: string }).code; + const authPassword = process.env.CODEMAN_PASSWORD; + + // No point if auth isn't enabled + if (!authPassword) { + return reply.redirect('/'); + } + + // Per-IP rate limit (separate counter from Basic Auth failures) + const clientIp = req.ip; + const qrFailures = ctx.authState.qrAuthFailures?.get(clientIp) ?? 0; + if (qrFailures >= 10) { + return reply.code(429).send('Too Many Requests'); + } + + // Validate and atomically consume the token + // consumeToken() also checks the global rate limit (30/min across all IPs) + if (!shortCode || !ctx.tunnelManager.consumeToken(shortCode)) { + ctx.authState.qrAuthFailures?.set(clientIp, qrFailures + 1); + return reply.code(401).send('Invalid or expired QR code'); + } + + // Issue session cookie (same as Basic Auth success path) + const sessionToken = randomBytes(32).toString('hex'); + const clientUA = req.headers['user-agent'] ?? ''; + ctx.authState.authSessions?.set(sessionToken, { + ip: clientIp, + ua: clientUA, + createdAt: Date.now(), + }); + ctx.authState.qrAuthFailures?.delete(clientIp); + + // Audit log — write to session-lifecycle.jsonl for forensic analysis + ctx.lifecycleLog?.append({ + event: 'qr_auth', + ip: clientIp, + ua: clientUA, + timestamp: Date.now(), + shortCodePrefix: shortCode.slice(0, 3) + '***', // partial for privacy + }); + + reply.setCookie(AUTH_COOKIE_NAME, sessionToken, { + httpOnly: true, + secure: ctx.https, + sameSite: 'lax', + maxAge: 86400, // 24h + path: '/', + }); + + // Broadcast auth notification — desktop sees who authenticated (QRLjacking detection) + broadcast('tunnel:qrAuthUsed', { + ip: clientIp, + ua: clientUA, + timestamp: Date.now(), + }); + + return reply.redirect('/'); +}); +``` + +### 4. Update QR Code URL — `src/web/routes/system-routes.ts` + +Modify `/api/tunnel/qr` to encode the short-code URL. Uses the `TunnelManager.getQrSvg()` cache — SVG is regenerated only when the token rotates, not on every request. + +```typescript +app.get('/api/tunnel/qr', async (_req, reply) => { + const url = ctx.tunnelManager.getUrl(); + if (!url) { + return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running')); + } + + const authPassword = process.env.CODEMAN_PASSWORD; + + // If auth is enabled, use the cached SVG with embedded short code + if (authPassword) { + const svg = await ctx.tunnelManager.getQrSvg(url); + return { svg, authEnabled: true }; + } + + // No auth — just encode the raw tunnel URL + const QRCode = require('qrcode'); + const svg = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 }); + return { svg, authEnabled: false }; +}); +``` + +### 5. Token Regeneration Endpoint — `src/web/routes/system-routes.ts` + +Manual revocation — invalidates ALL existing tokens and creates a fresh one: + +```typescript +app.post('/api/tunnel/qr/regenerate', async () => { + ctx.tunnelManager.regenerateQrToken(); + return { success: true }; +}); +``` + +### 6. Frontend Updates — `src/web/public/app.js` + +#### QR Overlay Changes + +- **Auto-refresh via inline SVG**: Listen for `tunnel:qrRotated` SSE events which now include the SVG directly in the payload — no extra HTTP fetch needed, sub-50ms refresh on desktop. +- **Countdown indicator**: Small "expires in Xs" text under the QR that counts down from 60. Reassures the user the QR is live and not stale. +- **Regenerate button**: "Regenerate QR" button. Calls `POST /api/tunnel/qr/regenerate` — SSE event delivers the new SVG. +- **Auth badge**: Lock icon or "Single-use auth" label when auth is active. +- **URL display**: Show the raw tunnel URL (not the auth URL) for manual copy — users who copy the URL authenticate via Basic Auth. The QR is the fast path. +- **Auth notification toast**: When `tunnel:qrAuthUsed` fires, show a 10-second toast: "Device [IP] authenticated via QR (Safari). Not you? [Revoke]". This is the primary QRLjacking detection mechanism (USENIX Flaw-5). + +```javascript +// Auto-refresh QR on rotation — SVG is inline in the event payload +addListener('tunnel:qrRotated', (data) => { + if (data.svg) { + updateQrDisplay(data.svg); // direct DOM update, no fetch + } else { + refreshTunnelQR(); // fallback: fetch from API + } +}); + +// Also refresh on manual regeneration +addListener('tunnel:qrRegenerated', (data) => { + if (data.svg) { + updateQrDisplay(data.svg); + } else { + refreshTunnelQR(); + } +}); + +// QRLjacking detection — notify desktop user when QR is consumed +addListener('tunnel:qrAuthUsed', (data) => { + showNotificationToast( + `Device authenticated via QR (${parseUAFamily(data.ua)}, ${data.ip}). Not you?`, + { + duration: 10000, + action: { label: 'Revoke', onClick: () => revokeAllSessions() }, + } + ); +}); + +// In showTunnelQR(), after fetching /api/tunnel/qr: +if (data.authEnabled) { + const badge = document.createElement('div'); + badge.textContent = 'Single-use auth \u00b7 refreshes every 60s'; + badge.style.cssText = 'margin-top:8px;font-size:11px;color:var(--text-secondary)'; + container.parentElement.appendChild(badge); +} +``` + +#### Welcome Screen QR + +Same auto-refresh behavior applies to `_updateWelcomeTunnelBtn()` — the QR is fetched from `/api/tunnel/qr` so token embedding happens automatically. + +### 7. SSE Events + +Three events for the frontend. QR rotation events embed the SVG directly in the payload to eliminate an extra HTTP fetch — the desktop gets the new QR in a single SSE push (~2-5KB SVG, well within SSE limits). + +```typescript +// In server.ts, listen for tunnelManager events: + +// Auto-rotation every 60s — desktop refreshes QR silently (SVG inline) +tunnelManager.on('qrTokenRotated', async () => { + const url = tunnelManager.getUrl(); + if (url && process.env.CODEMAN_PASSWORD) { + const svg = await tunnelManager.getQrSvg(url); + broadcast('tunnel:qrRotated', { svg }); + } else { + broadcast('tunnel:qrRotated', {}); + } +}); + +// Manual regeneration or post-consumption — desktop refreshes QR (SVG inline) +tunnelManager.on('qrTokenRegenerated', async () => { + const url = tunnelManager.getUrl(); + if (url && process.env.CODEMAN_PASSWORD) { + const svg = await tunnelManager.getQrSvg(url); + broadcast('tunnel:qrRegenerated', { svg }); + } else { + broadcast('tunnel:qrRegenerated', {}); + } +}); + +// QR auth consumed — desktop shows notification toast (QRLjacking detection) +// Note: this is broadcast from the route handler, not tunnelManager +// Event: tunnel:qrAuthUsed { ip, ua, timestamp } +``` + +### 8. Session Cookie Binding & Revocation + +Enhance session records to include device context for audit purposes. The UA is stored for **logging only** — not for blocking. + +**Why no UA-family blocking (`majorUAChanged`)?** Security review found this is security theater: +- UA strings are trivially spoofable by any attacker who can steal a cookie +- Chrome UA reduction (2022+) makes family detection unreliable +- Mobile WebView → browser switches trigger false positives on the same device +- HttpOnly + Secure + SameSite=lax + 24h TTL already protect against cookie theft +- The attacker who can exfiltrate a cookie can also replay the exact UA + +Instead, provide **manual session revocation** as the active defense: + +```typescript +// Session record stores device context for audit logging (not blocking): +ctx.authState.authSessions?.set(sessionToken, { + ip: clientIp, + ua: req.headers['user-agent'] ?? '', + createdAt: Date.now(), + method: 'qr', // 'qr' | 'basic' — tracks how session was created +}); + +// Manual revocation endpoint — kill specific session or all sessions +app.post('/api/auth/revoke', async (req, reply) => { + const { sessionToken: target } = req.body as { sessionToken?: string }; + if (target) { + ctx.authState.authSessions?.delete(target); + } else { + // Revoke all sessions (nuclear option) + ctx.authState.authSessions?.clear(); + } + return { success: true }; +}); +``` + +**Note**: This is a breaking type change. The `AuthState` interface must be updated from `StaleExpirationMap` (token → clientIp) to `StaleExpirationMap`. All session validation code in `auth.ts` must be updated simultaneously. + +### 9. Cleanup — `src/tunnel-manager.ts` + +Stop the rotation timer in the `stop()` method: + +```typescript +stop(): void { + this.stopRotation(); + // ... existing cleanup +} +``` + +## Security Analysis + +### Threat Model + +| Threat | Attack Vector | Mitigation | Residual Risk | +|--------|--------------|------------|---------------| +| **QR screenshot shared** | Attacker gets image of QR code | Single-use: token consumed on first scan. 60s TTL: expired by the time attacker tries. Desktop toast notification alerts user if someone else scans. | If attacker scans faster than legitimate user (~seconds), they win the race. Low risk: requires physical proximity + speed. User sees notification and can revoke. | +| **Cloudflare edge logs** | Cloudflare logs the full URL path | Short code is opaque (6-char lookup key), not the real token. Single-use: replaying from logs always fails. 60s TTL (90s grace): expired before log review. `trycloudflare.com` quick tunnels have no customer-accessible logging controls — the privacy implications are inherent to using free quick tunnels. | Cloudflare has TLS termination access regardless. Ephemeral short codes are far less valuable than a permanent token. | +| **Brute force short code** | Attacker guesses `/q/XXXXXX` | Per-IP rate limiting (10/IP/15min) + global path rate limit (30/min across all IPs). 62^6 = 56.8 billion combinations. Only ~2 valid codes at any time. | Infeasible: expected guesses to hit = ~2.8×10^10, rate limits block well before. | +| **Replay attack** | Reuse a previously valid URL | Single-use consumption + 60s TTL (90s grace). Old codes always 401. | None — replay is impossible by design. | +| **QRLjacking** | Attacker displays your QR on phishing site | No companion app = limited mitigation. However: 60s rotation means attacker must relay in real-time. Desktop toast notification ("Device [IP] authenticated via QR. Not you? [Revoke]") provides real-time detection. Self-hosted single-user context makes phishing implausible. | Theoretical risk for multi-user deployments. Mitigated by notification toast for single-user. Note: Signal's linked-device QR flow was exploited by Russian state actors (UNC5792/Sandworm) via quishing in 2025 — but that targeted a multi-user messaging platform, not a self-hosted dev tool. | +| **Session cookie theft** | XSS or network sniffing steals cookie | HttpOnly + Secure flags. SameSite=lax prevents CSRF. 24h TTL limits exposure window. Manual revocation via `/api/auth/revoke`. | Standard web cookie risks apply. Mitigated by security headers (CSP, etc.). | +| **Token in server logs** | Access log captures URL path | Log `/q/*` with short code masked or omitted. Configure Fastify logger to redact `/q/` paths. | Path still appears in server access logs (mitigated by masking). | +| **Timing attack** | Measure response time to leak short code | Map-based lookup (`Map.get()`) — hash-based O(1), no character-by-character timing leak. No string comparison in the hot path. | None — timing side channel eliminated by design. | +| **Token not in query params** | N/A (this is a mitigation) | Short code in URL path avoids browser history, Referer headers, and address bar exposure. | Path still appears in server access logs (mitigated by masking). | +| **Distributed brute force** | Multiple IPs guess codes simultaneously | Global rate limit (30/min total across all IPs) in addition to per-IP limit. | Infeasible given keyspace. Global limit prevents botnet-scale attempts. | +| **CSRF on regenerate** | Cross-origin POST to `/api/tunnel/qr/regenerate` | SameSite=lax cookies are NOT sent with cross-origin POST requests, providing CSRF protection. Endpoint requires authenticated session. | Verify SameSite=lax behavior through cloudflared tunnel. | + +### USENIX Security 2025 Flaw Coverage + +The [Zhang et al. paper](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) (USENIX Security 2025, 47 of top-100 websites vulnerable, 42 CVEs) identified 6 critical design flaws. Coverage: + +| USENIX Flaw | Status | Implementation | +|-------------|--------|----------------| +| Flaw-1: Missing single-use enforcement | **Fixed** | Atomic `consumed` flag, Map-based lookup | +| Flaw-2: Long-lived tokens | **Fixed** | 60s TTL, 90s grace, auto-rotation | +| Flaw-3: Predictable QrId generation | **Fixed** | `crypto.randomBytes(32)` — 256-bit entropy, rejection-sampled short codes | +| Flaw-4: Client-side QrId generation | **Fixed** | Server-side generation only | +| Flaw-5: Missing status notification | **Fixed** | Desktop toast notification via `tunnel:qrAuthUsed` SSE event. Shows device IP/UA with [Revoke] button. | +| Flaw-6: Inadequate session binding | **Partial** | IP + UA stored for audit. No cryptographic channel binding (requires companion app / FIDO2 — overkill for single-user). Manual revocation as active defense. | + +### Industry Comparison + +| Platform | Model | How This Plan Compares | +|----------|-------|----------------------| +| **Discord** | Long-lived session token, no confirmation, repeatedly exploited via QRLjacking | **Better** — single-use + TTL + notification toast | +| **WhatsApp Web** | Pre-authenticated phone confirms "Link device?", ~60s rotation | **Comparable** rotation model; missing WhatsApp's explicit confirmation prompt (acceptable: single-user, no account selection) | +| **Signal** | Ephemeral public key in QR, E2E encrypted channel via Signal protocol | **Below** — no cryptographic channel binding. Note: Signal's QR flow was exploited by state actors in 2025 despite stronger crypto, showing that protocol strength alone doesn't prevent social engineering. | +| **1Password** | Noise framework E2E channel, post-quantum pre-shared keys, confirmation codes | **Below** — but 1Password is a credential manager with different threat model. Overkill for a dev tool. | +| **FIDO2 CTAP 2.2** | BLE proximity + cryptographic binding + biometric verification | **Below** — but requires BLE stack, FIDO server, and companion authenticator. Completely inappropriate here. | + +### Comparison to Prior Design + +| Property | Original Plan | Current Plan | +|----------|--------------|--------------| +| Token TTL | Infinite (until restart) | 60 seconds (90s grace for previous token) | +| Reuse | Multi-use (same QR works forever) | Single-use (consumed atomically on first scan) | +| Secret in URL | Query param (`?t=64-char-hex`) | Opaque short code in path (`/q/Xk9mQ3`) | +| Leak impact | Permanent access until manual revoke | Worthless after first use or 90s, whichever comes first | +| Desktop QR refresh | Manual only | Auto-refresh every 60s via SSE with inline SVG | +| Session binding | IP only | IP + UA stored for audit (not blocking). Manual revocation endpoint. | +| Auth notification | None | Desktop toast: "Device [IP] authenticated via QR. Not you? [Revoke]" | +| Audit logging | None | `session-lifecycle.jsonl` entry on every QR auth event | +| Rate limiting | Per-IP only, shared with Basic Auth | Per-IP (separate counter) + global path limit (30/min) | +| Short code generation | Modulo-biased | Rejection-sampled (no bias) | +| Short code lookup | Array scan (timing leak) | Map-based O(1) (timing-safe) | +| Connect latency | ~50ms (localhost only) | ~150-300ms through Cloudflare tunnel (honest estimate) | + +### What This Does NOT Protect Against + +- **FIDO2/passkey-level phishing resistance**: Would require BLE proximity verification and cryptographic channel binding. Overkill for a self-hosted single-user dev tool. The FIDO2 CTAP 2.2 hybrid transport is the gold standard but requires BLE hardware and a companion authenticator. +- **Compromised phone**: If the attacker has physical access to the phone that scans, no QR scheme helps. +- **Compromised Cloudflare tunnel**: Cloudflare terminates TLS and can inspect all traffic. This is inherent to using `trycloudflare.com` quick tunnels — use `--https` for end-to-end encryption if this matters. +- **State-sponsored quishing**: Sophisticated attackers could create convincing phishing pages that relay the QR in real-time. The 60s rotation and desktop notification toast mitigate this for the single-user case, but a dedicated attacker with social engineering could theoretically succeed within the TTL window. + +### Standards Compliance Note + +This design is **inspired by but does not conform to** [OASIS SQRAP v1.0](https://docs.oasis-open.org/esat/sqrap/v1.0/cs01/sqrap-v1.0-cs01.html). SQRAP's architecture requires a companion mobile app with stored identity keys, public key channel binding, back-channel authentication, and user presence verification (biometric/PIN). These are fundamentally incompatible with a browser-scan-to-authenticate flow. SQRAP is referenced for awareness of formal QR auth standards, not as a compliance target. + +## Performance + +The design prioritizes speed on connect. Latency depends on whether the request goes through a Cloudflare tunnel or is localhost: + +### Localhost (no tunnel) + +| Step | Latency | +|------|---------| +| QR scan (physical) | ~1-2s (user action) | +| `GET /q/:code` → Map.get() lookup + consume | <1ms | +| Cookie set + 302 redirect | <1ms | +| Browser follows redirect to `/` | <5ms | +| **Total (after scan)** | **<10ms** | + +### Through Cloudflare Tunnel (typical mobile use case) + +Each request traverses: phone → Cloudflare edge (TLS termination) → cloudflared → localhost. The 302 redirect means **two full round trips** through the tunnel. + +| Step | Latency | +|------|---------| +| QR scan (physical) | ~1-2s (user action) | +| DNS resolution for `*.trycloudflare.com` | 20-80ms (first request, cached after) | +| TLS handshake to Cloudflare edge | 50-100ms (first request, 0 with TLS resumption) | +| `GET /q/:code` through tunnel (request + response) | 30-90ms | +| Browser follows 302 redirect: `GET /` through tunnel | 30-90ms | +| **Total first connection (cold)** | **~200-400ms** | +| **Total subsequent (TLS/DNS cached)** | **~100-200ms** | + +This is still fast — **imperceptible after the 1-2s physical QR scan action**. For comparison, VS Code Remote Tunnels (through Azure) adds 20-100ms per hop. + +### Why Not Eliminate the Redirect? + +The 302 means two round trips. Alternatives considered: +- **200 + serve `index.html` directly**: URL bar shows `/q/Xk9mQ3`, relative paths break, couples auth to static serving. Not worth the complexity. +- **200 + ``**: Still two requests, plus HTML parse delay. Actually slower. +- **200 + JavaScript redirect**: Same problem, plus fails if JS disabled. + +The 302 is clean, universally supported, and the extra 30-90ms is invisible to users. + +### QR Code Size Optimization + +The URL `https://xxx-yyy.trycloudflare.com/q/Xk9mQ3` is ~53-56 characters. At QR Error Correction Level M: + +| QR Version | Grid Size | Byte Capacity | Fits? | +|------------|-----------|---------------|-------| +| Version 3 | 29x29 | 42 bytes | No | +| Version 4 | 33x33 | 62 bytes | Yes (comfortably) | +| Version 5 | 37x37 | 84 bytes | Yes | + +The shortened `/q/` path (vs `/qr-auth/`) and 6-char code (vs 8-char) save 9 bytes, targeting Version 4 (33x33) for faster scanning on budget Android phones. Modern phones scan Version 4 QR codes in 100-300ms — the user action of pointing the camera dominates. + +### Desktop QR Refresh + +Token rotation SSE events now embed the SVG directly in the payload (~2-5KB). The desktop gets the new QR in a single SSE push — no extra HTTP fetch needed. Refresh latency: **sub-50ms** (SSE adaptive batching at 16-50ms). + +### SVG Caching + +QR SVG is cached per rotation cycle on `TunnelManager.cachedQrSvg`. The SVG is regenerated only when the token rotates (every 60s), not on every `/api/tunnel/qr` request. SVG format is optimal: resolution-independent (retina-safe), inline-able (no extra HTTP request), ~2-5KB, renders in <1ms. + +## Edge Cases + +1. **Scan during rotation**: The server keeps 2 tokens (current + previous). If the user scans right as rotation happens, the previous token is still valid for up to 60s more. Seamless. + +2. **Server restart**: All tokens cleared (in-memory). New token generated immediately. Tunnel URL also changes (trycloudflare gives a new subdomain), so old QR codes are doubly dead. + +3. **Multiple devices**: Each scan consumes the token and triggers a fresh one. To auth a second device, wait for the QR to refresh (≤60s) or hit "Regenerate QR" on the desktop, then scan the new code. + +4. **Token without tunnel**: `/qr-auth/:code` works even on localhost. If you have the code and it's valid, you get authenticated regardless of access method. + +5. **Tunnel restart (same server)**: Tokens survive tunnel restarts (stored on `TunnelManager` instance). But new tunnel URL = new QR code generated. Short code stays valid until consumed or expired. + +6. **Desktop browser closed during scan**: Token is consumed server-side. The scanning phone gets authenticated. When the desktop reopens, SSE reconnects and shows a fresh QR. No state corruption. + +7. **Race condition: two phones scan same QR**: First scanner wins (atomic `consumed = true`). Second scanner gets 401. This is correct behavior — single-use by design. + +## Files to Modify + +| File | Changes | +|------|---------| +| `src/tunnel-manager.ts` | `QrTokenRecord` type, `Map` token pool, rejection-sampled `generateShortCode()`, rotation timer, `consumeToken()`, `getCurrentShortCode()`, `getQrSvg()` (cached), `regenerateQrToken()`, global rate limit counter, cleanup in `stop()` | +| `src/web/middleware/auth.ts` | Add `/q/` bypass in `onRequest` hook. Enhance session record type from `string` to `{ ip, ua, createdAt, method }` (**breaking type change** — all consumers must update). Add `qrAuthFailures` StaleExpirationMap (separate from Basic Auth `authFailures`). | +| `src/web/routes/system-routes.ts` | Modify `/api/tunnel/qr` to use `getQrSvg()` cache. Add `GET /q/:code` with atomic consume, audit log, and `tunnel:qrAuthUsed` broadcast. Add `POST /api/tunnel/qr/regenerate`. Add `POST /api/auth/revoke`. | +| `src/web/server.ts` | Pass `authState` + `lifecycleLog` to route context. Listen for `qrTokenRotated` and `qrTokenRegenerated` events → broadcast SSE with inline SVG. | +| `src/web/public/app.js` | Auto-refresh QR from inline SSE SVG payload (no extra fetch). Countdown timer. Regenerate button. Auth badge. Auth notification toast on `tunnel:qrAuthUsed` with [Revoke] action. | +| `src/session-lifecycle-log.ts` | Add `qr_auth` event type to lifecycle log schema | +| `src/types/api.ts` | Update `AuthState` interface: `authSessions` value type, add `qrAuthFailures` map | + +## Complexity Estimate + +Medium change. Core logic (Map-based token pool, rejection-sampled short codes, SVG cache, atomic consumption, cookie issuance, audit logging) is ~120 lines. Rate limiting (separate QR counter + global path limit) adds ~20 lines. SSE plumbing with inline SVG adds ~30 lines. Frontend (inline SVG refresh, auth notification toast with revoke, countdown) is ~40 lines. Auth type migration (session record type change) touches ~10 lines across middleware. No new dependencies — `crypto` and `qrcode` are already available. + +## Testing + +### Automated + +```bash +# Unit test for token manager +npx vitest run test/qr-auth.test.ts +``` + +Test cases: +- Token rotation generates unique short codes (6-char, base62) +- Short codes have uniform character distribution (no modulo bias — verify with chi-squared test over 10K samples) +- `consumeToken()` returns true on first use, false on second +- Expired tokens (>90s old) return false +- Previous token still works during 90s grace period +- Token at exactly 60s still valid (within grace), token at 91s rejected +- `regenerateQrToken()` invalidates all existing tokens (Map cleared) +- Short code lookup is case-sensitive +- Per-IP rate limiting increments on invalid codes (separate from Basic Auth counter) +- Global rate limit (30/min) blocks attempts across all IPs +- SVG cache returns same string for same short code, regenerates on rotation +- Audit log entry written on successful QR auth +- `tunnel:qrAuthUsed` SSE event broadcast on successful QR auth +- `tunnel:qrRotated` SSE event includes inline SVG payload +- Map-based lookup does not leak timing information (no string comparison in hot path) + +### Manual + +1. Start server with `CODEMAN_PASSWORD=test` +2. Enable tunnel +3. Verify `/api/tunnel/qr` returns QR encoding `https://...trycloudflare.com/q/Xk9mQ3` +4. Open the QR URL in incognito → should auto-redirect to `/` with session cookie +5. Verify desktop shows notification toast: "Device [IP] authenticated via QR" +6. Open the **same** URL again → should get 401 (single-use consumed) +7. Wait 60s → verify QR display auto-updated (new short code, inline SVG via SSE) +8. Open just the tunnel URL → should get Basic Auth prompt +9. Call `POST /api/tunnel/qr/regenerate` → old QR URL returns 401, new QR appears +10. Verify per-IP rate limiting: 10+ failed `/q/badcode` → 429 +11. Verify Basic Auth failures don't consume QR rate limit budget (and vice versa) +12. Check `~/.codeman/session-lifecycle.jsonl` for `qr_auth` entries after successful scan +13. Click [Revoke] on the notification toast → verify session is invalidated + +## References + +- [USENIX Security 2025: "Demystifying the (In)Security of QR Code-based Login in Real-world Deployments"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) — 6 design flaws, 5 attack types, 42 CVEs across 47 of top-100 websites. Primary design reference for this plan. +- [OWASP QRLJacking](https://owasp.org/www-community/attacks/Qrljacking) — canonical QR session hijacking reference +- [OASIS SQRAP v1.0 Standard](https://docs.oasis-open.org/esat/sqrap/v1.0/cs01/sqrap-v1.0-cs01.html) — formal standard for secure QR authentication. **Not a compliance target** for this plan (requires companion app + PKI). Referenced for awareness only. +- [FIDO2 CTAP 2.2 Hybrid Transport](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html) — gold standard for cross-device auth (overkill for this use case) +- [Google GTIG: Signal QR quishing by Russian state actors (2025)](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger) — UNC5792/Sandworm exploited Signal's linked-device QR flow via phishing. Demonstrates that even cryptographically strong QR auth can be defeated by social engineering. +- [CVE-2026-2144: Magic Login QR Code Plugin race condition](https://www.cvedetails.com/cve/CVE-2026-2144/) — QR token stored as predictable static file, race window between creation and deletion. Validates this plan's in-memory-only approach. diff --git a/src/tunnel-manager.ts b/src/tunnel-manager.ts index 4d1e6a99..0b9d3d1e 100644 --- a/src/tunnel-manager.ts +++ b/src/tunnel-manager.ts @@ -18,6 +18,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import { randomBytes } from 'node:crypto'; // ========== Types ========== @@ -26,8 +27,41 @@ export interface TunnelStatus { url: string | null; } +interface QrTokenRecord { + token: string; // 64 hex chars (256 bits) + shortCode: string; // 6 chars base62 (for URL path) + createdAt: number; // Date.now() + consumed: boolean; // single-use flag +} + // ========== Constants ========== +/** QR token auto-rotation interval */ +const QR_TOKEN_TTL_MS = 60_000; +/** Grace period for previous token (scan-during-rotation race) */ +const QR_TOKEN_GRACE_MS = 90_000; +/** Length of short code in QR URL path */ +const SHORT_CODE_LENGTH = 6; +/** Global rate limit for QR attempts across all IPs */ +const QR_RATE_LIMIT_MAX = 30; +/** Global rate limit reset window */ +const QR_RATE_LIMIT_WINDOW_MS = 60_000; + +/** Rejection-sampled base62 short code — no modulo bias */ +function generateShortCode(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const maxUnbiased = 248; // largest multiple of 62 that fits in a byte (248 = 62 * 4) + const result: string[] = []; + while (result.length < SHORT_CODE_LENGTH) { + const [byte] = randomBytes(1); + if (byte < maxUnbiased) result.push(chars[byte % 62]); + // else: discard and re-draw (rejection sampling) + } + return result.join(''); +} + +// ========== Constants (Tunnel) ========== + /** Regex to extract the trycloudflare.com URL from cloudflared output */ const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/; @@ -54,6 +88,17 @@ export class TunnelManager extends EventEmitter { private localPort = 3000; private useHttps = false; + // ========== QR Token State ========== + /** Map-based lookup: shortCode → QrTokenRecord (hash-based, timing-safe) */ + private qrTokensByCode = new Map(); + private currentShortCode: string | null = null; + private rotationTimer: ReturnType | null = null; + /** SVG cache — regenerated only on token rotation, not per request */ + private cachedQrSvg: { shortCode: string; svg: string } | null = null; + /** Global rate limit counter (separate from Basic Auth rate limiting) */ + private qrAttemptCount = 0; + private qrRateLimitResetTimer: ReturnType | null = null; + /** * Resolve cloudflared binary path. * Checks ~/.local/bin first, then falls back to PATH. @@ -170,6 +215,10 @@ export class TunnelManager extends EventEmitter { // Detach listeners — no need to parse further output this.process?.stdout?.off('data', handleOutput); this.process?.stderr?.off('data', handleOutput); + // Start QR token rotation when tunnel URL is acquired (only if auth enabled) + if (process.env.CODEMAN_PASSWORD) { + this.startTokenRotation(); + } this.emit('started', { url: this.url }); } }; @@ -243,6 +292,7 @@ export class TunnelManager extends EventEmitter { stop(): void { this.stopped = true; this.clearTimers(); + this.stopTokenRotation(); if (this.process) { const pid = this.process.pid; @@ -264,6 +314,111 @@ export class TunnelManager extends EventEmitter { } } + // ========== QR Token Management ========== + + /** Start token rotation — called after tunnel URL is acquired */ + startTokenRotation(): void { + this.stopTokenRotation(); + this.rotateToken(); + this.rotationTimer = setInterval(() => this.rotateToken(), QR_TOKEN_TTL_MS); + this.qrRateLimitResetTimer = setInterval(() => { + this.qrAttemptCount = 0; + }, QR_RATE_LIMIT_WINDOW_MS); + } + + /** Stop token rotation and clear all tokens */ + stopTokenRotation(): void { + if (this.rotationTimer) { + clearInterval(this.rotationTimer); + this.rotationTimer = null; + } + if (this.qrRateLimitResetTimer) { + clearInterval(this.qrRateLimitResetTimer); + this.qrRateLimitResetTimer = null; + } + this.qrTokensByCode.clear(); + this.currentShortCode = null; + this.cachedQrSvg = null; + this.qrAttemptCount = 0; + } + + /** Create a new token, evict expired/consumed ones, emit rotation event */ + private rotateToken(): void { + const record: QrTokenRecord = { + token: randomBytes(32).toString('hex'), + shortCode: generateShortCode(), + createdAt: Date.now(), + consumed: false, + }; + + // Evict expired or consumed tokens + const now = Date.now(); + for (const [code, rec] of this.qrTokensByCode) { + if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) { + this.qrTokensByCode.delete(code); + } + } + + this.qrTokensByCode.set(record.shortCode, record); + this.currentShortCode = record.shortCode; + this.cachedQrSvg = null; // invalidate SVG cache + this.emit('qrTokenRotated'); + } + + /** Get the current (newest) token's short code for QR URL */ + getCurrentShortCode(): string | undefined { + return this.currentShortCode ?? undefined; + } + + /** Get cached QR SVG, regenerating only if the short code changed */ + async getQrSvg(tunnelUrl: string): Promise { + const code = this.currentShortCode; + if (!code) throw new Error('No QR token available'); + if (this.cachedQrSvg?.shortCode === code) return this.cachedQrSvg.svg; + + // eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic optional dependency + const QRCode = require('qrcode'); + const svg: string = await QRCode.toString(`${tunnelUrl}/q/${code}`, { + type: 'svg', + margin: 2, + width: 256, + }); + this.cachedQrSvg = { shortCode: code, svg }; + return svg; + } + + /** + * Validate and atomically consume a token by short code. + * Map.get() is hash-based — no timing side-channel from string comparison. + */ + consumeToken(shortCode: string): boolean { + // Global rate limit (across all IPs) + if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false; + this.qrAttemptCount++; + + const record = this.qrTokensByCode.get(shortCode); + if (!record) return false; + if (record.consumed) return false; + + const now = Date.now(); + if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false; + + // Atomic consume (single-threaded JS = no race) + record.consumed = true; + // Immediately rotate so desktop gets a fresh QR + this.rotateToken(); + this.emit('qrTokenRegenerated'); + return true; + } + + /** Force-regenerate (manual revocation via API) */ + regenerateQrToken(): void { + this.qrTokensByCode.clear(); + this.currentShortCode = null; + this.rotateToken(); + this.emit('qrTokenRegenerated'); + } + isRunning(): boolean { return this.process !== null || this.restartTimer !== null; } diff --git a/src/types/lifecycle.ts b/src/types/lifecycle.ts index 1f5b586f..c756df14 100644 --- a/src/types/lifecycle.ts +++ b/src/types/lifecycle.ts @@ -13,7 +13,8 @@ export type LifecycleEventType = | 'stale_cleaned' // Removed from state.json by cleanupStaleSessions() | 'mux_died' // tmux session died (detected by reconciliation) | 'server_started' // Server started (marker for restart detection) - | 'server_stopped'; // Server shutting down + | 'server_stopped' // Server shutting down + | 'qr_auth'; // Device authenticated via QR code scan /** A single entry in the session lifecycle audit log */ export interface LifecycleEntry { diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index 2e5e3da0..0f9008c3 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -11,6 +11,7 @@ import { FastifyInstance } from 'fastify'; import { randomBytes, timingSafeEqual } from 'node:crypto'; import { StaleExpirationMap } from '../../utils/index.js'; +import type { AuthSessionRecord } from '../ports/auth-port.js'; // Auth session cookie TTL (24h — matches autonomous run length) const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000; @@ -25,8 +26,9 @@ const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000; /** State returned from registerAuthMiddleware for cleanup in server stop() */ export interface AuthState { - authSessions: StaleExpirationMap | null; + authSessions: StaleExpirationMap | null; authFailures: StaleExpirationMap | null; + qrAuthFailures: StaleExpirationMap | null; } /** @@ -39,6 +41,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au const state: AuthState = { authSessions: null, authFailures: null, + qrAuthFailures: null, }; const authPassword = process.env.CODEMAN_PASSWORD; @@ -48,7 +51,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64'); // Session token store — active sessions extend TTL on access - state.authSessions = new StaleExpirationMap({ + state.authSessions = new StaleExpirationMap({ ttlMs: AUTH_SESSION_TTL_MS, refreshOnGet: true, }); @@ -59,6 +62,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au refreshOnGet: false, }); + // Separate QR auth failure counter — independent from Basic Auth failures + state.qrAuthFailures = new StaleExpirationMap({ + ttlMs: AUTH_FAILURE_WINDOW_MS, + refreshOnGet: false, + }); + const authSessions = state.authSessions; const authFailures = state.authFailures; @@ -75,6 +84,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au // Non-localhost hook requests fall through to normal auth } + // QR auth path — handled by the route itself (token validation + rate limiting) + if (req.url?.startsWith('/q/')) { + done(); + return; + } + const clientIp = req.ip; // Rate limit: reject if too many failed attempts from this IP @@ -106,7 +121,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au if (oldestKey !== undefined) authSessions.delete(oldestKey); } - authSessions.set(token, clientIp); + authSessions.set(token, { + ip: clientIp, + ua: req.headers['user-agent'] ?? '', + createdAt: Date.now(), + method: 'basic', + }); // Reset failure count on successful auth authFailures.delete(clientIp); diff --git a/src/web/ports/auth-port.ts b/src/web/ports/auth-port.ts new file mode 100644 index 00000000..ef022230 --- /dev/null +++ b/src/web/ports/auth-port.ts @@ -0,0 +1,20 @@ +/** + * @fileoverview Auth port — capabilities for authentication state. + * Route modules that need access to auth sessions or QR rate limiting depend on this port. + */ + +import type { StaleExpirationMap } from '../../utils/index.js'; + +/** Enhanced session record with device context for audit logging */ +export interface AuthSessionRecord { + ip: string; + ua: string; + createdAt: number; + method: 'qr' | 'basic'; +} + +export interface AuthPort { + readonly authSessions: StaleExpirationMap | null; + readonly qrAuthFailures: StaleExpirationMap | null; + readonly https: boolean; +} diff --git a/src/web/ports/index.ts b/src/web/ports/index.ts index b9f7b94c..6186476c 100644 --- a/src/web/ports/index.ts +++ b/src/web/ports/index.ts @@ -11,3 +11,4 @@ export type { EventPort } from './event-port.js'; export type { RespawnPort } from './respawn-port.js'; export type { ConfigPort } from './config-port.js'; export type { InfraPort, ScheduledRun } from './infra-port.js'; +export type { AuthPort, AuthSessionRecord } from './auth-port.js'; diff --git a/src/web/public/api-client.js b/src/web/public/api-client.js index 84278d3a..567cee1f 100644 --- a/src/web/public/api-client.js +++ b/src/web/public/api-client.js @@ -49,6 +49,16 @@ Object.assign(CodemanApp.prototype, { return this._api(path, { method: 'POST', body }); }, + /** + * PUT JSON to an API endpoint. + * @param {string} path - API path + * @param {object} body - JSON body + * @returns {Promise} + */ + async _apiPut(path, body) { + return this._api(path, { method: 'PUT', body }); + }, + /** * DELETE an API resource. * @param {string} path - API path diff --git a/src/web/public/app.js b/src/web/public/app.js index 670e987f..f689207c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2326,6 +2326,48 @@ class CodemanApp { if (btn) { btn.disabled = false; btn.classList.remove('connecting'); } }); + // QR auto-refresh — inline SVG from SSE (no extra fetch) + addListener('tunnel:qrRotated', (e) => { + const data = JSON.parse(e.data); + if (data.svg) { + const container = document.getElementById('tunnelQrContainer'); + if (container) container.innerHTML = data.svg; + const welcomeInner = document.getElementById('welcomeQrInner'); + if (welcomeInner) welcomeInner.innerHTML = data.svg; + } else { + this._refreshTunnelQrFromApi(); + } + this._resetQrCountdown(); + }); + + addListener('tunnel:qrRegenerated', (e) => { + const data = JSON.parse(e.data); + if (data.svg) { + const container = document.getElementById('tunnelQrContainer'); + if (container) container.innerHTML = data.svg; + const welcomeInner = document.getElementById('welcomeQrInner'); + if (welcomeInner) welcomeInner.innerHTML = data.svg; + } else { + this._refreshTunnelQrFromApi(); + } + this._resetQrCountdown(); + }); + + // QR auth consumed — notify desktop user (QRLjacking detection) + addListener('tunnel:qrAuthUsed', (e) => { + const data = JSON.parse(e.data); + const ua = data.ua || 'Unknown device'; + const family = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser'; + this.showToast(`Device authenticated via QR (${family}, ${data.ip}). Not you?`, 'warning', { + duration: 10000, + action: { label: 'Revoke All', onClick: () => { + fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) + .then(() => this.showToast('All sessions revoked', 'success')) + .catch(() => this.showToast('Failed to revoke sessions', 'error')); + }}, + }); + }); + // Plan subagent visibility events (show Opus agents during plan generation) addListener('plan:subagent', (e) => { const data = JSON.parse(e.data); @@ -2459,16 +2501,8 @@ class CodemanApp { this._updateConnectionIndicator(); for (const [sessionId, input] of queued) { - try { - const resp = await fetch(`/api/sessions/${sessionId}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input }) - }); - if (!resp.ok) { - this._enqueueInput(sessionId, input); - } - } catch { + const resp = await this._apiPost(`/api/sessions/${sessionId}/input`, { input }); + if (!resp?.ok) { this._enqueueInput(sessionId, input); } } @@ -3107,94 +3141,6 @@ class CodemanApp { } - // Show subagent dropdown on hover - showSubagentDropdown(badgeEl) { - this.cancelHideSubagentDropdown(); - const dropdown = badgeEl.querySelector('.subagent-dropdown'); - if (!dropdown || dropdown.classList.contains('open')) return; - - // Close other dropdowns first - document.querySelectorAll('.subagent-dropdown.open').forEach(d => { - d.classList.remove('open', 'pinned'); - if (d.parentElement === document.body && d._originalParent) { - d._originalParent.appendChild(d); - } - }); - - // Move to body to escape clipping - dropdown._originalParent = badgeEl; - document.body.appendChild(dropdown); - - // Position below badge - const rect = badgeEl.getBoundingClientRect(); - dropdown.style.top = `${rect.bottom + 2}px`; - dropdown.style.left = `${rect.left + rect.width / 2}px`; - dropdown.style.transform = 'translateX(-50%)'; - dropdown.classList.add('open'); - } - - // Schedule hide after delay (allows moving mouse to dropdown) - scheduleHideSubagentDropdown(badgeEl) { - this._subagentHideTimeout = setTimeout(() => { - const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') || - document.querySelector('.subagent-dropdown.open'); - if (dropdown && !dropdown.classList.contains('pinned')) { - dropdown.classList.remove('open'); - if (dropdown._originalParent) { - dropdown._originalParent.appendChild(dropdown); - } - } - }, 150); - } - - // Cancel scheduled hide - cancelHideSubagentDropdown() { - if (this._subagentHideTimeout) { - clearTimeout(this._subagentHideTimeout); - this._subagentHideTimeout = null; - } - } - - // Pin dropdown open on click (stays until clicking outside) - pinSubagentDropdown(badgeEl) { - const dropdown = document.querySelector('.subagent-dropdown.open'); - if (!dropdown) { - this.showSubagentDropdown(badgeEl); - // On mobile/touch, pin immediately so onmouseleave doesn't close it - const openedDropdown = document.querySelector('.subagent-dropdown.open'); - if (openedDropdown) { - openedDropdown.classList.add('pinned'); - const closeHandler = (e) => { - if (!badgeEl.contains(e.target) && !openedDropdown.contains(e.target)) { - openedDropdown.classList.remove('open', 'pinned'); - if (openedDropdown._originalParent) { - openedDropdown._originalParent.appendChild(openedDropdown); - } - document.removeEventListener('click', closeHandler); - } - }; - setTimeout(() => document.addEventListener('click', closeHandler), 0); - } - return; - } - dropdown.classList.toggle('pinned'); - - if (dropdown.classList.contains('pinned')) { - // Close on outside click - const closeHandler = (e) => { - if (!badgeEl.contains(e.target) && !dropdown.contains(e.target)) { - dropdown.classList.remove('open', 'pinned'); - if (dropdown._originalParent) { - dropdown._originalParent.appendChild(dropdown); - } - document.removeEventListener('click', closeHandler); - } - }; - setTimeout(() => document.addEventListener('click', closeHandler), 0); - } - } - - getSessionName(session) { // Use custom name if set if (session.name) { @@ -3514,7 +3460,7 @@ class CodemanApp { async closeSession(sessionId, killMux = true) { try { - await fetch(`/api/sessions/${sessionId}?killMux=${killMux}`, { method: 'DELETE' }); + await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`); this._cleanupSessionData(sessionId); if (this.activeSessionId === sessionId) { @@ -4543,7 +4489,7 @@ class CodemanApp { async stopRespawn() { if (!this.activeSessionId) return; try { - await fetch(`/api/sessions/${this.activeSessionId}/respawn/stop`, { method: 'POST' }); + await this._apiPost(`/api/sessions/${this.activeSessionId}/respawn/stop`, {}); delete this.respawnTimers[this.activeSessionId]; this.clearCountdownTimers(this.activeSessionId); } catch (err) { @@ -4567,7 +4513,7 @@ class CodemanApp { if (!confirm(`Kill all ${this.sessions.size} session(s)?`)) return; try { - await fetch('/api/sessions', { method: 'DELETE' }); + await this._apiDelete('/api/sessions'); this.sessions.clear(); this.terminalBuffers.clear(); this.terminalBufferCache.clear(); @@ -4949,11 +4895,7 @@ class CodemanApp { if (!this.editingSessionId) return; const name = document.getElementById('modalSessionName').value.trim(); try { - await fetch(`/api/sessions/${this.editingSessionId}/name`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }) - }); + await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name }); } catch (err) { this.showToast('Failed to save session name: ' + err.message, 'error'); } @@ -4962,14 +4904,10 @@ class CodemanApp { async autoSaveAutoCompact() { if (!this.editingSessionId) return; try { - await fetch(`/api/sessions/${this.editingSessionId}/auto-compact`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - enabled: document.getElementById('modalAutoCompactEnabled').checked, - threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000, - prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined - }) + await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-compact`, { + enabled: document.getElementById('modalAutoCompactEnabled').checked, + threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000, + prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined }); } catch { /* silent */ } } @@ -4977,13 +4915,9 @@ class CodemanApp { async autoSaveAutoClear() { if (!this.editingSessionId) return; try { - await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - enabled: document.getElementById('modalAutoClearEnabled').checked, - threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000 - }) + await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-clear`, { + enabled: document.getElementById('modalAutoClearEnabled').checked, + threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000 }); } catch { /* silent */ } } @@ -4992,11 +4926,7 @@ class CodemanApp { if (!this.editingSessionId) return; const enabled = document.getElementById('modalImageWatcherEnabled').checked; try { - await fetch(`/api/sessions/${this.editingSessionId}/image-watcher`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled }) - }); + await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled }); // Update local session state const session = this.sessions.get(this.editingSessionId); if (session) { @@ -5012,11 +4942,7 @@ class CodemanApp { if (!this.editingSessionId) return; const enabled = document.getElementById('modalFlickerFilterEnabled').checked; try { - await fetch(`/api/sessions/${this.editingSessionId}/flicker-filter`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled }) - }); + await this._apiPost(`/api/sessions/${this.editingSessionId}/flicker-filter`, { enabled }); // Update local session state const session = this.sessions.get(this.editingSessionId); if (session) { @@ -5038,11 +4964,7 @@ class CodemanApp { autoAcceptPrompts: document.getElementById('modalRespawnAutoAccept').checked, }; try { - await fetch(`/api/sessions/${this.editingSessionId}/respawn/config`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config) - }); + await this._apiPut(`/api/sessions/${this.editingSessionId}/respawn/config`, config); } catch { // Silent save - don't interrupt user } @@ -5841,9 +5763,8 @@ class CodemanApp { } try { // Get VAPID public key from server - const keyRes = await fetch('/api/push/vapid-key'); - const keyData = await keyRes.json(); - if (!keyData.success) throw new Error('Failed to get VAPID key'); + const keyData = await this._apiJson('/api/push/vapid-key'); + if (!keyData?.success) throw new Error('Failed to get VAPID key'); const applicationServerKey = urlBase64ToUint8Array(keyData.data.publicKey); const subscription = await this._swRegistration.pushManager.subscribe({ @@ -5853,18 +5774,16 @@ class CodemanApp { // Send subscription to server const subJson = subscription.toJSON(); - const res = await fetch('/api/push/subscribe', { + const data = await this._apiJson('/api/push/subscribe', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + body: { endpoint: subJson.endpoint, keys: subJson.keys, userAgent: navigator.userAgent, pushPreferences: this._buildPushPreferences(), - }), + }, }); - const data = await res.json(); - if (!data.success) throw new Error('Failed to register subscription'); + if (!data?.success) throw new Error('Failed to register subscription'); this._pushSubscription = subscription; this._pushSubscriptionId = data.data.id; @@ -6175,6 +6094,27 @@ class CodemanApp { .then(data => { const container = document.getElementById('tunnelQrContainer'); if (container && data.svg) container.innerHTML = data.svg; + // Show auth badge, countdown, and regenerate button when auth is enabled + if (data.authEnabled) { + const badge = document.createElement('div'); + badge.id = 'tunnelQrBadge'; + badge.style.cssText = 'margin-top:8px;font-size:11px;color:var(--text-muted)'; + badge.textContent = 'Single-use auth \u00b7 expires in 60s'; + const regenBtn = document.createElement('button'); + regenBtn.textContent = 'Regenerate QR'; + regenBtn.style.cssText = 'margin-top:8px;padding:4px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;font-size:11px'; + regenBtn.onclick = () => { + fetch('/api/tunnel/qr/regenerate', { method: 'POST' }) + .then(() => this.showToast('QR code regenerated', 'success')) + .catch(() => this.showToast('Failed to regenerate QR', 'error')); + }; + const card = container.parentElement; + if (card) { + card.appendChild(badge); + card.appendChild(regenBtn); + } + this._resetQrCountdown(); + } }) .catch(() => { const container = document.getElementById('tunnelQrContainer'); @@ -6209,6 +6149,50 @@ class CodemanApp { document.removeEventListener('keydown', this._tunnelQrEscHandler); this._tunnelQrEscHandler = null; } + this._clearQrCountdown(); + } + + /** Fallback: fetch QR SVG from API when SSE payload lacks it */ + _refreshTunnelQrFromApi() { + fetch('/api/tunnel/qr') + .then(res => res.ok ? res.json() : null) + .then(data => { + if (!data?.svg) return; + const container = document.getElementById('tunnelQrContainer'); + if (container) container.innerHTML = data.svg; + const welcomeInner = document.getElementById('welcomeQrInner'); + if (welcomeInner) welcomeInner.innerHTML = data.svg; + }) + .catch(() => {}); + } + + /** Start or reset the 60s countdown on the QR badge */ + _resetQrCountdown() { + this._clearQrCountdown(); + this._qrCountdownSec = 60; + this._updateQrCountdownText(); + this._qrCountdownTimer = setInterval(() => { + this._qrCountdownSec--; + if (this._qrCountdownSec <= 0) { + this._clearQrCountdown(); + return; + } + this._updateQrCountdownText(); + }, 1000); + } + + _updateQrCountdownText() { + const badge = document.getElementById('tunnelQrBadge'); + if (badge) { + badge.textContent = `Single-use auth \u00b7 expires in ${this._qrCountdownSec}s`; + } + } + + _clearQrCountdown() { + if (this._qrCountdownTimer) { + clearInterval(this._qrCountdownTimer); + this._qrCountdownTimer = null; + } } async toggleTunnelFromWelcome() { @@ -6613,11 +6597,7 @@ class CodemanApp { // Strip device-specific keys — localEchoEnabled is per-platform (touch default differs) const { localEchoEnabled: _leo, ...serverSettings } = settings; try { - await fetch('/api/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings }) - }); + await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings }); // Save model configuration separately await this.saveModelConfigFromSettings(); @@ -7666,11 +7646,7 @@ class CodemanApp { this.ralphClosedSessions.add(this.activeSessionId); // Disable tracker via API - await fetch(`/api/sessions/${this.activeSessionId}/ralph-config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: false }) - }); + await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-config`, { enabled: false }); // Clear local state and hide panel this.ralphStates.delete(this.activeSessionId); @@ -7697,12 +7673,10 @@ class CodemanApp { if (!this.activeSessionId) return; try { - const response = await fetch(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, { - method: 'POST', - }); - const data = await response.json(); + const response = await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {}); + const data = await response?.json(); - if (data.success) { + if (data?.success) { this.notificationManager?.notify({ urgency: 'info', category: 'circuit-breaker', @@ -8120,12 +8094,7 @@ class CodemanApp { async resetCircuitBreaker() { if (!this.activeSessionId) return; try { - const response = await fetch(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, { - method: 'POST', - }); - if (response.ok) { - console.log('Circuit breaker reset'); - } + await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {}); } catch (err) { console.error('Failed to reset circuit breaker:', err); } @@ -8882,9 +8851,9 @@ class CodemanApp { async killSubagent(agentId) { try { - const res = await fetch(`/api/subagents/${agentId}`, { method: 'DELETE' }); - const data = await res.json(); - if (data.success) { + const res = await this._apiDelete(`/api/subagents/${agentId}`); + const data = await res?.json(); + if (data?.success) { // Update local state const agent = this.subagents.get(agentId); if (agent) { @@ -11384,10 +11353,22 @@ class CodemanApp { return this.showToast(message, type); } - showToast(message, type = 'info') { + showToast(message, type = 'info', opts = {}) { + const { duration = 3000, action } = opts; const toast = document.createElement('div'); toast.className = `toast toast-${type}`; - toast.textContent = message; + + const msgSpan = document.createElement('span'); + msgSpan.textContent = message; + toast.appendChild(msgSpan); + + if (action) { + const btn = document.createElement('button'); + btn.textContent = action.label; + btn.style.cssText = 'margin-left:12px;padding:2px 10px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.3);border-radius:3px;color:inherit;cursor:pointer;font-size:12px'; + btn.onclick = (e) => { e.stopPropagation(); action.onClick(); toast.remove(); }; + toast.appendChild(btn); + } // Cache toast container reference if (!this._toastContainer) { @@ -11405,7 +11386,7 @@ class CodemanApp { setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 200); - }, 3000); + }, duration); } // ========== System Stats ========== diff --git a/src/web/public/subagent-windows.js b/src/web/public/subagent-windows.js index 23d60ecf..797ad482 100644 --- a/src/web/public/subagent-windows.js +++ b/src/web/public/subagent-windows.js @@ -1029,4 +1029,91 @@ Object.assign(CodemanApp.prototype, { // Return listener references for cleanup return { move: moveListener, up: upListener, touchMove: touchMoveListener }; }, + + // Show subagent dropdown on hover + showSubagentDropdown(badgeEl) { + this.cancelHideSubagentDropdown(); + const dropdown = badgeEl.querySelector('.subagent-dropdown'); + if (!dropdown || dropdown.classList.contains('open')) return; + + // Close other dropdowns first + document.querySelectorAll('.subagent-dropdown.open').forEach(d => { + d.classList.remove('open', 'pinned'); + if (d.parentElement === document.body && d._originalParent) { + d._originalParent.appendChild(d); + } + }); + + // Move to body to escape clipping + dropdown._originalParent = badgeEl; + document.body.appendChild(dropdown); + + // Position below badge + const rect = badgeEl.getBoundingClientRect(); + dropdown.style.top = `${rect.bottom + 2}px`; + dropdown.style.left = `${rect.left + rect.width / 2}px`; + dropdown.style.transform = 'translateX(-50%)'; + dropdown.classList.add('open'); + }, + + // Schedule hide after delay (allows moving mouse to dropdown) + scheduleHideSubagentDropdown(badgeEl) { + this._subagentHideTimeout = setTimeout(() => { + const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') || + document.querySelector('.subagent-dropdown.open'); + if (dropdown && !dropdown.classList.contains('pinned')) { + dropdown.classList.remove('open'); + if (dropdown._originalParent) { + dropdown._originalParent.appendChild(dropdown); + } + } + }, 150); + }, + + // Cancel scheduled hide + cancelHideSubagentDropdown() { + if (this._subagentHideTimeout) { + clearTimeout(this._subagentHideTimeout); + this._subagentHideTimeout = null; + } + }, + + // Pin dropdown open on click (stays until clicking outside) + pinSubagentDropdown(badgeEl) { + const dropdown = document.querySelector('.subagent-dropdown.open'); + if (!dropdown) { + this.showSubagentDropdown(badgeEl); + // On mobile/touch, pin immediately so onmouseleave doesn't close it + const openedDropdown = document.querySelector('.subagent-dropdown.open'); + if (openedDropdown) { + openedDropdown.classList.add('pinned'); + const closeHandler = (e) => { + if (!badgeEl.contains(e.target) && !openedDropdown.contains(e.target)) { + openedDropdown.classList.remove('open', 'pinned'); + if (openedDropdown._originalParent) { + openedDropdown._originalParent.appendChild(openedDropdown); + } + document.removeEventListener('click', closeHandler); + } + }; + setTimeout(() => document.addEventListener('click', closeHandler), 0); + } + return; + } + dropdown.classList.toggle('pinned'); + + if (dropdown.classList.contains('pinned')) { + // Close on outside click + const closeHandler = (e) => { + if (!badgeEl.contains(e.target) && !dropdown.contains(e.target)) { + dropdown.classList.remove('open', 'pinned'); + if (dropdown._originalParent) { + dropdown._originalParent.appendChild(dropdown); + } + document.removeEventListener('click', closeHandler); + } + }; + setTimeout(() => document.addEventListener('click', closeHandler), 0); + } + }, }); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 075e7baf..0894f41c 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -37,7 +37,7 @@ import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { imageWatcher } from '../../image-watcher.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; -import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js'; +import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js'; import { RunSummaryTracker } from '../../run-summary.js'; @@ -56,11 +56,16 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/; export function registerSessionRoutes( app: FastifyInstance, - ctx: SessionPort & EventPort & ConfigPort & InfraPort + ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort ): void { // ========== Logout ========== - app.post('/api/logout', async (_req, reply) => { + app.post('/api/logout', async (req, reply) => { + // Invalidate server-side session token (not just the browser cookie) + const sessionToken = req.cookies[AUTH_COOKIE_NAME]; + if (sessionToken) { + ctx.authSessions?.delete(sessionToken); + } reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' }); return { success: true }; }); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index a2a2975d..ee890d02 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os'; import { execSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; import { ConfigUpdateSchema, @@ -23,7 +24,8 @@ import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.js'; -import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js'; +import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; +import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; // Maximum screenshot upload size (10MB) const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024; @@ -81,7 +83,7 @@ function getSystemStats(): { export function registerSystemRoutes( app: FastifyInstance, - ctx: SessionPort & EventPort & ConfigPort & InfraPort + ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort ): void { const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json'); const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json'); @@ -100,15 +102,108 @@ export function registerSystemRoutes( return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running')); } try { + const authPassword = process.env.CODEMAN_PASSWORD; + if (authPassword) { + // Auth enabled — use cached SVG with embedded short code + const svg = await ctx.tunnelManager.getQrSvg(url); + return { svg, authEnabled: true }; + } + // No auth — just encode the raw tunnel URL // eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic optional dependency const QRCode = require('qrcode'); const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 }); - return { svg }; + return { svg, authEnabled: false }; } catch (err) { return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err))); } }); + // ========== QR Auth Route ========== + + app.get('/q/:code', async (req, reply) => { + const shortCode = (req.params as { code: string }).code; + const authPassword = process.env.CODEMAN_PASSWORD; + + // No point if auth isn't enabled — just redirect + if (!authPassword) { + return reply.redirect('/'); + } + + const clientIp = req.ip; + + // Per-IP rate limit (separate counter from Basic Auth failures) + const qrFailures = ctx.qrAuthFailures?.get(clientIp) ?? 0; + if (qrFailures >= 10) { + return reply.code(429).send('Too Many Requests'); + } + + // Validate and atomically consume the token + if (!shortCode || !ctx.tunnelManager.consumeToken(shortCode)) { + ctx.qrAuthFailures?.set(clientIp, qrFailures + 1); + return reply.code(401).send('Invalid or expired QR code'); + } + + // Issue session cookie (same pattern as Basic Auth success path) + const sessionToken = randomBytes(32).toString('hex'); + const clientUA = req.headers['user-agent'] ?? ''; + ctx.authSessions?.set(sessionToken, { + ip: clientIp, + ua: clientUA, + createdAt: Date.now(), + method: 'qr', + }); + ctx.qrAuthFailures?.delete(clientIp); + + // Audit log + const lifecycleLog = getLifecycleLog(); + lifecycleLog.log({ + event: 'qr_auth', + sessionId: 'system', + extra: { + ip: clientIp, + ua: clientUA, + shortCodePrefix: shortCode.slice(0, 3) + '***', + }, + }); + + reply.setCookie(AUTH_COOKIE_NAME, sessionToken, { + httpOnly: true, + secure: ctx.https, + sameSite: 'lax', + maxAge: 86400, // 24h + path: '/', + }); + + // Broadcast auth notification — desktop sees who authenticated + ctx.broadcast('tunnel:qrAuthUsed', { + ip: clientIp, + ua: clientUA, + timestamp: Date.now(), + }); + + return reply.redirect('/'); + }); + + // ========== QR Regeneration ========== + + app.post('/api/tunnel/qr/regenerate', async () => { + ctx.tunnelManager.regenerateQrToken(); + return { success: true }; + }); + + // ========== Auth Session Revocation ========== + + app.post('/api/auth/revoke', async (req) => { + const body = req.body as { sessionToken?: string } | undefined; + if (body?.sessionToken) { + ctx.authSessions?.delete(body.sessionToken); + } else { + // Revoke all sessions (nuclear option) + ctx.authSessions?.clear(); + } + return { success: true }; + }); + // ========== OpenCode ========== app.get('/api/opencode/status', async () => { diff --git a/src/web/server.ts b/src/web/server.ts index d006c2c5..807934f3 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -255,8 +255,9 @@ export class WebServer extends EventEmitter { error: (error: Error, sessionId?: string) => void; } | null = null; private tunnelManager: TunnelManager = new TunnelManager(); - private authSessions: StaleExpirationMap | null = null; + private authSessions: StaleExpirationMap | null = null; private authFailures: StaleExpirationMap | null = null; + private qrAuthFailures: StaleExpirationMap | null = null; private pushStore: PushSubscriptionStore = new PushSubscriptionStore(); private teamWatcher: TeamWatcher = new TeamWatcher(); private teamWatcherHandlers: { @@ -321,6 +322,31 @@ export class WebServer extends EventEmitter { this.tunnelManager.on('progress', (data: { message: string }) => { this.broadcast('tunnel:progress', data); }); + + // QR token rotation — broadcast inline SVG for instant desktop refresh + this.tunnelManager.on('qrTokenRotated', async () => { + const url = this.tunnelManager.getUrl(); + if (url && process.env.CODEMAN_PASSWORD) { + try { + const svg = await this.tunnelManager.getQrSvg(url); + this.broadcast('tunnel:qrRotated', { svg }); + } catch { + // QR generation failed — skip this rotation + } + } + }); + + this.tunnelManager.on('qrTokenRegenerated', async () => { + const url = this.tunnelManager.getUrl(); + if (url && process.env.CODEMAN_PASSWORD) { + try { + const svg = await this.tunnelManager.getQrSvg(url); + this.broadcast('tunnel:qrRegenerated', { svg }); + } catch { + // QR generation failed — skip + } + } + }); } /** @@ -485,6 +511,9 @@ export class WebServer extends EventEmitter { pushStore: this.pushStore, startScheduledRun: this.startScheduledRun.bind(this), stopScheduledRun: this.stopScheduledRun.bind(this), + // AuthPort + authSessions: this.authSessions, + qrAuthFailures: this.qrAuthFailures, }; } @@ -510,6 +539,7 @@ export class WebServer extends EventEmitter { if (authState) { this.authSessions = authState.authSessions; this.authFailures = authState.authFailures; + this.qrAuthFailures = authState.qrAuthFailures; } // Security headers + CORS @@ -2652,6 +2682,10 @@ export class WebServer extends EventEmitter { this.authFailures.dispose(); this.authFailures = null; } + if (this.qrAuthFailures) { + this.qrAuthFailures.dispose(); + this.qrAuthFailures = null; + } this.activePlanOrchestrators.clear(); this.cleaningUp.clear(); diff --git a/test/qr-auth.test.ts b/test/qr-auth.test.ts new file mode 100644 index 00000000..9f72f637 --- /dev/null +++ b/test/qr-auth.test.ts @@ -0,0 +1,345 @@ +/** + * QR Authentication tests — verifies: + * 1. Token rotation generates unique 6-char base62 short codes + * 2. Short code generation has no modulo bias (rejection sampling) + * 3. consumeToken() is single-use (true first, false after) + * 4. Expired tokens (>90s grace) are rejected + * 5. Previous token works within 90s grace period + * 6. regenerateQrToken() clears all tokens + * 7. Per-IP QR rate limiting (separate from Basic Auth) + * 8. Global rate limiting (30/min across all IPs) + * 9. SVG caching (same SVG for same short code) + * 10. Full server integration: GET /q/:code issues cookie + redirects + * 11. Session revocation via POST /api/auth/revoke + * 12. QR auth bypass in auth middleware + * + * Port: 3162 (qr-auth tests) + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { TunnelManager } from '../src/tunnel-manager.js'; +import { WebServer } from '../src/web/server.js'; + +const QR_AUTH_PORT = 3162; +const TEST_PASS = 'qr-test-pass-xyz'; +const TEST_USER = 'admin'; + +function basicAuthHeader(user: string, pass: string): string { + return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); +} + +// ========== Unit Tests: TunnelManager Token Logic ========== + +describe('QR Token Manager (unit)', () => { + let tm: TunnelManager; + + beforeEach(() => { + tm = new TunnelManager(); + // Start token rotation manually (normally triggered on tunnel URL acquisition) + tm.startTokenRotation(); + }); + + afterAll(() => { + // Clean up any lingering timers + tm?.stopTokenRotation(); + }); + + it('should generate a 6-char base62 short code', () => { + const code = tm.getCurrentShortCode(); + expect(code).toBeDefined(); + expect(code!.length).toBe(6); + expect(code).toMatch(/^[A-Za-z0-9]{6}$/); + tm.stopTokenRotation(); + }); + + it('should generate unique short codes on rotation', () => { + const codes = new Set(); + for (let i = 0; i < 20; i++) { + tm.regenerateQrToken(); + const code = tm.getCurrentShortCode(); + expect(code).toBeDefined(); + codes.add(code!); + } + // All 20 codes should be unique (collision on 62^6 space is vanishingly unlikely) + expect(codes.size).toBe(20); + tm.stopTokenRotation(); + }); + + it('consumeToken should return true on first use, false on second', () => { + const code = tm.getCurrentShortCode()!; + expect(tm.consumeToken(code)).toBe(true); + // After consumption, a new code is generated — old code should be consumed + expect(tm.consumeToken(code)).toBe(false); + tm.stopTokenRotation(); + }); + + it('should reject unknown short codes', () => { + expect(tm.consumeToken('ZZZZZZ')).toBe(false); + expect(tm.consumeToken('')).toBe(false); + expect(tm.consumeToken('short')).toBe(false); + tm.stopTokenRotation(); + }); + + it('should reject expired tokens beyond grace period', () => { + const code = tm.getCurrentShortCode()!; + + // Manually expire the token by manipulating its createdAt + // Access the private map — this is a unit test, we need to verify the TTL logic + const tokenMap = (tm as unknown as { qrTokensByCode: Map }) + .qrTokensByCode; + const record = tokenMap.get(code)!; + record.createdAt = Date.now() - 91_000; // 91 seconds ago (beyond 90s grace) + + expect(tm.consumeToken(code)).toBe(false); + tm.stopTokenRotation(); + }); + + it('should accept tokens within grace period', () => { + const code = tm.getCurrentShortCode()!; + + // Set createdAt to 80 seconds ago (within 90s grace) + const tokenMap = (tm as unknown as { qrTokensByCode: Map }) + .qrTokensByCode; + const record = tokenMap.get(code)!; + record.createdAt = Date.now() - 80_000; + + expect(tm.consumeToken(code)).toBe(true); + tm.stopTokenRotation(); + }); + + it('regenerateQrToken should invalidate all existing tokens', () => { + const oldCode = tm.getCurrentShortCode()!; + tm.regenerateQrToken(); + const newCode = tm.getCurrentShortCode()!; + + expect(newCode).not.toBe(oldCode); + expect(tm.consumeToken(oldCode)).toBe(false); + expect(tm.consumeToken(newCode)).toBe(true); + tm.stopTokenRotation(); + }); + + it('should enforce global rate limit', () => { + // Exhaust global rate limit (30 attempts) + for (let i = 0; i < 30; i++) { + tm.consumeToken('BADCODE'); + } + // Now even valid codes should be rejected + const validCode = tm.getCurrentShortCode()!; + expect(tm.consumeToken(validCode)).toBe(false); + tm.stopTokenRotation(); + }); + + it('should emit qrTokenRotated on rotation', () => { + let rotateCount = 0; + tm.on('qrTokenRotated', () => rotateCount++); + tm.regenerateQrToken(); // calls rotateToken() internally + // regenerateQrToken calls rotateToken which emits qrTokenRotated + expect(rotateCount).toBeGreaterThanOrEqual(1); + tm.stopTokenRotation(); + }); + + it('should emit qrTokenRegenerated on consume and regenerate', () => { + let regenCount = 0; + tm.on('qrTokenRegenerated', () => regenCount++); + + const code = tm.getCurrentShortCode()!; + tm.consumeToken(code); // should emit qrTokenRegenerated + expect(regenCount).toBe(1); + + tm.regenerateQrToken(); // should also emit + expect(regenCount).toBe(2); + tm.stopTokenRotation(); + }); + + it('SVG cache should return same string for same short code', async () => { + const fakeUrl = 'https://test.trycloudflare.com'; + const svg1 = await tm.getQrSvg(fakeUrl); + const svg2 = await tm.getQrSvg(fakeUrl); + expect(svg1).toBe(svg2); // Same reference (cached) + expect(svg1).toContain(' { + const fakeUrl = 'https://test.trycloudflare.com'; + const svg1 = await tm.getQrSvg(fakeUrl); + tm.regenerateQrToken(); + const svg2 = await tm.getQrSvg(fakeUrl); + expect(svg1).not.toBe(svg2); // Different content (new short code) + tm.stopTokenRotation(); + }); +}); + +describe('Short code distribution (bias check)', () => { + it('should produce roughly uniform character distribution', () => { + // Generate 6000 codes (36000 chars) and check distribution + const tm = new TunnelManager(); + tm.startTokenRotation(); + + const charCounts = new Map(); + for (let i = 0; i < 6000; i++) { + tm.regenerateQrToken(); + const code = tm.getCurrentShortCode()!; + for (const ch of code) { + charCounts.set(ch, (charCounts.get(ch) ?? 0) + 1); + } + } + + // Expected count per char: 36000 / 62 ≈ 580.6 + const expected = 36000 / 62; + let maxDeviation = 0; + for (const [, count] of charCounts) { + const deviation = Math.abs(count - expected) / expected; + maxDeviation = Math.max(maxDeviation, deviation); + } + + // With rejection sampling, deviation should be < 15% (generous) + // Without rejection sampling (modulo bias), first 6 chars would be ~25% overrepresented + expect(maxDeviation).toBeLessThan(0.15); + + tm.stopTokenRotation(); + }); +}); + +// ========== Integration Tests: Full Server ========== + +describe('QR Auth Integration', () => { + let server: WebServer; + let baseUrl: string; + + beforeAll(async () => { + process.env.CODEMAN_PASSWORD = TEST_PASS; + process.env.CODEMAN_USERNAME = TEST_USER; + server = new WebServer(QR_AUTH_PORT, false, true); + await server.start(); + baseUrl = `http://localhost:${QR_AUTH_PORT}`; + }); + + afterAll(async () => { + await server.stop(); + delete process.env.CODEMAN_PASSWORD; + delete process.env.CODEMAN_USERNAME; + }); + + it('GET /q/:code should bypass auth middleware (not 401)', async () => { + // Even with a bad code, we should get 401 from the route handler, + // NOT from the auth middleware (which would show WWW-Authenticate) + const res = await fetch(`${baseUrl}/q/BADCODE`, { redirect: 'manual' }); + expect(res.status).toBe(401); + // The auth middleware's 401 sends WWW-Authenticate header; the route handler doesn't + expect(res.headers.get('www-authenticate')).toBeNull(); + }); + + it('GET /q/:code should redirect to / when no auth configured', async () => { + // Temporarily remove password + const savedPass = process.env.CODEMAN_PASSWORD; + delete process.env.CODEMAN_PASSWORD; + + const res = await fetch(`${baseUrl}/q/ANYCODE`, { redirect: 'manual' }); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/'); + + process.env.CODEMAN_PASSWORD = savedPass; + }); + + it('GET /api/tunnel/qr should return authEnabled flag', async () => { + // Tunnel is not running, so this should 404 + const res = await fetch(`${baseUrl}/api/tunnel/qr`, { + headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) }, + }); + // Tunnel not running = 404 (expected) + expect(res.status).toBe(404); + }); + + it('POST /api/tunnel/qr/regenerate should succeed', async () => { + const res = await fetch(`${baseUrl}/api/tunnel/qr/regenerate`, { + method: 'POST', + headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) }, + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('POST /api/auth/revoke should revoke all sessions', async () => { + // First authenticate to create a session + const authRes = await fetch(`${baseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) }, + }); + expect(authRes.status).toBe(200); + const setCookie = authRes.headers.get('set-cookie')!; + const cookieMatch = setCookie.match(/codeman_session=([^;]+)/); + expect(cookieMatch).toBeTruthy(); + const cookie = `codeman_session=${cookieMatch![1]}`; + + // Verify cookie works + const beforeRes = await fetch(`${baseUrl}/api/status`, { + headers: { Cookie: cookie }, + }); + expect(beforeRes.status).toBe(200); + + // Revoke all sessions + const revokeRes = await fetch(`${baseUrl}/api/auth/revoke`, { + method: 'POST', + headers: { + Cookie: cookie, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + expect(revokeRes.status).toBe(200); + + // Cookie should no longer work + const afterRes = await fetch(`${baseUrl}/api/status`, { + headers: { Cookie: cookie }, + }); + expect(afterRes.status).toBe(401); + }); + + it('POST /api/auth/revoke should revoke a specific session', async () => { + // Create two sessions + const auth1 = await fetch(`${baseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) }, + }); + const cookie1 = auth1.headers.get('set-cookie')!.match(/codeman_session=([^;]+)/)![1]; + + const auth2 = await fetch(`${baseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) }, + }); + const cookie2 = auth2.headers.get('set-cookie')!.match(/codeman_session=([^;]+)/)![1]; + + // Revoke only cookie1 + await fetch(`${baseUrl}/api/auth/revoke`, { + method: 'POST', + headers: { + Cookie: `codeman_session=${cookie2}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ sessionToken: cookie1 }), + }); + + // cookie1 should fail + const res1 = await fetch(`${baseUrl}/api/status`, { + headers: { Cookie: `codeman_session=${cookie1}` }, + }); + expect(res1.status).toBe(401); + + // cookie2 should still work + const res2 = await fetch(`${baseUrl}/api/status`, { + headers: { Cookie: `codeman_session=${cookie2}` }, + }); + expect(res2.status).toBe(200); + }); + + it('QR auth failures should not affect Basic Auth rate limit', async () => { + // Send QR auth failures + for (let i = 0; i < 5; i++) { + await fetch(`${baseUrl}/q/BAD${i}xx`, { redirect: 'manual' }); + } + + // Basic Auth should still work (not rate-limited by QR failures) + const res = await fetch(`${baseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) }, + }); + expect(res.status).toBe(200); + }); +});