Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions doc/auth.spec.md
Original file line number Diff line number Diff line change
@@ -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: <server>/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: <self>, sub: jkt, aud: <server>/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: <server>/host, iat, exp: now+60s, jti }`.
2. CLI opens WS: `wss://server/host?proof=<JWT>&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.
Expand Down
57 changes: 35 additions & 22 deletions doc/main.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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` |

Expand Down
14 changes: 13 additions & 1 deletion doc/protocol.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions host/auth.js
Original file line number Diff line number Diff line change
@@ -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 <serverHttp>/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 <serverHttp>/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;
}
Loading