diff --git a/README.md b/README.md index 80cc464..8d155d5 100644 --- a/README.md +++ b/README.md @@ -64,19 +64,39 @@ Multiple clients can connect to the same host. Multiple hosts (different usernam ## Environment variables +### Server + | Variable | Default | Description | |---|---|---| -| `SERVER_URL` | `ws://localhost:3000` | Server WebSocket URL (host → server) | -| `CLIENT_USERNAME` | `whoami` | Username for web login | -| `CLIENT_PASSWORD` | `changeme` | Password for web login | -| `HOST_KEY` (server) | _(none)_ | Optional master-shortcut secret. If set, any host presenting it authenticates without going through `/install.sh`. Unset = only per-IP tokens accepted. | -| `CODETTE_HOST_KEY` (host) | _(none)_ | Token the host presents to the server. Normally written into `credentials.json` by `install.sh`. Required on the host side. | | `PORT` | `3000` | Server listen port | -| `CODETTE_DATA_HOME` | platform default | Override data directory (host keys, session names) | +| `SERVER_HOSTNAME` | _(required for `/install.sh`)_ | Hostname served in the install script | +| `PUBLIC_URL` | `http://localhost:PORT` | Used as JWT issuer and audience root | +| `X2_DATA_DIR` | `/data/x2` | Stores `id-key.pem`, `username-owners.json`, `trial-claims.json` | +| `TRIAL_MAX_CLAIMS` | `5` | Max trial registrations per IP in the window | +| `TRIAL_WINDOW_MS` | `1296000000` (15d) | Sliding window for trial rate limit | | `CODETTE_TRACE` | off | Set to `1` for protocol-level trace logging | + +### Host + +| Variable | Default | Description | +|---|---|---| +| `CODETTE_SERVER_URL` | `ws://localhost:3000` | Server WebSocket URL | +| `CODETTE_USERNAME` | `$(whoami)` | Username for web login | +| `CODETTE_PASSWORD` | `changeme` | Password for web login (chat-domain HMAC) | +| `CODETTE_DATA_HOME` | platform default | Host data directory (`host-key.pem`, session names) | | `E2E` | on | Set to `0` to disable e2e encryption (debug only) | -Change every default before exposing the server to the public internet. +Legacy env vars also supported on host: `SERVER_URL`, `CLIENT_USERNAME`, `CLIENT_PASSWORD`. + +## Registration + +After installing, run `codette login` to register the host's identity with the server. The CLI: +1. Prompts for username and a browser password. +2. Opens the server's consent page in your browser. +3. After you click "Try without registration", polls until registration is confirmed. +4. Writes `~/.config/codette/credentials.json`. + +No host tokens or OAuth credentials are involved. The host's `host-key.pem` keypair is its identity. ## Related projects diff --git a/client/package-lock.json b/client/package-lock.json index 0547cd1..9e1427d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,13 +1,21 @@ { "name": "codette-client", +<<<<<<< Updated upstream "version": "0.1.2", +======= + "version": "1.0.0", +>>>>>>> Stashed changes "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codette-client", +<<<<<<< Updated upstream "version": "0.1.2", "license": "Apache-2.0", +======= + "version": "1.0.0", +>>>>>>> Stashed changes "dependencies": { "dompurify": "^3.4.2", "katex": "^0.16.45", diff --git a/doc/auth.spec.md b/doc/auth.spec.md index 03f8453..cecb98d 100644 --- a/doc/auth.spec.md +++ b/doc/auth.spec.md @@ -1,3 +1,55 @@ +## Host registration via self-signed identity (CLI login) + +The host CLI owns a long-lived EC P-256 keypair (`host-key.pem`, also used for chat-domain JWT signing). In X2, this keypair IS the host's identity — for both chat-domain (existing) and server-host authentication (new). There are no bearer tokens, no OIDC provider, no PKCE, no refresh tokens. + +### Registration (browser-required, one-time per username) + +1. CLI computes the JWK representation of its public key and the RFC 7638 JWK thumbprint (`jkt`). +2. CLI generates a state nonce and signs a `host_proof` JWT: + `{ iss: jkt, aud: /register, username, iat, exp: now+5min, jti }` +3. CLI pre-flight checks username availability at `GET /auth/username-available/:name`. +4. CLI opens browser at `GET /register/start?state=…&username=…&jwk=…&host_proof=…&idp=trial`. +5. Server validates the `host_proof` signature against the supplied JWK, stores + `pending[state] = {username, jwk, jkt, idp, expires: now+5min}`. +6. For `idp=trial`: server sets a CSRF cookie and renders a consent page. +7. User clicks "Try without registration". Browser POSTs `state`+`csrf` to `/register/finish-trial`. +8. Server verifies CSRF, checks per-IP rate limit (`TRIAL_MAX_CLAIMS/TRIAL_WINDOW_MS`), + self-issues a trial `id_token`: + `{ iss: , sub: jkt, aud: /register/callback, username, iat, exp: now+5min, iss_idp: 'self' }`, + and redirects browser to `/register/callback?state=…&id_token=…`. +9. `/register/callback`: server verifies id_token, asserts sub===jkt, username match, and calls + `claimBinding(username, jkt, jwk, {idp, idp_sub})` — atomically writes `username-owners.json`. + Renders a "Registered — close this tab" page. +10. CLI polls `GET /register/status?state=…` every 500 ms until status is `"claimed"` or 5-min timeout. + On success, writes `credentials.json { server, username, password }` (password is for the + chat-domain HMAC browser-auth flow, unchanged from v1). + +**No tokens property:** the host never receives an access_token or refresh_token from the server. +The registration flow only establishes the `(username ↔ jkt)` binding. + +**IdP-extensibility:** `idp=trial` has the server act as its own IdP (self-issues the id_token). +Other `idp=` values (google, github, …) will redirect the browser to that IdP instead; the +`/register/callback` handler is IdP-agnostic — it verifies the id_token against the IdP's JWKS. + +### Connect (every WS connect) + +1. CLI signs a fresh handshake JWT: `{ iss: jkt, aud: /host, iat, exp: now+60s, jti }`. +2. CLI opens WS: `wss://server/host?proof=&clientUsername=<>`. +3. Server WS handler: + - Decodes JWT without verifying, extracts `iss` (the `jkt`). + - Looks up `byPubkey[jkt]` → `{username, jwk}`. + - Verifies JWT signature against stored JWK. + - Checks `iat` freshness (within 5 min), `exp` not past, `jti` not seen recently. + - Checks `iat >= SERVER_START_TIME` (kill-switch: invalidates all handshakes on server restart). + - Confirms `clientUsername === username` (sanity). + - Enforces single-host slot: rejects if `hosts.has(username)`. + - Accepts and places the connection in the hosts map. + +**Replay defenses:** jti dedup (in-memory Map with TTL eviction), iat freshness window, +iat-killswitch on server restart. + +--- + ## Authentication via device pairing Three credentials work in concert. diff --git a/doc/main.spec.md b/doc/main.spec.md index 74d26f1..c409930 100644 --- a/doc/main.spec.md +++ b/doc/main.spec.md @@ -122,7 +122,7 @@ agent the syntax. See `inline-file.spec.md`. ### Install -Server serves a shell script at `GET /install.sh` with `HOST_KEY` and `SERVER_URL` baked in. +Server serves a shell script at `GET /install.sh` with `SERVER_URL` baked in. ``` curl -fsSL https://your-server:3000/install.sh | sh @@ -131,41 +131,54 @@ curl -fsSL https://your-server:3000/install.sh | sh The script: 1. Clones the GitHub repo into `~/.local/share/codette/` 2. Runs `npm install --prefix ~/.local/share/codette/host` -3. Prompts for username and password (enter to accept defaults): - ``` - Username [dan]: - Password [a3kR4mXq2p]: - ``` - Username defaults to `$(whoami)`. Password defaults to a random 10-char alphanumeric. -4. Writes `~/.config/codette/credentials.json` (mode 0600): +3. Writes `~/.config/codette/config.json` with the server URL. +4. Symlinks `~/.local/bin/codette` → `~/.local/share/codette/host/index.js` +5. If `~/.local/bin` is not in `$PATH`, prints the `export PATH=…` line. +6. Prints `Run: codette login` + +### Activation (one-time, per username) + +After install, run: + +``` +codette login +``` + +The CLI will: +1. Prompt for a username (defaults to `$(whoami)`; checks availability on the server). +2. Prompt for a browser password (used for the chat-domain HMAC auth flow — unrelated to X2 registration). +3. Open a browser tab at the server's consent page. +4. Wait for the user to click "Try without registration". +5. Poll the server until registration is confirmed. +6. Write `~/.config/codette/credentials.json` (mode 0600): ```json - { "server": "wss://your-server:3000", "hostKey": "...", "username": "dan", "password": "a3kR4mXq2p" } + { "server": "wss://your-server:3000", "username": "dan", "password": "a3kR4mXq2p" } ``` -5. Symlinks `~/.local/bin/codette` → `~/.local/share/codette/host/index.js` -6. If `~/.local/bin` is not in `$PATH`, prints: - ``` - Add to your shell profile: - export PATH="$HOME/.local/bin:$PATH" - ``` -7. Prints `Run: codette` +7. Print `✓ Registered. Run codette to start the host.` + +No `hostKey` or `refresh_token` is stored — the host's keypair (`host-key.pem`) is the identity. ### Startup -On connect the host prints the server URL and credentials so the user can log in: ``` -Connected to https://your-server:3000 - Username: dan - Password: a3kR4mXq2p +codette +``` + +The host signs a fresh handshake JWT with `host-key.pem` and connects to the server. + +On connect it prints the server URL and credentials so the user can log in via the browser: +``` +Claude Web Host wss://your-server:3000 + Serving clients as: dan ``` ### Config precedence -CLI flags → `~/.config/codette/credentials.json` → env vars → defaults. +CLI flags → `~/.config/codette/credentials.json` → `~/.config/codette/config.json` → env vars → defaults. | Setting | Config key | Env var | CLI flag | Default | |---------|-----------|---------|----------|---------| | Server URL | `server` | `CODETTE_SERVER_URL` | `--server`, `-s` | `ws://localhost:3000` | -| Host key | `hostKey` | `CODETTE_HOST_KEY` | — | _required (no default)_ | | Username | `username` | `CODETTE_USERNAME` | `--username`, `-u` | `$(whoami)` | | Password | `password` | `CODETTE_PASSWORD` | `--password`, `-p` | `changeme` | diff --git a/doc/protocol.spec.md b/doc/protocol.spec.md index 2fa602a..8cd7f27 100644 --- a/doc/protocol.spec.md +++ b/doc/protocol.spec.md @@ -42,7 +42,19 @@ claude --dangerously-skip-permissions \ --- -## Layer 2 — Host ↔ Server (WebSocket `/host?key=HOST_KEY`) +## Layer 2 — Host ↔ Server (WebSocket `/host?proof=JWT&clientUsername=NAME`) + +### Registration REST API + +| method | path | query / body | response | notes | +|--------|------|------|----------|-------| +| `GET` | `/register/start` | `state`, `username`, `jwk` (b64url JSON), `host_proof` (JWT), `idp` | HTML (consent page) or 302 (external IdP) | validates host_proof, stores pending state | +| `POST` | `/register/finish-trial` | form: `csrf`, `state` | 302 → `/register/callback` | CSRF check + rate limit; self-issues trial id_token | +| `GET` | `/register/callback` | `state`, `id_token` | HTML (done page) | verifies id_token, claims binding | +| `GET` | `/register/status` | `state` | `{status: 'pending'\|'claimed'\|'expired'\|'error'}` | CLI polls this; returns immediately | +| `GET` | `/auth/username-available/:name` | — | `{available: bool, reason?: 'invalid'\|'taken'}` | pre-flight check; advisory only | + + One persistent connection. Host reconnects on drop. diff --git a/docker-compose.yml b/docker-compose.yml index 021b692..069a30b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,10 @@ services: ports: - "127.0.0.1:3000:3000" environment: - HOST_KEY: ${HOST_KEY:-} - HOST_TOKEN_TTL: ${HOST_TOKEN_TTL:-0} PORT: "3000" + SERVER_HOSTNAME: ${SERVER_HOSTNAME} + PUBLIC_URL: ${PUBLIC_URL:-https://${SERVER_HOSTNAME}} + X2_DATA_DIR: /data/x2 volumes: - server-data:/data restart: unless-stopped diff --git a/host/auth.js b/host/auth.js new file mode 100644 index 0000000..6605f69 --- /dev/null +++ b/host/auth.js @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// X2 host-side key management and proof signing. +// Uses the same host-key.pem as the chat-domain JWT signer. + +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { randomBytes } from 'crypto'; +import { SignJWT, importPKCS8, exportJWK, calculateJwkThumbprint } from 'jose'; + +let cachedKey = null; +let cachedJwk = null; +let cachedJkt = null; + +/** + * Load and cache key material from host-key.pem. + * keyFilePath: absolute path to host-key.pem + */ +export async function loadKeyMaterial(keyFilePath) { + if (cachedKey) return { key: cachedKey, jwk: cachedJwk, jkt: cachedJkt }; + const pem = readFileSync(keyFilePath, 'utf8'); + // extractable: true is required to call exportJWK on the imported key + cachedKey = await importPKCS8(pem, 'ES256', { extractable: true }); + cachedJwk = await exportJWK(cachedKey); + // exportJWK on a private key includes d,x,y,crv,kty — strip private fields + // to produce the public-only JWK that will be shared with the server. + const { d: _d, ...publicJwk } = cachedJwk; + cachedJwk = publicJwk; + cachedJkt = await calculateJwkThumbprint(cachedJwk, 'sha256'); + return { key: cachedKey, jwk: cachedJwk, jkt: cachedJkt }; +} + +/** + * Sign a host_proof JWT for the /register/start flow. + * aud is /register + */ +export async function signHostProof({ keyFilePath, aud, username }) { + const { key, jwk, jkt } = await loadKeyMaterial(keyFilePath); + const jwt = await new SignJWT({ username }) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuer(jkt) + .setAudience(aud) + .setIssuedAt() + .setExpirationTime('5m') + .setJti(randomBytes(16).toString('hex')) + .sign(key); + return { jwt, jwk, jkt }; +} + +/** + * Sign a WS handshake proof JWT for /host connections. + * aud is /host + */ +export async function signHandshakeProof({ keyFilePath, aud }) { + const { key, jkt } = await loadKeyMaterial(keyFilePath); + return new SignJWT({}) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuer(jkt) + .setAudience(aud) + .setIssuedAt() + .setExpirationTime('1m') + .setJti(randomBytes(16).toString('hex')) + .sign(key); +} + +/** Reset cached key material (used in tests to simulate fresh key) */ +export function _resetKeyCache() { + cachedKey = null; + cachedJwk = null; + cachedJkt = null; +} diff --git a/host/index.js b/host/index.js index 0d2fb4e..4f655f2 100755 --- a/host/index.js +++ b/host/index.js @@ -15,6 +15,8 @@ import { RpcServer } from './rpc.js'; import { makeInlineFilePrompt, HTML_RENDER_PROMPT } from '../shared/prompts.js'; import { hmacVerify, deriveKey, deriveNonceKey, deriveAuthKey, encrypt, encryptDet, decrypt } from '../shared/crypto.js'; import { APP_NAME } from '../shared/constants.js'; +import { signHandshakeProof } from './auth.js'; + // ── Config loading ────────────────────────────────────────────────────────── // Precedence: CLI flags > env vars > credentials.json > defaults @@ -58,10 +60,6 @@ const CLIENT_USERNAME = _cli.username const CLIENT_PASSWORD = _cli.password || process.env.CODETTE_PASSWORD || process.env.CLIENT_PASSWORD || _creds.password || 'changeme'; -// Token presented on /host WS. Normally written by install.sh into -// credentials.json. Required — fail-fast happens after early-exit subcommands. -const HOST_TOKEN = process.env.CODETTE_HOST_KEY || process.env.HOST_KEY - || _creds.hostKey || null; const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); const E2E_ENABLED = process.env.E2E !== '0'; // Client-originated types that must arrive encrypted under e2e. Server-initiated @@ -181,9 +179,23 @@ if (process.argv[2] === 'update') { // --no-dir-privacy: disable cwd-restriction check on get_fs / get_file const NO_DIR_PRIVACY = process.argv.includes('--no-dir-privacy'); +// ── login subcommand ────────────────────────────────────────────────────────── +if (process.argv[2] === 'login') { + try { + const { runLogin, PromptAborted } = await import('./login.js'); + await runLogin({ serverUrl: SERVER_URL, keyFilePath: KEY_FILE }); + process.exit(0); + } catch (e) { + if (e?.name === 'PromptAborted') { process.exit(130); } + process.stderr.write(`Login failed: ${e.message}\n`); + process.exit(1); + } +} + if (process.argv.includes('--help') || process.argv.includes('-h')) { process.stdout.write(`Usage: codette [options] - codette update Pull latest source + reinstall dependencies + codette login Register this host with the server + codette update Pull latest source + reinstall dependencies Options: -s, --server Server WebSocket URL @@ -198,20 +210,18 @@ Options: Config precedence: CLI flags > env vars > ~/.config/codette/credentials.json > defaults Environment variables: - CODETTE_SERVER_URL WebSocket server URL (default: ws://localhost:3000) - CODETTE_USERNAME Username shown in chat (default: whoami) - CODETTE_PASSWORD Password for web login (default: changeme) - CODETTE_HOST_KEY Token issued by the server (required; install.sh writes it) + CODETTE_SERVER_URL WebSocket server URL (default: ws://localhost:3000) + CODETTE_USERNAME Username shown in chat (default: whoami) + CODETTE_PASSWORD Password for web login (default: changeme) -Legacy env vars also supported: SERVER_URL, CLIENT_USERNAME, CLIENT_PASSWORD, HOST_KEY +Legacy env vars also supported: SERVER_URL, CLIENT_USERNAME, CLIENT_PASSWORD `); process.exit(0); } -if (!HOST_TOKEN) { - process.stderr.write('codette: no host token configured.\n'); - process.stderr.write(' Run the installer: curl -fsSL https:///install.sh | sh\n'); - process.stderr.write(' Or set CODETTE_HOST_KEY in the environment.\n'); +if (!_creds.username && !_cli.username && !process.env.CODETTE_USERNAME && !process.env.CLIENT_USERNAME) { + process.stderr.write('codette: no username configured.\n'); + process.stderr.write(' Run: codette login\n'); process.exit(1); } @@ -706,8 +716,20 @@ async function checkUpdate() { } catch {} } -function connect() { - ws = new WebSocket(`${SERVER_URL}/host?token=${encodeURIComponent(HOST_TOKEN)}&clientUsername=${encodeURIComponent(CLIENT_USERNAME)}`); +async function connect() { + const serverHttp = SERVER_URL.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:'); + let proof; + try { + proof = await signHandshakeProof({ + keyFilePath: KEY_FILE, + aud: serverHttp + '/host', + }); + } catch (e) { + log('error', 'failed to sign handshake proof', { err: e.message }); + setTimeout(connect, 3000); + return; + } + ws = new WebSocket(`${SERVER_URL}/host?proof=${encodeURIComponent(proof)}&clientUsername=${encodeURIComponent(CLIENT_USERNAME)}`); ws.on('open', () => { hr(); @@ -884,13 +906,13 @@ function connect() { ws.on('close', () => { log('warn', 'disconnected from server, reconnecting…'); - setTimeout(connect, 3000); + setTimeout(() => connect().catch(e => log('error', 'reconnect failed', { err: e.message })), 3000); }); ws.on('error', (e) => log('error', `ws error: ${e.message}`)); } -connect(); +connect().catch(e => { log('error', 'connect failed', { err: e.message }); process.exit(1); }); // ── Graceful shutdown ───────────────────────────────────────────────────────── function shutdown() { diff --git a/host/login.js b/host/login.js new file mode 100644 index 0000000..51e316c --- /dev/null +++ b/host/login.js @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// `codette login` — X2 self-signed-identity registration. +// Signs a host_proof with host-key.pem, opens the browser to the consent page, +// and polls /register/status until the binding is confirmed. + +import { randomBytes } from 'crypto'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { execSync, spawn } from 'child_process'; +import readline from 'readline'; +import { signHostProof } from './auth.js'; + +function base64UrlEncode(buf) { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function openBrowser(url) { + const cmd = process.platform === 'darwin' ? 'open' + : process.platform === 'win32' ? 'start' + : 'xdg-open'; + try { + spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref(); + } catch { /* OK — user will use the printed URL */ } +} + +// ── Prompt helpers ──────────────────────────────────────────────────────────── +export class PromptAborted extends Error { + constructor() { super('Aborted by user'); this.name = 'PromptAborted'; } +} + +function makePrompt() { + const lines = []; + const waiters = []; + let aborted = false; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + rl.on('line', (line) => { + if (waiters.length > 0) waiters.shift().resolve(line); + else lines.push(line); + }); + + rl.on('SIGINT', () => { + process.stdout.write('\n'); + aborted = true; + const err = new PromptAborted(); + while (waiters.length) waiters.shift().reject(err); + rl.close(); + }); + + const ask = (question, fallback) => new Promise((resolve, reject) => { + if (aborted) return reject(new PromptAborted()); + process.stdout.write(`${question}${fallback !== undefined ? ` [${fallback}]` : ''}: `); + if (lines.length > 0) { + const answer = lines.shift(); + resolve(answer.trim() || fallback || ''); + } else { + waiters.push({ + resolve: (line) => resolve(line.trim() || fallback || ''), + reject, + }); + } + }); + + const close = () => rl.close(); + return { ask, close }; +} + +function defaultUsername() { + try { return execSync('whoami').toString().trim(); } catch { return 'user'; } +} + +function generatePassword() { + return randomBytes(8).toString('base64').replace(/[+/=]/g, '').slice(0, 10); +} + +// ── Username availability check ─────────────────────────────────────────────── +async function checkAvailability(serverHttp, name) { + let resp; + try { + resp = await fetch(`${serverHttp}/auth/username-available/${encodeURIComponent(name)}`); + } catch (e) { + throw new Error(`Could not reach the server (${serverHttp}): ${e.message}`); + } + const body = await resp.text(); + try { + return JSON.parse(body); + } catch { + const preview = body.slice(0, 60).replace(/\s+/g, ' '); + console.log(` (skipping availability check — HTTP ${resp.status}: ${preview}…)`); + return { available: true, _skipped: true }; + } +} + +// ── Main registration flow ──────────────────────────────────────────────────── +export async function runLogin({ serverUrl, keyFilePath }) { + const serverHttp = serverUrl.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:'); + console.log(`Server: ${serverUrl}`); + + const configDir = join(homedir(), '.config', 'codette'); + let existing = {}; + const credsPath = join(configDir, 'credentials.json'); + try { + if (existsSync(credsPath)) existing = JSON.parse(readFileSync(credsPath, 'utf8')); + } catch {} + + const prompter = makePrompt(); + + // ── Choose username ────────────────────────────────────────────────────────── + let username; + while (true) { + username = await prompter.ask('Username', existing.username || defaultUsername()); + if (!/^[a-z][a-z0-9_-]{1,31}$/.test(username)) { + console.log(' Invalid: lowercase, start with a letter, 2–32 chars from [a-z0-9_-]'); + continue; + } + const { available, reason } = await checkAvailability(serverHttp, username); + if (available) break; + console.log(reason === 'invalid' ? ' Invalid username.' : ` '${username}' is already taken.`); + } + + // ── Browser password (for chat-domain HMAC auth — unrelated to X2 registration) ── + const password = await prompter.ask('Password', existing.password || generatePassword()); + prompter.close(); + + // ── Sign host_proof and assemble URL ───────────────────────────────────────── + const state = base64UrlEncode(randomBytes(16)); + const { jwt: hostProof, jwk } = await signHostProof({ + keyFilePath, + aud: serverHttp + '/register', + username, + }); + const jwkB64 = base64UrlEncode(Buffer.from(JSON.stringify(jwk))); + + const registerUrl = `${serverHttp}/register/start?` + new URLSearchParams({ + state, + username, + jwk: jwkB64, + host_proof: hostProof, + idp: 'trial', + }); + + console.log('\nOpen: ' + registerUrl + '\n'); + openBrowser(registerUrl); + + // ── Poll /register/status ───────────────────────────────────────────────── + console.log('Waiting for registration…'); + const deadline = Date.now() + 5 * 60 * 1000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 500)); + let resp; + try { + resp = await fetch(`${serverHttp}/register/status?state=${encodeURIComponent(state)}`); + } catch { + continue; // network blip + } + if (!resp.ok) continue; + const { status } = await resp.json(); + if (status === 'claimed') break; + if (status === 'error') { + throw new Error('Registration failed on the server. Check the browser for details.'); + } + if (status === 'expired') { + throw new Error('Registration session expired. Run codette login again.'); + } + } + + if (Date.now() >= deadline) { + throw new Error('Registration timed out. Run codette login again.'); + } + + // ── Write credentials.json ──────────────────────────────────────────────── + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + writeFileSync( + credsPath, + JSON.stringify({ server: serverUrl, username, password }, null, 2), + { mode: 0o600 } + ); + + console.log('\n✓ Registered. Run `codette` to start the host.'); +} diff --git a/host/package-lock.json b/host/package-lock.json index cc96d58..37569e7 100644 --- a/host/package-lock.json +++ b/host/package-lock.json @@ -1,12 +1,38 @@ { "name": "codette", +<<<<<<< Updated upstream "version": "0.1.2", +======= + "version": "0.1.0", +>>>>>>> Stashed changes "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codette", +<<<<<<< Updated upstream "version": "0.1.2", +======= + "version": "0.1.0", + "dependencies": { + "jsonwebtoken": "^9.0.2", + "ws": "^8.17.0" + }, + "bin": { + "codette": "index.js" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", +>>>>>>> Stashed changes "license": "Apache-2.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.145", diff --git a/init.sh b/init.sh index c7a3ff8..fa5f8c8 100755 --- a/init.sh +++ b/init.sh @@ -19,18 +19,21 @@ ask() { echo "${val:-$2}" } -HOST_KEY=$(openssl rand -hex 32) DEFAULT_HOSTNAME=$(hostname -f 2>/dev/null || echo "localhost") SERVER_HOSTNAME=$(ask "Server hostname" "$DEFAULT_HOSTNAME") +PUBLIC_URL="https://$SERVER_HOSTNAME" cat > "$ENV_FILE" <&2 - echo "Obtain one by piping the server-hosted installer: curl -fsSL /install.sh | sh" >&2 - echo "Or set CODETTE_HOST_KEY in the environment before running this script." >&2 - exit 1 -fi echo "Installing codette host..." @@ -68,63 +56,34 @@ fi # 2. Install host dependencies (cd "$INSTALL_DIR/host" && npm ci --silent) -# 3. Prompt for username and password -DEFAULT_USER="$(whoami)" -DEFAULT_PASS="$(LC_ALL=C tr -dc 'a-zA-Z0-9' /dev/tty < "$CONFIG_DIR/credentials.json" < "$CONFIG_DIR/config.json" <=11.0.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/playwright": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", @@ -71,6 +87,27 @@ "engines": { "node": ">=18" } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 76cda0a..fb6c031 100644 --- a/package.json +++ b/package.json @@ -10,5 +10,9 @@ }, "devDependencies": { "@playwright/test": "^1.52.0" + }, + "dependencies": { + "jose": "^6.2.3", + "ws": "^8.21.0" } } diff --git a/run_dev.sh b/run_dev.sh index 6a34103..1aa965f 100755 --- a/run_dev.sh +++ b/run_dev.sh @@ -6,13 +6,16 @@ set -e ROOT="$(cd "$(dirname "$0")" && pwd)" -# Local-dev defaults. HOST_KEY is regenerated per run and shared via env so -# alice/bob can use the master-shortcut without going through install.sh. -export HOST_KEY="${HOST_KEY:-$(openssl rand -hex 32 2>/dev/null || node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')}" export SERVER_URL="ws://localhost:3000" export SERVER_HOSTNAME="localhost:3000" +export PUBLIC_URL="http://localhost:3000" export PORT=3000 +# Per-user isolated data dirs (like tests/start-test-env.js) +ALICE_DATA="$ROOT/.dev-data/alice" +BOB_DATA="$ROOT/.dev-data/bob" +X2_DATA="$ROOT/.dev-data/x2" + SERVER_LOG=/tmp/e2e-server.log HOST1_LOG=/tmp/e2e-host1.log HOST2_LOG=/tmp/e2e-host2.log @@ -37,25 +40,57 @@ fi fuser -k "$PORT"/tcp 2>/dev/null || true sleep 0.3 +# Fresh data dirs — keep .claude symlinks +for d in alice bob; do + rm -rf "$ROOT/.dev-data/$d" + mkdir -p "$ROOT/.dev-data/$d/.claude" + ln -sf ~/.claude/.credentials.json "$ROOT/.dev-data/$d/.claude/.credentials.json" 2>/dev/null || true +done +rm -rf "$X2_DATA" +mkdir -p "$X2_DATA" + echo "==> Building client (dev mode)..." (cd "$ROOT/client" && npx vite build --mode development) echo "==> Starting server on :$PORT ($SERVER_LOG)" -(cd "$ROOT/server" && node src/index.js) >"$SERVER_LOG" 2>&1 & +(cd "$ROOT/server" && X2_DATA_DIR="$X2_DATA" node src/index.js) >"$SERVER_LOG" 2>&1 & SERVER_PID=$! -mkdir -p "$ROOT/.dev-data/alice/.claude" "$ROOT/.dev-data/bob/.claude" -# Symlink Claude credentials so dev hosts can spawn Claude -for d in "$ROOT/.dev-data/alice/.claude" "$ROOT/.dev-data/bob/.claude"; do - [ -L "$d/.credentials.json" ] || ln -sf ~/.claude/.credentials.json "$d/.credentials.json" +# Wait for server to be ready +for i in $(seq 1 20); do + sleep 0.3 + if curl -sf "http://localhost:$PORT/" >/dev/null 2>&1; then break; fi done +# ── Register alice and bob via headless helper ───────────────────────────────── +echo "==> Registering alice and bob..." +node --input-type=module <<'JSEOF' +import { headlessRegister, generateTestKeypair } from '/home/danlkv/math_code/codette/_worktrees/x2-login/tests/oauth-flow.js'; +import { writeFileSync } from 'fs'; +const BASE = 'http://localhost:3000'; + +for (const username of ['alice', 'bob']) { + const kp = await generateTestKeypair(); + await headlessRegister({ serverBase: BASE, username, ...kp }); + // Write key path to a temp file so the host can find it + writeFileSync(`/tmp/codette-dev-${username}-keydir`, kp.dir); + console.log(`registered ${username} -> ${kp.dir}`); +} +JSEOF + +ALICE_KEYDIR=$(cat /tmp/codette-dev-alice-keydir) +BOB_KEYDIR=$(cat /tmp/codette-dev-bob-keydir) + echo "==> Starting host1: alice ($HOST1_LOG)" -(cd "$ROOT/host" && CODETTE_DATA_HOME="$ROOT/.dev-data/alice" CLAUDE_CONFIG_DIR="$ROOT/.dev-data/alice/.claude" node index.js --server "$SERVER_URL" --username alice --password pass1 --no-dir-privacy --permission-mode default) >"$HOST1_LOG" 2>&1 & +(cd "$ROOT/host" && CODETTE_DATA_HOME="$ALICE_KEYDIR" CLAUDE_CONFIG_DIR="$ALICE_DATA/.claude" \ + node index.js --server "$SERVER_URL" --username alice --password pass1 --no-dir-privacy --permission-mode default) \ + >"$HOST1_LOG" 2>&1 & HOST1_PID=$! echo "==> Starting host2: bob ($HOST2_LOG)" -(cd "$ROOT/host" && CODETTE_DATA_HOME="$ROOT/.dev-data/bob" CLAUDE_CONFIG_DIR="$ROOT/.dev-data/bob/.claude" node index.js --server "$SERVER_URL" --username bob --password pass2) >"$HOST2_LOG" 2>&1 & +(cd "$ROOT/host" && CODETTE_DATA_HOME="$BOB_KEYDIR" CLAUDE_CONFIG_DIR="$BOB_DATA/.claude" \ + node index.js --server "$SERVER_URL" --username bob --password pass2) \ + >"$HOST2_LOG" 2>&1 & HOST2_PID=$! printf '%s\n' "$SERVER_PID" "$HOST1_PID" "$HOST2_PID" > "$PIDFILE" diff --git a/server/package-lock.json b/server/package-lock.json index d715634..f1949cd 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,14 +1,23 @@ { "name": "codette-server", +<<<<<<< Updated upstream "version": "0.1.2", +======= + "version": "1.0.0", +>>>>>>> Stashed changes "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codette-server", +<<<<<<< Updated upstream "version": "0.1.2", "license": "Apache-2.0", +======= + "version": "1.0.0", +>>>>>>> Stashed changes "dependencies": { + "cookie-parser": "^1.4.7", "express": "^5.0.0", "jose": "^6.0.0", "ws": "^8.20.1" @@ -120,6 +129,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", diff --git a/server/package.json b/server/package.json index e92673a..2e23521 100644 --- a/server/package.json +++ b/server/package.json @@ -4,6 +4,7 @@ "license": "Apache-2.0", "type": "module", "dependencies": { + "cookie-parser": "^1.4.7", "express": "^5.0.0", "jose": "^6.0.0", "ws": "^8.20.1" diff --git a/server/src/index.js b/server/src/index.js index 5e3f993..1ccb0e9 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -7,67 +7,25 @@ import { jwtVerify, importSPKI } from 'jose'; import { createServer } from 'http'; import { fileURLToPath } from 'url'; import path from 'path'; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { readFileSync, existsSync } from 'fs'; import { execFileSync } from 'child_process'; -import { randomBytes } from 'crypto'; +import cookieParser from 'cookie-parser'; import { RpcClient } from './rpc.js'; import { unpackParam } from '../../shared/crypto.js'; +import { mountRegisterRoutes } from './x2/register.js'; +import { lookupByPubkey } from './x2/owners.js'; +import { verifyHandshakeProof } from './x2/ws-auth.js'; +import { makeJtiCache } from './x2/jti-cache.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Optional master-shortcut. Unset = only per-IP tokens accepted. -const HOST_KEY = process.env.HOST_KEY; -const HOST_TOKEN_TTL = parseInt(process.env.HOST_TOKEN_TTL || '0', 10); // 0 = never expires -if (HOST_TOKEN_TTL === 0) { - console.warn('[server] HOST_TOKEN_TTL=0 (host tokens never expire). Set a finite value (e.g. 2592000 = 30 days) in .env.'); -} - -const PORT = parseInt(process.env.PORT || '3000', 10); - -// ── Host token store ───────────────────────────────────────────────────────── -// Tokens are persisted in /data/host-tokens.json (Docker volume). -// Each entry is keyed by IP: { token, created, expires, label? } -const TOKEN_FILE = '/data/host-tokens.json'; +const PORT = parseInt(process.env.PORT || '3000', 10); -function loadTokens() { - try { return existsSync(TOKEN_FILE) ? JSON.parse(readFileSync(TOKEN_FILE, 'utf8')) : {}; } catch { return {}; } -} - -function saveTokens(tokens) { - mkdirSync('/data', { recursive: true }); - writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2)); -} - -function validateToken(token) { - if (HOST_KEY && token === HOST_KEY) return true; // master shortcut, when configured - const tokens = loadTokens(); - const entry = Object.values(tokens).find(e => e.token === token); - if (!entry) return false; - if (entry.expires > 0 && Date.now() > entry.expires) return false; - return true; -} - -function getOrCreateTokenForIp(ip) { - const tokens = loadTokens(); - const existing = tokens[ip]; - if (existing) { - if (existing.expires > 0 && Date.now() > existing.expires) { - return { error: 'Token expired for this address' }; - } - return { token: existing.token }; - } - // Issue new token - const token = randomBytes(16).toString('hex'); - const now = Date.now(); - tokens[ip] = { - token, - created: now, - expires: HOST_TOKEN_TTL > 0 ? now + HOST_TOKEN_TTL * 1000 : 0, - }; - saveTokens(tokens); - return { token }; -} +// Server issuer URL (used as iss in id_tokens and as expected audience root) +const SERVER_ISSUER = process.env.PUBLIC_URL || `http://localhost:${PORT}`; +// JTI cache for WS handshake proofs (separate from registration jti cache) +const wsJtiCache = makeJtiCache(); // ── Per-host state ──────────────────────────────────────────────────────────── class HostContext { @@ -98,10 +56,14 @@ const clients = new Map(); // username → Set const app = express(); app.set('trust proxy', true); app.use(express.json()); +app.use(cookieParser()); + +// ── X2 registration routes ──────────────────────────────────────────────────── +mountRegisterRoutes(app, SERVER_ISSUER); // ── REST request logging ────────────────────────────────────────────────────── // Query params that are bearer-credential-shaped and must never appear in logs. -const REDACTED_QS_KEYS = new Set(['token', 'access_token', 'auth']); +const REDACTED_QS_KEYS = new Set(['token', 'access_token', 'auth', 'host_proof', 'id_token']); app.use((req, res, next) => { res.on('finish', () => { if (req.path.startsWith('/api/')) { @@ -162,9 +124,6 @@ function requireHost(req, res, next) { } // ── Rate limiting ───────────────────────────────────────────────────────────── -// Limiters key on req.ip. Under `trust proxy: true`, that value reflects -// X-Forwarded-For — pin trust proxy to a specific upstream before relying -// on these limits. function makeRateLimit(label, windowMs, max) { const attempts = new Map(); // ip → { count, resetAt } setInterval(() => { @@ -191,21 +150,16 @@ function makeRateLimit(label, windowMs, max) { }; } -// Tiered limits. apiLimiter is a broad flood guard; expensiveLimiter wraps -// host-RPC handlers (history/file/fs/git) that force the host to do work; -// installLimiter and tarballLimiter protect unauthenticated endpoints that -// issue credentials or shell out per request. -const authRateLimit = makeRateLimit('auth', 60_000, 10); const apiLimiter = makeRateLimit('api', 60_000, 600); const expensiveLimiter = makeRateLimit('expensive', 60_000, 60); -const installLimiter = makeRateLimit('install', 60_000, 5); const tarballLimiter = makeRateLimit('tarball', 60_000, 5); app.use('/api', apiLimiter); -app.use('/install.sh', installLimiter); app.use('/host.tar.gz', tarballLimiter); // ── Auth ────────────────────────────────────────────────────────────────────── +const authRateLimit = makeRateLimit('auth', 60_000, 10); + app.post('/api/auth/challenge', authRateLimit, (req, res) => { const { username } = req.body || {}; wtrace('client', 'server', 'auth_challenge'); @@ -229,7 +183,6 @@ app.post('/api/auth/verify', authRateLimit, (req, res) => { wtrace('host', 'server', 'auth_verify'); if (res.headersSent) return; if (err) return res.status(401).json({ error: err.message }); - // username cookie is a non-credential routing hint; Secure when over TLS. const secure = req.secure ? '; Secure' : ''; res.setHeader('Set-Cookie', `username=${encodeURIComponent(username)}; Path=/; SameSite=Strict${secure}`); res.json(result); @@ -363,23 +316,17 @@ app.delete('/api/sessions/:id', requireJwt, requireHost, (req, res) => { // ── Install script ─────────────────────────────────────────────────────────── const installShPath = path.resolve(__dirname, '../../install.sh'); -// Hostname is read from env, never from request headers — the value is -// interpolated into a shell script every installer pipes to `sh`. app.get('/install.sh', (req, res) => { const hostname = process.env.SERVER_HOSTNAME; if (!hostname) { return res.status(503).type('text/plain') .send('# SERVER_HOSTNAME not configured on server. Set it in .env before serving installs.\n'); } - const ip = req.ip; - const { token, error } = getOrCreateTokenForIp(ip); - if (error) return res.status(403).type('text/plain').send(`# Error: ${error}\n`); const isLocal = /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(hostname); const wsProto = isLocal ? 'ws' : 'wss'; const serverUrl = `${wsProto}://${hostname}`; let script = readFileSync(installShPath, 'utf8'); script = script.replace('SERVER_URL="${CODETTE_SERVER_URL:-}"', `SERVER_URL="${serverUrl}"`); - script = script.replace('HOST_KEY="${CODETTE_HOST_KEY:-}"', `HOST_KEY="${token}"`); res.type('text/plain').send(script); }); @@ -391,7 +338,7 @@ if (existsSync(hostDir)) { readFileSync(path.join(hostDir, 'package.json'), 'utf8') ).version; app.get('/version', (_req, res) => res.json({ host: hostPkgVersion })); - app.get('/host.tar.gz', (_req, res) => { + app.get('/host.tar.gz', tarballLimiter, (_req, res) => { try { const tar = execFileSync('tar', [ 'czf', '-', '-C', appRoot, 'host', 'shared', @@ -415,18 +362,39 @@ wss.on('connection', async (ws, req) => { // ── Host connection ──────────────────────────────────────────────────────── if (url.pathname === '/host') { - if (!validateToken(url.searchParams.get('token'))) { ws.close(1008, 'Unauthorized'); return; } + const proof = url.searchParams.get('proof'); const clientUsername = url.searchParams.get('clientUsername'); - if (!clientUsername) { ws.close(1008, 'clientUsername required'); return; } - if (hosts.has(clientUsername)) { ws.close(1008, 'Host already connected for this username'); return; } - const host = new HostContext(clientUsername, ws); - hosts.set(clientUsername, host); - console.log(`[server] host connected: ${clientUsername} (${hosts.size} total)`); - wtrace('host', 'server', 'connect', { username: clientUsername }); + // Validate via X2 handshake proof + const validated = await verifyHandshakeProof({ + proofJwt: proof, + lookupByPubkey, + expectedAud: SERVER_ISSUER + '/host', + jtiCache: wsJtiCache, + }); + + if (!validated) { + ws.close(1008, 'Unauthorized'); + return; + } + if (!clientUsername) { + ws.close(1008, 'clientUsername required'); + return; + } + if (clientUsername !== validated.username) { + ws.close(1008, 'clientUsername mismatch'); + return; + } + if (hosts.has(validated.username)) { + ws.close(1008, 'Host already connected for this username'); + return; + } + + const host = new HostContext(validated.username, ws); + hosts.set(validated.username, host); + console.log(`[server] host connected: ${validated.username} (${hosts.size} total)`); + wtrace('host', 'server', 'connect', { username: validated.username }); - // Don't request sessions here — wait for a client to connect and send - // capabilities first, so the host can latch e2e before responding. host.broadcast({ type: 'host_status', connected: true }); ws.on('message', (data) => { @@ -438,17 +406,14 @@ wss.on('connection', async (ws, req) => { if (ev?.type === 'host_pubkey') { wtrace('host', 'server', 'host_pubkey'); host.pubkey = ev.pubkey; - // Eagerly import as a Promise — verify sites just `await host.pubkeyKey`. - // A rejected import will surface as a verification failure (caught downstream). host.pubkeyKey = importSPKI(ev.pubkey, 'ES256'); - host.pubkeyKey.catch(e => console.error(`[server] host pubkey import failed (${clientUsername}):`, e.message)); - console.log(`[server] host pubkey registered: ${clientUsername}`); + host.pubkeyKey.catch(e => console.error(`[server] host pubkey import failed (${validated.username}):`, e.message)); + console.log(`[server] host pubkey registered: ${validated.username}`); return; } if (ev?.type === 'claude_line') { wtrace('host', 'server', 'claude_line'); - // Pass through opaquely — may contain {sessionId, line} or {nonce, ciphertext} host.broadcast(ev); wtrace('server', 'client', 'claude_line', { sessionId: ev.sessionId?.slice(0, 8) ?? null }); return; @@ -469,7 +434,6 @@ wss.on('connection', async (ws, req) => { if (ev?.type === 'session_list') { wtrace('host', 'server', 'session_list'); if (ev.ciphertext) { - // E2e: cache encrypted blob for REST endpoint host.sessionListResponse = { nonce: ev.nonce, ciphertext: ev.ciphertext }; } else { host.sessionCache = ev.sessions || []; @@ -478,7 +442,6 @@ wss.on('connection', async (ws, req) => { } host.broadcast(ev); wtrace('server', 'client', 'session_list', { sessionId: null }); - // Resolve pending deletes from cache (plaintext only; under e2e, delete waits for next plaintext session_list) if (!ev.ciphertext) { const remainingIds = new Set(host.sessionCache.map(s => s.id)); for (const [sessionId, res] of host.pendingDelete) { @@ -493,18 +456,16 @@ wss.on('connection', async (ws, req) => { }); ws.on('close', () => { - hosts.delete(clientUsername); + hosts.delete(validated.username); host.rpc.flush(); - console.log(`[server] host disconnected: ${clientUsername} (${hosts.size} remaining)`); - wtrace('host', 'server', 'disconnect', { username: clientUsername }); + console.log(`[server] host disconnected: ${validated.username} (${hosts.size} remaining)`); + wtrace('host', 'server', 'disconnect', { username: validated.username }); host.broadcast({ type: 'host_status', connected: false }); }); - ws.on('error', (e) => console.error(`[server] host error (${clientUsername}):`, e.message)); + ws.on('error', (e) => console.error(`[server] host error (${validated.username}):`, e.message)); // ── Client connection ────────────────────────────────────────────────────── } else if (url.pathname === '/ws') { - // Same trust model as requireJwt: cookie/query `username` is untrusted, - // JWT is host-issued, payload.username must match. const token = url.searchParams.get('token'); const username = getCookie(req, 'username') ?? url.searchParams.get('username'); const host = hosts.get(username); diff --git a/server/src/x2/csrf.js b/server/src/x2/csrf.js new file mode 100644 index 0000000..3ad5c86 --- /dev/null +++ b/server/src/x2/csrf.js @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// Minimal double-submit CSRF cookie pattern. +// Issue: set a random cookie + return the same value for the hidden form field. +// Verify: compare cookie against the submitted form field (constant-time-ish via +// early-return on mismatch, acceptable for a 32-hex token space). + +import { randomBytes } from 'crypto'; + +const COOKIE_NAME = 'x2_csrf'; + +export function issueCsrfCookie(res, secure) { + const tok = randomBytes(16).toString('hex'); + res.cookie(COOKIE_NAME, tok, { + httpOnly: true, + sameSite: 'lax', + secure: !!secure, + }); + return tok; +} + +export function verifyCsrf(req) { + const cookieTok = req.cookies?.[COOKIE_NAME]; + const formTok = req.body?.csrf; + return !!(cookieTok && formTok && cookieTok === formTok); +} diff --git a/server/src/x2/idtoken.js b/server/src/x2/idtoken.js new file mode 100644 index 0000000..8610e7a --- /dev/null +++ b/server/src/x2/idtoken.js @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// id_token minting (trial) and verification (self-issued or future IdPs). + +import { SignJWT, jwtVerify } from 'jose'; +import { randomBytes } from 'crypto'; +import { loadOrGenerateIdTokenKey } from './keys.js'; + +/** + * Issue a self-signed trial id_token for the registration flow. + * sub = jkt (the host's public-key fingerprint). + */ +export async function issueSelfTrialIdToken({ jkt, username, serverIssuer }) { + const { privateKey } = await loadOrGenerateIdTokenKey(); + return new SignJWT({ username, iss_idp: 'self' }) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuer(serverIssuer) + .setSubject(jkt) + .setAudience(serverIssuer + '/register/callback') + .setIssuedAt() + .setExpirationTime('5m') + .setJti(randomBytes(16).toString('hex')) + .sign(privateKey); +} + +/** + * Verify any id_token. + * + * knownIssuers: { self: serverIssuer, ... } + * + * For v1, only the 'self' branch is implemented. + * Future: verify against external IdP JWKS by iss. + * + * Returns { sub, username, iss_idp } on success; throws on failure. + */ +export async function verifyAnyIdToken({ idToken, expectedAud, knownIssuers }) { + // Decode without verifying to get iss + const [, payloadB64] = idToken.split('.'); + let iss; + try { + iss = JSON.parse(Buffer.from(payloadB64, 'base64').toString()).iss; + } catch { + throw new Error('id_token: malformed payload'); + } + + const selfIssuer = knownIssuers?.self; + if (selfIssuer && iss === selfIssuer) { + const { publicKey } = await loadOrGenerateIdTokenKey(); + const { payload } = await jwtVerify(idToken, publicKey, { + audience: expectedAud, + algorithms: ['ES256'], + issuer: selfIssuer, + }); + return { + sub: payload.sub, + username: payload.username, + iss_idp: payload.iss_idp || 'self', + }; + } + + // TODO: External IdP dispatch — verify against IdP's JWKS when iss is in + // a configured allow-list. Not implemented in v1. + throw new Error(`id_token: unsupported issuer "${iss}"`); +} diff --git a/server/src/x2/jti-cache.js b/server/src/x2/jti-cache.js new file mode 100644 index 0000000..b08a45f --- /dev/null +++ b/server/src/x2/jti-cache.js @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// In-memory JTI dedup cache with TTL eviction. +// Keyed by jti string; value is exp epoch (seconds). +// Has() evicts expired entries on lookup. + +export function makeJtiCache() { + const map = new Map(); // jti → exp (seconds) + + function has(jti) { + if (!map.has(jti)) return false; + const exp = map.get(jti); + if (Math.floor(Date.now() / 1000) > exp) { + map.delete(jti); + return false; + } + return true; + } + + function mark(jti, exp) { + map.set(jti, exp); + } + + // Periodic eviction sweep (every 5 min) to bound memory usage. + const timer = setInterval(() => { + const now = Math.floor(Date.now() / 1000); + for (const [jti, exp] of map) { + if (now > exp) map.delete(jti); + } + }, 5 * 60 * 1000); + if (timer.unref) timer.unref(); + + return { has, mark }; +} diff --git a/server/src/x2/keys.js b/server/src/x2/keys.js new file mode 100644 index 0000000..05022ca --- /dev/null +++ b/server/src/x2/keys.js @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// Generate / load the server's id_token signing keypair. +// Stored under $X2_DATA_DIR (default: /data/x2) as id-key.pem (PKCS8 private key). +// Auto-generated on first run. + +import { generateKeyPairSync, createPrivateKey, createPublicKey } from 'crypto'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { importPKCS8, importSPKI, exportJWK } from 'jose'; + +function dataDir() { + return process.env.X2_DATA_DIR || '/data/x2'; +} + +function keyFile() { + return join(dataDir(), 'id-key.pem'); +} + +let _privateKey = null; // CryptoKey (jose) +let _publicKey = null; // CryptoKey (jose) +let _publicJwk = null; // JWK object + +export async function loadOrGenerateIdTokenKey() { + if (_privateKey) return { privateKey: _privateKey, publicKey: _publicKey, publicJwk: _publicJwk }; + + mkdirSync(dataDir(), { recursive: true, mode: 0o700 }); + + let privPem, pubPem; + if (existsSync(keyFile())) { + privPem = readFileSync(keyFile(), 'utf8'); + pubPem = createPublicKey(createPrivateKey(privPem)) + .export({ type: 'spki', format: 'pem' }); + } else { + const kp = generateKeyPairSync('ec', { + namedCurve: 'P-256', + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + privPem = kp.privateKey; + pubPem = kp.publicKey; + writeFileSync(keyFile(), privPem, { mode: 0o600 }); + console.log('[x2/keys] generated new id_token signing key at', keyFile()); + } + + _privateKey = await importPKCS8(privPem, 'ES256'); + _publicKey = await importSPKI(pubPem, 'ES256'); + _publicJwk = await exportJWK(_publicKey); + + return { privateKey: _privateKey, publicKey: _publicKey, publicJwk: _publicJwk }; +} diff --git a/server/src/x2/owners.js b/server/src/x2/owners.js new file mode 100644 index 0000000..78b2476 --- /dev/null +++ b/server/src/x2/owners.js @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// Username ↔ pubkey-fingerprint binding store. +// +// Schema of $X2_DATA_DIR/username-owners.json: +// { +// byName: { "alice": { fp, claimedAt, idp, idp_sub } }, +// byPubkey: { "": { username, jwk } } +// } +// +// First-to-claim wins. Both uniqueness constraints (username, pubkey) are +// checked and written atomically (single sync read-modify-write). + +import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'; +import { join } from 'path'; + +function dataDir() { + return process.env.X2_DATA_DIR || '/data/x2'; +} + +function file() { + return join(dataDir(), 'username-owners.json'); +} + +function load() { + try { return JSON.parse(readFileSync(file(), 'utf8')); } + catch { return { byName: {}, byPubkey: {} }; } +} + +function save(data) { + mkdirSync(dataDir(), { recursive: true, mode: 0o700 }); + const path = file(); + const tmp = path + '.tmp'; + writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }); + renameSync(tmp, path); +} + +const NAME_RE = /^[a-z][a-z0-9_-]{1,31}$/; + +export function isValidUsername(name) { + return typeof name === 'string' && NAME_RE.test(name); +} + +export function isUsernameClaimed(name) { + if (!isValidUsername(name)) return false; + return !!load().byName[name]; +} + +export function isPubkeyClaimed(fp) { + return !!load().byPubkey[String(fp)]; +} + +export function lookupByName(name) { + return load().byName[String(name)] || null; +} + +export function lookupByPubkey(fp) { + return load().byPubkey[String(fp)] || null; +} + +/** + * Atomically claim a (username, pubkey) binding. + * Returns: + * 'claimed' — newly bound + * 'name-taken' — username already bound to a different pubkey + * 'pubkey-taken'— pubkey already bound to a different username + * 'invalid' — username fails validation + */ +export function claimBinding(name, fp, jwk, { idp, idp_sub }) { + if (!isValidUsername(name)) return 'invalid'; + const data = load(); + if (data.byName[name]) return 'name-taken'; + if (data.byPubkey[String(fp)]) return 'pubkey-taken'; + data.byName[name] = { fp, claimedAt: Date.now(), idp, idp_sub }; + data.byPubkey[String(fp)] = { username: name, jwk }; + save(data); + return 'claimed'; +} diff --git a/server/src/x2/proof.js b/server/src/x2/proof.js new file mode 100644 index 0000000..160a611 --- /dev/null +++ b/server/src/x2/proof.js @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// Verify a host_proof JWT submitted during /register/start. +// +// host_proof payload: +// { iss: jkt, aud: /register, username, iat, exp: now+5min, jti } +// Signed by the host's private key; the matching public key JWK is also +// supplied in the same request (querystring param `jwk`). + +import { importJWK, jwtVerify, calculateJwkThumbprint } from 'jose'; + +/** + * Verify a host_proof JWT. + * + * @param {object} opts + * proofJwt — the signed JWT string + * jwk — the JWK object (host's public key) + * expectedAud — expected audience string (e.g. "https://server/register") + * expectedUsername — username asserted in the proof + * jtiCache — { has(jti): bool, mark(jti, exp): void } + * + * @returns { jkt, username } on success + * @throws Error on any validation failure + */ +export async function verifyHostProof({ proofJwt, jwk, expectedAud, expectedUsername, jtiCache }) { + // 1. Import the JWK as a crypto key for verification + let key; + try { + key = await importJWK(jwk, 'ES256'); + } catch (e) { + throw new Error('host_proof: invalid JWK: ' + e.message); + } + + // 2. Compute the RFC 7638 thumbprint of the supplied JWK + let jkt; + try { + jkt = await calculateJwkThumbprint(jwk, 'sha256'); + } catch (e) { + throw new Error('host_proof: thumbprint computation failed: ' + e.message); + } + + // 3. Verify JWT signature + claims + let payload; + try { + ({ payload } = await jwtVerify(proofJwt, key, { + audience: expectedAud, + algorithms: ['ES256'], + })); + } catch (e) { + throw new Error('host_proof: jwt verification failed: ' + e.message); + } + + // 4. username claim must match + if (payload.username !== expectedUsername) { + throw new Error('host_proof: username mismatch'); + } + + // 5. iat freshness (within 5 min of now) + const nowSec = Math.floor(Date.now() / 1000); + if (!payload.iat || Math.abs(nowSec - payload.iat) > 300) { + throw new Error('host_proof: iat out of range'); + } + + // 6. iss must equal the JWK thumbprint (proves key ownership) + if (payload.iss !== jkt) { + throw new Error('host_proof: iss does not match JWK thumbprint'); + } + + // 7. jti dedup + if (!payload.jti) throw new Error('host_proof: missing jti'); + if (jtiCache.has(payload.jti)) throw new Error('host_proof: jti already seen (replay)'); + jtiCache.mark(payload.jti, payload.exp ?? nowSec + 600); + + return { jkt, username: payload.username }; +} diff --git a/server/src/x2/register.js b/server/src/x2/register.js new file mode 100644 index 0000000..9e0a9c2 --- /dev/null +++ b/server/src/x2/register.js @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// X2 registration endpoints: +// GET /register/start +// POST /register/finish-trial +// GET /register/callback +// GET /register/status +// GET /auth/username-available/:name + +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import path from 'path'; +import { importJWK } from 'jose'; +import express from 'express'; +import { verifyHostProof } from './proof.js'; +import { issueSelfTrialIdToken, verifyAnyIdToken } from './idtoken.js'; +import { issueCsrfCookie, verifyCsrf } from './csrf.js'; +import { claimIfAllowed, revokeTrialClaim } from './trial.js'; +import { isValidUsername, isUsernameClaimed, claimBinding } from './owners.js'; +import { makeJtiCache } from './jti-cache.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Pre-load HTML templates at module startup +const CONSENT_HTML = readFileSync(path.join(__dirname, 'views/consent.html'), 'utf8'); +const DONE_HTML = readFileSync(path.join(__dirname, 'views/done.html'), 'utf8'); +const ERROR_HTML = readFileSync(path.join(__dirname, 'views/error.html'), 'utf8'); + +function renderError(res, { title, message, hint }) { + return res.status(400).type('html').send( + ERROR_HTML + .replace('__TITLE__', escapeHtml(title || 'Error')) + .replace('__MESSAGE__', escapeHtml(message || '')) + .replace('__HINT__', hint || '') + ); +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Pending registrations: state → { username, jwk, jkt, idp, expires, ip } +const pending = new Map(); + +// Status map: state → 'pending' | 'claimed' | 'error' +const statusMap = new Map(); + +// Per-request JTI cache (shared across all incoming proofs for this server lifetime) +const jtiCache = makeJtiCache(); + +// Evict expired pending entries every 60 s +setInterval(() => { + const now = Date.now(); + for (const [state, entry] of pending) { + if (now > entry.expires) { + pending.delete(state); + if (statusMap.get(state) === 'pending') statusMap.set(state, 'expired'); + } + } +}, 60_000).unref?.(); + +/** + * Mount all X2 registration routes on an Express app. + * @param {import('express').Application} app + * @param {string} serverIssuer — e.g. "https://your-server.example.com" + */ +export function mountRegisterRoutes(app, serverIssuer) { + + // ── GET /register/start ───────────────────────────────────────────────────── + app.get('/register/start', async (req, res) => { + const { state, username, jwk: jwkB64, host_proof, idp } = req.query; + + if (!state || !username || !jwkB64 || !host_proof || !idp) { + return renderError(res, { + title: 'Missing parameters', + message: 'state, username, jwk, host_proof, and idp are all required.', + hint: 'Run codette login to start again.', + }); + } + + // Decode JWK + let jwk; + try { + jwk = JSON.parse(Buffer.from(jwkB64, 'base64url').toString()); + } catch { + return renderError(res, { + title: 'Invalid JWK', + message: 'Could not decode the supplied JWK.', + hint: 'Run codette login to start again.', + }); + } + + // Validate JWK is importable + try { + await importJWK(jwk, 'ES256'); + } catch (e) { + return renderError(res, { + title: 'Invalid JWK', + message: e.message, + hint: 'Run codette login to start again.', + }); + } + + // Verify host_proof + let jkt; + try { + ({ jkt } = await verifyHostProof({ + proofJwt: host_proof, + jwk, + expectedAud: serverIssuer + '/register', + expectedUsername: String(username), + jtiCache, + })); + } catch (e) { + return renderError(res, { + title: 'Invalid host proof', + message: e.message, + hint: 'Run codette login to start again.', + }); + } + + // Username validation + const name = String(username).toLowerCase(); + if (!isValidUsername(name)) { + return renderError(res, { + title: 'Invalid username', + message: `"${escapeHtml(name)}" is not a valid username.`, + hint: 'Lowercase, start with a letter, 2–32 chars from [a-z0-9_-].', + }); + } + if (isUsernameClaimed(name)) { + return renderError(res, { + title: 'Username taken', + message: `"${escapeHtml(name)}" is already registered.`, + hint: 'Run codette login and choose a different username.', + }); + } + + // Store pending + pending.set(state, { + username: name, + jwk, + jkt, + idp: String(idp), + expires: Date.now() + 5 * 60 * 1000, + ip: req.ip, + }); + statusMap.set(state, 'pending'); + + // Branch by idp + if (idp === 'trial') { + const csrf = issueCsrfCookie(res, req.secure); + return res.type('html').send( + CONSENT_HTML + .replace('__USERNAME__', escapeHtml(name)) + .replace('__STATE__', escapeHtml(state)) + .replace('__CSRF__', escapeHtml(csrf)) + ); + } + + // TODO: external IdP redirect + return renderError(res, { + title: 'IdP not supported', + message: `idp="${escapeHtml(idp)}" is not implemented yet.`, + hint: 'Use idp=trial or wait for a future release.', + }); + }); + + // ── POST /register/finish-trial ────────────────────────────────────────────── + app.post('/register/finish-trial', express.urlencoded({ extended: false }), async (req, res) => { + // CSRF check + if (!verifyCsrf(req)) { + return renderError(res, { + title: 'CSRF validation failed', + message: 'Your session may have expired. Please try again.', + hint: 'Run codette login to start a fresh registration.', + }); + } + + const { state } = req.body || {}; + if (!state) { + return renderError(res, { + title: 'Missing state', + message: 'No state parameter in the form submission.', + hint: 'Run codette login to start again.', + }); + } + + const entry = pending.get(state); + if (!entry || Date.now() > entry.expires) { + pending.delete(state); + return renderError(res, { + title: 'Session expired', + message: 'The registration session has expired.', + hint: 'Run codette login to start again.', + }); + } + + if (entry.idp !== 'trial') { + return renderError(res, { + title: 'IdP mismatch', + message: 'This endpoint is only for trial registrations.', + hint: '', + }); + } + + // Rate limit check + const ip = req.ip; + if (!claimIfAllowed(ip)) { + return renderError(res, { + title: 'Rate limit exceeded', + message: 'Too many trial registrations from this IP address.', + hint: 'Wait before trying again.', + }); + } + + // Issue self id_token and redirect to callback + let idToken; + try { + idToken = await issueSelfTrialIdToken({ + jkt: entry.jkt, + username: entry.username, + serverIssuer, + }); + } catch (e) { + revokeTrialClaim(ip); + return renderError(res, { + title: 'Token issuance failed', + message: e.message, + hint: 'Try again in a moment.', + }); + } + + return res.redirect( + `/register/callback?state=${encodeURIComponent(state)}&id_token=${encodeURIComponent(idToken)}` + ); + }); + + // ── GET /register/callback ─────────────────────────────────────────────────── + app.get('/register/callback', async (req, res) => { + const { state, id_token } = req.query; + + if (!state || !id_token) { + return renderError(res, { + title: 'Missing parameters', + message: 'state and id_token are required.', + hint: 'Run codette login to start again.', + }); + } + + const entry = pending.get(state); + if (!entry || Date.now() > entry.expires) { + pending.delete(state); + return renderError(res, { + title: 'Session expired', + message: 'The registration session has expired.', + hint: 'Run codette login to start again.', + }); + } + + // Verify id_token + let tokenPayload; + try { + tokenPayload = await verifyAnyIdToken({ + idToken: id_token, + expectedAud: serverIssuer + '/register/callback', + knownIssuers: { self: serverIssuer }, + }); + } catch (e) { + statusMap.set(state, 'error'); + return renderError(res, { + title: 'Invalid id_token', + message: e.message, + hint: 'Run codette login to start again.', + }); + } + + // Assert sub matches jkt and username matches + if (tokenPayload.sub !== entry.jkt) { + statusMap.set(state, 'error'); + return renderError(res, { + title: 'Identity mismatch', + message: 'id_token subject does not match host key fingerprint.', + hint: 'Run codette login to start again.', + }); + } + if (tokenPayload.username !== entry.username) { + statusMap.set(state, 'error'); + return renderError(res, { + title: 'Username mismatch', + message: 'id_token username does not match pending registration.', + hint: 'Run codette login to start again.', + }); + } + + // Atomic binding claim + const result = claimBinding(entry.username, entry.jkt, entry.jwk, { + idp: tokenPayload.iss_idp, + idp_sub: tokenPayload.sub, + }); + + if (result === 'name-taken') { + if (entry.idp === 'trial') revokeTrialClaim(entry.ip); + statusMap.set(state, 'error'); + pending.delete(state); + return renderError(res, { + title: 'Username taken', + message: `"${escapeHtml(entry.username)}" was claimed by someone else.`, + hint: 'Run codette login and choose a different username.', + }); + } + if (result === 'pubkey-taken') { + if (entry.idp === 'trial') revokeTrialClaim(entry.ip); + statusMap.set(state, 'error'); + pending.delete(state); + return renderError(res, { + title: 'Key already registered', + message: 'This host key is already bound to another username.', + hint: 'Delete host-key.pem and run codette login again to generate a fresh key.', + }); + } + + // Success + pending.delete(state); + statusMap.set(state, 'claimed'); + + return res.type('html').send( + DONE_HTML.replace(/__USERNAME__/g, escapeHtml(entry.username)) + ); + }); + + // ── GET /register/status ───────────────────────────────────────────────────── + app.get('/register/status', (req, res) => { + const { state } = req.query; + if (!state) return res.status(400).json({ status: 'error', reason: 'missing state' }); + + const entry = pending.get(state); + const status = statusMap.get(state); + + // Expired but still in pending map + if (entry && Date.now() > entry.expires) { + pending.delete(state); + statusMap.set(state, 'expired'); + return res.json({ status: 'expired' }); + } + + if (status) return res.json({ status }); + + // Unknown state + return res.json({ status: 'expired' }); + }); + + // ── GET /auth/username-available/:name ─────────────────────────────────────── + app.get('/auth/username-available/:name', (req, res) => { + const name = String(req.params.name || '').toLowerCase(); + if (!isValidUsername(name)) return res.json({ available: false, reason: 'invalid' }); + res.json({ available: !isUsernameClaimed(name) }); + }); +} + diff --git a/server/src/x2/trial.js b/server/src/x2/trial.js new file mode 100644 index 0000000..c87a3a6 --- /dev/null +++ b/server/src/x2/trial.js @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// Trial claim rate limiting. Tracks successful claims per IP in a sliding window. +// Persists to $X2_DATA_DIR/trial-claims.json. + +import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'; +import { join } from 'path'; + +function file() { + const dir = process.env.X2_DATA_DIR || '/data/x2'; + return join(dir, 'trial-claims.json'); +} + +function loadAll() { + try { return JSON.parse(readFileSync(file(), 'utf8')); } catch { return {}; } +} + +function saveAll(data) { + mkdirSync(process.env.X2_DATA_DIR || '/data/x2', { recursive: true, mode: 0o700 }); + const path = file(); + const tmp = path + '.tmp'; + writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }); + renameSync(tmp, path); +} + +const DEFAULT_MAX = 5; +const DEFAULT_WINDOW_MS = 15 * 24 * 60 * 60 * 1000; + +function maxClaims() { return parseInt(process.env.TRIAL_MAX_CLAIMS || String(DEFAULT_MAX), 10); } +function windowMs() { return parseInt(process.env.TRIAL_WINDOW_MS || String(DEFAULT_WINDOW_MS), 10); } + +function pruneIp(claims, ip) { + const cutoff = Date.now() - windowMs(); + claims[ip] = (claims[ip] || []).filter(t => t > cutoff); + if (claims[ip].length === 0) delete claims[ip]; +} + +export function checkTrialRateLimit(ip) { + const claims = loadAll(); + pruneIp(claims, ip); + return (claims[ip] || []).length < maxClaims(); +} + +export function recordTrialClaim(ip) { + const claims = loadAll(); + pruneIp(claims, ip); + if (!claims[ip]) claims[ip] = []; + claims[ip].push(Date.now()); + saveAll(claims); +} + +// Atomic check-and-record: loads, prunes, checks the limit, and appends in one +// synchronous read-modify-write cycle. Returns true if the claim was recorded +// (allowed), false if blocked. +export function claimIfAllowed(ip) { + const claims = loadAll(); + pruneIp(claims, ip); + if ((claims[ip] || []).length >= maxClaims()) return false; + if (!claims[ip]) claims[ip] = []; + claims[ip].push(Date.now()); + saveAll(claims); + return true; +} + +// Revoke the most recent claim for an IP (roll-back on downstream failure). +export function revokeTrialClaim(ip) { + const claims = loadAll(); + pruneIp(claims, ip); + if (!claims[ip] || claims[ip].length === 0) return; + claims[ip].pop(); + if (claims[ip].length === 0) delete claims[ip]; + saveAll(claims); +} + +export function _resetForTest() { /* no-op — file-backed */ } diff --git a/server/src/x2/views/consent.html b/server/src/x2/views/consent.html new file mode 100644 index 0000000..a24b173 --- /dev/null +++ b/server/src/x2/views/consent.html @@ -0,0 +1,98 @@ + + + + + + + +codette — register + + + +
+
codette
+

Register host as __USERNAME__.

+
+ + + +
+
+ + diff --git a/server/src/x2/views/done.html b/server/src/x2/views/done.html new file mode 100644 index 0000000..d3e7420 --- /dev/null +++ b/server/src/x2/views/done.html @@ -0,0 +1,76 @@ + + + + + + + +codette — registered + + + +
+
codette
+

Registered as __USERNAME__.

+

You can close this tab. Return to your terminal.

+
+ + diff --git a/server/src/x2/views/error.html b/server/src/x2/views/error.html new file mode 100644 index 0000000..0bf6f61 --- /dev/null +++ b/server/src/x2/views/error.html @@ -0,0 +1,87 @@ + + + + + + + +codette — error + + + +
+
codette
+

__TITLE__

+

__MESSAGE__

+
__HINT__
+
+ + diff --git a/server/src/x2/ws-auth.js b/server/src/x2/ws-auth.js new file mode 100644 index 0000000..a31494e --- /dev/null +++ b/server/src/x2/ws-auth.js @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// Verify a WS handshake proof JWT for /host connections. +// +// Handshake JWT (signed by host-priv): +// { iss: jkt, aud: /host, iat, exp: now+60s, jti } +// +// The server looks up the binding by jkt, verifies the signature, and +// enforces iat freshness + SERVER_START_TIME kill-switch + jti dedup. + +import { jwtVerify, decodeJwt, importJWK } from 'jose'; + +// SERVER_START_TIME: any JWT issued before this epoch is rejected. +// Invalidates all outstanding proofs when the server restarts. +export const SERVER_START_TIME = Math.floor(Date.now() / 1000); + +/** + * Verify a host handshake proof. + * + * @param {object} opts + * proofJwt — the JWT string + * lookupByPubkey — (fp) => {username, jwk} | null + * expectedAud — audience string (e.g. "https://server/host") + * jtiCache — { has(jti): bool, mark(jti, exp): void } + * + * @returns { username, jkt } on success, or null on any failure + */ +export async function verifyHandshakeProof({ proofJwt, lookupByPubkey, expectedAud, jtiCache }) { + // Decode without verifying to extract iss (the jkt) + let unverified; + try { + unverified = decodeJwt(proofJwt); + } catch { + return null; + } + + const fp = unverified.iss; + if (!fp) return null; + + const binding = lookupByPubkey(fp); + if (!binding) return null; + + // Import the stored JWK and verify + let key; + try { + key = await importJWK(binding.jwk, 'ES256'); + } catch { + return null; + } + + let payload; + try { + ({ payload } = await jwtVerify(proofJwt, key, { + audience: expectedAud, + algorithms: ['ES256'], + })); + } catch { + return null; + } + + // iat freshness: within 5 minutes + const nowSec = Math.floor(Date.now() / 1000); + if (!payload.iat || Math.abs(nowSec - payload.iat) > 300) return null; + + // iat kill-switch: must not predate server start + if (payload.iat < SERVER_START_TIME) return null; + + // jti dedup + if (!payload.jti) return null; + if (jtiCache.has(payload.jti)) return null; + jtiCache.mark(payload.jti, payload.exp ?? nowSec + 120); + + return { username: binding.username, jkt: fp }; +} diff --git a/tests/e2e-register.spec.js b/tests/e2e-register.spec.js new file mode 100644 index 0000000..e358667 --- /dev/null +++ b/tests/e2e-register.spec.js @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// E2E: X2 registration flow +// 1. Generate an ES256 keypair + sign host_proof +// 2. page.goto /register/start → expect consent page +// 3. Click "Try without registration" → expect done page +// 4. fetch /register/status → expect {status: 'claimed'} +// 5. Open WS to /host with a fresh signed handshake → expect it to upgrade + +import { test, expect } from '@playwright/test'; +import { randomBytes } from 'crypto'; +import { WebSocket } from 'ws'; +import { generateTestKeypair } from './oauth-flow.js'; +import { signHandshakeProof } from '../host/auth.js'; +import { _resetKeyCache } from '../host/auth.js'; +import { SignJWT } from 'jose'; + +const TEST_PORT = process.env.TEST_PORT || '3111'; +const SERVER_BASE = `http://localhost:${TEST_PORT}`; +const SERVER_WS = `ws://localhost:${TEST_PORT}`; + +function b64url(buf) { + return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +test('X2 registration: browser consent click → /register/status claimed', async ({ page }) => { + const username = 'e2e-' + randomBytes(4).toString('hex'); + const state = b64url(randomBytes(16)); + + const { jwk, jkt, privateKeyJose } = await generateTestKeypair(); + + // Sign host_proof + const hostProof = await new SignJWT({ username }) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuer(jkt) + .setAudience(`${SERVER_BASE}/register`) + .setIssuedAt() + .setExpirationTime('5m') + .setJti(randomBytes(16).toString('hex')) + .sign(privateKeyJose); + + const jwkB64 = b64url(Buffer.from(JSON.stringify(jwk))); + const startUrl = `${SERVER_BASE}/register/start?` + new URLSearchParams({ + state, username, jwk: jwkB64, host_proof: hostProof, idp: 'trial', + }); + + // Navigate to consent page + await page.goto(startUrl); + await expect(page.locator('.brand', { hasText: 'codette' })).toBeVisible(); + await expect(page.locator('.uname', { hasText: username })).toBeVisible(); + + // Click the consent button + await page.locator('button', { hasText: /without registration/ }).click(); + + // Should land on done page + await expect(page.locator('.brand', { hasText: 'codette' })).toBeVisible(); + await expect(page.locator('.ok')).toBeVisible(); + + // Poll status + let status = null; + for (let i = 0; i < 20; i++) { + const res = await page.request.get(`${SERVER_BASE}/register/status?state=${encodeURIComponent(state)}`); + const body = await res.json(); + status = body.status; + if (status === 'claimed') break; + await page.waitForTimeout(300); + } + expect(status).toBe('claimed'); +}); + +test('X2 WS handshake: registered host can connect via signed proof', async () => { + const username = 'ws-' + randomBytes(4).toString('hex'); + const state = b64url(randomBytes(16)); + + const { keyFilePath, jwk, jkt, privateKeyJose, dir } = await generateTestKeypair(); + + // Sign host_proof and register via headless helper + const { headlessRegister } = await import('./oauth-flow.js'); + await headlessRegister({ serverBase: SERVER_BASE, username, keyFilePath, jwk, jkt, privateKeyJose }); + + // Sign WS handshake proof using the same key + // Reset cache so auth.js loads the test key + _resetKeyCache(); + const proof = await signHandshakeProof({ + keyFilePath, + aud: `${SERVER_BASE}/host`, + }); + _resetKeyCache(); + + // Open WS to /host + await new Promise((resolve, reject) => { + const ws = new WebSocket( + `${SERVER_WS}/host?proof=${encodeURIComponent(proof)}&clientUsername=${encodeURIComponent(username)}` + ); + const timer = setTimeout(() => { + ws.terminate(); + reject(new Error('WS connect timeout')); + }, 5000); + + ws.on('open', () => { + clearTimeout(timer); + ws.close(1000, 'test done'); + resolve(); + }); + + ws.on('error', (e) => { + clearTimeout(timer); + reject(e); + }); + + ws.on('unexpected-response', (_req, res) => { + clearTimeout(timer); + reject(new Error(`WS upgrade rejected: ${res.statusCode}`)); + }); + }); +}); diff --git a/tests/oauth-flow.js b/tests/oauth-flow.js new file mode 100644 index 0000000..87ce89c --- /dev/null +++ b/tests/oauth-flow.js @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// Headless X2 registration helper. Used by start-test-env.js and Playwright +// tests to bind a keypair to a username without needing a browser. +// +// Flow: +// 1. Generate an ES256 keypair (or use a provided key file) +// 2. Sign host_proof +// 3. GET /register/start → get consent page + CSRF cookie +// 4. POST /register/finish-trial → follow redirect to /register/callback +// 5. GET /register/callback → confirm 'done' +// 6. Poll /register/status until 'claimed' + +import { generateKeyPairSync, createPrivateKey, createPublicKey } from 'crypto'; +import { writeFileSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { importPKCS8, exportJWK, calculateJwkThumbprint, SignJWT } from 'jose'; +import { randomBytes } from 'crypto'; + +function b64url(buf) { + return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function makeJar() { + const jar = new Map(); + return { + consumeResponse(headers) { + const setCookies = typeof headers.getSetCookie === 'function' + ? headers.getSetCookie() + : (headers.get('set-cookie') ? [headers.get('set-cookie')] : []); + for (const sc of setCookies) { + const [pair] = sc.split(';'); + const eq = pair.indexOf('='); + if (eq < 0) continue; + const name = pair.slice(0, eq).trim(); + const value = pair.slice(eq + 1).trim(); + if (value === '') jar.delete(name); + else jar.set(name, value); + } + }, + header() { + if (jar.size === 0) return undefined; + return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); + }, + get(name) { return jar.get(name); }, + }; +} + +/** + * Generate a temporary ES256 keypair, write the private key to a temp file, + * and return { keyFilePath, jwk, jkt, loadKeyMaterial }. + */ +export async function generateTestKeypair() { + const kp = generateKeyPairSync('ec', { + namedCurve: 'P-256', + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const dir = join(tmpdir(), 'codette-test-' + randomBytes(6).toString('hex')); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const keyFilePath = join(dir, 'host-key.pem'); + writeFileSync(keyFilePath, kp.privateKey, { mode: 0o600 }); + + // Compute JWK + jkt (public key only) + const privateKeyJose = await importPKCS8(kp.privateKey, 'ES256', { extractable: true }); + const fullJwk = await exportJWK(privateKeyJose); + const { d: _d, ...jwk } = fullJwk; + const jkt = await calculateJwkThumbprint(jwk, 'sha256'); + + return { keyFilePath, jwk, jkt, privateKeyJose, dir }; +} + +/** + * Run a full headless X2 registration dance against the given server. + * Returns { username, keyFilePath, jkt, jwk }. + */ +export async function headlessRegister({ serverBase, username, keyFilePath, jwk, jkt, privateKeyJose }) { + if (!username) username = 'test-' + randomBytes(6).toString('hex'); + + // Generate keypair if not provided + if (!keyFilePath) { + const kp = await generateTestKeypair(); + keyFilePath = kp.keyFilePath; + jwk = kp.jwk; + jkt = kp.jkt; + privateKeyJose = kp.privateKeyJose; + } + + const state = b64url(randomBytes(16)); + const serverHttp = serverBase; + + // Sign host_proof + const hostProof = await new SignJWT({ username }) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuer(jkt) + .setAudience(serverHttp + '/register') + .setIssuedAt() + .setExpirationTime('5m') + .setJti(randomBytes(16).toString('hex')) + .sign(privateKeyJose); + + const jwkB64 = b64url(Buffer.from(JSON.stringify(jwk))); + + const jar = makeJar(); + const fetchWithJar = async (url, init = {}) => { + const headers = { ...(init.headers || {}) }; + const cookie = jar.header(); + if (cookie) headers.cookie = cookie; + const res = await fetch(url, { ...init, headers, redirect: 'manual' }); + jar.consumeResponse(res.headers); + return res; + }; + + // 1. GET /register/start → consent page + CSRF cookie + const startUrl = `${serverHttp}/register/start?` + new URLSearchParams({ + state, username, jwk: jwkB64, host_proof: hostProof, idp: 'trial', + }); + let res = await fetchWithJar(startUrl); + if (!res.ok) { + const body = await res.text(); + throw new Error(`/register/start failed (${res.status}): ${body.slice(0, 200)}`); + } + const html = await res.text(); + const csrfMatch = html.match(/name="csrf"\s+value="([^"]+)"/); + if (!csrfMatch) throw new Error('CSRF token not found in consent page'); + const csrf = csrfMatch[1]; + + // 2. POST /register/finish-trial → 302 to /register/callback + res = await fetchWithJar(`${serverHttp}/register/finish-trial`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ state, csrf }), + }); + if (res.status !== 302 && res.status !== 303) { + const body = await res.text(); + throw new Error(`/register/finish-trial expected redirect, got ${res.status}: ${body.slice(0, 200)}`); + } + const callbackUrl = new URL(res.headers.get('location'), serverHttp).href; + + // 3. GET /register/callback → done page + res = await fetchWithJar(callbackUrl); + if (!res.ok) { + const body = await res.text(); + throw new Error(`/register/callback failed (${res.status}): ${body.slice(0, 200)}`); + } + + // 4. Poll /register/status + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const statusRes = await fetch(`${serverHttp}/register/status?state=${encodeURIComponent(state)}`); + const { status } = await statusRes.json(); + if (status === 'claimed') break; + if (status === 'error') throw new Error('Registration failed on server'); + await new Promise(r => setTimeout(r, 100)); + } + + return { username, keyFilePath, jkt, jwk }; +} diff --git a/tests/start-test-env.js b/tests/start-test-env.js index 99b425e..1c17752 100644 --- a/tests/start-test-env.js +++ b/tests/start-test-env.js @@ -6,14 +6,15 @@ * Starts server + host on a test port for Playwright e2e tests. * Usage: TEST_PORT=3111 node tests/start-test-env.js * - * Mirrors run_dev.sh: symlinks ~/.claude credentials into an isolated - * data dir so the host can spawn Claude Code without leaking production state. + * Uses the headless X2 register helper to bind a test keypair before + * starting the host — mirrors run_dev.sh but without HOST_KEY. */ import { spawn } from 'child_process'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; -import { mkdirSync, symlinkSync, lstatSync, openSync, readFileSync, rmSync } from 'fs'; +import { mkdirSync, symlinkSync, openSync, readFileSync, rmSync } from 'fs'; import { homedir } from 'os'; +import { headlessRegister, generateTestKeypair } from './oauth-flow.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); @@ -21,17 +22,16 @@ const root = join(__dirname, '..'); const PORT = process.env.TEST_PORT || '3111'; const USERNAME = process.env.TEST_USERNAME || 'testuser'; const PASSWORD = process.env.TEST_PASSWORD || 'testpass'; -// Server refuses to start with the placeholder HOST_KEY; use a fixed test -// value so tests are deterministic but the server's safety check still works. -const HOST_KEY = process.env.HOST_KEY || 'test-host-key-do-not-use-in-prod'; // ── Isolated data dir (like run_dev.sh) ────────────────────────────────────── const dataDir = join(root, '.dev-data', USERNAME); const claudeDir = join(dataDir, '.claude'); +const x2DataDir = join(dataDir, 'x2'); // Clean slate: remove old session data but preserve credentials symlink rmSync(dataDir, { recursive: true, force: true }); mkdirSync(claudeDir, { recursive: true }); +mkdirSync(x2DataDir, { recursive: true }); const credSrc = join(homedir(), '.claude', '.credentials.json'); const credDst = join(claudeDir, '.credentials.json'); @@ -42,7 +42,6 @@ try { symlinkSync(credSrc, credDst); } catch (e) { let server, host; function cleanup() { - // SIGTERM lets host kill its Claude children gracefully host?.kill('SIGTERM'); server?.kill('SIGTERM'); setTimeout(() => { @@ -60,7 +59,13 @@ const hostLogFd = openSync('/tmp/e2e-host.log', 'w'); // ── Start server ────────────────────────────────────────────────────────────── server = spawn('node', ['server/src/index.js'], { cwd: root, - env: { ...process.env, PORT, HOST_KEY }, + env: { + ...process.env, + PORT, + X2_DATA_DIR: x2DataDir, + PUBLIC_URL: `http://localhost:${PORT}`, + SERVER_HOSTNAME: `localhost:${PORT}`, + }, stdio: ['ignore', serverLogFd, serverLogFd], }); @@ -70,13 +75,13 @@ server.on('exit', (code) => { process.exit(code ?? 1); }); -// ── Wait for server to bind, then start host ───────────────────────────────── +// ── Wait for server to bind ─────────────────────────────────────────────────── async function waitForPort(port, timeout = 10000) { const start = Date.now(); while (Date.now() - start < timeout) { try { const res = await fetch(`http://localhost:${port}/`); - if (res.ok) return; + if (res.ok || res.status === 404) return; // SPA fallback also counts } catch {} await new Promise(r => setTimeout(r, 200)); } @@ -92,18 +97,38 @@ try { process.exit(1); } +// ── Generate keypair and register with server ───────────────────────────────── +// This populates x2DataDir/username-owners.json so the host can connect. +let keypair; +try { + keypair = await generateTestKeypair(); + await headlessRegister({ + serverBase: `http://localhost:${PORT}`, + username: USERNAME, + keyFilePath: keypair.keyFilePath, + jwk: keypair.jwk, + jkt: keypair.jkt, + privateKeyJose: keypair.privateKeyJose, + }); + console.log(`[test-env] X2 registration succeeded for ${USERNAME}`); +} catch (e) { + console.error(`[test-env] X2 registration failed: ${e.message}`); + cleanup(); + process.exit(1); +} + +// ── Start host ──────────────────────────────────────────────────────────────── const hostEnv = { ...process.env, - SERVER_URL: `ws://localhost:${PORT}`, - CLIENT_USERNAME: USERNAME, - CLIENT_PASSWORD: PASSWORD, - CODETTE_DATA_HOME: dataDir, + SERVER_URL: `ws://localhost:${PORT}`, + CLIENT_USERNAME: USERNAME, + CLIENT_PASSWORD: PASSWORD, + CODETTE_DATA_HOME: keypair.dir, // dir containing host-key.pem CLAUDE_CONFIG_DIR: claudeDir, - HOST_KEY, }; delete hostEnv.CLAUDECODE; // allow Claude Code to spawn inside test env -const hostArgs = ['host/index.js']; +const hostArgs = ['host/index.js', '--server', `ws://localhost:${PORT}`, '--username', USERNAME]; if (process.env.TEST_BACKEND) hostArgs.push('--backend', process.env.TEST_BACKEND); host = spawn('node', hostArgs, {