diff --git a/.env.example b/.env.example index 4d024da..1dc480c 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,9 @@ PORT=3000 # server-generated metadata stays consistent (the share modal link is built # from this value in the browser). # ICLAW_CLOUD_URL=http://localhost:4000 + +# ── Remote Access (E2E alpha) ───────────────────────────────────────────────── +# WebSocket URL of iclaw-relay. Unset = Remote Access disabled in Settings UI. +# ICLAW_RELAY_URL=wss://relay.example.com/tunnel +# OPAQUE_SERVER_SETUP= # required when ICLAW_RELAY_URL is set +# # npx @serenity-kit/opaque create-server-setup diff --git a/.gitignore b/.gitignore index 5813da3..58f0788 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ data/ .env.local .DS_Store *.log -.claude \ No newline at end of file +*.tgz +.claude +public/js/vendor/opaque/ +public/js/vendor/noble/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1da5e4d..f15a769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to iClaw are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/) and the project uses [Semantic Versioning](https://semver.org/) starting at v0.1.0. +## [Unreleased] + +### Added + +- **Send-button discovery pill.** Above the Send button, "Did you know? Hold Send for more" surfaces the hidden long-press menu (Scheduled message / Create task). Server gates on an "ever-created" threshold — ≥ 2 tasks AND ≥ 3 scheduled messages, read from `sqlite_sequence` so the metric survives row deletion. Browser throttles to once per day. Auto-dismiss on textarea focus, Send pointerdown, or 12 s timer. +- **Sidebar discovery pill.** "Tip: right-click a chat for options" under the toolbar. Hides forever after the first contextmenu (or hover-hold) on a chat item; otherwise throttled to once per day. +- **Hover-hold gestures (1.5 s)** — open the same menus as long-press / right-click without clicking. Cursor parked on the Send button opens the schedule menu; cursor parked on a sidebar chat item opens the context menu. +- **Hover-intent auto-close (3.5 s)** for both menus. Mouse leaves the menu → 3.5 s timer; returns → timer resets. Replaces the schedule menu's old 10 s blanket timeout. +- **Dynamic favicon.** The browser tab icon is now canvas-rendered with Apple-style rounded corners, and carries up to two status dots aggregated across all chats/tasks: 🟠 needs-human, 🔵 chat ready / task-review, 🟢 working (priority orange → blue → green). It's a derived view of the sidebar status dots — repainted only when the verdict actually changes (debounced), never animated, so steady-state cost is zero. + +### Removed + +- Native browser tooltip on sidebar chat items (`title=""`). The full title is already visible inline; the tooltip just got in the way of the new hover-hold gesture. + ## [0.1.4] — 2026-05-26 ### Removed diff --git a/README.md b/README.md index 5eb1d4f..9913125 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,22 @@ Hit **Share** in any chat to get an encrypted link. The chat is encrypted in you Powered by [iClaw-cloud](https://github.com/iClawApp/iClaw-cloud) — defaults to `https://app.iclaw.digital`. +## Remote Access (alpha) + +Open the iClaw UI from another device through an **iclaw-relay** tunnel (Settings → Remote Access). + +**Security model (summary):** + +- **Relay access token** — blocks visitors who only guess the subdomain. +- **OPAQUE login** — passphrase is not sent in plaintext over the tunnel. +- **Encrypted HTTP/WebSocket** (E2E alpha) — payloads are encrypted between the browser and **local** iClaw; the relay forwards encrypted frames only. +- **Device sessions** — trusted browsers can reconnect without retyping the passphrase. +- **Alpha** — not externally audited. The relay still sees metadata (subdomain, timing, sizes, E2E endpoint paths). + +Two setups: **local mode** (iClaw + OpenClaw on the same laptop) and **host mode** (always-on machine, browse from phone/laptop). See [docs/REMOTE_ACCESS.md](../docs/REMOTE_ACCESS.md). + +Env on the iClaw host: `ICLAW_RELAY_URL` and `OPAQUE_SERVER_SETUP` (required when Remote Access is enabled). + --- ## For developers @@ -51,7 +67,13 @@ cd iClaw && npm install && npm run dev | Gateway | http://127.0.0.1:18789 (`OPENCLAW_BASE_URL`) | Optional env vars: [.env.example](.env.example). -Architecture + coding rules: [AGENTS.md](AGENTS.md). +Architecture + coding rules: [AGENTS.md](AGENTS.md). +Remote Access alpha: [docs/REMOTE_ACCESS.md](../docs/REMOTE_ACCESS.md), smoke [docs/REMOTE_ACCESS_SMOKE.md](../docs/REMOTE_ACCESS_SMOKE.md). + +```bash +npm run test:ra-smoke # E2E adversarial smoke (vitest) +npm run scan:relay-capture -- frames.ndjson # scan relay frame capture +``` ## Star history diff --git a/docs/REMOTE_ACCESS.md b/docs/REMOTE_ACCESS.md new file mode 100644 index 0000000..4587193 --- /dev/null +++ b/docs/REMOTE_ACCESS.md @@ -0,0 +1,176 @@ +# Remote Access (E2E alpha) + +Remote Access gives you a temporary public URL that lets you open this iClaw +from another device — your phone, another laptop, or someone you trust — +without opening any inbound port on your machine. + +> **Alpha. Not externally audited.** The encryption design below is real and +> tested, but it has not had an independent security review. Treat it as +> "much better than plaintext," not as a hardened, audited product. See +> [Security model](#security-model) for exactly what is and isn't protected. + +--- + +## How it works (one paragraph) + +Your local iClaw opens **one outbound WebSocket** to a public **relay** +(`relay.iclaw.digital`). The relay hands out a temporary subdomain +(`https://.iclaw.digital`) and forwards traffic back down that +WebSocket. A visitor opens the URL, passes the relay **access gate** (a +one-time `?access=` token baked into the link), then logs in with the +**passphrase** via **OPAQUE** (the passphrase never crosses the wire). After +login, every HTTP request and WebSocket message is **encrypted end-to-end** +between the visitor's browser and your local iClaw; the relay only ever sees +ciphertext envelopes. + +``` +[Browser] --TLS--> [Cloudflare] --> [relay] --(single WS)--> [your iClaw] + \__________ E2E (OPAQUE-derived keys) __________/ + relay sees only ciphertext + metadata +``` + +--- + +## Setup + +### 1. OPAQUE server setup — automatic + +Nothing to do. The first time you create a tunnel, iClaw generates an OPAQUE +server setup and stores it locally (in its SQLite, alongside the passphrases +and access tokens it already keeps). Fresh installs "just work". + +**Optional override** — set `OPAQUE_SERVER_SETUP` to pin a specific value +(advanced / shared-host scenarios). The env var always wins over the +auto-generated one: + +```bash +# generate one explicitly, if you want to control it: +npx @serenity-kit/opaque create-server-setup # prints a base64 string +OPAQUE_SERVER_SETUP= +``` + +- Keep it **secret** (don't commit it; `.env` should be git-ignored) and + **stable**. If it changes, existing tunnels' stored OPAQUE records are + invalidated and re-registered on next start. +- To run the **same** tunnel/passphrase across several of your machines, set + the **same** `OPAQUE_SERVER_SETUP` on each; otherwise each host gets its own + auto-generated value (independent). + +### 2. (Optional) point at a different relay + +By default iClaw uses the hosted relay: + +``` +wss://relay.iclaw.digital/tunnel +``` + +Override for local development against your own `iclaw-relay`: + +```bash +ICLAW_RELAY_URL=ws://127.0.0.1:4100/tunnel +``` + +### 3. Create a tunnel + +Open iClaw → gear icon → **Settings → Remote Access → Share**. Pick a name +and a duration (30 min / 12 h / 7 d / 30 d). You get: + +- a **URL** (`https://.iclaw.digital?access=…`) — already contains the + one-time relay access token, +- a **passphrase** (4 words + digits) — shown separately. + +Share **both**, ideally over a private channel. The visitor opens the URL and +enters the passphrase. + +--- + +## Local mode vs always-on host + +- **Local mode (default):** iClaw runs on your own machine and the tunnel + lives only while iClaw is running and the tunnel hasn't expired. Quitting + iClaw drops the tunnel; restarting within ~10 min restores the **same** URL + (the relay reserves the subdomain during a short grace window). After that + the URL changes on next start. +- **Always-on host:** run iClaw on a server you control (same `OPAQUE_SERVER_SETUP` + in its env) so the tunnel survives across your laptop sleeping. Same code, + just a longer-lived host. The relay is still in the trust boundary for + metadata (see below). + +--- + +## Managing access + +- **New access link** (per tunnel) rotates the relay access token: old links + and any already-issued access cookies stop working immediately; a fresh + `?access=` link is minted. The passphrase and URL host stay the same. +- **Devices:** after a successful passphrase login, a browser registers a + device keypair (private key stays in the browser) and appears under + *Connected devices*; revoke individual devices from the tunnel card. + > **Alpha limitation:** the E2E session keys live only in the tab's + > `sessionStorage`, so reopening the link in a new tab (or after closing it) + > currently **requires re-entering the passphrase** to re-derive them — the + > device record updates "last seen" but does not yet skip the passphrase for + > the encrypted session. Device-based E2E resume is planned, not shipped. +- **Disable** tears the tunnel down immediately; the URL 404s. + +--- + +## Security model + +**What is protected** + +- **Passphrase** — never sent over the wire. OPAQUE proves knowledge of it + without transmitting it; the relay cannot learn it. +- **HTTP & WebSocket payloads that carry your data** — encrypted end-to-end + (AES-256-GCM with per-stream subkeys derived from the OPAQUE session key via + HKDF). Page contents (the rendered HTML), `/api` calls, chat messages, + uploads/media, and the live WebSocket all travel as ciphertext envelopes; the + relay cannot read their paths, bodies, responses, or content. +- **Tamper / replay** — each record is authenticated (GCM tag over a context + AAD: tunnelId, streamId, direction, counter, frame kind, relay binding) and + a per-stream monotonic counter ledger rejects replays. + +**What the relay still sees (metadata)** + +- The subdomain in use, connection timing, and approximate request/response + **sizes**. +- Which **E2E endpoints** are hit (`/__ra/e2e/http`, `/__ra/e2e/ws`) and the + bootstrap/OPAQUE/gate-asset requests that happen before the encrypted + session is established. +- The **public app-shell assets** — stylesheets, scripts and icons under + `/css/*`, `/js/*` (incl. vendored libs) and the favicons. These are loaded by + the browser via ``/` + + +`; +} + +/* ----------------------------------------------------------- middleware -- */ + +export const remoteAccessAuthMiddleware: RequestHandler = (req, res, next) => { + // Direct localhost / non-tunneled → never interfere. + if (!isTunneledRequest(req)) return next(); + + const tunnelId = getTunnelIdFromRequest(req); + if (!tunnelId || !isGateEnabled(tunnelId)) { + // Tunneled request claiming to belong to an unknown tunnel — refuse + // hard. Should be impossible in normal flow since the loopback + // injects both headers itself, but be loud if it ever happens. + res.status(404).type('text/plain').send('tunnel not found'); + return; + } + + // POST /__ra/login is handled by a separate route; let it through. + if (req.method === 'POST' && req.path === LOGIN_PATH) return next(); + + // OPAQUE login (E2E password handshake). + if (req.method === 'POST' && isOpaqueLoginPath(req.path)) return next(); + + if (req.path.startsWith('/__ra/e2e/')) return next(); + + // Device challenge-response (no cookie yet). + if (req.method === 'POST' && req.path.startsWith('/__ra/device/')) return next(); + + // Public app-shell static assets (css/js/icons) — needed by the gate page and + // by the workspace after an E2E-delivered page is written to the DOM. No user + // data; served without a session. (static is mounted after this middleware.) + if (req.method === 'GET' && (isGatePublicAsset(req.path) || isPublicStaticAsset(req.path))) { + return next(); + } + + const cookies = parseCookieHeader(req.headers.cookie); + if (isValidSession(cookies[SESSION_COOKIE], tunnelId)) return next(); + + const nextUrl = req.originalUrl && req.originalUrl !== LOGIN_PATH ? req.originalUrl : '/'; + res + .status(401) + .type('html') + .send(renderGateLoginPage({ tunnelId, next: nextUrl })); +}; + +export const remoteAccessLoginHandler: RequestHandler = (req, res) => { + const tunnelId = getTunnelIdFromRequest(req); + const expected = tunnelId ? passphrases.get(tunnelId) : undefined; + if (!tunnelId || !expected) { + res.status(404).type('text/plain').send('not found'); + return; + } + + if (isTunneledRequest(req)) { + const json = wantsJsonResponse(req); + if (json) { + res.status(426).json({ error: E2E_LOGIN_REQUIRED_MSG, login: 'opaque' }); + return; + } + res.status(426).type('text/plain').send(E2E_LOGIN_REQUIRED_MSG); + return; + } + + const nextUrl = + typeof req.body?.next === 'string' && req.body.next.startsWith('/') + ? req.body.next + : '/'; + const json = wantsJsonResponse(req); + + // Rate-limit per (ip, tunnelId) so a flood against one tunnel doesn't + // burn another tunnel's budget on the same IP. + const ip = (req.ip ?? 'unknown').toString(); + const limitKey = `${ip}|${tunnelId}`; + const limit = checkRateLimit(limitKey); + if (!limit.ok) { + res.setHeader('Retry-After', String(limit.retryAfterSec)); + const msg = `Too many attempts. Try again in ${limit.retryAfterSec}s.`; + if (json) { + res.status(429).json({ error: msg, retryAfterSec: limit.retryAfterSec }); + return; + } + res + .status(429) + .type('html') + .send(renderGateLoginPage({ + tunnelId, + errorMessage: msg, + next: nextUrl, + })); + return; + } + + const submitted = typeof req.body?.passphrase === 'string' ? req.body.passphrase : ''; + if (!constantTimeEquals(submitted, expected)) { + if (json) { + res.status(401).json({ error: 'Wrong passphrase.' }); + return; + } + res + .status(401) + .type('html') + .send(renderGateLoginPage({ + tunnelId, + errorMessage: 'Wrong passphrase.', + next: nextUrl, + })); + return; + } + + attachSessionCookie(res, tunnelId, req); + + const reg = req.body?.registerDevice as Record | undefined; + let deviceId: string | undefined; + if (reg && typeof reg.publicKey === 'string' && isValidDevicePublicKey(reg.publicKey)) { + const device = remoteAccessDevices.register({ + tunnelId, + publicKey: reg.publicKey, + name: typeof reg.name === 'string' ? reg.name : null, + userAgent: + typeof reg.userAgent === 'string' + ? reg.userAgent + : (req.headers['user-agent'] ?? '').toString(), + }); + deviceId = device.id; + } + + if (json) { + res.json({ ok: true, next: nextUrl, deviceId: deviceId ?? null }); + return; + } + res.redirect(303, nextUrl); +}; + +/* ----------------------------------------------------- header sanitation -- */ + +/** + * Header names that must NEVER come from a public client through the + * tunnel — they're meant to be set only by the iClaw loopback forwarder. + * The relay also strips every `x-iclaw-*`; this is defence in depth on + * the iClaw side. + */ +export const INTERNAL_TUNNEL_HEADER_NAMES = ['x-iclaw-tunneled', 'x-iclaw-tunnel-id']; + +export function stripInternalHeaders(headers: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (INTERNAL_TUNNEL_HEADER_NAMES.includes(k.toLowerCase())) continue; + out[k] = v; + } + return out; +} + +export const TUNNELED_HEADER = 'x-iclaw-tunneled'; +export const TUNNELED_VALUE = '1'; +export const TUNNEL_ID_HEADER = 'x-iclaw-tunnel-id'; + +/** + * Standalone session check for code paths that don't run through Express + * middleware (notably the WS-upgrade forwarder in `remoteAccess.ts`). + * Returns true only when the supplied tunnelId has an enabled gate AND + * the cookie carries a valid session id bound to THAT tunnel. + */ +export function isValidTunnelSession( + tunnelId: string, + cookieHeader: string | undefined, +): boolean { + if (!passphrases.has(tunnelId)) return false; + const cookies = parseCookieHeader(cookieHeader); + return isValidSession(cookies[SESSION_COOKIE], tunnelId); +} diff --git a/src/services/remoteAccessCaptureScan.ts b/src/services/remoteAccessCaptureScan.ts new file mode 100644 index 0000000..c9d77d7 --- /dev/null +++ b/src/services/remoteAccessCaptureScan.ts @@ -0,0 +1,85 @@ +/** + * Scan relay/iClaw tunnel frame captures for secrets and app plaintext (E2E smoke). + */ + +export interface CaptureScanHit { + rule: string; + detail: string; +} + +export interface CaptureScanResult { + ok: boolean; + hits: CaptureScanHit[]; +} + +/** Patterns that must NOT appear in relay-visible payloads when E2E transport is active. */ +export const E2E_FORBIDDEN_CAPTURE_RULES: ReadonlyArray<{ + id: string; + test: (text: string) => boolean; + describe: string; +}> = [ + { + id: 'raw-passphrase-field', + describe: 'JSON/form passphrase field', + test: (t) => /"passphrase"\s*:\s*"/.test(t) || /passphrase=/.test(t), + }, + { + id: 'iclaw-ra-cookie', + describe: 'iclaw_ra session cookie value', + test: (t) => /iclaw_ra=[A-Za-z0-9%._-]{8,}/.test(t), + }, + { + id: 'html-doctype', + describe: 'HTML document', + test: (t) => /]/i.test(t), + }, + { + id: 'chat-api-json', + describe: 'chat/API JSON bodies', + test: (t) => + /"chatId"\s*:/.test(t) || + /"type"\s*:\s*"(message-appended|turn-delta|subscribe)"/.test(t), + }, + { + id: 'ws-subscribe-plain', + describe: 'WS subscribe plaintext', + test: (t) => /"type"\s*:\s*"subscribe"/.test(t) && !/"ct"\s*:/.test(t), + }, +]; + +/** Allowed visible strings on relay when E2E is on (routing metadata). */ +export const E2E_ALLOWED_SUBSTRINGS = [ + '/__ra/e2e/http', + '/__ra/e2e/ws', + '/__ra/opaque/', + '"ct"', + '"sid"', +]; + +export function scanRelayCaptureText(text: string): CaptureScanResult { + const hits: CaptureScanHit[] = []; + for (const rule of E2E_FORBIDDEN_CAPTURE_RULES) { + if (rule.test(text)) { + hits.push({ rule: rule.id, detail: rule.describe }); + } + } + return { ok: hits.length === 0, hits }; +} + +/** Scan JSON-serialized req/res/ws frames (one line per frame). */ +export function scanRelayCaptureLines(lines: string[]): CaptureScanResult { + const allHits: CaptureScanHit[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]?.trim(); + if (!line) continue; + const r = scanRelayCaptureText(line); + for (const h of r.hits) { + allHits.push({ rule: h.rule, detail: `${h.detail} (line ${i + 1})` }); + } + } + return { ok: allHits.length === 0, hits: allHits }; +} + +export function looksLikeE2eWireEnvelope(text: string): boolean { + return /"v"\s*:\s*1/.test(text) && /"ct"\s*:\s*"/.test(text) && /"sid"\s*:\s*"/.test(text); +} diff --git a/src/services/remoteAccessDeviceAuth.ts b/src/services/remoteAccessDeviceAuth.ts new file mode 100644 index 0000000..24b6b81 --- /dev/null +++ b/src/services/remoteAccessDeviceAuth.ts @@ -0,0 +1,203 @@ +/** + * Challenge-response auth for persisted Remote Access devices. + */ + +import { randomBytes } from 'node:crypto'; +import type { Request, RequestHandler, Response } from 'express'; + +import { remoteAccessDevices } from './remoteAccessDevices'; +import { verifyDeviceChallengeSignature, isValidDevicePublicKey } from './remoteAccessDeviceCrypto'; +import { + getTunnelIdFromRequest, + isGateEnabled, + isTunneledRequest, + parseCookieHeader, + isValidTunnelSessionForTunnel, +} from './remoteAccessAuth'; + +const CHALLENGE_TTL_MS = 60_000; +const CHALLENGE_PREFIX = '/__ra/device/'; + +interface PendingChallenge { + tunnelId: string; + deviceId: string; + challengeB64: string; + expiresAt: number; +} + +const pendingChallenges = new Map(); + +function sweepChallenges(): void { + const now = Date.now(); + for (const [id, c] of pendingChallenges) { + if (c.expiresAt <= now) pendingChallenges.delete(id); + } +} + +export function isDeviceAuthPath(path: string): boolean { + return path.startsWith(CHALLENGE_PREFIX); +} + +export function registerTrustedDevice(opts: { + tunnelId: string; + publicKey: string; + name?: string | null; + userAgent?: string | null; +}): { deviceId: string } | null { + if (!isValidDevicePublicKey(opts.publicKey)) return null; + const device = remoteAccessDevices.register({ + tunnelId: opts.tunnelId, + publicKey: opts.publicKey, + name: opts.name, + userAgent: opts.userAgent, + }); + return { deviceId: device.id }; +} + +function parseDeviceBody(req: Request): { deviceId?: string; challengeId?: string; signature?: string } { + const body = req.body as Record | undefined; + return { + deviceId: typeof body?.deviceId === 'string' ? body.deviceId : undefined, + challengeId: typeof body?.challengeId === 'string' ? body.challengeId : undefined, + signature: typeof body?.signature === 'string' ? body.signature : undefined, + }; +} + +export const remoteAccessDeviceChallengeHandler: RequestHandler = (req, res) => { + if (!isTunneledRequest(req)) { + res.status(404).type('text/plain').send('not found'); + return; + } + const tunnelId = getTunnelIdFromRequest(req); + if (!tunnelId || !isGateEnabled(tunnelId)) { + res.status(404).type('text/plain').send('not found'); + return; + } + + const { deviceId } = parseDeviceBody(req); + if (!deviceId) { + res.status(400).json({ error: 'deviceId required' }); + return; + } + + const device = remoteAccessDevices.getActive(tunnelId, deviceId); + if (!device) { + res.status(403).json({ error: 'device not found or revoked' }); + return; + } + + sweepChallenges(); + const challengeId = randomBytes(16).toString('base64url'); + const challengeB64 = randomBytes(32).toString('base64url'); + pendingChallenges.set(challengeId, { + tunnelId, + deviceId, + challengeB64, + expiresAt: Date.now() + CHALLENGE_TTL_MS, + }); + + res.json({ challengeId, challenge: challengeB64 }); +}; + +export const remoteAccessDeviceVerifyHandler: RequestHandler = (req, res) => { + if (!isTunneledRequest(req)) { + res.status(404).type('text/plain').send('not found'); + return; + } + const tunnelId = getTunnelIdFromRequest(req); + if (!tunnelId || !isGateEnabled(tunnelId)) { + res.status(404).type('text/plain').send('not found'); + return; + } + + const { deviceId, challengeId, signature } = parseDeviceBody(req); + if (!deviceId || !challengeId || !signature) { + res.status(400).json({ error: 'deviceId, challengeId, and signature required' }); + return; + } + + const pending = pendingChallenges.get(challengeId); + pendingChallenges.delete(challengeId); + if (!pending || pending.expiresAt <= Date.now()) { + res.status(403).json({ error: 'challenge expired' }); + return; + } + if (pending.tunnelId !== tunnelId || pending.deviceId !== deviceId) { + res.status(403).json({ error: 'challenge mismatch' }); + return; + } + + const device = remoteAccessDevices.getActive(tunnelId, deviceId); + if (!device) { + res.status(403).json({ error: 'device not found or revoked' }); + return; + } + + if ( + !verifyDeviceChallengeSignature(device.publicKey, pending.challengeB64, signature) + ) { + res.status(403).json({ error: 'invalid signature' }); + return; + } + + remoteAccessDevices.touchLastSeen(tunnelId, deviceId); + finishDeviceLogin(res, tunnelId, req); +}; + +function finishDeviceLogin(res: Response, _tunnelId: string, req: Request): void { + // E2E-only: do NOT Set-Cookie an iclaw_ra session here. The relay forwards + // outer responses verbatim, so a Set-Cookie would leak the session id to the + // relay — and a device challenge cannot derive the E2E transport keys anyway + // (those come only from an OPAQUE passphrase login). The browser detects the + // missing E2E keys and prompts for the passphrase to start the encrypted + // session. Device recognition still updates "last seen" for the UI. + const nextUrl = + typeof req.body?.next === 'string' && req.body.next.startsWith('/') + ? req.body.next + : '/'; + const accept = (req.headers.accept ?? '').toString(); + if (accept.includes('application/json')) { + res.json({ ok: true, next: nextUrl, needsPassphrase: true }); + return; + } + res.redirect(303, nextUrl); +} + +/** Optional: register device when caller already has a fresh session cookie. */ +export const remoteAccessDeviceRegisterHandler: RequestHandler = (req, res) => { + if (!isTunneledRequest(req)) { + res.status(404).type('text/plain').send('not found'); + return; + } + const tunnelId = getTunnelIdFromRequest(req); + if (!tunnelId || !isGateEnabled(tunnelId)) { + res.status(404).type('text/plain').send('not found'); + return; + } + + const cookies = parseCookieHeader(req.headers.cookie); + if (!isValidTunnelSessionForTunnel(tunnelId, cookies)) { + res.status(401).json({ error: 'passphrase session required' }); + return; + } + + const body = req.body as Record | undefined; + const publicKey = typeof body?.publicKey === 'string' ? body.publicKey : ''; + const name = typeof body?.name === 'string' ? body.name : null; + const userAgent = + typeof body?.userAgent === 'string' + ? body.userAgent + : (req.headers['user-agent'] ?? '').toString(); + + const registered = registerTrustedDevice({ + tunnelId, + publicKey, + name, + userAgent, + }); + if (!registered) { + res.status(400).json({ error: 'invalid publicKey' }); + return; + } + res.status(201).json(registered); +}; diff --git a/src/services/remoteAccessDeviceCrypto.ts b/src/services/remoteAccessDeviceCrypto.ts new file mode 100644 index 0000000..f4072dd --- /dev/null +++ b/src/services/remoteAccessDeviceCrypto.ts @@ -0,0 +1,59 @@ +/** + * Ed25519 challenge verification for Remote Access trusted devices. + * Public keys are SPKI DER, base64url (exported from WebCrypto in the browser). + */ + +import { createPublicKey, verify, type KeyObject } from 'node:crypto'; + +const SPKI_B64URL_RE = /^[A-Za-z0-9_-]{40,256}$/; +const B64URL_RE = /^[A-Za-z0-9_-]+$/; + +export function isValidDevicePublicKey(publicKeyB64: string): boolean { + return SPKI_B64URL_RE.test(publicKeyB64); +} + +export function isValidSignatureB64(signatureB64: string): boolean { + return signatureB64.length >= 80 && signatureB64.length <= 128 && B64URL_RE.test(signatureB64); +} + +function loadPublicKey(publicKeySpkiB64: string): KeyObject | null { + if (!isValidDevicePublicKey(publicKeySpkiB64)) return null; + try { + return createPublicKey({ + key: Buffer.from(publicKeySpkiB64, 'base64url'), + format: 'der', + type: 'spki', + }); + } catch { + return null; + } +} + +/** Verify Ed25519 signature over raw challenge bytes. */ +export function verifyDeviceChallengeSignature( + publicKeySpkiB64: string, + challengeB64: string, + signatureB64: string, +): boolean { + if (!isValidSignatureB64(signatureB64)) return false; + const key = loadPublicKey(publicKeySpkiB64); + if (!key) return false; + let challenge: Buffer; + try { + challenge = Buffer.from(challengeB64, 'base64url'); + } catch { + return false; + } + if (challenge.length < 16 || challenge.length > 64) return false; + let signature: Buffer; + try { + signature = Buffer.from(signatureB64, 'base64url'); + } catch { + return false; + } + try { + return verify(null, challenge, key, signature); + } catch { + return false; + } +} diff --git a/src/services/remoteAccessDevices.ts b/src/services/remoteAccessDevices.ts new file mode 100644 index 0000000..2f3ce32 --- /dev/null +++ b/src/services/remoteAccessDevices.ts @@ -0,0 +1,126 @@ +/** + * Persistent trusted devices for Remote Access (per tunnel). + */ + +import { randomBytes } from 'node:crypto'; +import { db } from '../db/database'; + +export interface RemoteAccessDevice { + id: string; + tunnelId: string; + name: string | null; + userAgent: string | null; + publicKey: string; + createdAt: number; + lastSeenAt: number; + revokedAt: number | null; +} + +interface Row { + id: string; + tunnel_id: string; + name: string | null; + user_agent: string | null; + public_key: string; + created_at: number; + last_seen_at: number; + revoked_at: number | null; +} + +const LIST_BY_TUNNEL = db.prepare<[string], Row>( + `SELECT * FROM remote_access_devices + WHERE tunnel_id = ? + ORDER BY last_seen_at DESC`, +); +const GET = db.prepare<[string, string], Row>( + `SELECT * FROM remote_access_devices WHERE id = ? AND tunnel_id = ?`, +); +const GET_ACTIVE = db.prepare<[string, string], Row>( + `SELECT * FROM remote_access_devices + WHERE id = ? AND tunnel_id = ? AND revoked_at IS NULL`, +); +const INSERT = db.prepare(` + INSERT INTO remote_access_devices + (id, tunnel_id, name, user_agent, public_key, created_at, last_seen_at, revoked_at) + VALUES + (@id, @tunnel_id, @name, @user_agent, @public_key, @created_at, @last_seen_at, NULL) +`); +const TOUCH_SEEN = db.prepare( + 'UPDATE remote_access_devices SET last_seen_at = ? WHERE id = ? AND tunnel_id = ?', +); +const REVOKE = db.prepare( + 'UPDATE remote_access_devices SET revoked_at = ? WHERE id = ? AND tunnel_id = ? AND revoked_at IS NULL', +); +const DELETE_FOR_TUNNEL = db.prepare('DELETE FROM remote_access_devices WHERE tunnel_id = ?'); + +function rowToDevice(row: Row): RemoteAccessDevice { + return { + id: row.id, + tunnelId: row.tunnel_id, + name: row.name, + userAgent: row.user_agent, + publicKey: row.public_key, + createdAt: row.created_at, + lastSeenAt: row.last_seen_at, + revokedAt: row.revoked_at, + }; +} + +export function generateDeviceId(): string { + return `d-${randomBytes(8).toString('hex')}`; +} + +export const remoteAccessDevices = { + listByTunnel(tunnelId: string): RemoteAccessDevice[] { + return LIST_BY_TUNNEL.all(tunnelId).map(rowToDevice); + }, + + listActiveByTunnel(tunnelId: string): RemoteAccessDevice[] { + return remoteAccessDevices.listByTunnel(tunnelId).filter((d) => d.revokedAt == null); + }, + + get(tunnelId: string, deviceId: string): RemoteAccessDevice | null { + const row = GET.get(deviceId, tunnelId); + return row ? rowToDevice(row) : null; + }, + + getActive(tunnelId: string, deviceId: string): RemoteAccessDevice | null { + const row = GET_ACTIVE.get(deviceId, tunnelId); + return row ? rowToDevice(row) : null; + }, + + register(opts: { + tunnelId: string; + publicKey: string; + name?: string | null; + userAgent?: string | null; + }): RemoteAccessDevice { + const now = Date.now(); + const id = generateDeviceId(); + const name = opts.name?.trim().slice(0, 128) || null; + const userAgent = opts.userAgent?.trim().slice(0, 512) || null; + INSERT.run({ + id, + tunnel_id: opts.tunnelId, + name, + user_agent: userAgent, + public_key: opts.publicKey, + created_at: now, + last_seen_at: now, + }); + return remoteAccessDevices.get(opts.tunnelId, id)!; + }, + + touchLastSeen(tunnelId: string, deviceId: string, at = Date.now()): void { + TOUCH_SEEN.run(at, deviceId, tunnelId); + }, + + revoke(tunnelId: string, deviceId: string, at = Date.now()): boolean { + const info = REVOKE.run(at, deviceId, tunnelId); + return info.changes > 0; + }, + + deleteAllForTunnel(tunnelId: string): void { + DELETE_FOR_TUNNEL.run(tunnelId); + }, +}; diff --git a/src/services/remoteAccessE2eCrypto.ts b/src/services/remoteAccessE2eCrypto.ts new file mode 100644 index 0000000..86507cd --- /dev/null +++ b/src/services/remoteAccessE2eCrypto.ts @@ -0,0 +1,363 @@ +/** + * RA-E2E v1 transport crypto — AES-256-GCM with a per-(direction,streamId) + * subkey and monotonic per-stream counters. + * + * Nonce uniqueness: the 12-byte GCM nonce is derived only from + * (tunnelId, counter), so it repeats across streams that each start their + * counter at 0. To keep (key, nonce) unique we derive a distinct AEAD key + * per (direction, streamId) via HKDF from the direction key — so a repeated + * nonce is always paired with a different key. Do NOT remove the subkey + * derivation without also making the nonce stream-unique. + * See docs/REMOTE_ACCESS.md + */ + +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import { hkdf } from '@noble/hashes/hkdf.js'; +import { sha256 } from '@noble/hashes/sha2.js'; + +export const E2E_INFO = 'iClaw-ra-e2e-v1'; + +/** + * Forward-gap tolerance for the per-stream counter ledger. + * + * The receiver expects monotonically increasing counters per (direction, + * streamId). A counter `< expected` is always rejected (replay protection), + * and accepting a counter advances `expected` to `ctr + 1` — so once a value + * is seen, every value `<=` it is permanently rejected. MAX_CTR_SKIP only + * widens how far AHEAD a counter may jump in one step, absorbing a few + * dropped/coalesced frames without resyncing. It is NOT a replay window. + * + * Frames travel in order over a single TCP-backed WebSocket, so large skips + * never occur in practice; 32 is a deliberately tight bound that still leaves + * slack for benign gaps. Out-of-order frames inside the window are dropped, + * not buffered. + */ +export const MAX_CTR_SKIP = 32; + +/** + * Upper bound on a frame counter. The 12-byte GCM nonce packs the counter as + * a 64-bit LE integer, but it is carried through JS as a `number`; past + * 2^53 (`Number.MAX_SAFE_INTEGER`) integer arithmetic loses precision and two + * distinct logical counters could collapse to the same nonce. We fail closed + * well before that — reaching even 2^32 frames on a single stream is already + * unreachable in any real session. + */ +export const MAX_CTR = Number.MAX_SAFE_INTEGER; + +export type E2eDirection = 'c2s' | 's2c'; +export type E2eFrameKind = 'http-req' | 'http-res' | 'ws-open' | 'ws-data' | 'ws-close'; + +export interface E2ePlainRecord { + v: 1; + ctr: number; + kind: E2eFrameKind; + streamId: string; + inner: Uint8Array; +} + +export interface E2eSessionKeys { + c2s: Uint8Array; + s2c: Uint8Array; +} + +export function relayAccessBindingHash(cookieValue: string | undefined): Uint8Array { + if (!cookieValue) return new Uint8Array(32); + return relayAccessBindingFromAccessToken(cookieValue); +} + +/** SHA-256 of the raw relay ?access= token (matches browser sessionStorage binding). */ +export function relayAccessBindingFromAccessToken(accessToken: string): Uint8Array { + if (!accessToken) return new Uint8Array(32); + return sha256(new TextEncoder().encode(accessToken)); +} + +/** Base64url relay binding for gate/workspace meta (matches browser HKDF salt). */ +export function relayBindingB64urlForAccessToken(accessToken: string): string { + return Buffer.from(relayAccessBindingFromAccessToken(accessToken)).toString('base64url'); +} + +export function deriveE2eSessionKeys( + opaqueSessionKey: Uint8Array, + tunnelId: string, + relayBinding: Uint8Array, +): E2eSessionKeys { + const salt = sha256( + new TextEncoder().encode(`${tunnelId}\x00${Buffer.from(relayBinding).toString('base64url')}`), + ); + const master = hkdf(sha256, opaqueSessionKey, salt, new TextEncoder().encode(E2E_INFO), 32); + return { + c2s: hkdf(sha256, master, undefined, new TextEncoder().encode('c2s'), 32), + s2c: hkdf(sha256, master, undefined, new TextEncoder().encode('s2c'), 32), + }; +} + +function keyForDirection(keys: E2eSessionKeys, dir: E2eDirection): Uint8Array { + return dir === 'c2s' ? keys.c2s : keys.s2c; +} + +/** + * Per-(direction, streamId) AEAD subkey. Counters restart at 0 for every + * new stream and the nonce only encodes (tunnelId, counter), so without a + * distinct key per stream two streams would reuse (key, nonce) under GCM — + * catastrophic. Binding the key to streamId removes that collision. + * MUST match the browser implementation in ra-e2e-crypto.mjs. + */ +function deriveStreamKey( + dirKey: Uint8Array, + direction: E2eDirection, + streamId: string, +): Uint8Array { + return hkdf( + sha256, + dirKey, + undefined, + new TextEncoder().encode(`rec\x00${direction}\x00${streamId}`), + 32, + ); +} + +function buildAad(opts: { + tunnelId: string; + streamId: string; + direction: E2eDirection; + ctr: number; + kind: E2eFrameKind; + relayBinding: Uint8Array; +}): Uint8Array { + const ctrBuf = Buffer.alloc(8); + ctrBuf.writeBigUInt64LE(BigInt(opts.ctr)); + return new Uint8Array( + Buffer.concat([ + Buffer.from(opts.tunnelId, 'utf8'), + Buffer.from([0]), + Buffer.from(opts.streamId, 'utf8'), + Buffer.from([0]), + Buffer.from(opts.direction, 'utf8'), + Buffer.from([0]), + ctrBuf, + Buffer.from([0]), + Buffer.from(opts.kind, 'utf8'), + Buffer.from([0]), + Buffer.from(opts.relayBinding), + ]), + ); +} + +function encodePlainRecord(rec: E2ePlainRecord): Uint8Array { + const innerB64 = Buffer.from(rec.inner).toString('base64url'); + const json = JSON.stringify({ + v: rec.v, + ctr: rec.ctr, + kind: rec.kind, + streamId: rec.streamId, + innerB64, + }); + return new TextEncoder().encode(json); +} + +function decodePlainRecord(bytes: Uint8Array): E2ePlainRecord | null { + try { + const o = JSON.parse(new TextDecoder().decode(bytes)) as { + v?: number; + ctr?: number; + kind?: E2eFrameKind; + streamId?: string; + innerB64?: string; + }; + if (o.v !== 1 || typeof o.ctr !== 'number' || !o.kind || typeof o.streamId !== 'string') { + return null; + } + if (typeof o.innerB64 !== 'string') return null; + return { + v: 1, + ctr: o.ctr, + kind: o.kind, + streamId: o.streamId, + inner: new Uint8Array(Buffer.from(o.innerB64, 'base64url')), + }; + } catch { + return null; + } +} + +/** 12-byte GCM nonce: tunnel-scoped prefix + 8-byte LE counter (deterministic for decrypt). */ +function buildNonce(ctr: number, tunnelId: string): Buffer { + const nonce = Buffer.alloc(12); + Buffer.from(sha256(new TextEncoder().encode(tunnelId))).copy(nonce, 0, 0, 4); + nonce.writeBigUInt64LE(BigInt(ctr), 4); + return nonce; +} + +function aesGcmEncrypt(key: Uint8Array, nonce: Buffer, plaintext: Uint8Array, aad: Uint8Array): Buffer { + const cipher = createCipheriv('aes-256-gcm', Buffer.from(key), nonce); + cipher.setAAD(Buffer.from(aad)); + return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]); +} + +function aesGcmDecrypt( + key: Uint8Array, + nonce: Buffer, + ciphertext: Uint8Array, + aad: Uint8Array, +): Buffer { + const buf = Buffer.from(ciphertext); + const tag = buf.subarray(buf.length - 16); + const data = buf.subarray(0, buf.length - 16); + const decipher = createDecipheriv('aes-256-gcm', Buffer.from(key), nonce); + decipher.setAAD(Buffer.from(aad)); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(data), decipher.final()]); +} + +export class E2eCounterLedger { + private readonly next = new Map(); + + private key(streamId: string, direction: E2eDirection): string { + return `${direction}\x00${streamId}`; + } + + checkAndAdvance(streamId: string, direction: E2eDirection, ctr: number): boolean { + // Fail closed on malformed counters so the 64-bit nonce derivation never + // sees a fractional/negative/precision-losing value (see MAX_CTR). + if (!Number.isSafeInteger(ctr) || ctr < 0 || ctr > MAX_CTR) return false; + const k = this.key(streamId, direction); + const expected = this.next.get(k) ?? 0; + if (ctr < expected) return false; + if (ctr > expected + MAX_CTR_SKIP) return false; + this.next.set(k, ctr + 1); + return true; + } + + reset(): void { + this.next.clear(); + } +} + +export function encryptE2eRecord( + keys: E2eSessionKeys, + direction: E2eDirection, + opts: { + tunnelId: string; + streamId: string; + ctr: number; + kind: E2eFrameKind; + inner: Uint8Array; + relayBinding: Uint8Array; + }, +): Uint8Array { + const key = deriveStreamKey(keyForDirection(keys, direction), direction, opts.streamId); + const plain: E2ePlainRecord = { + v: 1, + ctr: opts.ctr, + kind: opts.kind, + streamId: opts.streamId, + inner: opts.inner, + }; + const aad = buildAad({ + tunnelId: opts.tunnelId, + streamId: opts.streamId, + direction, + ctr: opts.ctr, + kind: opts.kind, + relayBinding: opts.relayBinding, + }); + const nonce = buildNonce(opts.ctr, opts.tunnelId); + return new Uint8Array(aesGcmEncrypt(key, nonce, encodePlainRecord(plain), aad)); +} + +export function decryptE2eRecord( + keys: E2eSessionKeys, + direction: E2eDirection, + opts: { + tunnelId: string; + streamId: string; + ctr: number; + kind: E2eFrameKind; + ciphertext: Uint8Array; + relayBinding: Uint8Array; + }, + ledger: E2eCounterLedger, +): E2ePlainRecord | null { + if (!ledger.checkAndAdvance(opts.streamId, direction, opts.ctr)) return null; + const key = deriveStreamKey(keyForDirection(keys, direction), direction, opts.streamId); + const aad = buildAad({ + tunnelId: opts.tunnelId, + streamId: opts.streamId, + direction, + ctr: opts.ctr, + kind: opts.kind, + relayBinding: opts.relayBinding, + }); + const nonce = buildNonce(opts.ctr, opts.tunnelId); + let plainBytes: Uint8Array; + try { + plainBytes = new Uint8Array(aesGcmDecrypt(key, nonce, opts.ciphertext, aad)); + } catch { + return null; + } + const rec = decodePlainRecord(plainBytes); + if (!rec || rec.ctr !== opts.ctr || rec.kind !== opts.kind || rec.streamId !== opts.streamId) { + return null; + } + return rec; +} + +/** Parse outer envelope sent on the wire (base64url ciphertext + metadata). */ +export function encodeWireEnvelope(opts: { + sid: string; + ctr: number; + kind: E2eFrameKind; + streamId: string; + ciphertext: Uint8Array; +}): string { + return JSON.stringify({ + v: 1, + sid: opts.sid, + ctr: opts.ctr, + kind: opts.kind, + streamId: opts.streamId, + ct: Buffer.from(opts.ciphertext).toString('base64url'), + }); +} + +export function decodeWireEnvelope(raw: string): { + sid: string; + ctr: number; + kind: E2eFrameKind; + streamId: string; + ciphertext: Uint8Array; +} | null { + try { + const o = JSON.parse(raw) as { + v?: number; + sid?: string; + ctr?: number; + kind?: E2eFrameKind; + streamId?: string; + ct?: string; + }; + if ( + o.v !== 1 || + typeof o.sid !== 'string' || + typeof o.ctr !== 'number' || + !o.kind || + typeof o.streamId !== 'string' + ) { + return null; + } + if (typeof o.ct !== 'string') return null; + return { + sid: o.sid, + ctr: o.ctr, + kind: o.kind, + streamId: o.streamId, + ciphertext: new Uint8Array(Buffer.from(o.ct, 'base64url')), + }; + } catch { + return null; + } +} + +export function hashForLogSafe(data: string): string { + return createHash('sha256').update(data).digest('hex').slice(0, 12); +} diff --git a/src/services/remoteAccessE2eSession.ts b/src/services/remoteAccessE2eSession.ts new file mode 100644 index 0000000..59ca597 --- /dev/null +++ b/src/services/remoteAccessE2eSession.ts @@ -0,0 +1,91 @@ +/** + * In-memory E2E transport sessions (keys derived from OPAQUE sessionKey). + */ + +import { randomBytes } from 'node:crypto'; + +import { + deriveE2eSessionKeys, + E2eCounterLedger, + E2eSessionKeys, + relayAccessBindingFromAccessToken, +} from './remoteAccessE2eCrypto'; + +export interface E2eTransportSession { + handle: string; + tunnelId: string; + raSessionId: string; + keys: E2eSessionKeys; + relayBinding: Uint8Array; + c2sLedger: E2eCounterLedger; + /** Next outbound s2c counter per streamId. */ + s2cCtr: Map; +} + +const byHandle = new Map(); +const handleByRaSession = new Map(); + +export function decodeOpaqueSessionKey(sessionKey: string): Uint8Array { + const buf = Buffer.from(sessionKey, 'base64url'); + if (buf.length !== 64) { + throw new Error('invalid OPAQUE sessionKey length'); + } + return new Uint8Array(buf); +} + +export function createE2eTransportSession(opts: { + tunnelId: string; + raSessionId: string; + opaqueSessionKey: string; + accessToken: string; +}): string { + const existing = handleByRaSession.get(opts.raSessionId); + if (existing) { + byHandle.delete(existing); + handleByRaSession.delete(opts.raSessionId); + } + + const handle = randomBytes(18).toString('base64url'); + const opaqueKey = decodeOpaqueSessionKey(opts.opaqueSessionKey); + const relayBinding = relayAccessBindingFromAccessToken(opts.accessToken); + const keys = deriveE2eSessionKeys(opaqueKey, opts.tunnelId, relayBinding); + + const session: E2eTransportSession = { + handle, + tunnelId: opts.tunnelId, + raSessionId: opts.raSessionId, + keys, + relayBinding, + c2sLedger: new E2eCounterLedger(), + s2cCtr: new Map(), + }; + byHandle.set(handle, session); + handleByRaSession.set(opts.raSessionId, handle); + return handle; +} + +export function getE2eTransportSession(handle: string): E2eTransportSession | null { + return byHandle.get(handle) ?? null; +} + +export function nextS2cCounter(session: E2eTransportSession, streamId: string): number { + const n = session.s2cCtr.get(streamId) ?? 0; + session.s2cCtr.set(streamId, n + 1); + return n; +} + +export function clearE2eTransportForTunnel(tunnelId: string): void { + for (const [handle, rec] of byHandle) { + if (rec.tunnelId === tunnelId) { + byHandle.delete(handle); + handleByRaSession.delete(rec.raSessionId); + } + } +} + +export function clearE2eTransportForRaSession(raSessionId: string): void { + const handle = handleByRaSession.get(raSessionId); + if (!handle) return; + byHandle.delete(handle); + handleByRaSession.delete(raSessionId); +} diff --git a/src/services/remoteAccessE2eTransport.ts b/src/services/remoteAccessE2eTransport.ts new file mode 100644 index 0000000..fdd6d4a --- /dev/null +++ b/src/services/remoteAccessE2eTransport.ts @@ -0,0 +1,475 @@ +/** + * E2E transport — decrypt tunneled HTTP/WS envelopes and replay on loopback. + */ + +import http from 'node:http'; +import { URL } from 'node:url'; +import { WebSocket } from 'ws'; + +import { + decryptE2eRecord, + decodeWireEnvelope, + encodeWireEnvelope, + encryptE2eRecord, + type E2eFrameKind, +} from './remoteAccessE2eCrypto'; +import { + getE2eTransportSession, + nextS2cCounter, + type E2eTransportSession, +} from './remoteAccessE2eSession'; +import { + isGatePublicAsset, + isPublicStaticAsset, + stripInternalHeaders, + SESSION_COOKIE, + TUNNELED_HEADER, + TUNNELED_VALUE, + TUNNEL_ID_HEADER, +} from './remoteAccessAuth'; + +/** + * Inner E2E requests are authenticated by *possession of the E2E session keys* + * — a request only decrypts here if it was sealed with keys derived from a + * successful OPAQUE login. The browser cannot carry the HttpOnly `iclaw_ra` + * cookie inside the encrypted inner request (it isn't visible to JS), and we + * deliberately never send that cookie to the browser anymore so the relay + * can't see it either. So we re-attach the session id here, server-side, + * keyed off the transport session, letting the inner request pass the gate + * middleware without the cookie ever crossing the wire. + */ +function injectSessionCookie( + headers: Record, + session: E2eTransportSession, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === 'cookie') continue; // rebuilt below + out[k] = v; + } + const existing = headers.cookie ?? headers.Cookie ?? ''; + const sessionPair = `${SESSION_COOKIE}=${encodeURIComponent(session.raSessionId)}`; + out.cookie = existing ? `${existing}; ${sessionPair}` : sessionPair; + return out; +} + +export const E2E_HTTP_PATH = '/__ra/e2e/http'; +export const E2E_WS_PATH = '/__ra/e2e/ws'; +export const E2E_BOOTSTRAP_PATH = '/__ra/e2e/bootstrap'; + +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', + 'upgrade', + 'host', + 'content-length', +]); + +function stripHopByHop( + headers: http.IncomingHttpHeaders | Record, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (v === undefined) continue; + const lower = k.toLowerCase(); + if (HOP_BY_HOP.has(lower)) continue; + out[lower] = Array.isArray(v) ? v.join(', ') : String(v); + } + return out; +} + +export interface InnerHttpReq { + id: string; + method: string; + path: string; + headers: Record; + bodyB64: string; +} + +export interface InnerHttpRes { + id: string; + status: number; + headers: Record; + bodyB64: string; +} + +export interface InnerWsOpen { + path: string; + headers: Record; +} + +export interface InnerWsData { + binary: boolean; + dataB64: string; +} + +export interface InnerWsClose { + code?: number; + reason?: string; +} + +export interface E2ePlaintextTunnelContext { + method: string; + path: string; + tunnelId: string; + cookieHeader?: string; + /** Request Accept header — used to tell a page navigation from an XHR. */ + accept?: string; + /** `Sec-Fetch-Mode` (e.g. "navigate") if the browser sent it. */ + secFetchMode?: string; + /** `Sec-Fetch-Dest` (e.g. "document") if the browser sent it. */ + secFetchDest?: string; +} + +/** + * A top-level page navigation (address bar, link click, reload, deep-link) as + * opposed to an in-page fetch/XHR. The E2E transport can only wrap fetch/WS, so + * navigations arrive in the clear — we answer them with the gate bootstrap, + * which re-establishes the encrypted session (or prompts for the passphrase) + * and then loads the target over E2E. + */ +function isHtmlNavigation(ctx: E2ePlaintextTunnelContext): boolean { + if ((ctx.secFetchMode ?? '').toLowerCase() === 'navigate') return true; + if ((ctx.secFetchDest ?? '').toLowerCase() === 'document') return true; + return (ctx.accept ?? '').toLowerCase().includes('text/html'); +} + +/** + * What may travel plaintext over the relay: OPAQUE/E2E startup, gate static + * assets, and top-level HTML navigations (which get the gate bootstrap, never + * the workspace — the plaintext path strips the session cookie, so the gate + * middleware always renders the login/bootstrap shell). Everything else + * (the app's data XHRs, non-GET) must go through the encrypted E2E channel. + */ +export function isE2ePlaintextTunnelExempt(ctx: E2ePlaintextTunnelContext): boolean { + const p = ctx.path.split('?')[0] ?? ctx.path; + const m = ctx.method.toUpperCase(); + + if (p === E2E_HTTP_PATH || p === E2E_WS_PATH) return true; + if (p === E2E_BOOTSTRAP_PATH) return true; + if (p.startsWith('/__ra/opaque/')) return true; + if (p.startsWith('/__ra/device/')) return true; + if (p === '/__ra/login') return true; + + if (m === 'GET' || m === 'HEAD') { + // Public app-shell assets (css/js/icons) — loaded via tags after an + // E2E-delivered page is written to the DOM, so they can't be wrapped. + if (isPublicStaticAsset(p) || isGatePublicAsset(p)) return true; + // The entry point and any top-level navigation get the gate bootstrap. + if (p === '/') return true; + if (isHtmlNavigation(ctx)) return true; + } + + return false; +} + +/** @deprecated Use isE2ePlaintextTunnelExempt */ +export function isE2ePlaintextExemptPath( + path: string, + method = 'GET', + tunnelId = '', + cookieHeader?: string, +): boolean { + return isE2ePlaintextTunnelExempt({ method, path, tunnelId, cookieHeader }); +} + +function decryptInbound( + session: E2eTransportSession, + wireRaw: string, +): { kind: E2eFrameKind; streamId: string; inner: Uint8Array } | null { + const wire = decodeWireEnvelope(wireRaw); + if (!wire || wire.sid !== session.handle) return null; + const plain = decryptE2eRecord( + session.keys, + 'c2s', + { + tunnelId: session.tunnelId, + streamId: wire.streamId, + ctr: wire.ctr, + kind: wire.kind, + ciphertext: wire.ciphertext, + relayBinding: session.relayBinding, + }, + session.c2sLedger, + ); + if (!plain) return null; + return { kind: plain.kind, streamId: plain.streamId, inner: plain.inner }; +} + +function encryptOutbound( + session: E2eTransportSession, + opts: { kind: E2eFrameKind; streamId: string; inner: Uint8Array }, +): string { + const ctr = nextS2cCounter(session, opts.streamId); + const ciphertext = encryptE2eRecord(session.keys, 's2c', { + tunnelId: session.tunnelId, + streamId: opts.streamId, + ctr, + kind: opts.kind, + inner: opts.inner, + relayBinding: session.relayBinding, + }); + return encodeWireEnvelope({ + sid: session.handle, + ctr, + kind: opts.kind, + streamId: opts.streamId, + ciphertext, + }); +} + +function parseInnerJson(inner: Uint8Array): T | null { + try { + return JSON.parse(new TextDecoder().decode(inner)) as T; + } catch { + return null; + } +} + +export interface E2eWsBridge { + streamId: string; + ready: boolean; + local: WebSocket | null; + pendingPublic: { dataB64: string; binary: boolean }[]; +} + +export async function handleE2eHttpFrame(opts: { + tunnelId: string; + bodyRaw: string; + localHost: string; + localPort: number; +}): Promise<{ status: number; body: string }> { + const wire = decodeWireEnvelope(opts.bodyRaw); + if (!wire) { + return { status: 400, body: JSON.stringify({ error: 'invalid E2E envelope' }) }; + } + const session = getE2eTransportSession(wire.sid); + if (!session || session.tunnelId !== opts.tunnelId) { + return { status: 403, body: JSON.stringify({ error: 'unknown E2E session' }) }; + } + + const decrypted = decryptInbound(session, opts.bodyRaw); + if (!decrypted || decrypted.kind !== 'http-req') { + return { status: 400, body: JSON.stringify({ error: 'invalid E2E http-req' }) }; + } + + const inner = parseInnerJson(decrypted.inner); + if (!inner?.id || !inner.method || !inner.path) { + return { status: 400, body: JSON.stringify({ error: 'malformed inner http-req' }) }; + } + + const safeHeaders = injectSessionCookie(stripInternalHeaders(inner.headers ?? {}), session); + const reqOpts: http.RequestOptions = { + host: opts.localHost, + port: opts.localPort, + method: inner.method, + path: inner.path, + headers: { + ...safeHeaders, + host: `${opts.localHost}:${opts.localPort}`, + [TUNNELED_HEADER]: TUNNELED_VALUE, + [TUNNEL_ID_HEADER]: opts.tunnelId, + }, + }; + + const httpRes = await new Promise((resolve, reject) => { + const lr = http.request(reqOpts, (resp) => { + const chunks: Buffer[] = []; + resp.on('data', (c: Buffer) => chunks.push(c)); + resp.on('end', () => { + const body = Buffer.concat(chunks); + resolve({ + id: inner.id, + status: resp.statusCode ?? 502, + headers: stripHopByHop(resp.headers), + bodyB64: body.length > 0 ? body.toString('base64') : '', + }); + }); + resp.on('error', reject); + }); + lr.on('error', reject); + if (inner.bodyB64) { + lr.write(Buffer.from(inner.bodyB64, 'base64')); + } + lr.end(); + }); + + const innerBytes = new TextEncoder().encode(JSON.stringify(httpRes)); + const wireOut = encryptOutbound(session, { + kind: 'http-res', + streamId: decrypted.streamId, + inner: innerBytes, + }); + + return { + status: 200, + body: wireOut, + }; +} + +export function handleE2eWsOpen( + tunnelId: string, + streamId: string, + bridges: Map, +): E2eWsBridge { + const bridge: E2eWsBridge = { + streamId, + ready: false, + local: null, + pendingPublic: [], + }; + bridges.set(streamId, bridge); + return bridge; +} + +export function handleE2eWsData(opts: { + tunnelId: string; + streamId: string; + dataRaw: string; + bridges: Map; + localHost: string; + localPort: number; + sendPublic: (wire: string, binary: boolean) => void; +}): void { + const bridge = opts.bridges.get(opts.streamId); + if (!bridge) return; + + const session = findSessionForWsData(opts.dataRaw); + if (!session || session.tunnelId !== opts.tunnelId) return; + + const decrypted = decryptInbound(session, opts.dataRaw); + if (!decrypted) return; + + if (decrypted.kind === 'ws-open') { + const inner = parseInnerJson(decrypted.inner); + if (!inner?.path) return; + openLocalWs({ + bridge, + session, + tunnelId: opts.tunnelId, + inner, + localHost: opts.localHost, + localPort: opts.localPort, + sendPublic: opts.sendPublic, + }); + return; + } + + if (decrypted.kind === 'ws-data') { + const inner = parseInnerJson(decrypted.inner); + if (!inner) return; + if (bridge.local && bridge.local.readyState === WebSocket.OPEN) { + bridge.local.send(Buffer.from(inner.dataB64, 'base64'), { binary: inner.binary }); + } else if (bridge.local?.readyState === WebSocket.CONNECTING) { + bridge.pendingPublic.push({ dataB64: inner.dataB64, binary: inner.binary }); + } + return; + } + + if (decrypted.kind === 'ws-close') { + const inner = parseInnerJson(decrypted.inner); + if (bridge.local) { + try { + bridge.local.close(inner?.code, inner?.reason); + } catch { + // ignore + } + } + opts.bridges.delete(opts.streamId); + } +} + +function findSessionForWsData(dataRaw: string): E2eTransportSession | null { + const wire = decodeWireEnvelope(dataRaw); + if (!wire) return null; + return getE2eTransportSession(wire.sid); +} + +function openLocalWs(opts: { + bridge: E2eWsBridge; + session: E2eTransportSession; + tunnelId: string; + inner: InnerWsOpen; + localHost: string; + localPort: number; + sendPublic: (wire: string, binary: boolean) => void; +}): void { + const safeHeaders = injectSessionCookie( + stripInternalHeaders(opts.inner.headers ?? {}), + opts.session, + ); + const url = `ws://${opts.localHost}:${opts.localPort}${opts.inner.path}`; + let local: WebSocket; + try { + local = new WebSocket(url, { + headers: { + ...safeHeaders, + host: `${opts.localHost}:${opts.localPort}`, + [TUNNELED_HEADER]: TUNNELED_VALUE, + [TUNNEL_ID_HEADER]: opts.tunnelId, + }, + }); + } catch { + return; + } + opts.bridge.local = local; + + local.on('open', () => { + opts.bridge.ready = true; + for (const m of opts.bridge.pendingPublic) { + local.send(Buffer.from(m.dataB64, 'base64'), { binary: m.binary }); + } + opts.bridge.pendingPublic.length = 0; + }); + + local.on('message', (data, isBinary) => { + const buf = Buffer.isBuffer(data) + ? data + : Array.isArray(data) + ? Buffer.concat(data) + : Buffer.from(data as ArrayBuffer); + const inner: InnerWsData = { + binary: !!isBinary, + dataB64: buf.toString('base64'), + }; + const wire = encryptOutbound(opts.session, { + kind: 'ws-data', + streamId: opts.bridge.streamId, + inner: new TextEncoder().encode(JSON.stringify(inner)), + }); + opts.sendPublic(wire, false); + }); + + local.on('close', (code, reason) => { + const inner: InnerWsClose = { + code, + reason: reason?.length ? reason.toString('utf8') : undefined, + }; + const wire = encryptOutbound(opts.session, { + kind: 'ws-close', + streamId: opts.bridge.streamId, + inner: new TextEncoder().encode(JSON.stringify(inner)), + }); + opts.sendPublic(wire, false); + }); + + local.on('error', () => { + // Swallow — the matching 'close' event forwards the close to the client. + }); +} + +export function closeE2eWsBridge(bridge: E2eWsBridge): void { + if (bridge.local) { + try { + bridge.local.close(); + } catch { + // ignore + } + bridge.local = null; + } +} diff --git a/src/services/remoteAccessOpaque.ts b/src/services/remoteAccessOpaque.ts new file mode 100644 index 0000000..3c576c3 --- /dev/null +++ b/src/services/remoteAccessOpaque.ts @@ -0,0 +1,240 @@ +/** + * OPAQUE (RFC 9807) for Remote Access — passphrase never sent over the tunnel. + */ + +import { createHash, randomBytes } from 'node:crypto'; + +import * as opaque from '@serenity-kit/opaque'; + +import { db } from '../db/database'; + +let readyPromise: Promise | null = null; + +const OPAQUE_FP_KV_KEY = 'opaque_server_setup_sha256'; +const OPAQUE_SETUP_KV_KEY = 'opaque_server_setup'; + +const GET_KV = db.prepare<[string], { value: string } | undefined>( + 'SELECT value FROM iclaw_kv WHERE key = ?', +); +const SET_KV = db.prepare( + 'INSERT INTO iclaw_kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', +); +const CLEAR_ALL_OPAQUE = db.prepare( + 'UPDATE remote_access_tunnels SET opaque_registration_record = NULL', +); + +export async function ensureOpaqueReady(): Promise { + if (!readyPromise) readyPromise = opaque.ready; + await readyPromise; +} + +/** + * Resolve the OPAQUE server setup. Precedence: + * 1. OPAQUE_SERVER_SETUP env override (advanced / shared-host scenarios), + * 2. the value auto-generated and persisted in the local DB. + * Returns null only on a fresh install that hasn't created a tunnel yet — + * `ensureOpaqueServerSetup()` mints and stores one on demand at that point. + */ +function loadServerSetup(): string | null { + const fromEnv = process.env.OPAQUE_SERVER_SETUP?.trim(); + if (fromEnv) return fromEnv; + const stored = GET_KV.get(OPAQUE_SETUP_KV_KEY)?.value?.trim(); + return stored && stored.length > 0 ? stored : null; +} + +export function assertOpaqueServerSetup(): void { + if (!loadServerSetup()) { + throw new Error('OPAQUE server setup is not available for Remote Access'); + } +} + +function getServerSetup(): string { + const setup = loadServerSetup(); + if (!setup) throw new Error('OPAQUE server setup is not available'); + return setup; +} + +/** + * Lazily ensure an OPAQUE server setup exists — generated and persisted the + * first time a tunnel is created, so a fresh install needs zero manual config. + * The env override always wins. Stored in the local SQLite (same trust level + * as the passphrases and access tokens already kept there). Keep it stable: + * changing it invalidates existing tunnels' OPAQUE registration records. + */ +let setupEnsure: Promise | null = null; +export function ensureOpaqueServerSetup(): Promise { + const existing = loadServerSetup(); + if (existing) return Promise.resolve(existing); + if (!setupEnsure) { + setupEnsure = (async () => { + await ensureOpaqueReady(); + const again = loadServerSetup(); // a concurrent caller may have won + if (again) return again; + const setup = opaque.server.createSetup(); + SET_KV.run(OPAQUE_SETUP_KV_KEY, setup); + console.log('[remote-access] generated a fresh OPAQUE server setup (stored locally)'); + return setup; + })().finally(() => { + setupEnsure = null; + }); + } + return setupEnsure; +} + +function opaqueSetupFingerprint(): string { + return createHash('sha256').update(getServerSetup()).digest('hex'); +} + +/** + * When OPAQUE_SERVER_SETUP changes, stale registration records cause "Wrong passphrase". + * Clear and re-register all persisted tunnels against the current setup. + */ +export async function syncOpaqueRegistrationsWithServerSetup( + tunnels: ReadonlyArray<{ id: string; passphrase: string }>, +): Promise { + if (tunnels.length === 0) return; + assertOpaqueServerSetup(); + await ensureOpaqueReady(); + + const fp = opaqueSetupFingerprint(); + const prev = GET_KV.get(OPAQUE_FP_KV_KEY)?.value; + if (prev === fp) { + await Promise.all( + tunnels.map((t) => ensureOpaqueRegistrationForTunnel(t.id, t.passphrase)), + ); + return; + } + + CLEAR_ALL_OPAQUE.run(); + SET_KV.run(OPAQUE_FP_KV_KEY, fp); + if (prev) { + console.warn( + '[remote-access] OPAQUE_SERVER_SETUP changed — re-registering OPAQUE credentials for active tunnels', + ); + } + + await Promise.all(tunnels.map((t) => forceOpaqueRegistrationForTunnel(t.id, t.passphrase))); +} + +/** Register OPAQUE server record for a tunnel (skips if record exists for current setup). */ +export async function ensureOpaqueRegistrationForTunnel( + tunnelId: string, + passphrase: string, +): Promise { + if (getOpaqueRegistrationRecord(tunnelId)) return; + await forceOpaqueRegistrationForTunnel(tunnelId, passphrase); +} + +export async function forceOpaqueRegistrationForTunnel( + tunnelId: string, + passphrase: string, +): Promise { + const record = await registerOpaqueForTunnel(tunnelId, passphrase); + saveOpaqueRegistrationRecord(tunnelId, record); +} + +/** Register OPAQUE credentials for a tunnel (localhost only — uses passphrase once). */ +export async function registerOpaqueForTunnel( + tunnelId: string, + passphrase: string, +): Promise { + await ensureOpaqueReady(); + const serverSetup = getServerSetup(); + const { clientRegistrationState, registrationRequest } = opaque.client.startRegistration({ + password: passphrase, + }); + const { registrationResponse } = opaque.server.createRegistrationResponse({ + serverSetup, + userIdentifier: tunnelId, + registrationRequest, + }); + const { registrationRecord } = opaque.client.finishRegistration({ + clientRegistrationState, + registrationResponse, + password: passphrase, + }); + return registrationRecord; +} + +export async function startOpaqueLogin( + tunnelId: string, + registrationRecord: string, + startLoginRequest: string, +): Promise<{ loginResponse: string; serverLoginState: string }> { + await ensureOpaqueReady(); + const serverSetup = getServerSetup(); + return opaque.server.startLogin({ + userIdentifier: tunnelId, + registrationRecord, + serverSetup, + startLoginRequest, + }); +} + +export async function finishOpaqueLogin(opts: { + tunnelId: string; + registrationRecord: string; + serverLoginState: string; + finishLoginRequest: string; +}): Promise<{ sessionKey: string }> { + await ensureOpaqueReady(); + const result = opaque.server.finishLogin({ + serverLoginState: opts.serverLoginState, + finishLoginRequest: opts.finishLoginRequest, + }); + return { sessionKey: result.sessionKey }; +} + +const GET_OPAQUE = db.prepare<[string], { opaque_registration_record: string | null }>( + 'SELECT opaque_registration_record FROM remote_access_tunnels WHERE id = ?', +); +const SET_OPAQUE = db.prepare( + 'UPDATE remote_access_tunnels SET opaque_registration_record = ? WHERE id = ?', +); + +export function getOpaqueRegistrationRecord(tunnelId: string): string | null { + const row = GET_OPAQUE.get(tunnelId); + return row?.opaque_registration_record ?? null; +} + +export function saveOpaqueRegistrationRecord(tunnelId: string, record: string): void { + SET_OPAQUE.run(record, tunnelId); +} + +/** In-memory server login state between start and finish (tunneled round-trip). */ +const pendingOpaqueLogins = new Map< + string, + { tunnelId: string; serverLoginState: string; expiresAt: number } +>(); + +const LOGIN_STATE_TTL_MS = 60_000; + +export function stashOpaqueLoginState( + tunnelId: string, + serverLoginState: string, +): string { + const id = opaqueRandomId(); + pendingOpaqueLogins.set(id, { + tunnelId, + serverLoginState, + expiresAt: Date.now() + LOGIN_STATE_TTL_MS, + }); + return id; +} + +export function takeOpaqueLoginState( + loginStateId: string, + tunnelId: string, +): string | null { + const rec = pendingOpaqueLogins.get(loginStateId); + pendingOpaqueLogins.delete(loginStateId); + if (!rec || rec.tunnelId !== tunnelId || rec.expiresAt <= Date.now()) return null; + return rec.serverLoginState; +} + +function opaqueRandomId(): string { + // Used as the lookup key for in-flight OPAQUE server login state. Must be + // unguessable: Math.random() is not cryptographically secure, so derive the + // id from CSPRNG bytes instead. + return `ol-${randomBytes(18).toString('base64url')}`; +} diff --git a/src/services/remoteAccessOpaqueAuth.ts b/src/services/remoteAccessOpaqueAuth.ts new file mode 100644 index 0000000..ed18ab7 --- /dev/null +++ b/src/services/remoteAccessOpaqueAuth.ts @@ -0,0 +1,168 @@ +/** + * Tunneled OPAQUE login routes — passphrase never appears in relay frames. + * Transport payload encryption is a separate phase; this is E2E password handshake only. + */ + +import type { Request, RequestHandler, Response } from 'express'; + +import { + getTunnelIdFromRequest, + isGateEnabled, + isTunneledRequest, + mintSession, +} from './remoteAccessAuth'; +import { registerTrustedDevice } from './remoteAccessDeviceAuth'; +import { isValidDevicePublicKey } from './remoteAccessDeviceCrypto'; +import { createE2eTransportSession } from './remoteAccessE2eSession'; +import { remoteAccessState } from './remoteAccessState'; +import { + assertOpaqueServerSetup, + finishOpaqueLogin, + getOpaqueRegistrationRecord, + startOpaqueLogin, + stashOpaqueLoginState, + takeOpaqueLoginState, +} from './remoteAccessOpaque'; + +export const E2E_LOGIN_REQUIRED_MSG = + 'E2E login required — use POST /__ra/opaque/login/start and /__ra/opaque/login/finish'; + +const OPAQUE_START_PATH = '/__ra/opaque/login/start'; +const OPAQUE_FINISH_PATH = '/__ra/opaque/login/finish'; + +export function isOpaqueLoginPath(path: string): boolean { + return path === OPAQUE_START_PATH || path === OPAQUE_FINISH_PATH; +} + +function rejectNotTunneled(res: Response): void { + res.status(404).type('text/plain').send('not found'); +} + +function tunnelContext(req: Request): { tunnelId: string } | null { + if (!isTunneledRequest(req)) return null; + const tunnelId = getTunnelIdFromRequest(req); + if (!tunnelId || !isGateEnabled(tunnelId)) return null; + return { tunnelId }; +} + +export const remoteAccessOpaqueLoginStartHandler: RequestHandler = async (req, res) => { + const ctx = tunnelContext(req); + if (!ctx) { + rejectNotTunneled(res); + return; + } + + const startLoginRequest = + typeof req.body?.startLoginRequest === 'string' ? req.body.startLoginRequest : ''; + if (!startLoginRequest) { + res.status(400).json({ error: 'startLoginRequest required' }); + return; + } + + try { + assertOpaqueServerSetup(); + } catch (err) { + res.status(503).json({ + error: err instanceof Error ? err.message : 'OPAQUE_SERVER_SETUP missing', + }); + return; + } + + let registrationRecord = getOpaqueRegistrationRecord(ctx.tunnelId); + if (!registrationRecord) { + res.status(503).json({ + error: 'OPAQUE registration missing for this tunnel — recreate tunnel or restart iClaw', + }); + return; + } + + try { + const { loginResponse, serverLoginState } = await startOpaqueLogin( + ctx.tunnelId, + registrationRecord, + startLoginRequest, + ); + const loginStateId = stashOpaqueLoginState(ctx.tunnelId, serverLoginState); + res.json({ loginResponse, loginStateId }); + } catch { + res.status(400).json({ error: 'invalid OPAQUE startLoginRequest' }); + } +}; + +export const remoteAccessOpaqueLoginFinishHandler: RequestHandler = async (req, res) => { + const ctx = tunnelContext(req); + if (!ctx) { + rejectNotTunneled(res); + return; + } + + const loginStateId = + typeof req.body?.loginStateId === 'string' ? req.body.loginStateId : ''; + const finishLoginRequest = + typeof req.body?.finishLoginRequest === 'string' ? req.body.finishLoginRequest : ''; + if (!loginStateId || !finishLoginRequest) { + res.status(400).json({ error: 'loginStateId and finishLoginRequest required' }); + return; + } + + const serverLoginState = takeOpaqueLoginState(loginStateId, ctx.tunnelId); + if (!serverLoginState) { + res.status(403).json({ error: 'login state expired or invalid' }); + return; + } + + const registrationRecord = getOpaqueRegistrationRecord(ctx.tunnelId); + if (!registrationRecord) { + res.status(503).json({ error: 'OPAQUE registration missing for this tunnel' }); + return; + } + + let opaqueSessionKey: string; + try { + const result = await finishOpaqueLogin({ + tunnelId: ctx.tunnelId, + registrationRecord, + serverLoginState, + finishLoginRequest, + }); + opaqueSessionKey = result.sessionKey; + } catch { + res.status(401).json({ error: 'OPAQUE login failed' }); + return; + } + + // E2E-only: do NOT Set-Cookie the iclaw_ra session to the browser. The relay + // forwards outer responses verbatim, so any Set-Cookie here would leak the + // session id to the relay. Inner encrypted requests are authenticated by + // possession of the E2E session keys; the transport layer re-attaches this + // session id server-side at loopback (see remoteAccessE2eTransport). + const raSessionId = mintSession(ctx.tunnelId); + const persisted = remoteAccessState.get(ctx.tunnelId); + const transportHandle = createE2eTransportSession({ + tunnelId: ctx.tunnelId, + raSessionId, + opaqueSessionKey, + accessToken: persisted?.accessToken ?? '', + }); + const nextUrl = + typeof req.body?.next === 'string' && req.body.next.startsWith('/') + ? req.body.next + : '/'; + + const reg = req.body?.registerDevice as Record | undefined; + let deviceId: string | null = null; + if (reg && typeof reg.publicKey === 'string' && isValidDevicePublicKey(reg.publicKey)) { + const registered = registerTrustedDevice({ + tunnelId: ctx.tunnelId, + publicKey: reg.publicKey, + name: typeof reg.name === 'string' ? reg.name : null, + userAgent: + typeof reg.userAgent === 'string' + ? reg.userAgent + : (req.headers['user-agent'] ?? '').toString(), + }); + deviceId = registered?.deviceId ?? null; + } + + res.json({ ok: true, next: nextUrl, deviceId, transportHandle }); +}; diff --git a/src/services/remoteAccessState.ts b/src/services/remoteAccessState.ts new file mode 100644 index 0000000..7fa3a7d --- /dev/null +++ b/src/services/remoteAccessState.ts @@ -0,0 +1,154 @@ +/** + * Persistent storage for the Remote Access feature. + * + * One row per active tunnel. The runtime ({@link ./remoteAccess.ts}) + * keeps an in-memory `Map` alongside this, and + * mirrors changes here so any tunnel enabled with e.g. 30-day duration + * survives an iClaw restart. + * + * Plaintext passphrase storage — the iClaw DB lives on the user's local + * machine and isn't synced. Encrypted-at-rest is a follow-up if/when we + * add cloud sync. + */ + +import { randomBytes } from 'node:crypto'; +import { db } from '../db/database'; +import { generateAccessToken } from './remoteAccessToken'; + +export interface PersistedTunnel { + id: string; + label: string | null; + passphrase: string; + /** Plaintext relay access token — local DB only; relay stores hash only. */ + accessToken: string; + /** + * Tunnel ownership secret — local DB only; relay stores SHA-256 only. + * Proves to the relay that this iClaw owns the tunnelId, blocking subdomain + * hijack via reconnect-restore by a stranger who learns the tunnelId. + */ + ownerSecret: string; + durationMs: number; + startedAt: number; + expiresAt: number; + createdAt: number; +} + +interface Row { + id: string; + label: string | null; + passphrase: string; + access_token: string | null; + owner_secret: string | null; + duration_ms: number; + started_at: number; + expires_at: number; + created_at: number; +} + +const LIST_STMT = db.prepare<[], Row>( + 'SELECT * FROM remote_access_tunnels ORDER BY created_at ASC', +); +const GET_STMT = db.prepare<[string], Row>( + 'SELECT * FROM remote_access_tunnels WHERE id = ?', +); +const INSERT_STMT = db.prepare(` + INSERT INTO remote_access_tunnels + (id, label, passphrase, access_token, owner_secret, duration_ms, started_at, expires_at, created_at) + VALUES + (@id, @label, @passphrase, @access_token, @owner_secret, @duration_ms, @started_at, @expires_at, @created_at) +`); +const UPDATE_STMT = db.prepare(` + UPDATE remote_access_tunnels + SET label = @label, + passphrase = @passphrase, + access_token = @access_token, + owner_secret = @owner_secret, + duration_ms = @duration_ms, + started_at = @started_at, + expires_at = @expires_at + WHERE id = @id +`); +const DELETE_STMT = db.prepare('DELETE FROM remote_access_tunnels WHERE id = ?'); +const DELETE_ALL_STMT = db.prepare('DELETE FROM remote_access_tunnels'); + +function rowToTunnel(row: Row): PersistedTunnel { + return { + id: row.id, + label: row.label, + passphrase: row.passphrase, + accessToken: row.access_token ?? '', + ownerSecret: row.owner_secret ?? '', + durationMs: row.duration_ms, + startedAt: row.started_at, + expiresAt: row.expires_at, + createdAt: row.created_at, + }; +} + +/** Backfill access_token for tunnels created before the relay gate. */ +export function ensureAccessToken(p: PersistedTunnel): PersistedTunnel { + if (p.accessToken) return p; + const updated = { ...p, accessToken: generateAccessToken() }; + remoteAccessState.save(updated); + return updated; +} + +/** Backfill owner_secret for tunnels created before ownership proofs existed. */ +export function ensureOwnerSecret(p: PersistedTunnel): PersistedTunnel { + if (p.ownerSecret) return p; + const updated = { ...p, ownerSecret: generateOwnerSecret() }; + remoteAccessState.save(updated); + return updated; +} + +export function generateTunnelId(): string { + // 16 bytes (128-bit) of entropy. The old 5-byte (40-bit) id was guessable + // enough that, combined with an unauthenticated relay restore, a stranger + // could target a specific tunnel; ownership proofs are the real defence, but + // a wide id removes the targeting primitive entirely. + return `t-${randomBytes(16).toString('hex')}`; +} + +/** 256-bit tunnel ownership secret (base64url). Relay stores SHA-256 only. */ +export function generateOwnerSecret(): string { + return randomBytes(32).toString('base64url'); +} + +export const remoteAccessState = { + list(): PersistedTunnel[] { + return LIST_STMT.all().map(rowToTunnel).map(ensureAccessToken); + }, + + get(id: string): PersistedTunnel | null { + const row = GET_STMT.get(id); + return row ? ensureAccessToken(rowToTunnel(row)) : null; + }, + + save(t: PersistedTunnel): void { + const existing = GET_STMT.get(t.id); + const params = { + id: t.id, + label: t.label, + passphrase: t.passphrase, + access_token: t.accessToken, + owner_secret: t.ownerSecret, + duration_ms: t.durationMs, + started_at: t.startedAt, + expires_at: t.expiresAt, + created_at: t.createdAt, + }; + if (existing) { + UPDATE_STMT.run(params); + } else { + INSERT_STMT.run(params); + } + }, + + delete(id: string): void { + DELETE_STMT.run(id); + }, + + clearAll(): void { + DELETE_ALL_STMT.run(); + }, +}; diff --git a/src/services/remoteAccessToken.ts b/src/services/remoteAccessToken.ts new file mode 100644 index 0000000..ffdc162 --- /dev/null +++ b/src/services/remoteAccessToken.ts @@ -0,0 +1,39 @@ +/** + * Relay access token — proves the client was given the full remote URL once. + * Distinct from the iClaw passphrase (workspace login). + * + * Algorithm must match `iclaw-relay/src/tunnel/accessToken.ts`. + */ + +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +export const ACCESS_QUERY_PARAM = 'access'; + +const TOKEN_RE = /^[A-Za-z0-9_-]{43,128}$/; + +export function generateAccessToken(): string { + return randomBytes(32).toString('base64url'); +} + +export function hashAccessToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('base64url'); +} + +export function buildPublicAccessUrl(publicUrl: string, accessToken: string): string { + const u = new URL(publicUrl); + u.searchParams.set(ACCESS_QUERY_PARAM, accessToken); + return u.toString(); +} + +/** For tests — constant-time compare of token to stored hash. */ +export function verifyAccessToken(token: string, storedHash: string): boolean { + if (!TOKEN_RE.test(token)) return false; + const computed = hashAccessToken(token); + const a = Buffer.from(computed, 'utf8'); + const b = Buffer.from(storedHash, 'utf8'); + if (a.length !== b.length) { + timingSafeEqual(a, Buffer.alloc(a.length)); + return false; + } + return timingSafeEqual(a, b); +} diff --git a/src/services/sendHint.ts b/src/services/sendHint.ts new file mode 100644 index 0000000..c706e6a --- /dev/null +++ b/src/services/sendHint.ts @@ -0,0 +1,38 @@ +/** + * Send-button discovery hint. + * + * The long-press menu on the composer's Send button hides two power features + * — scheduled messages and "create task". New users rarely find these on + * their own, so we surface a small one-shot pill next to the button that + * teaches the gesture. + * + * Threshold: we stop nagging when the user has EVER CREATED at least + * `SEND_HINT_TASK_THRESHOLD` tasks AND `SEND_HINT_SCHEDULED_THRESHOLD` + * scheduled messages. We deliberately measure "ever created" (via + * sqlite_sequence under the hood) rather than current row counts — a + * scheduled message is deleted after it fires, and a power user who + * dispatched 10 of them shouldn't see a "did you know?" pill again just + * because the table is empty right now. + * + * Once-per-day throttling lives in the browser (localStorage) — see + * `public/js/iclaw.js`. The server only decides whether the pill is + * eligible to render at all. + */ +import { scheduledMessages, tasks } from './store'; + +/** When BOTH thresholds are met (ever-created), the user has discovered the feature. */ +export const SEND_HINT_TASK_THRESHOLD = 2; +export const SEND_HINT_SCHEDULED_THRESHOLD = 3; + +/** + * Returns `true` if the discovery hint should be eligible to render on + * this page-load. Cheap — two reads of the sqlite_sequence table. + */ +export function shouldShowSendHint(): boolean { + const tasksCount = tasks.everCreatedCount(); + const scheduledCount = scheduledMessages.everCreatedCount(); + return ( + tasksCount < SEND_HINT_TASK_THRESHOLD || + scheduledCount < SEND_HINT_SCHEDULED_THRESHOLD + ); +} diff --git a/src/services/store.ts b/src/services/store.ts index d1c6cfc..dbcc5fa 100644 --- a/src/services/store.ts +++ b/src/services/store.ts @@ -880,11 +880,45 @@ export const scheduledMessages = { ) .all(chatId) as ScheduledMessage[]; }, + /** Distinct chat ids with at least one pending scheduled row (sidebar dots). */ + chatIdsWithPending(): number[] { + return Object.keys(this.pendingCountByChatId()).map(Number); + }, + /** Pending scheduled row counts keyed by chat id (sidebar + bootstrapping). */ + pendingCountByChatId(): Record { + const rows = db + .prepare( + `SELECT chat_id, COUNT(*) AS n + FROM scheduled_messages + GROUP BY chat_id + ORDER BY chat_id ASC`, + ) + .all() as { chat_id: number; n: number }[]; + const out: Record = {}; + for (const r of rows) out[r.chat_id] = r.n; + return out; + }, + countByChat(chatId: number): number { + const row = db + .prepare('SELECT COUNT(*) AS n FROM scheduled_messages WHERE chat_id = ?') + .get(chatId) as { n: number }; + return row.n; + }, get(id: number): ScheduledMessage | undefined { return db.prepare('SELECT * FROM scheduled_messages WHERE id = ?').get(id) as | ScheduledMessage | undefined; }, + /** How many scheduled messages this user has EVER created (monotonic, + * survives delete-after-fire). Reads sqlite_sequence directly — the + * AUTOINCREMENT counter never goes down. Used by the discovery hint to + * decide whether the user has discovered the feature for good. */ + everCreatedCount(): number { + const row = db + .prepare("SELECT seq FROM sqlite_sequence WHERE name = 'scheduled_messages'") + .get() as { seq: number } | undefined; + return row?.seq ?? 0; + }, /** Rows whose `scheduled_at` is now or in the past — what the sweeper fires. */ listDue(limit = 50): ScheduledMessage[] { return db @@ -1012,6 +1046,15 @@ export const tasks = { const row = db.prepare('SELECT 1 AS n FROM tasks LIMIT 1').get() as { n: number } | undefined; return row != null; }, + /** How many tasks this user has EVER created (monotonic, survives row + * deletion). Reads sqlite_sequence directly — the AUTOINCREMENT counter + * never goes down. Used by the send-button discovery hint. */ + everCreatedCount(): number { + const row = db + .prepare("SELECT seq FROM sqlite_sequence WHERE name = 'tasks'") + .get() as { seq: number } | undefined; + return row?.seq ?? 0; + }, statusSignals(): { needsHuman: boolean; running: boolean; needsReview: boolean } { const row = db .prepare( diff --git a/src/services/taskAsk.ts b/src/services/taskAsk.ts index 39d71a4..33f606b 100644 --- a/src/services/taskAsk.ts +++ b/src/services/taskAsk.ts @@ -15,7 +15,11 @@ import { tasks, } from './store'; import { toolActivityLabel } from './toolLabels'; -import { buildContextSnapshot, truncateSnapshotForPrompt } from './taskRunner'; +import { + buildContextSnapshot, + truncateSnapshotForPrompt, + TASK_ASK_LANGUAGE_POLICY, +} from './taskRunner'; import { wsHub } from './wsHub'; import type { ServerMsg } from '../types/protocol'; import type { Task, TaskContextSnapshotPayload } from '../types'; @@ -54,6 +58,8 @@ function buildAskFirstTurnMessage( 'Context snapshot (captured when the user opened Ask — may be newer than the task frozen snapshot):', truncateSnapshotForPrompt(payload), '', + TASK_ASK_LANGUAGE_POLICY, + '', 'User question:', userMessage.trim(), ].join('\n'); diff --git a/src/services/taskRunner.ts b/src/services/taskRunner.ts index 26c24d2..651871e 100644 --- a/src/services/taskRunner.ts +++ b/src/services/taskRunner.ts @@ -249,6 +249,38 @@ export function truncateSnapshotForPrompt(payload: TaskContextSnapshotPayload): return parts.join('\n\n'); } +/** + * Language policy for every task prompt. OpenClaw must answer in the language + * the user actually wrote in (goal + chat context) instead of defaulting to + * English. We deliberately do NOT detect the language in JS — that is brittle + * across scripts and code-switching — and instead instruct the model to mirror + * it, which it does reliably. + * + * The catch: some lines in the agent's reply are machine-parsed, not shown to + * the user, and MUST stay verbatim/English or the runner breaks — + * `parsePlanLines` keys off the `agent:` / `human:` prefixes and + * `parseTaskOutcome` / `stripTaskOutcomeMarkers` key off the protocol markers. + * So the policy says "translate prose, keep tokens". + */ +const LANG_MIRROR = + 'Write every human-facing line in the same language the user used in the ' + + 'task goal and context above — for example, if they wrote in Ukrainian, ' + + 'respond entirely in Ukrainian. Never silently switch to English.'; + +/** Plan output only carries the `agent:`/`human:` prefixes as machine tokens. */ +export const TASK_PLAN_LANGUAGE_POLICY = + `${LANG_MIRROR} Keep only the "agent:" / "human:" step prefixes in English; ` + + 'the step titles themselves go in the user\'s language.'; + +/** Execution output additionally carries the outcome protocol markers. */ +export const TASK_EXECUTION_LANGUAGE_POLICY = + `${LANG_MIRROR} Do not translate the protocol keywords TASK_DONE, ` + + 'NEEDS_HUMAN, ASK_USER, NEEDS_REVIEW, ADD_HUMAN_STEP — emit them exactly, ' + + 'in English, on their own line; only the surrounding explanation is translated.'; + +/** Ask answers have no machine tokens — pure prose. */ +export const TASK_ASK_LANGUAGE_POLICY = LANG_MIRROR; + export function buildTaskPlanPrompt(goal: string, payload: TaskContextSnapshotPayload): string { return [ 'TASK: Create an execution plan for the iClaw task below.', @@ -262,6 +294,8 @@ export function buildTaskPlanPrompt(goal: string, payload: TaskContextSnapshotPa `Goal: ${goal.trim()}`, '', truncateSnapshotForPrompt(payload), + '', + TASK_PLAN_LANGUAGE_POLICY, ].join('\n'); } @@ -306,6 +340,7 @@ function buildExecutionPrompt(opts: { : []), ...(opts.resumeNote ? [`Human input for resume:\n${opts.resumeNote}`, ''] : []), 'Rules:', + `- ${TASK_EXECUTION_LANGUAGE_POLICY}`, current?.actor === 'human' ? '- Current step is HUMAN: do not run agent work; return NEEDS_HUMAN immediately.' : '- Execute only the current agent step; do not skip ahead past unfinished human steps.', diff --git a/src/startup.ts b/src/startup.ts index 1bc60dc..afcdbff 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -17,6 +17,45 @@ export function shouldAutoOpenBrowser(): boolean { return v === '1' || v === 'true' || v === 'yes'; } +/** + * Is a local GUI browser reachable on this machine? + * macOS/Windows always have one; a Linux box only does inside a desktop + * session (DISPLAY / WAYLAND_DISPLAY). A headless server has neither. + */ +export function hasLocalBrowser(): boolean { + if (process.platform === 'darwin' || process.platform === 'win32') return true; + return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); +} + +/** + * Should we offer the "use iClaw from another device" setup on startup? + * Yes only when there's no local browser to open — i.e. a headless server. + */ +export function shouldOfferRemoteSetup(): boolean { + if (process.env.NODE_ENV === 'test') return false; + return !hasLocalBrowser(); +} + +/** + * Ask a single [Y/n] question on the terminal; defaults to Yes on empty input. + * Resolves `false` immediately when there's no interactive terminal (e.g. the + * process was started by systemd/docker), so callers can fall back gracefully. + */ +export function promptYesNo(question: string): Promise { + if (!process.stdin.isTTY) return Promise.resolve(false); + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(question, (answer) => { + rl.close(); + const a = answer.trim().toLowerCase(); + resolve(a === '' || a === 'y' || a === 'yes'); + }); + }); +} + export interface InstanceLockData { pid: number; port: number; @@ -294,11 +333,11 @@ function air(lines = CLI_AIR): void { for (let i = 0; i < lines; i++) console.log(''); } -function printCliFooter(url: string, color: boolean): void { +function printCliFooter(url: string, color: boolean, showBrowserKey: boolean): void { air(); console.log(centerLine(paint(color, c.lime, url))); air(); - if (process.stdin.isTTY) { + if (showBrowserKey && process.stdin.isTTY) { console.log( centerLine( `Press ${highlightKey(color, 'g')} to open in browser`, @@ -314,6 +353,9 @@ function printCliFooter(url: string, color: boolean): void { export interface StartupBannerOpts { url: string; gatewayUp: boolean; + /** Show the "Press g to open in browser" hint — only when the raw-mode key + * controls are actually attached (real CLI launch, not tsx-watch dev). */ + controls: boolean; } /** @@ -356,12 +398,17 @@ export function startCliShuttingDownAnimation(): () => void { }; } -export function printStartupBanner(opts: StartupBannerOpts): void { - const color = useColor(); +/** Centered lime iClaw wordmark — shared by the banner and the onboarding. */ +function printLogo(color: boolean): void { air(); for (const line of ICLAW_LOGO) { console.log(centerLine(paint(color, c.lime, line))); } +} + +export function printStartupBanner(opts: StartupBannerOpts): void { + const color = useColor(); + printLogo(color); if (!opts.gatewayUp) { air(); console.log( @@ -370,7 +417,7 @@ export function printStartupBanner(opts: StartupBannerOpts): void { ), ); } - printCliFooter(opts.url, color); + printCliFooter(opts.url, color, opts.controls); } export function printAlreadyRunningBanner(url: string): void { @@ -379,7 +426,214 @@ export function printAlreadyRunningBanner(url: string): void { console.log( centerLine(paint(color, CLI_LIME, 'iClaw is already running')), ); - printCliFooter(url, color); + printCliFooter(url, color, false); +} + +/* ------------------------------------------- remote-access onboarding -- */ + +/** Clear the screen for a clean onboarding transition (TTY only). */ +function clearScreen(): void { + if (process.stdout.isTTY) console.clear(); +} + +/** + * Headless splash: logo + the "no browser → use from another device?" pitch. + * The [Y/n] prompt itself is asked separately via promptYesNo(). + */ +export function printRemoteOnboardingIntro(): void { + const color = useColor(); + clearScreen(); + printLogo(color); + air(2); + console.log(centerLine('No browser here — looks like a server')); + air(1); + console.log(centerLine('Want to use iClaw from your phone or laptop?')); + air(2); + console.log( + centerLine(`${highlightKey(color, '[Y]')} yes ${highlightKey(color, '[N]')} no`), + ); + air(1); +} + +/** The reveal after Yes: link + password left-aligned (easy to copy). */ +/** Transient "creating…" screen while the relay assigns the subdomain. */ +export function printRemoteSettingUp(): void { + const color = useColor(); + clearScreen(); + printLogo(color); + air(2); + console.log(centerLine('Setting up your private link…')); + air(1); +} + +export function printRemoteLinks( + links: Array<{ url: string | null; passphrase: string; days: number }>, +): void { + const color = useColor(); + clearScreen(); + printLogo(color); + air(2); + const multi = links.length > 1; + const days1 = links[0]?.days ?? 30; + console.log( + centerLine( + paint( + color, + CLI_LIME, + multi + ? 'Your private links' + : `Your private link · next ${days1} day${days1 === 1 ? '' : 's'}`, + ), + ), + ); + air(1); + for (const l of links) { + if (multi) console.log(centerLine(`— next ${l.days} day${l.days === 1 ? '' : 's'} —`)); + console.log( + centerLine( + l.url + ? paint(color, c.lime, l.url) + : '(waiting for the relay — press S again in a moment)', + ), + ); + console.log(centerLine('password: ' + paint(color, c.bold, l.passphrase))); + air(1); + } + console.log( + centerLine( + multi + ? 'Open one on your phone or laptop and enter its password' + : 'Open it on your phone or laptop and enter the password', + ), + ); + console.log(centerLine("End-to-end encrypted — even the relay can't read your traffic")); + console.log(centerLine('Turn it off anytime in iClaw settings (in your browser)')); + air(1); +} + +/** The graceful "No" fallback — never a dead end (the controls footer follows). */ +export function printRemoteNoFallback(): void { + clearScreen(); + printLogo(useColor()); + air(2); + console.log(centerLine('No problem — iClaw is running')); + air(1); +} + +/** + * Controls footer for the headless terminal: press `S` to set up / re-show the + * link right here, Ctrl+C to stop. Keeps everything in this one terminal so the + * operator never needs a second window or a command to remember. + */ +export function printHeadlessControlsFooter( + mode: 'none' | 'hidden' | 'shown', + localUrl: string, + linkCount = 0, +): void { + const color = useColor(); + air(1); + // On the resting screens (no link on screen) also surface the local address + // — handy for SSH port-forwarding or a curl on the box. The link screen + // stays focused on the public link, so we skip it there. + if (mode !== 'shown') { + console.log(centerLine('On this machine: ' + paint(color, c.lime, localUrl))); + air(1); + const action = + mode === 'hidden' + ? `show your ${linkCount} link${linkCount === 1 ? '' : 's'}` + : 'set up a link for your phone or laptop'; + console.log(centerLine(`Press ${highlightKey(color, 'S')} to ${action}`)); + } + console.log(centerLine(`${highlightKey(color, 'Ctrl+C')} to stop`)); + air(1); +} + +/** + * Keep the headless terminal interactive after onboarding: `S` runs the share + * action (passed in) right here, Ctrl+C stops. No-op without a TTY. Mirrors + * attachCliBrowserControls so the raw-mode handling stays consistent. + */ +export function attachHeadlessControls(opts: { + onShare: () => void | Promise; + onStop: () => void; +}): void { + if (!process.stdin.isTTY) return; + const stdin = process.stdin as NodeJS.ReadStream & { isRaw?: boolean }; + const rl = readline.createInterface({ input: stdin, output: process.stdout }); + readline.emitKeypressEvents(stdin, rl); + const wasRaw = stdin.isRaw; + if (!wasRaw) stdin.setRawMode(true); + stdin.resume(); + + // tsx-watch (npm run dev) restarts this process on every file change — make + // sure raw mode is never left on, on any exit path, or the terminal breaks. + const restoreRaw = (): void => { + if (!wasRaw && stdin.isRaw) { + try { + stdin.setRawMode(false); + } catch { + /* ignore */ + } + } + }; + process.once('exit', restoreRaw); + + let stopped = false; + let busy = false; + const stop = (): void => { + if (stopped) return; + stopped = true; + stdin.off('keypress', onKeypress); + if (!wasRaw && stdin.isRaw) stdin.setRawMode(false); + rl.close(); + opts.onStop(); + }; + rl.on('SIGINT', stop); + + const onKeypress = (_str: string, key: readline.Key | undefined): void => { + if (!key) return; + if (key.ctrl && key.name === 'c') { + stop(); + return; + } + if (key.name === 's' && !busy) { + busy = true; + Promise.resolve(opts.onShare()).finally(() => { + busy = false; + }); + } + }; + stdin.on('keypress', onKeypress); +} + +/** Non-interactive headless (systemd/docker): a one-line log hint, no splash. */ +export function printRemoteHeadlessHint(localUrl: string): void { + const color = useColor(); + console.log(''); + console.log(' Headless server — remote access is off.'); + console.log( + ` Reach iClaw at ${paint(color, c.lime, localUrl)} over SSH, or create a link with: ${highlightKey(color, 'iclaw share')}`, + ); + console.log(''); +} + +/** Restart with remote access already configured — styled confirmation screen. */ +export function printRemoteAlreadyOn(): void { + const color = useColor(); + clearScreen(); + printLogo(color); + air(2); + console.log(centerLine(paint(color, CLI_LIME, '✓ Remote access is on'))); + air(1); +} + +/** Clean error screen if setting up the link fails (logo + the message). */ +export function printRemoteError(message: string): void { + clearScreen(); + printLogo(useColor()); + air(2); + console.log(centerLine(message)); + air(1); } /** `g` hotkey opens the UI while the server keeps running. */ @@ -397,6 +651,19 @@ export function attachCliBrowserControls( if (!wasRaw) stdin.setRawMode(true); stdin.resume(); + // tsx-watch (npm run dev) restarts this process on every file change — make + // sure raw mode is never left on, on any exit path, or the terminal breaks. + const restoreRaw = (): void => { + if (!wasRaw && stdin.isRaw) { + try { + stdin.setRawMode(false); + } catch { + /* ignore */ + } + } + }; + process.once('exit', restoreRaw); + let stopped = false; const stop = (): void => { if (stopped) return; diff --git a/test/integration/routes.projects.test.ts b/test/integration/routes.projects.test.ts index bcf0174..7d5f9f1 100644 --- a/test/integration/routes.projects.test.ts +++ b/test/integration/routes.projects.test.ts @@ -47,7 +47,12 @@ vi.mock('../../src/services/gatewayWs', () => ({ })); vi.mock('../../src/services/openclaw', () => ({ - openclaw: { baseUrl: 'http://127.0.0.1:18789', hasToken: true, tokenSource: 'test' }, + openclaw: { + baseUrl: 'http://127.0.0.1:18789', + hasToken: true, + tokenSource: 'test', + health: vi.fn(async () => true), + }, })); const { createApp } = await import('../../src/app'); diff --git a/test/unit/openclawWs.runTurn.test.ts b/test/unit/openclawWs.runTurn.test.ts index 3aaf005..4690095 100644 --- a/test/unit/openclawWs.runTurn.test.ts +++ b/test/unit/openclawWs.runTurn.test.ts @@ -1,7 +1,8 @@ /** * Unit tests for `openclawWs.runTurn` that stub `gatewayWs` to exercise - * the abort-intent race fix, the lifecycle:end watchdog, and the - * authoritativeText history-slice resolution. + * the abort-intent race fix, `lifecycle:end`-gated turn settlement (the + * native tool-loop truncation fix), and the authoritativeText history-slice + * resolution. * * We mock `gatewayWs` at the module boundary so we can: * - drive `request()` outcomes deterministically (chat.send, chat.abort, @@ -11,6 +12,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { RawGatewayFrame } from '../../src/services/gatewayWs'; +import type { TurnEvent } from '../../src/services/openclawWs'; type FrameListener = (frame: RawGatewayFrame) => void; type RequestImpl = ( @@ -42,7 +44,7 @@ vi.mock('../../src/services/gatewayWs', () => { const { openclawWs } = await import('../../src/services/openclawWs'); -function emit(payload: { event: 'agent' | 'chat'; payload: unknown }): void { +function emit(payload: { event: string; payload: unknown }): void { const frame: RawGatewayFrame = { type: 'event', event: payload.event, @@ -75,6 +77,30 @@ function emitLifecycle(sessionKey: string, runId: string, phase: string): void { }); } +function emitToolStart(sessionKey: string, runId: string, name: string): void { + emit({ + event: 'agent', + payload: { + sessionKey, + runId, + stream: 'item', + data: { kind: 'command', name, phase: 'start' }, + }, + }); +} + +function emitToolEnd(sessionKey: string, runId: string, name: string): void { + emit({ + event: 'agent', + payload: { + sessionKey, + runId, + stream: 'item', + data: { kind: 'command', name, phase: 'end' }, + }, + }); +} + /** Matches `POST_FINAL_CANONICAL_GRACE_MS` in openclawWs.ts */ const CANONICAL_GRACE_MS = 1000; @@ -127,6 +153,7 @@ describe('runTurn — authoritativeText from chat.history slice', () => { await Promise.resolve(); await Promise.resolve(); emitChatFinal(SK, RID, 'ok'); + emitLifecycle(SK, RID, 'end'); await settleCanonicalGrace(); await turnP; expect(historyLimit).toBe(1000); @@ -172,6 +199,7 @@ describe('runTurn — authoritativeText from chat.history slice', () => { await Promise.resolve(); await Promise.resolve(); emitChatFinal(SK, RID, 'self-action status'); + emitLifecycle(SK, RID, 'end'); await settleCanonicalGrace(); const result = await turnP; @@ -200,6 +228,7 @@ describe('runTurn — authoritativeText from chat.history slice', () => { emitChatFinal(SK, RID, 'Написав у чат короткий статус.'); await vi.advanceTimersByTimeAsync(100); emitChatFinal(SK, RID, canonical); + emitLifecycle(SK, RID, 'end'); await settleCanonicalGrace(); const result = await turnP; @@ -227,6 +256,7 @@ describe('runTurn — authoritativeText from chat.history slice', () => { await Promise.resolve(); emitChatFinal(SK, RID, 'статус у стрімі'); emitSessionMessage(SK, canonical); + emitLifecycle(SK, RID, 'end'); await settleCanonicalGrace(); const result = await turnP; @@ -285,6 +315,7 @@ describe('runTurn — authoritativeText from chat.history slice', () => { await Promise.resolve(); await Promise.resolve(); emitChatFinal(SK, RID, 'streamed answer'); + emitLifecycle(SK, RID, 'end'); await settleCanonicalGrace(); // Post-turn history fetch retries use HISTORY_FETCH_RETRY_DELAY_MS. await vi.runAllTimersAsync(); @@ -366,6 +397,7 @@ describe('runTurn — abort intent (stop-during-chat.send race)', () => { await Promise.resolve(); await Promise.resolve(); emitChatFinal(SK, 'r-fresh', 'normal answer'); + emitLifecycle(SK, 'r-fresh', 'end'); await settleCanonicalGrace(); const result = await turnP; @@ -375,8 +407,8 @@ describe('runTurn — abort intent (stop-during-chat.send race)', () => { }); }); -describe('runTurn — lifecycle:end watchdog', () => { - it('does not resolve on lifecycle:end alone if state:final arrives shortly after', async () => { +describe('runTurn — lifecycle:end terminator', () => { + it('settles after lifecycle:end, keeping the trailing state:final body', async () => { const SK = 'agent:test:end-then-final'; const RID = 'run-end'; requestImpl = async (method) => { @@ -394,7 +426,8 @@ describe('runTurn — lifecycle:end watchdog', () => { await Promise.resolve(); await Promise.resolve(); emitLifecycle(SK, RID, 'end'); - // state:final follows almost immediately on a healthy run. + // state:final follows almost immediately on a healthy run; the grace + // re-arm captures it before we settle. emitChatFinal(SK, RID, 'final body'); await settleCanonicalGrace(); @@ -425,3 +458,132 @@ describe('runTurn — lifecycle:end watchdog', () => { await expect(turnP).rejects.toThrow(/failed/); }); }); + +describe('runTurn — native (non-codex) tool loop', () => { + // Regression for the truncation bug: in the native agent loop the gateway + // commits EACH assistant segment as its own transcript row. The preamble + // that precedes a tool_use is therefore emitted as an intermediate + // session.message / chat:final BEFORE any tool runs. The old code settled + // on those, so the turn was saved as just the preamble and the real answer + // (after several tool round-trips) was dropped. Settlement is now gated on + // lifecycle:end, the one true run terminator. + it('does not settle on the intermediate preamble; waits for lifecycle:end and keeps the full answer', async () => { + const SK = 'agent:test:native-loop'; + const RID = 'run-native'; + const preamble = 'Окей, зараз виконаю кілька кроків.'; + const fullAnswer = 'Готово! Виконав усі кроки, підсумок: 42.'; + + requestImpl = async (method) => { + if (method === 'chat.send') return { runId: RID }; + if (method === 'chat.history') { + // By settle time the gateway transcript holds the WHOLE turn. + return { + messages: [ + { role: 'user', content: 'multi-step task' }, + { role: 'assistant', content: preamble }, + { role: 'toolResult', toolName: 'bash', content: 'ok' }, + { role: 'assistant', content: fullAnswer }, + ], + }; + } + return {}; + }; + + const events: TurnEvent[] = []; + const turnP = openclawWs.runTurn({ + sessionKey: SK, + message: 'multi-step task', + onEvent: (ev) => events.push(ev), + }); + let resolved = false; + void turnP.then( + () => { + resolved = true; + }, + () => { + resolved = true; + }, + ); + + await Promise.resolve(); + await Promise.resolve(); + + // Native loop: the preamble (+tool_use) is committed as its own row → + // intermediate session.message AND chat:final BEFORE the tool runs. + emitSessionMessage(SK, preamble); + emitChatFinal(SK, RID, preamble); + emitToolStart(SK, RID, 'bash'); + + // Tool runs well past the canonical grace. The OLD code would have + // settled here with just the preamble. + await vi.advanceTimersByTimeAsync(CANONICAL_GRACE_MS + 500); + await Promise.resolve(); + + // Turn must STILL be running — no premature settle, no text-final yet. + expect(resolved).toBe(false); + expect(events.some((e) => e.type === 'text-final')).toBe(false); + + // Tool completes, model streams the real answer, then the run ends. + emitToolEnd(SK, RID, 'bash'); + emitSessionMessage(SK, fullAnswer); + emitChatFinal(SK, RID, fullAnswer); + emitLifecycle(SK, RID, 'end'); + await settleCanonicalGrace(); + + const result = await turnP; + expect(result.text).toBe(fullAnswer); + expect(result.authoritativeText).toBe(fullAnswer); + + const finals = events.filter( + (e): e is Extract => e.type === 'text-final', + ); + expect(finals.at(-1)?.text).toBe(fullAnswer); + // Sanity: the tool round-trip was delivered (the loop was not cut short). + expect(events.some((e) => e.type === 'tool-start')).toBe(true); + expect(events.some((e) => e.type === 'tool-end')).toBe(true); + }); + + it('persists the full answer from history even if the live final lands after the grace', async () => { + // Even when the live chat:final arrives a hair too late to be the streamed + // text-final, authoritativeText (what chatRunner persists) is sourced from + // chat.history, which by settle time holds the complete turn. + const SK = 'agent:test:native-late-final'; + const RID = 'run-native-late'; + const preamble = 'Беруся за роботу…'; + const fullAnswer = 'Підсумок готовий: усе зроблено.'; + + requestImpl = async (method) => { + if (method === 'chat.send') return { runId: RID }; + if (method === 'chat.history') { + return { + messages: [ + { role: 'user', content: 'task' }, + { role: 'assistant', content: preamble }, + { role: 'assistant', content: fullAnswer }, + ], + }; + } + return {}; + }; + + const turnP = openclawWs.runTurn({ + sessionKey: SK, + message: 'task', + onEvent: () => undefined, + }); + await Promise.resolve(); + await Promise.resolve(); + + emitSessionMessage(SK, preamble); + emitToolStart(SK, RID, 'bash'); + await vi.advanceTimersByTimeAsync(2000); + emitToolEnd(SK, RID, 'bash'); + + // Run ends; the canonical final never streams to us live. + emitLifecycle(SK, RID, 'end'); + await settleCanonicalGrace(); + + const result = await turnP; + expect(result.authoritativeText).toBe(fullAnswer); + }); +}); diff --git a/test/unit/remoteAccessDeviceAuth.test.ts b/test/unit/remoteAccessDeviceAuth.test.ts new file mode 100644 index 0000000..0685a6b --- /dev/null +++ b/test/unit/remoteAccessDeviceAuth.test.ts @@ -0,0 +1,216 @@ +import { generateKeyPairSync, randomBytes, sign } from 'node:crypto'; +import { describe, expect, it, beforeEach } from 'vitest'; + +import { db } from '../../src/db/database'; +import { enableGate, disableGate, TUNNEL_ID_HEADER, TUNNELED_HEADER, TUNNELED_VALUE } from '../../src/services/remoteAccessAuth'; +import { remoteAccessDevices } from '../../src/services/remoteAccessDevices'; +import { remoteAccessState } from '../../src/services/remoteAccessState'; +import { + remoteAccessDeviceChallengeHandler, + remoteAccessDeviceVerifyHandler, +} from '../../src/services/remoteAccessDeviceAuth'; +import { verifyDeviceChallengeSignature } from '../../src/services/remoteAccessDeviceCrypto'; + +const TUNNEL = 't-testdevice01'; +const PASS = 'amber-apple-arrow-aspen-123'; + +function saveTunnel(): void { + const now = Date.now(); + remoteAccessState.save({ + id: TUNNEL, + label: 'Test', + passphrase: PASS, + accessToken: randomBytes(32).toString('base64url'), + durationMs: 30 * 60_000, + startedAt: now, + expiresAt: now + 30 * 60_000, + createdAt: now, + }); +} + +function makeKeyMaterial(): { publicKeySpki: string; signChallenge: (challengeB64: string) => string } { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const publicKeySpki = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'); + return { + publicKeySpki, + signChallenge(challengeB64: string) { + const challenge = Buffer.from(challengeB64, 'base64url'); + return sign(null, challenge, privateKey).toString('base64url'); + }, + }; +} + +type MockRes = { + statusCode: number; + headers: Record; + body: unknown; + status(code: number): MockRes; + json(payload: unknown): void; + redirect(code: number, url: string): void; + setHeader(name: string, value: string): void; +}; + +function mockRes(): MockRes { + const state = { statusCode: 200, headers: {} as Record, body: undefined as unknown }; + const res: MockRes = { + get statusCode() { + return state.statusCode; + }, + get headers() { + return state.headers; + }, + get body() { + return state.body; + }, + status(code: number) { + state.statusCode = code; + return res; + }, + json(payload: unknown) { + state.body = payload; + return; + }, + redirect(code: number, url: string) { + state.statusCode = code; + state.headers.Location = url; + return; + }, + setHeader(name: string, value: string) { + state.headers[name] = value; + }, + }; + return res; +} + +function tunneledReq(body: Record) { + return { + method: 'POST', + path: '/__ra/device/challenge', + ip: '127.0.0.1', + headers: { + [TUNNELED_HEADER]: TUNNELED_VALUE, + [TUNNEL_ID_HEADER]: TUNNEL, + 'content-type': 'application/json', + accept: 'application/json', + }, + body, + } as Parameters[0]; +} + +describe('remoteAccessDeviceAuth', () => { + beforeEach(() => { + db.exec('DELETE FROM remote_access_devices'); + remoteAccessState.delete(TUNNEL); + disableGate(TUNNEL); + saveTunnel(); + enableGate(TUNNEL, PASS); + }); + + it('registers device via store and issues challenge', () => { + const { publicKeySpki } = makeKeyMaterial(); + const device = remoteAccessDevices.register({ + tunnelId: TUNNEL, + publicKey: publicKeySpki, + name: 'Phone', + userAgent: 'TestAgent/1.0', + }); + expect(device.id).toMatch(/^d-/); + + const res = mockRes(); + remoteAccessDeviceChallengeHandler( + tunneledReq({ deviceId: device.id }), + res as never, + () => undefined, + ); + expect(res.statusCode).toBe(200); + const body = res.body as { challengeId: string; challenge: string }; + expect(body.challengeId).toBeTruthy(); + expect(body.challenge).toBeTruthy(); + }); + + it('accepts valid signature and updates last_seen_at', () => { + const keys = makeKeyMaterial(); + const device = remoteAccessDevices.register({ + tunnelId: TUNNEL, + publicKey: keys.publicKeySpki, + userAgent: 'UA', + }); + const seenBefore = device.lastSeenAt; + + const chRes = mockRes(); + remoteAccessDeviceChallengeHandler( + tunneledReq({ deviceId: device.id }), + chRes as never, + () => undefined, + ); + const ch = chRes.body as { challengeId: string; challenge: string }; + const signature = keys.signChallenge(ch.challenge); + expect(verifyDeviceChallengeSignature(keys.publicKeySpki, ch.challenge, signature)).toBe(true); + + const vRes = mockRes(); + const vReq = tunneledReq({ + deviceId: device.id, + challengeId: ch.challengeId, + signature, + next: '/', + }); + vReq.path = '/__ra/device/verify'; + remoteAccessDeviceVerifyHandler(vReq, vRes as never, () => undefined); + + expect(vRes.statusCode).toBe(200); + expect((vRes.body as { ok: boolean }).ok).toBe(true); + // E2E-only: device verify must NOT hand the browser an iclaw_ra cookie + // (it would leak to the relay and can't unlock E2E anyway). The client is + // told it still needs the passphrase to derive the encrypted session. + expect(String(vRes.headers['Set-Cookie'] ?? '')).not.toContain('iclaw_ra='); + expect((vRes.body as { needsPassphrase?: boolean }).needsPassphrase).toBe(true); + + const updated = remoteAccessDevices.get(TUNNEL, device.id)!; + expect(updated.lastSeenAt).toBeGreaterThanOrEqual(seenBefore); + }); + + it('rejects invalid signature', () => { + const keys = makeKeyMaterial(); + const device = remoteAccessDevices.register({ + tunnelId: TUNNEL, + publicKey: keys.publicKeySpki, + }); + + const chRes = mockRes(); + remoteAccessDeviceChallengeHandler( + tunneledReq({ deviceId: device.id }), + chRes as never, + () => undefined, + ); + const ch = chRes.body as { challengeId: string; challenge: string }; + + const vRes = mockRes(); + const vReq = tunneledReq({ + deviceId: device.id, + challengeId: ch.challengeId, + signature: randomBytes(64).toString('base64url'), + next: '/', + }); + vReq.path = '/__ra/device/verify'; + remoteAccessDeviceVerifyHandler(vReq, vRes as never, () => undefined); + + expect(vRes.statusCode).toBe(403); + }); + + it('rejects revoked device at challenge', () => { + const keys = makeKeyMaterial(); + const device = remoteAccessDevices.register({ + tunnelId: TUNNEL, + publicKey: keys.publicKeySpki, + }); + remoteAccessDevices.revoke(TUNNEL, device.id); + + const res = mockRes(); + remoteAccessDeviceChallengeHandler( + tunneledReq({ deviceId: device.id }), + res as never, + () => undefined, + ); + expect(res.statusCode).toBe(403); + }); +}); diff --git a/test/unit/remoteAccessDevices.test.ts b/test/unit/remoteAccessDevices.test.ts new file mode 100644 index 0000000..2757fd2 --- /dev/null +++ b/test/unit/remoteAccessDevices.test.ts @@ -0,0 +1,62 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { describe, expect, it, beforeEach } from 'vitest'; + +import { db } from '../../src/db/database'; +import { remoteAccessDevices } from '../../src/services/remoteAccessDevices'; +import { remoteAccessState } from '../../src/services/remoteAccessState'; + +const TUNNEL = 't-devstore01'; + +function seedTunnel(): void { + const now = Date.now(); + remoteAccessState.save({ + id: TUNNEL, + label: null, + passphrase: 'test', + accessToken: 'a'.repeat(43), + durationMs: 60_000, + startedAt: now, + expiresAt: now + 60_000, + createdAt: now, + }); +} + +describe('remoteAccessDevices', () => { + beforeEach(() => { + db.exec('DELETE FROM remote_access_devices'); + remoteAccessState.delete(TUNNEL); + seedTunnel(); + }); + + it('registers and revokes a device', () => { + const { publicKey } = generateKeyPairSync('ed25519'); + const spki = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'); + const d = remoteAccessDevices.register({ + tunnelId: TUNNEL, + publicKey: spki, + name: 'Laptop', + userAgent: 'Mozilla/5.0', + }); + expect(remoteAccessDevices.listByTunnel(TUNNEL)).toHaveLength(1); + expect(remoteAccessDevices.revoke(TUNNEL, d.id)).toBe(true); + expect(remoteAccessDevices.getActive(TUNNEL, d.id)).toBeNull(); + expect(remoteAccessDevices.get(TUNNEL, d.id)?.revokedAt).not.toBeNull(); + }); + + it('touchLastSeen updates timestamp', () => { + const { publicKey } = generateKeyPairSync('ed25519'); + const spki = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'); + const d = remoteAccessDevices.register({ tunnelId: TUNNEL, publicKey: spki }); + const t0 = d.lastSeenAt; + remoteAccessDevices.touchLastSeen(TUNNEL, d.id, t0 + 5000); + expect(remoteAccessDevices.get(TUNNEL, d.id)?.lastSeenAt).toBe(t0 + 5000); + }); + + it('deleteAllForTunnel removes rows', () => { + const { publicKey } = generateKeyPairSync('ed25519'); + const spki = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'); + remoteAccessDevices.register({ tunnelId: TUNNEL, publicKey: spki }); + remoteAccessDevices.deleteAllForTunnel(TUNNEL); + expect(remoteAccessDevices.listByTunnel(TUNNEL)).toHaveLength(0); + }); +}); diff --git a/test/unit/remoteAccessE2eBrowserInterop.test.ts b/test/unit/remoteAccessE2eBrowserInterop.test.ts new file mode 100644 index 0000000..99024b2 --- /dev/null +++ b/test/unit/remoteAccessE2eBrowserInterop.test.ts @@ -0,0 +1,188 @@ +/** + * Browser ↔ server E2E crypto interop (Step 4 — the "crypto.subtle" coverage). + * + * Loads the REAL browser crypto module (public/js/ra-e2e-crypto.mjs) — the one + * shipped to visitors — in Node, with its vendored noble imports rewritten to + * the on-disk files and AES-GCM running through the WebCrypto `crypto.subtle` + * available in modern Node. It then proves byte-level compatibility with the + * server implementation (src/services/remoteAccessE2eCrypto.ts): + * + * - identical session-key derivation (HKDF) from the same inputs, + * - browser-encrypted (c2s) records decrypt on the server, + * - server-encrypted (s2c) records decrypt in the browser, + * - the C1 per-stream subkey holds across implementations, + * - wire envelopes round-trip both ways. + * + * The Node integration test (scripts/ra-e2e-integration.mjs) stands in for the + * server data path; this test closes the gap on the actual browser code so a + * drift between the two crypto files fails CI instead of breaking real visitors. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import * as server from '../../src/services/remoteAccessE2eCrypto'; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const browserSrcPath = path.join(pkgRoot, 'public/js/ra-e2e-crypto.mjs'); +const nobleDirUrl = pathToFileURL(path.join(pkgRoot, 'public/js/vendor/noble') + '/').href; + +type BrowserCrypto = typeof import('../../public/js/ra-e2e-crypto.mjs'); +let browser: BrowserCrypto; +let tmpFile: string; + +beforeAll(async () => { + // Rewrite the two absolute browser import paths to the on-disk vendored + // files; noble's own relative imports then resolve from their real location. + const src = fs + .readFileSync(browserSrcPath, 'utf8') + .split('/js/vendor/noble/') + .join(nobleDirUrl); + tmpFile = path.join(os.tmpdir(), `ra-e2e-crypto-browser-interop-${process.pid}.mjs`); + fs.writeFileSync(tmpFile, src); + browser = (await import(pathToFileURL(tmpFile).href)) as BrowserCrypto; +}); + +const TUNNEL = 't-interop'; +const ACCESS = 'a'.repeat(43); +const binding = server.relayAccessBindingFromAccessToken(ACCESS); +const opaqueBytes = new Uint8Array(64); +for (let i = 0; i < opaqueBytes.length; i++) opaqueBytes[i] = (i * 7 + 11) & 0xff; +const opaqueB64 = Buffer.from(opaqueBytes).toString('base64url'); + +describe('browser ↔ server E2E crypto interop', () => { + it('derives identical session keys from the same inputs', () => { + const s = server.deriveE2eSessionKeys(opaqueBytes, TUNNEL, binding); + const b = browser.deriveE2eSessionKeys(opaqueB64, TUNNEL, binding); + expect(Buffer.from(b.c2s).toString('hex')).toBe(Buffer.from(s.c2s).toString('hex')); + expect(Buffer.from(b.s2c).toString('hex')).toBe(Buffer.from(s.s2c).toString('hex')); + }); + + it('browser-encrypted c2s record decrypts on the server', async () => { + const keys = browser.deriveE2eSessionKeys(opaqueB64, TUNNEL, binding); + const inner = new TextEncoder().encode(JSON.stringify({ method: 'GET', path: '/chat' })); + const ct = await browser.encryptE2eRecord(keys, 'c2s', { + tunnelId: TUNNEL, + streamId: 's-1', + ctr: 0, + kind: 'http-req', + inner, + relayBinding: binding, + }); + const sKeys = server.deriveE2eSessionKeys(opaqueBytes, TUNNEL, binding); + const rec = server.decryptE2eRecord( + sKeys, + 'c2s', + { + tunnelId: TUNNEL, + streamId: 's-1', + ctr: 0, + kind: 'http-req', + ciphertext: ct, + relayBinding: binding, + }, + new server.E2eCounterLedger(), + ); + expect(rec).toBeTruthy(); + expect(new TextDecoder().decode(rec!.inner)).toBe('{"method":"GET","path":"/chat"}'); + }); + + it('server-encrypted s2c record decrypts in the browser', async () => { + const sKeys = server.deriveE2eSessionKeys(opaqueBytes, TUNNEL, binding); + const inner = new TextEncoder().encode(JSON.stringify({ status: 200, bodyB64: '' })); + const ct = server.encryptE2eRecord(sKeys, 's2c', { + tunnelId: TUNNEL, + streamId: 's-2', + ctr: 0, + kind: 'http-res', + inner, + relayBinding: binding, + }); + const keys = browser.deriveE2eSessionKeys(opaqueB64, TUNNEL, binding); + const rec = await browser.decryptE2eRecord( + keys, + 's2c', + { + tunnelId: TUNNEL, + streamId: 's-2', + ctr: 0, + kind: 'http-res', + ciphertext: ct, + relayBinding: binding, + }, + new browser.E2eCounterLedger(), + ); + expect(rec).toBeTruthy(); + expect(new TextDecoder().decode(rec!.inner)).toBe('{"status":200,"bodyB64":""}'); + }); + + it('C1: per-stream subkey holds across implementations (no cross-stream decrypt)', async () => { + const keys = browser.deriveE2eSessionKeys(opaqueB64, TUNNEL, binding); + const inner = new TextEncoder().encode('x'); + const ctA = await browser.encryptE2eRecord(keys, 'c2s', { + tunnelId: TUNNEL, + streamId: 'stream-A', + ctr: 0, + kind: 'http-req', + inner, + relayBinding: binding, + }); + const ctB = await browser.encryptE2eRecord(keys, 'c2s', { + tunnelId: TUNNEL, + streamId: 'stream-B', + ctr: 0, + kind: 'http-req', + inner, + relayBinding: binding, + }); + // Same plaintext + same counter, different stream → different ciphertext. + expect(Buffer.from(ctA).toString('hex')).not.toBe(Buffer.from(ctB).toString('hex')); + + const sKeys = server.deriveE2eSessionKeys(opaqueBytes, TUNNEL, binding); + // Server must reject stream-A ciphertext presented under stream-B context. + const cross = server.decryptE2eRecord( + sKeys, + 'c2s', + { + tunnelId: TUNNEL, + streamId: 'stream-B', + ctr: 0, + kind: 'http-req', + ciphertext: ctA, + relayBinding: binding, + }, + new server.E2eCounterLedger(), + ); + expect(cross).toBeNull(); + }); + + it('wire envelopes round-trip between implementations', () => { + const ciphertext = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const wireFromBrowser = browser.encodeWireEnvelope({ + sid: 'handle-xyz', + ctr: 3, + kind: 'ws-data', + streamId: 'ws-1', + ciphertext, + }); + const decodedByServer = server.decodeWireEnvelope(wireFromBrowser); + expect(decodedByServer?.sid).toBe('handle-xyz'); + expect(decodedByServer?.ctr).toBe(3); + expect(decodedByServer?.streamId).toBe('ws-1'); + expect(Buffer.from(decodedByServer!.ciphertext).toString('hex')).toBe('0102030405060708'); + + const wireFromServer = server.encodeWireEnvelope({ + sid: 'handle-xyz', + ctr: 3, + kind: 'ws-data', + streamId: 'ws-1', + ciphertext, + }); + const decodedByBrowser = browser.decodeWireEnvelope(wireFromServer); + expect(decodedByBrowser?.sid).toBe('handle-xyz'); + expect(Buffer.from(decodedByBrowser!.ciphertext).toString('hex')).toBe('0102030405060708'); + }); +}); diff --git a/test/unit/remoteAccessE2eCrypto.test.ts b/test/unit/remoteAccessE2eCrypto.test.ts new file mode 100644 index 0000000..6a747ae --- /dev/null +++ b/test/unit/remoteAccessE2eCrypto.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; + +import { + decryptE2eRecord, + deriveE2eSessionKeys, + E2eCounterLedger, + encryptE2eRecord, + encodeWireEnvelope, + decodeWireEnvelope, +} from '../../src/services/remoteAccessE2eCrypto'; + +const tunnelId = 't-e2etest01'; +const relayBinding = new Uint8Array(32); +const opaqueKey = new Uint8Array(32).fill(7); + +describe('remoteAccessE2eCrypto', () => { + it('round-trips encrypt/decrypt client→server', () => { + const keys = deriveE2eSessionKeys(opaqueKey, tunnelId, relayBinding); + const ledger = new E2eCounterLedger(); + const inner = new TextEncoder().encode('{"method":"GET","path":"/"}'); + const ct = encryptE2eRecord(keys, 'c2s', { + tunnelId, + streamId: 'req-1', + ctr: 0, + kind: 'http-req', + inner, + relayBinding, + }); + const plain = decryptE2eRecord( + keys, + 'c2s', + { + tunnelId, + streamId: 'req-1', + ctr: 0, + kind: 'http-req', + ciphertext: ct, + relayBinding, + }, + ledger, + ); + expect(plain?.inner).toEqual(inner); + }); + + it('rejects replayed counter', () => { + const keys = deriveE2eSessionKeys(opaqueKey, tunnelId, relayBinding); + const ledger = new E2eCounterLedger(); + const inner = new Uint8Array([1, 2, 3]); + const ct0 = encryptE2eRecord(keys, 'c2s', { + tunnelId, + streamId: 's1', + ctr: 0, + kind: 'ws-data', + inner, + relayBinding, + }); + expect( + decryptE2eRecord( + keys, + 'c2s', + { + tunnelId, + streamId: 's1', + ctr: 0, + kind: 'ws-data', + ciphertext: ct0, + relayBinding, + }, + ledger, + ), + ).toBeTruthy(); + expect( + decryptE2eRecord( + keys, + 'c2s', + { + tunnelId, + streamId: 's1', + ctr: 0, + kind: 'ws-data', + ciphertext: ct0, + relayBinding, + }, + ledger, + ), + ).toBeNull(); + }); + + it('rejects tampered ciphertext', () => { + const keys = deriveE2eSessionKeys(opaqueKey, tunnelId, relayBinding); + const ledger = new E2eCounterLedger(); + const ct = encryptE2eRecord(keys, 'c2s', { + tunnelId, + streamId: 's2', + ctr: 0, + kind: 'http-req', + inner: new Uint8Array([9]), + relayBinding, + }); + const tampered = new Uint8Array(ct); + tampered[tampered.length - 1] ^= 0xff; + expect( + decryptE2eRecord( + keys, + 'c2s', + { + tunnelId, + streamId: 's2', + ctr: 0, + kind: 'http-req', + ciphertext: tampered, + relayBinding, + }, + ledger, + ), + ).toBeNull(); + }); + + it('does NOT reuse keystream across streams (C1 regression)', () => { + // Two different streams, same direction, same ctr=0, same plaintext. + // With a per-stream subkey the ciphertexts MUST differ; if the key were + // shared (the old bug) identical plaintext + identical (key,nonce) would + // produce byte-identical ciphertext — the catastrophic GCM nonce reuse. + const keys = deriveE2eSessionKeys(opaqueKey, tunnelId, relayBinding); + const inner = new TextEncoder().encode('{"cookie":"iclaw_ra=secret"}'); + const common = { tunnelId, ctr: 0, kind: 'http-req' as const, inner, relayBinding }; + const ctA = encryptE2eRecord(keys, 'c2s', { ...common, streamId: 'stream-A' }); + const ctB = encryptE2eRecord(keys, 'c2s', { ...common, streamId: 'stream-B' }); + expect(Buffer.from(ctA).equals(Buffer.from(ctB))).toBe(false); + + // And a ciphertext from stream-B must not decrypt under stream-A's context + // (key is bound to streamId), proving the subkeys are actually distinct. + const ledger = new E2eCounterLedger(); + const wrong = decryptE2eRecord( + keys, + 'c2s', + { tunnelId, streamId: 'stream-A', ctr: 0, kind: 'http-req', ciphertext: ctB, relayBinding }, + ledger, + ); + expect(wrong).toBeNull(); + }); + + it('wire envelope round-trip', () => { + const ct = new Uint8Array([1, 2, 3, 4]); + const wire = encodeWireEnvelope({ + sid: 'th-test-handle01', + ctr: 5, + kind: 'http-res', + streamId: 'r1', + ciphertext: ct, + }); + expect(wire).not.toContain('passphrase'); + const parsed = decodeWireEnvelope(wire); + expect(parsed?.sid).toBe('th-test-handle01'); + expect(parsed?.ctr).toBe(5); + expect(parsed?.ciphertext).toEqual(ct); + }); +}); diff --git a/test/unit/remoteAccessE2eSmoke.test.ts b/test/unit/remoteAccessE2eSmoke.test.ts new file mode 100644 index 0000000..d2628f4 --- /dev/null +++ b/test/unit/remoteAccessE2eSmoke.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import * as opaque from '@serenity-kit/opaque'; + +import { + looksLikeE2eWireEnvelope, + scanRelayCaptureText, +} from '../../src/services/remoteAccessCaptureScan'; +import { + decodeWireEnvelope, + deriveE2eSessionKeys, + encryptE2eRecord, + encodeWireEnvelope, + E2eCounterLedger, + decryptE2eRecord, + relayAccessBindingFromAccessToken, +} from '../../src/services/remoteAccessE2eCrypto'; +import { generateAccessToken } from '../../src/services/remoteAccessToken'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +describe('Remote Access E2E smoke (alpha)', () => { + it('capture scanner flags plaintext leaks', () => { + const bad = scanRelayCaptureText( + JSON.stringify({ + t: 'req', + method: 'POST', + path: '/__ra/login', + body: Buffer.from(JSON.stringify({ passphrase: 'amber-apple' })).toString('base64'), + headers: { cookie: 'iclaw_ra=supersecret123456' }, + }), + ); + expect(bad.ok).toBe(false); + expect(bad.hits.length).toBeGreaterThan(0); + }); + + it('capture scanner accepts E2E wire envelope', () => { + const tunnelId = 't-smoke'; + const access = generateAccessToken(); + const binding = relayAccessBindingFromAccessToken(access); + const keys = deriveE2eSessionKeys(new Uint8Array(64).fill(3), tunnelId, binding); + const ct = encryptE2eRecord(keys, 'c2s', { + tunnelId, + streamId: 's1', + ctr: 0, + kind: 'http-req', + inner: new TextEncoder().encode('{"method":"GET","path":"/"}'), + relayBinding: binding, + }); + const wire = encodeWireEnvelope({ + sid: 'handle-smoke01', + ctr: 0, + kind: 'http-req', + streamId: 's1', + ciphertext: ct, + }); + const frame = JSON.stringify({ + t: 'req', + method: 'POST', + path: '/__ra/e2e/http', + headers: {}, + body: Buffer.from(wire, 'utf8').toString('base64'), + }); + const good = scanRelayCaptureText(frame); + expect(good.ok).toBe(true); + expect(looksLikeE2eWireEnvelope(wire)).toBe(true); + }); + + it('replayed E2E frame fails decrypt', () => { + const tunnelId = 't-smoke-replay'; + const binding = relayAccessBindingFromAccessToken('token-for-smoke-test-only'); + const keys = deriveE2eSessionKeys(new Uint8Array(64).fill(9), tunnelId, binding); + const ledger = new E2eCounterLedger(); + const ct = encryptE2eRecord(keys, 'c2s', { + tunnelId, + streamId: 's-r', + ctr: 0, + kind: 'http-req', + inner: new Uint8Array([1]), + relayBinding: binding, + }); + const ok1 = decryptE2eRecord( + keys, + 'c2s', + { + tunnelId, + streamId: 's-r', + ctr: 0, + kind: 'http-req', + ciphertext: ct, + relayBinding: binding, + }, + ledger, + ); + expect(ok1).toBeTruthy(); + const ok2 = decryptE2eRecord( + keys, + 'c2s', + { + tunnelId, + streamId: 's-r', + ctr: 0, + kind: 'http-req', + ciphertext: ct, + relayBinding: binding, + }, + ledger, + ); + expect(ok2).toBeNull(); + }); + + it('OPAQUE wrong passphrase fails client finish', async () => { + await opaque.ready; + process.env.OPAQUE_SERVER_SETUP = opaque.server.createSetup(); + const tid = 't-smoke-wrong'; + const pw = 'correct-passphrase-smoke'; + const { clientRegistrationState, registrationRequest } = opaque.client.startRegistration({ + password: pw, + }); + const { registrationResponse } = opaque.server.createRegistrationResponse({ + serverSetup: process.env.OPAQUE_SERVER_SETUP, + userIdentifier: tid, + registrationRequest, + }); + const { registrationRecord } = opaque.client.finishRegistration({ + clientRegistrationState, + registrationResponse, + password: pw, + }); + const { clientLoginState, startLoginRequest } = opaque.client.startLogin({ + password: 'wrong-passphrase-smoke', + }); + const { loginResponse } = await opaque.server.startLogin({ + serverSetup: process.env.OPAQUE_SERVER_SETUP, + userIdentifier: tid, + registrationRecord, + startLoginRequest, + }); + const clientResult = opaque.client.finishLogin({ + clientLoginState, + loginResponse, + password: 'wrong-passphrase-smoke', + }); + expect(clientResult).toBeFalsy(); + }); + + it('gate JS avoids plain tunneled login', () => { + const js = fs.readFileSync(path.join(pkgRoot, 'public/js/ra-device-auth.js'), 'utf8'); + expect(js).toContain('runOpaqueLogin'); + const transport = fs.readFileSync(path.join(pkgRoot, 'public/js/ra-e2e-transport.mjs'), 'utf8'); + expect(transport).toContain('/__ra/e2e/http'); + expect(transport).not.toMatch(/postJson\(['"]\/__ra\/login/); + }); + + it('wire decode includes sid', () => { + const w = encodeWireEnvelope({ + sid: 'h1', + ctr: 1, + kind: 'ws-data', + streamId: 'ws1', + ciphertext: new Uint8Array([5, 6]), + }); + expect(decodeWireEnvelope(w)?.sid).toBe('h1'); + }); +}); diff --git a/test/unit/remoteAccessE2eTransport.test.ts b/test/unit/remoteAccessE2eTransport.test.ts new file mode 100644 index 0000000..fb55333 --- /dev/null +++ b/test/unit/remoteAccessE2eTransport.test.ts @@ -0,0 +1,273 @@ +import http from 'node:http'; +import * as opaque from '@serenity-kit/opaque'; +import { afterAll, describe, expect, it, beforeAll } from 'vitest'; + +import { + createE2eTransportSession, + decodeOpaqueSessionKey, +} from '../../src/services/remoteAccessE2eSession'; +import { + decodeWireEnvelope, + decryptE2eRecord, + deriveE2eSessionKeys, + encryptE2eRecord, + encodeWireEnvelope, + E2eCounterLedger, + relayAccessBindingFromAccessToken, +} from '../../src/services/remoteAccessE2eCrypto'; +import { handleE2eHttpFrame } from '../../src/services/remoteAccessE2eTransport'; +import { generateAccessToken } from '../../src/services/remoteAccessToken'; + +describe('remoteAccessE2eTransport', () => { + const tunnelId = 't-e2e-transport'; + const accessToken = generateAccessToken(); + let opaqueSessionKey = ''; + let loopbackPort = 0; + let loopbackServer: http.Server; + + beforeAll(async () => { + loopbackServer = http.createServer((_req, res) => { + res.statusCode = 200; + res.setHeader('content-type', 'text/plain'); + res.end('tunnel-ok'); + }); + await new Promise((resolve) => { + loopbackServer.listen(0, '127.0.0.1', () => resolve()); + }); + loopbackPort = (loopbackServer.address() as { port: number }).port; + + await opaque.ready; + process.env.OPAQUE_SERVER_SETUP = opaque.server.createSetup(); + const password = 'amber-apple-arrow-aspen-888'; + const { clientRegistrationState, registrationRequest } = opaque.client.startRegistration({ + password, + }); + const { registrationResponse } = opaque.server.createRegistrationResponse({ + serverSetup: process.env.OPAQUE_SERVER_SETUP, + userIdentifier: tunnelId, + registrationRequest, + }); + const { registrationRecord } = opaque.client.finishRegistration({ + clientRegistrationState, + registrationResponse, + password, + }); + const { clientLoginState, startLoginRequest } = opaque.client.startLogin({ password }); + const { loginResponse, serverLoginState } = opaque.server.startLogin({ + serverSetup: process.env.OPAQUE_SERVER_SETUP, + userIdentifier: tunnelId, + registrationRecord, + startLoginRequest, + }); + const clientResult = opaque.client.finishLogin({ + clientLoginState, + loginResponse, + password, + }); + if (!clientResult) throw new Error('opaque login failed'); + opaqueSessionKey = clientResult.sessionKey; + opaque.server.finishLogin({ + serverLoginState, + finishLoginRequest: clientResult.finishLoginRequest, + }); + }); + + afterAll(() => { + loopbackServer.close(); + }); + + it('HTTP E2E round-trip via handleE2eHttpFrame', async () => { + const handle = createE2eTransportSession({ + tunnelId, + raSessionId: 'ra-sess-test', + opaqueSessionKey, + accessToken, + }); + const relayBinding = relayAccessBindingFromAccessToken(accessToken); + const keys = deriveE2eSessionKeys(decodeOpaqueSessionKey(opaqueSessionKey), tunnelId, relayBinding); + + const inner = JSON.stringify({ + id: 'req-http-1', + method: 'GET', + path: '/', + headers: { accept: 'text/plain' }, + bodyB64: '', + }); + const ct = encryptE2eRecord(keys, 'c2s', { + tunnelId, + streamId: 'req-http-1', + ctr: 0, + kind: 'http-req', + inner: new TextEncoder().encode(inner), + relayBinding, + }); + const wire = encodeWireEnvelope({ + sid: handle, + ctr: 0, + kind: 'http-req', + streamId: 'req-http-1', + ciphertext: ct, + }); + + const result = await handleE2eHttpFrame({ + tunnelId, + bodyRaw: wire, + localHost: '127.0.0.1', + localPort: loopbackPort, + }); + + expect(result.status).toBe(200); + const outWire = decodeWireEnvelope(result.body); + expect(outWire?.kind).toBe('http-res'); + expect(outWire?.sid).toBe(handle); + expect(result.body).not.toContain('iclaw_ra'); + expect(result.body).not.toContain('tunnel-ok'); + }); + + it('re-attaches the session cookie at loopback so inner requests pass the gate (H1)', async () => { + // A loopback target that mimics the gate middleware: 200 only when the + // request carries the right iclaw_ra session cookie, 401 otherwise. The + // encrypted inner request never carries it (HttpOnly is invisible to JS), + // so this only succeeds if the transport layer injects it server-side. + const gated = http.createServer((req, res) => { + const cookie = req.headers.cookie ?? ''; + if (cookie.includes('iclaw_ra=ra-sess-inject')) { + res.statusCode = 200; + res.end('workspace'); + } else { + res.statusCode = 401; + res.end('gate'); + } + }); + await new Promise((resolve) => gated.listen(0, '127.0.0.1', () => resolve())); + const gatedPort = (gated.address() as { port: number }).port; + + try { + const handle = createE2eTransportSession({ + tunnelId: `${tunnelId}-inject`, + raSessionId: 'ra-sess-inject', + opaqueSessionKey, + accessToken, + }); + const relayBinding = relayAccessBindingFromAccessToken(accessToken); + const keys = deriveE2eSessionKeys( + decodeOpaqueSessionKey(opaqueSessionKey), + `${tunnelId}-inject`, + relayBinding, + ); + const inner = new TextEncoder().encode( + JSON.stringify({ + id: 'inj-1', + method: 'GET', + path: '/', + // No cookie here — exactly what the browser's encrypted inner request + // looks like (the HttpOnly iclaw_ra is not in document.cookie). + headers: { accept: 'text/html' }, + bodyB64: '', + }), + ); + const ct = encryptE2eRecord(keys, 'c2s', { + tunnelId: `${tunnelId}-inject`, + streamId: 'inj-1', + ctr: 0, + kind: 'http-req', + inner, + relayBinding, + }); + const wire = encodeWireEnvelope({ + sid: handle, + ctr: 0, + kind: 'http-req', + streamId: 'inj-1', + ciphertext: ct, + }); + + const result = await handleE2eHttpFrame({ + tunnelId: `${tunnelId}-inject`, + bodyRaw: wire, + localHost: '127.0.0.1', + localPort: gatedPort, + }); + expect(result.status).toBe(200); + + const outWire = decodeWireEnvelope(result.body); + expect(outWire).toBeTruthy(); + const plain = decryptE2eRecord( + keys, + 's2c', + { + tunnelId: `${tunnelId}-inject`, + streamId: outWire!.streamId, + ctr: outWire!.ctr, + kind: outWire!.kind, + ciphertext: outWire!.ciphertext, + relayBinding, + }, + new E2eCounterLedger(), + ); + expect(plain).toBeTruthy(); + const innerRes = JSON.parse(new TextDecoder().decode(plain!.inner)) as { + status: number; + bodyB64: string; + }; + // 200 = the loopback saw the injected session cookie (gate passed). + expect(innerRes.status).toBe(200); + expect(Buffer.from(innerRes.bodyB64, 'base64').toString('utf8')).toBe('workspace'); + } finally { + gated.close(); + } + }); + + it('rejects replayed wire counter', async () => { + const handle = createE2eTransportSession({ + tunnelId: `${tunnelId}-replay`, + raSessionId: 'ra-sess-replay', + opaqueSessionKey, + accessToken, + }); + const relayBinding = relayAccessBindingFromAccessToken(accessToken); + const keys = deriveE2eSessionKeys( + decodeOpaqueSessionKey(opaqueSessionKey), + `${tunnelId}-replay`, + relayBinding, + ); + const inner = new TextEncoder().encode( + JSON.stringify({ + id: 'r1', + method: 'GET', + path: '/', + headers: {}, + bodyB64: '', + }), + ); + const ct = encryptE2eRecord(keys, 'c2s', { + tunnelId: `${tunnelId}-replay`, + streamId: 's-replay', + ctr: 0, + kind: 'http-req', + inner, + relayBinding, + }); + const wire = encodeWireEnvelope({ + sid: handle, + ctr: 0, + kind: 'http-req', + streamId: 's-replay', + ciphertext: ct, + }); + + await handleE2eHttpFrame({ + tunnelId: `${tunnelId}-replay`, + bodyRaw: wire, + localHost: '127.0.0.1', + localPort: loopbackPort, + }); + const replay = await handleE2eHttpFrame({ + tunnelId: `${tunnelId}-replay`, + bodyRaw: wire, + localHost: '127.0.0.1', + localPort: loopbackPort, + }); + expect(replay.status).toBe(400); + }); +}); diff --git a/test/unit/remoteAccessGateUi.test.ts b/test/unit/remoteAccessGateUi.test.ts new file mode 100644 index 0000000..00b59f7 --- /dev/null +++ b/test/unit/remoteAccessGateUi.test.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { renderGateLoginPage } from '../../src/services/remoteAccessAuth'; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +function readPublicJs(name: string): string { + return fs.readFileSync(path.join(pkgRoot, 'public', 'js', name), 'utf8'); +} + +describe('Remote Access gate UI (OPAQUE)', () => { + it('gate HTML uses OPAQUE module without native POST to /', () => { + const html = renderGateLoginPage({ tunnelId: 't-ui-02', next: '/' }); + expect(html).toContain('meta name="iclaw-ra-e2e" content="true"'); + expect(html).toContain('meta name="iclaw-ra-relay-binding"'); + expect(html).toContain('ra-gate-opaque.mjs'); + expect(html).toContain('type="button"'); + expect(html).not.toMatch(/method\s*=\s*["']POST["']/i); + expect(html).not.toContain('action="/__ra/login"'); + }); + + it('ra-device-auth.js uses OPAQUE flow only', () => { + const js = readPublicJs('ra-device-auth.js'); + expect(js).toContain('runOpaqueLogin'); + expect(js).toContain('iclawRaOpaqueLogin'); + expect(js).toContain('navigateViaE2eDocument'); + expect(js).not.toContain('runLegacyLogin'); + expect(js).not.toMatch(/postJson\(['"]\/__ra\/login/); + }); + + it('ra-gate-opaque.mjs calls start/finish only', () => { + const mjs = readPublicJs('ra-gate-opaque.mjs'); + expect(mjs).toContain('/__ra/opaque/login/start'); + expect(mjs).toContain('/__ra/opaque/login/finish'); + expect(mjs).not.toMatch(/postJson\(['"]\/__ra\/login/); + expect(mjs).not.toMatch(/fetch\(['"]\/__ra\/login/); + expect(mjs).not.toMatch(/console\.(log|debug|info)/); + }); + + it('gate public assets list includes OPAQUE vendor bundle paths', () => { + const authSrc = fs.readFileSync( + path.join(pkgRoot, 'src', 'services', 'remoteAccessAuth.ts'), + 'utf8', + ); + expect(authSrc).toContain("'/js/ra-gate-opaque.mjs'"); + expect(authSrc).toContain("'/js/vendor/opaque/index.js'"); + }); +}); diff --git a/test/unit/remoteAccessOpaque.test.ts b/test/unit/remoteAccessOpaque.test.ts new file mode 100644 index 0000000..9009b22 --- /dev/null +++ b/test/unit/remoteAccessOpaque.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, beforeAll } from 'vitest'; +import * as opaque from '@serenity-kit/opaque'; + +import { + ensureOpaqueReady, + registerOpaqueForTunnel, + startOpaqueLogin, + finishOpaqueLogin, +} from '../../src/services/remoteAccessOpaque'; + +describe('remoteAccessOpaque', () => { + const tunnelId = 't-opaque-test'; + const password = 'amber-apple-arrow-aspen-999'; + + beforeAll(async () => { + await ensureOpaqueReady(); + process.env.OPAQUE_SERVER_SETUP = opaque.server.createSetup(); + }); + + it('register + login never exposes password in messages', async () => { + const registrationRecord = await registerOpaqueForTunnel(tunnelId, password); + + const { clientLoginState, startLoginRequest } = opaque.client.startLogin({ password }); + expect(startLoginRequest).not.toContain(password); + + const { loginResponse, serverLoginState } = await startOpaqueLogin( + tunnelId, + registrationRecord, + startLoginRequest, + ); + expect(loginResponse).not.toContain(password); + + const clientResult = opaque.client.finishLogin({ + clientLoginState, + loginResponse, + password, + }); + expect(clientResult).toBeTruthy(); + if (!clientResult) return; + + const serverResult = await finishOpaqueLogin({ + tunnelId, + registrationRecord, + serverLoginState, + finishLoginRequest: clientResult.finishLoginRequest, + }); + expect(serverResult.sessionKey).toBeTruthy(); + expect(clientResult.sessionKey).toBe(serverResult.sessionKey); + }); + + it('wrong password fails login', async () => { + const registrationRecord = await registerOpaqueForTunnel(`${tunnelId}-2`, password); + const { clientLoginState, startLoginRequest } = opaque.client.startLogin({ + password: 'wrong-password-value', + }); + const { loginResponse, serverLoginState } = await startOpaqueLogin( + `${tunnelId}-2`, + registrationRecord, + startLoginRequest, + ); + const clientResult = opaque.client.finishLogin({ + clientLoginState, + loginResponse, + password: 'wrong-password-value', + }); + expect(clientResult).toBeFalsy(); + }); +}); diff --git a/test/unit/remoteAccessOpaqueGate.test.ts b/test/unit/remoteAccessOpaqueGate.test.ts new file mode 100644 index 0000000..73efb9f --- /dev/null +++ b/test/unit/remoteAccessOpaqueGate.test.ts @@ -0,0 +1,259 @@ +import * as opaque from '@serenity-kit/opaque'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { db } from '../../src/db/database'; +import { + disableGate, + enableGate, + TUNNELED_HEADER, + TUNNELED_VALUE, + TUNNEL_ID_HEADER, + remoteAccessLoginHandler, +} from '../../src/services/remoteAccessAuth'; +import { remoteAccess } from '../../src/services/remoteAccess'; +import { + assertOpaqueServerSetup, + ensureOpaqueReady, + getOpaqueRegistrationRecord, + registerOpaqueForTunnel, + saveOpaqueRegistrationRecord, +} from '../../src/services/remoteAccessOpaque'; +import { + remoteAccessOpaqueLoginFinishHandler, + remoteAccessOpaqueLoginStartHandler, +} from '../../src/services/remoteAccessOpaqueAuth'; +import { remoteAccessState } from '../../src/services/remoteAccessState'; + +const TUNNEL = 't-opaque-gate01'; +const PASS = 'amber-apple-arrow-aspen-777'; + +function tunneledReq( + path: string, + body: Record, + method = 'POST', +): Parameters[0] { + return { + method, + path, + ip: '127.0.0.1', + headers: { + [TUNNELED_HEADER]: TUNNELED_VALUE, + [TUNNEL_ID_HEADER]: TUNNEL, + 'content-type': 'application/json', + accept: 'application/json', + }, + body, + } as Parameters[0]; +} + +type MockRes = { + statusCode: number; + headers: Record; + body: unknown; + status(code: number): MockRes; + json(payload: unknown): void; + type(mime: string): MockRes; + send(payload: unknown): void; + setHeader(name: string, value: string): void; +}; + +function mockRes(): MockRes { + const state = { statusCode: 200, headers: {} as Record, body: undefined as unknown }; + const res: MockRes = { + get statusCode() { + return state.statusCode; + }, + get headers() { + return state.headers; + }, + get body() { + return state.body; + }, + status(code: number) { + state.statusCode = code; + return res; + }, + json(payload: unknown) { + state.body = payload; + return; + }, + type() { + return res; + }, + send(payload: unknown) { + state.body = payload; + return; + }, + setHeader(name: string, value: string) { + state.headers[name] = value; + }, + }; + return res; +} + +async function runOpaqueClientLogin(password: string): Promise<{ + startLoginRequest: string; + finishLoginRequest: string; + loginStateId: string; +}> { + await ensureOpaqueReady(); + const { clientLoginState, startLoginRequest } = opaque.client.startLogin({ password }); + const startRes = mockRes(); + await remoteAccessOpaqueLoginStartHandler( + tunneledReq('/__ra/opaque/login/start', { startLoginRequest }), + startRes as never, + () => undefined, + ); + expect(startRes.statusCode).toBe(200); + const { loginResponse, loginStateId } = startRes.body as { + loginResponse: string; + loginStateId: string; + }; + const clientResult = opaque.client.finishLogin({ + clientLoginState, + loginResponse, + password, + }); + if (!clientResult) throw new Error('client finishLogin failed'); + return { + startLoginRequest, + finishLoginRequest: clientResult.finishLoginRequest, + loginStateId, + }; +} + +describe('remoteAccessOpaqueGate', () => { + const envBackup = { ...process.env }; + + beforeAll(async () => { + await ensureOpaqueReady(); + }); + + beforeEach(async () => { + process.env = { ...envBackup }; + process.env.OPAQUE_SERVER_SETUP = opaque.server.createSetup(); + db.exec('DELETE FROM remote_access_devices'); + remoteAccessState.delete(TUNNEL); + disableGate(TUNNEL); + const now = Date.now(); + remoteAccessState.save({ + id: TUNNEL, + label: 'Gate test', + passphrase: PASS, + accessToken: 'a'.repeat(43), + durationMs: 30 * 60_000, + startedAt: now, + expiresAt: now + 30 * 60_000, + createdAt: now, + }); + const record = await registerOpaqueForTunnel(TUNNEL, PASS); + saveOpaqueRegistrationRecord(TUNNEL, record); + enableGate(TUNNEL, PASS); + remoteAccess.configure({ + relayUrl: 'ws://127.0.0.1:4100/tunnel', + localHost: '127.0.0.1', + localPort: 3000, + }); + }); + + afterEach(() => { + process.env = { ...envBackup }; + }); + + it('tunneled plain /__ra/login is rejected with 426', () => { + const res = mockRes(); + remoteAccessLoginHandler( + tunneledReq('/__ra/login', { passphrase: PASS, next: '/' }), + res as never, + () => undefined, + ); + expect(res.statusCode).toBe(426); + expect((res.body as { login: string }).login).toBe('opaque'); + const serialized = JSON.stringify(res.body); + expect(serialized).not.toContain(PASS); + }); + + it('OPAQUE finish returns a transport handle and does NOT leak iclaw_ra to the browser', async () => { + const { finishLoginRequest, loginStateId } = await runOpaqueClientLogin(PASS); + const finishRes = mockRes(); + await remoteAccessOpaqueLoginFinishHandler( + tunneledReq('/__ra/opaque/login/finish', { + loginStateId, + finishLoginRequest, + next: '/', + }), + finishRes as never, + () => undefined, + ); + expect(finishRes.statusCode).toBe(200); + expect((finishRes.body as { ok: boolean }).ok).toBe(true); + // E2E-only: the session id is NOT handed to the browser as a cookie (that + // would leak it to the relay). Inner encrypted requests authenticate via + // the transport session re-attaching the cookie server-side at loopback. + expect(String(finishRes.headers['Set-Cookie'] ?? '')).not.toContain('iclaw_ra='); + expect(typeof (finishRes.body as { transportHandle?: unknown }).transportHandle).toBe('string'); + }); + + it('wrong passphrase fails OPAQUE login', async () => { + await ensureOpaqueReady(); + const { clientLoginState, startLoginRequest } = opaque.client.startLogin({ + password: 'totally-wrong-passphrase-value', + }); + const startRes = mockRes(); + await remoteAccessOpaqueLoginStartHandler( + tunneledReq('/__ra/opaque/login/start', { startLoginRequest }), + startRes as never, + () => undefined, + ); + expect(startRes.statusCode).toBe(200); + const { loginResponse, loginStateId } = startRes.body as { + loginResponse: string; + loginStateId: string; + }; + const clientResult = opaque.client.finishLogin({ + clientLoginState, + loginResponse, + password: 'totally-wrong-passphrase-value', + }); + expect(clientResult).toBeFalsy(); + + const finishRes = mockRes(); + await remoteAccessOpaqueLoginFinishHandler( + tunneledReq('/__ra/opaque/login/finish', { + loginStateId, + finishLoginRequest: 'invalid-opaque-finish-blob', + }), + finishRes as never, + () => undefined, + ); + expect(finishRes.statusCode).toBe(401); + }); + + it('OPAQUE wire bodies never contain raw passphrase', async () => { + const { startLoginRequest } = await runOpaqueClientLogin(PASS); + expect(startLoginRequest).not.toContain(PASS); + expect(JSON.stringify({ startLoginRequest })).not.toContain(PASS); + }); + + it('service layer requires a setup when none is configured (API route auto-provisions)', async () => { + // With no env override and nothing persisted, the low-level service refuses + // (the HTTP route is what lazily generates+persists one — see + // ensureOpaqueServerSetup / POST /api/remote-access/tunnels). + delete process.env.OPAQUE_SERVER_SETUP; + db.exec("DELETE FROM iclaw_kv WHERE key = 'opaque_server_setup'"); + expect(() => assertOpaqueServerSetup()).toThrow(/OPAQUE server setup/i); + await expect( + remoteAccess.createTunnel(30 * 60_000, 'should-fail'), + ).rejects.toThrow(/OPAQUE server setup/i); + }); + + it('createTunnel registers opaque record', async () => { + process.env.OPAQUE_SERVER_SETUP = opaque.server.createSetup(); + const status = await remoteAccess.createTunnel(30 * 60_000, 'e2e-tunnel'); + expect(status.id).toBeTruthy(); + const record = getOpaqueRegistrationRecord(status.id); + expect(record).toBeTruthy(); + expect(record!.length).toBeGreaterThan(10); + remoteAccess.deleteTunnel(status.id); + }); +}); diff --git a/test/unit/remoteAccessOpaqueSetupSync.test.ts b/test/unit/remoteAccessOpaqueSetupSync.test.ts new file mode 100644 index 0000000..98edb62 --- /dev/null +++ b/test/unit/remoteAccessOpaqueSetupSync.test.ts @@ -0,0 +1,153 @@ +import * as opaque from '@serenity-kit/opaque'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { db } from '../../src/db/database'; +import { remoteAccessState } from '../../src/services/remoteAccessState'; +import { + ensureOpaqueServerSetup, + forceOpaqueRegistrationForTunnel, + getOpaqueRegistrationRecord, + registerOpaqueForTunnel, + syncOpaqueRegistrationsWithServerSetup, +} from '../../src/services/remoteAccessOpaque'; + +const TUNNEL = 't-opaque-fp-sync'; + +describe('remoteAccessOpaque setup sync', () => { + const envBackup = process.env.OPAQUE_SERVER_SETUP; + + beforeAll(async () => { + await opaque.ready; + }); + + afterEach(() => { + if (envBackup !== undefined) process.env.OPAQUE_SERVER_SETUP = envBackup; + else delete process.env.OPAQUE_SERVER_SETUP; + db.exec('DELETE FROM iclaw_kv'); + remoteAccessState.delete(TUNNEL); + }); + + it('re-registers OPAQUE when OPAQUE_SERVER_SETUP changes', async () => { + process.env.OPAQUE_SERVER_SETUP = opaque.server.createSetup(); + const pw = 'amber-apple-arrow-aspen-fp1'; + const now = Date.now(); + remoteAccessState.save({ + id: TUNNEL, + label: 'fp', + passphrase: pw, + accessToken: 'a'.repeat(43), + durationMs: 60_000, + startedAt: now, + expiresAt: now + 60_000, + createdAt: now, + }); + + await syncOpaqueRegistrationsWithServerSetup([{ id: TUNNEL, passphrase: pw }]); + const rec1 = getOpaqueRegistrationRecord(TUNNEL); + expect(rec1).toBeTruthy(); + + process.env.OPAQUE_SERVER_SETUP = opaque.server.createSetup(); + await syncOpaqueRegistrationsWithServerSetup([{ id: TUNNEL, passphrase: pw }]); + const rec2 = getOpaqueRegistrationRecord(TUNNEL); + expect(rec2).toBeTruthy(); + expect(rec2).not.toBe(rec1); + + const { clientLoginState, startLoginRequest } = opaque.client.startLogin({ password: pw }); + const { loginResponse, serverLoginState } = await opaque.server.startLogin({ + serverSetup: process.env.OPAQUE_SERVER_SETUP, + userIdentifier: TUNNEL, + registrationRecord: rec2!, + startLoginRequest, + }); + const clientResult = opaque.client.finishLogin({ + clientLoginState, + loginResponse, + password: pw, + }); + expect(clientResult).toBeTruthy(); + expect( + await opaque.server.finishLogin({ + serverLoginState, + finishLoginRequest: clientResult!.finishLoginRequest, + }), + ).toBeTruthy(); + }); + + it('forceOpaqueRegistrationForTunnel replaces stale record', async () => { + process.env.OPAQUE_SERVER_SETUP = opaque.server.createSetup(); + const pw = 'amber-apple-arrow-aspen-fp2'; + const stale = await registerOpaqueForTunnel(TUNNEL, 'wrong-passphrase-for-stale'); + remoteAccessState.save({ + id: TUNNEL, + label: 'fp2', + passphrase: pw, + accessToken: 'b'.repeat(43), + durationMs: 60_000, + startedAt: Date.now(), + expiresAt: Date.now() + 60_000, + createdAt: Date.now(), + }); + db.prepare('UPDATE remote_access_tunnels SET opaque_registration_record = ? WHERE id = ?').run( + stale, + TUNNEL, + ); + + await forceOpaqueRegistrationForTunnel(TUNNEL, pw); + const { clientLoginState, startLoginRequest } = opaque.client.startLogin({ password: pw }); + const { loginResponse, serverLoginState } = await opaque.server.startLogin({ + serverSetup: process.env.OPAQUE_SERVER_SETUP, + userIdentifier: TUNNEL, + registrationRecord: getOpaqueRegistrationRecord(TUNNEL)!, + startLoginRequest, + }); + expect( + opaque.client.finishLogin({ + clientLoginState, + loginResponse, + password: pw, + }), + ).toBeTruthy(); + expect( + await opaque.server.finishLogin({ + serverLoginState, + finishLoginRequest: opaque.client.finishLogin({ + clientLoginState, + loginResponse, + password: pw, + })!.finishLoginRequest, + }), + ).toBeTruthy(); + }); + + it('auto-generates and persists an OPAQUE setup when none is configured', async () => { + delete process.env.OPAQUE_SERVER_SETUP; + db.exec('DELETE FROM iclaw_kv'); + + // Nothing in env, nothing persisted → minted on demand. + const setup1 = await ensureOpaqueServerSetup(); + expect(typeof setup1).toBe('string'); + expect(setup1.length).toBeGreaterThan(20); + + // Persisted in the DB → a second call returns the SAME value (stable, so + // existing tunnels' OPAQUE records stay valid across restarts). + const setup2 = await ensureOpaqueServerSetup(); + expect(setup2).toBe(setup1); + + // And it is actually usable for a register round-trip. + const rec = await registerOpaqueForTunnel(TUNNEL, 'amber-apple-arrow-aspen-auto'); + expect(rec).toBeTruthy(); + }); + + it('env override takes precedence over a persisted setup', async () => { + delete process.env.OPAQUE_SERVER_SETUP; + db.exec('DELETE FROM iclaw_kv'); + + const generated = await ensureOpaqueServerSetup(); // persists to the DB + const envSetup = opaque.server.createSetup(); + process.env.OPAQUE_SERVER_SETUP = envSetup; + + const resolved = await ensureOpaqueServerSetup(); + expect(resolved).toBe(envSetup); + expect(resolved).not.toBe(generated); + }); +}); diff --git a/test/unit/remoteAccessPlaintextExempt.test.ts b/test/unit/remoteAccessPlaintextExempt.test.ts new file mode 100644 index 0000000..0344b3b --- /dev/null +++ b/test/unit/remoteAccessPlaintextExempt.test.ts @@ -0,0 +1,263 @@ +import * as opaque from '@serenity-kit/opaque'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + disableGate, + enableGate, + mintSession, + renderGateLoginPage, + remoteAccessAuthMiddleware, + SESSION_COOKIE, + stripSessionCookie, + TUNNELED_HEADER, + TUNNELED_VALUE, + TUNNEL_ID_HEADER, +} from '../../src/services/remoteAccessAuth'; +import { + E2E_HTTP_PATH, + isE2ePlaintextTunnelExempt, +} from '../../src/services/remoteAccessE2eTransport'; +import { + deriveE2eSessionKeys, + encryptE2eRecord, + encodeWireEnvelope, + relayAccessBindingFromAccessToken, +} from '../../src/services/remoteAccessE2eCrypto'; +import { generateAccessToken } from '../../src/services/remoteAccessToken'; + +const TUNNEL = 't-plaintext-exempt'; + +function captureContainsAppSecrets(frameOrText: string): boolean { + let text = frameOrText; + try { + const frame = JSON.parse(frameOrText) as { path?: string; body?: string }; + if (typeof frame.path === 'string') { + const p = frame.path.split('?')[0] ?? frame.path; + if ( + /^\/(chats|projects|tasks|settings)(\/|$)/.test(p) && + !p.startsWith('/__ra/') + ) { + return true; + } + } + if (typeof frame.body === 'string' && frame.body.length > 0) { + text += Buffer.from(frame.body, 'base64').toString('utf8'); + } + } catch { + // not JSON + } + return ( + /"chatId"\s*:/.test(text) || + /"projectId"\s*:/.test(text) || + /"type"\s*:\s*"message-appended"/.test(text) + ); +} + +function relayReqFrame(opts: { + method: string; + path: string; + body?: string; + cookie?: string; +}): string { + const headers: Record = {}; + if (opts.cookie) headers.cookie = opts.cookie; + return JSON.stringify({ + t: 'req', + tunnelId: TUNNEL, + method: opts.method, + path: opts.path, + headers, + body: opts.body ? Buffer.from(opts.body, 'utf8').toString('base64') : '', + }); +} + +describe('Remote Access plaintext tunnel exempt (gate only)', () => { + beforeEach(() => { + disableGate(TUNNEL); + enableGate(TUNNEL, 'amber-apple-arrow-aspen-777'); + }); + + afterEach(() => { + disableGate(TUNNEL); + }); + + it('1. GET / without iclaw_ra is exempt; Express serves gate HTML', () => { + expect( + isE2ePlaintextTunnelExempt({ + method: 'GET', + path: '/', + tunnelId: TUNNEL, + }), + ).toBe(true); + + const gateHtml = renderGateLoginPage({ tunnelId: TUNNEL, next: '/' }); + expect(gateHtml).toContain('ra-gate-page'); + expect(captureContainsAppSecrets(gateHtml)).toBe(false); + + const res = { statusCode: 0, body: '' as unknown }; + const mockRes = { + get statusCode() { + return res.statusCode; + }, + get body() { + return res.body; + }, + status(code: number) { + res.statusCode = code; + return mockRes; + }, + type() { + return mockRes; + }, + send(payload: unknown) { + res.body = payload; + }, + }; + remoteAccessAuthMiddleware( + { + method: 'GET', + path: '/', + originalUrl: '/', + headers: { + [TUNNELED_HEADER]: TUNNELED_VALUE, + [TUNNEL_ID_HEADER]: TUNNEL, + }, + } as never, + mockRes as never, + () => undefined, + ); + expect(res.statusCode).toBe(401); + expect(String(res.body)).toContain('ra-gate-page'); + }); + + it('2. GET / is always exempt (gate bootstrap), even with a session cookie', () => { + // The plaintext path strips the session cookie before this runs; GET / is + // the entry point and always serves the gate bootstrap (which resumes the + // E2E session client-side from sessionStorage, or prompts for the + // passphrase). It never serves the workspace plaintext (middleware sees no + // session and renders the gate). + const sid = mintSession(TUNNEL); + const cookie = `${SESSION_COOKIE}=${encodeURIComponent(sid)}`; + expect(isE2ePlaintextTunnelExempt({ method: 'GET', path: '/', tunnelId: TUNNEL })).toBe(true); + expect( + isE2ePlaintextTunnelExempt({ method: 'GET', path: '/', tunnelId: TUNNEL, cookieHeader: cookie }), + ).toBe(true); + }); + + it('2b. deep-link navigation serves the gate bootstrap; data XHR is forced to E2E', () => { + // Reported bug: clicking a chat / reloading a deep link is a top-level + // HTML navigation to a non-"/" path. It must serve the gate bootstrap + // (which resumes E2E and loads the page encrypted), NOT the "E2E transport + // required" JSON dead-end. + expect( + isE2ePlaintextTunnelExempt({ + method: 'GET', + path: '/chats/82', + tunnelId: TUNNEL, + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }), + ).toBe(true); + expect( + isE2ePlaintextTunnelExempt({ + method: 'GET', + path: '/chats/82', + tunnelId: TUNNEL, + secFetchDest: 'document', + }), + ).toBe(true); + // A data XHR (Accept: application/json) is NOT a navigation → must use E2E. + expect( + isE2ePlaintextTunnelExempt({ + method: 'GET', + path: '/chats/82', + tunnelId: TUNNEL, + accept: 'application/json', + }), + ).toBe(false); + + // stripSessionCookie keeps the relay access cookie, drops only the session, + // and collapses to undefined when the session was the only cookie. + const sid = mintSession(TUNNEL); + expect( + stripSessionCookie(`iclaw_tunnel_access=abc; ${SESSION_COOKIE}=${encodeURIComponent(sid)}`), + ).toBe('iclaw_tunnel_access=abc'); + expect(stripSessionCookie(`${SESSION_COOKIE}=${encodeURIComponent(sid)}`)).toBeUndefined(); + }); + + it('3. authenticated workspace paths blocked; E2E HTTP path allowed', () => { + const sid = mintSession(TUNNEL); + const cookie = `${SESSION_COOKIE}=${encodeURIComponent(sid)}`; + expect( + isE2ePlaintextTunnelExempt({ + method: 'GET', + path: '/chats/x', + tunnelId: TUNNEL, + cookieHeader: cookie, + }), + ).toBe(false); + expect( + isE2ePlaintextTunnelExempt({ + method: 'POST', + path: E2E_HTTP_PATH, + tunnelId: TUNNEL, + cookieHeader: cookie, + }), + ).toBe(true); + }); + + it('3b. public app-shell assets are plaintext-exempt; user data is not', () => { + // CSS/JS/icons are loaded by / - + <% if (cloudShareBaseUrl) { %> <% } %> diff --git a/views/index.ejs b/views/index.ejs index cc5e4ae..bdbd1d9 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -5,6 +5,7 @@
+ <%- include('partials/mobile-nav-toggle') %>
+
+ +
+ +
+ Expires after +
+ <% allowedDurationsMs.forEach(function (ms, i) { + var label; + var title; + if (ms === 30 * 60000) { label = '30m'; title = '30 minutes'; } + else if (ms === 12 * 60 * 60000) { label = '12h'; title = '12 hours'; } + else if (ms === 7 * 86400000) { label = '7d'; title = '7 days'; } + else if (ms === 30 * 86400000) { label = '30d'; title = '30 days'; } + else { label = Math.round(ms / 60000) + 'm'; title = label; } + %> + + <% }); %> +
+
+ + +
+ +
+ + + + + + + + + +<%- include('partials/foot') %> diff --git a/views/task.ejs b/views/task.ejs index 96b157e..41c6f0e 100644 --- a/views/task.ejs +++ b/views/task.ejs @@ -85,9 +85,12 @@
- - Tasks - +
+ <%- include('partials/mobile-nav-toggle') %> + + Tasks + +
@@ -412,6 +415,6 @@ - + <%- include('partials/foot') %> diff --git a/views/tasks.ejs b/views/tasks.ejs index 34a8ff7..16b7174 100644 --- a/views/tasks.ejs +++ b/views/tasks.ejs @@ -5,7 +5,8 @@
-
+
+ <%- include('partials/mobile-nav-toggle') %>

Tasks