farcrewDocsHomeConsole

Security

Security model

What the control plane can and cannot see, and exactly how terminal traffic, device trust, and account auth are built.

This describes what the code actually does, not what the product promises. Claims below are traceable to web/src/crypto.ts, web/src/control.ts, web/src/sync.ts, daemon/internal/crypto, daemon/internal/authz, daemon/internal/supervisor, and control-plane/src/schema.ts.

Summary

The control plane is a Cloudflare Worker, a Durable Object per machine (RelayRoom), and a D1 database. It relays terminal bytes without parsing them, and it stores account, machine, and workspace state — but the payload of every terminal session and every workspace document is AES-256-GCM ciphertext sealed on your machine or in your browser, under keys the server never holds. A full compromise of the Worker, the D1 database, or Cloudflare’s infrastructure does not yield terminal input, terminal output, or workspace contents.

It does yield metadata: account email, machine names, session/command names, running/waiting status, timestamps, IP and user-agent of your own sessions, and (separately, by design) live traffic on any port you’ve exposed through the preview feature, which is explicitly not end-to-end encrypted. See Threat model for the precise boundary.

Keys

Three independent key types exist, none of which the server generates, stores in usable form, or can derive.

Daemon identity — a persistent X25519 keypair (daemon/internal/crypto). Generated on first run, written to disk hex-encoded at 0600 (~/.config/farcrew/identity.key by default — os.UserConfigDir(), override with FARCREW_IDENTITY_PATH), and reused across restarts. Its fingerprint (SHA256: + base64 of SHA-256(pubkey), unpadded) is what a browser pins on first contact (see Device enrollment). A read failure other than “file doesn’t exist” refuses to generate a replacement — silently rotating this key would break every browser’s pinned trust.

Browser device key — a persistent Ed25519 keypair (web/src/device.ts), generated in the browser and stored in localStorage. It signs every control command (start/stop/rename/resize/focus/expose session — the CMD frame type); it never encrypts anything. It’s cleared on explicit sign-out but survives a session expiry — it’s the browser’s own identity, not a login credential.

Per-session content key — a fresh random 32-byte key (crypto.NewContentKey()), generated by the daemon for each PTY session. It never leaves the machine directly; it’s wrapped per-viewer (below) and never written to disk.

Key agreement. Each browser tab generates a fresh X25519 ephemeral keypair per terminal it subscribes to (generateEphemeral() in web/src/crypto.ts). On subscribe, it sends this ephemeral public key in a signed hello command. The daemon computes shared = X25519(daemon_priv, viewer_ephemeral_pub), then wrapKey = HKDF-SHA256(shared, salt=sessionID, info="farcrew/v1/wrap", 32 bytes), seals the session content key under wrapKey with AES-256-GCM, and sends it back as a keys event addressed to that viewer’s public key. Both sides derive the identical wrapKey independently; it’s never transmitted. The relay sees only the sealed content key — it cannot compute shared without one of the two private keys, neither of which it ever has.

Workspace sync key. Workspace layout (per-machine session organization) is encrypted separately (web/src/sync.ts). The browser generates a random 32-byte data key (DK) once, on first workspace creation. DK is wrapped with AES-256-GCM under a KEK derived client-side as PBKDF2-SHA256(account password, random 16-byte salt, 600,000 iterations, 32 bytes). The server stores only the salt and the wrapped DK (sync_keys table); it cannot unwrap it without the account password, which this derivation never sends anywhere. Note this is a separate, stronger PBKDF2 pass than the one the server itself runs against your password at login (100,000 iterations — see Account auth) — the two exist for different purposes and don’t share a derived value.

Terminal encryption

All PTY traffic is AES-256-GCM, sealed and opened only at the daemon and the browser tab. Frame layout, exactly as implemented:

Daemon → browser (output). crypto.Seal(contentKey, chunk, aad) produces nonce(12B) ‖ ciphertext+tag. The wire payload is seq(8B, big-endian) ‖ nonce ‖ ciphertext+tag, sent as a DATA frame. AAD is "<sessionID>|o|" + seq(8B BE) — binds the ciphertext to its session and position in the output stream. The nonce is fresh random bytes on every call.

Browser → daemon (input). Same seal, plus the sender’s ephemeral public key in the AAD and on the wire: payload is seq(8B BE) ‖ viewerEphemeralPub(32B) ‖ nonce(12B) ‖ ciphertext+tag, AAD is "<sessionID>|i|" + seq(8B BE) + viewerEphemeralPub(32B). Binding the pubkey into the AAD stops one viewer’s input from being replayed under another’s identity.

Replay/reorder. Both sides track the last accepted sequence number per direction and reject anything <= it — a frame duplicated, delayed, or reordered by the (untrusted) relay is dropped, not decrypted twice. This matters because the relay is explicitly allowed to reorder, drop, or delay frames; the crypto only guarantees tampering doesn’t produce readable output, not that every frame arrives.

Reattach. When a viewer reattaches to a running session, the daemon replays recent scrollback as a separately sealed, per-viewer replay event (same content key, AAD "<id>|o|", addressed only to the reattaching viewer’s pubkey) — not broadcast as a DATA frame, so a second viewer’s attach doesn’t repaint everyone else’s terminal.

File uploads ride the same content key: 192 KiB plaintext chunks sealed with AAD "<id>|u|" + uploadID + "|" + seq(8B BE) + viewerPub before leaving the browser. The daemon’s uploaded/upload-error acknowledgment is unsealed plaintext (it’s the one relay-forgeable string that can land in terminal input), so the browser validates its shape — an absolute path matching the daemon’s deterministic naming pattern — before trusting it.

Device enrollment & trust

Two separate trust relationships exist, and neither alone is sufficient to drive a terminal.

Machine ↔ account (pairing). The console issues a one-time pairing code: 8 bytes of randomness in RFC 4648 base32 (A–Z, 2–7 — no 0/1 ambiguity), 15-minute TTL, single account, single use. farcrewd pair <code> (or the install wizard) exchanges it at /api/daemon/pair for a machine ID and a random 32-byte bearer token; the server stores only SHA-256(token). That token authenticates the daemon’s relay connection — it does not grant any browser decrypt or command authority by itself.

Browser device ↔ specific machine (command authorization). Every signed CMD envelope is checked by the daemon’s authz.Verify: allowlisted device pubkey, timestamp within a 30-second window, an unreplayed nonce, and a valid Ed25519 signature over machine|frameID|timestamp|nonce|body. A browser’s device key starts off not on that allowlist (devices.json, one file per machine, 0600). Signature and freshness are checked before allowlist membership, specifically so the daemon can distinguish “not enrolled” (ErrUnenrolled) from “forged” (ErrUnauthorized) without giving a keyless attacker an oracle for either. When the console sees ErrUnenrolled surfaced back to it, it shows the exact command to run, as the daemon’s user, on the box:

farcrewd enroll <device-pubkey-base64>

This appends the key to devices.json (atomic write, deduplicated) and takes effect immediately — no daemon restart. Net effect: a stolen account session, or even a stolen daemon bearer token, is not enough to control a PTY on a machine. Someone also needs shell access on that specific box to run enroll.

TOFU fingerprint pinning. The first time a browser completes a key handshake with a machine, it pins SHA256(daemon_pubkey) in localStorage under that machine’s name (web/src/tofu.ts). Every later handshake is compared against the pin; a mismatch throws MitmError and the channel is hard-failed before any decryption is attempted, rather than silently accepting a new identity. Reinstalling the daemon generates a new identity and will trip this — that’s a deliberate trust reset, not a false positive to suppress.

Account auth

Signup takes an email and password; the server hashes the password with PBKDF2-SHA256, 100,000 iterations (control-plane/src/auth.ts — this is the Cloudflare Workers platform ceiling, below OWASP’s recommended 600,000, and the code says so explicitly). This is a different derivation from — and serves a different purpose than — the 600,000-iteration client-side KEK derivation used for workspace sync above.

Passkey enrollment gates the operations that matter, not signup. A password-only session can enroll a passkey and list its own machines. It cannot pair a new machine (/api/pair returns 409 twofa_required if countCredentials(account) === 0) and it cannot obtain a viewer ticket (/api/ticket, same check) — and a ticket is required to open the /term websocket at all. So in practice: no passkey, no terminal access, for either a new or an existing account. Once at least one passkey exists, /api/login stops issuing a session on the password alone; it returns a WebAuthn challenge, and only /api/login/finish (a valid passkey assertion) or /api/login/recovery (a one-time recovery code) completes the login.

WebAuthn is ES256/P-256 only. Registration and assertion both check clientData.type, the expected challenge, the allowed origin list, and the RP ID hash; assertions additionally enforce a monotonic signCount as a replay guard (synced passkeys reporting 0 are exempt, since they don’t maintain one).

Recovery codes: 10 random base32 codes (XXXXX-XXXXX), issued once when the first passkey is registered, stored as SHA-256 hashes, single-use, and regenerable (which invalidates the previous set).

Sessions: the bearer token itself is never stored — only SHA-256(token). Session rows carry user-agent, IP, and Cloudflare-derived country, visible to the account holder via “active sessions” and revocable individually or in bulk. Login, signup, pairing, and recovery attempts are all rate-limited per IP and/or per account.

What the control plane stores

From control-plane/src/schema.ts — the tables with anything worth flagging (short-lived operational bookkeeping like rate_limits, housekeeping, auth_challenges, push_mutes, and telegram_link_codes are omitted; none hold anything more sensitive than expiring tokens or counters):

Table Notable columns Plaintext or ciphertext
accounts email, pw_hash/pw_salt/pw_iters Email plaintext. Password hash only (PBKDF2 output, not reversible; the raw password crosses the wire once, at login, to be verified).
sessions id, user_agent, ip, country id is SHA-256(token), not the token. UA/IP/country plaintext.
machines label, daemon_token_hash, last_seen Label (machine name) and last-seen timestamp plaintext. Token hash only.
pairing_codes / tickets code_hash/id Hashed or opaque, short-lived.
webauthn_credentials public_key, label Public key (public by definition). User-chosen label (e.g. “MacBook Touch ID”) plaintext.
recovery_codes code_hash Hash only.
sync_keys sync_salt, wrapped_dk Ciphertext — AES-256-GCM-sealed data key. Server cannot unwrap without your password.
workspace_blobs ciphertext Ciphertext — your workspace layout/session organization. machine_id (an opaque ID, not a name) is plaintext.
push_tokens token, platform Necessarily plaintext/usable — this is how push delivery works.
telegram_links chat_id Plaintext.
previews token, machine_id, port, label All plaintext, deliberately. The schema comment is explicit: “Tokens are stored plaintext: the CP must map hostname → machine/port on every request… Previews are explicitly not E2E; a CP compromise already sees preview traffic.”

Outside D1, the per-machine RelayRoom Durable Object also holds, in its own durable storage and process memory: session display names/labels (parsed from plaintext EVENT frames — by protocol design, since every viewer reads them too), the running/waiting status of each session, and detected port/process metadata for the ports panel. DATA frames (actual terminal bytes) are “forwarded verbatim, never parsed,” per the relay’s own comment. Push notification titles are composed server-side from this same plaintext metadata, e.g. "<machine label> · <session name> needs you", and sent via APNs, Web Push, or Telegram.

Threat model

Full control-plane compromise (D1 dump plus Worker/DO code execution) gets an attacker: every account email and password hash (crackable offline, at a cost weakened by the 100,000-iteration ceiling — a known, documented tradeoff); WebAuthn public keys (useless without the paired authenticator); session/pairing/recovery-code hashes (can revoke sessions, cannot mint valid ones); machine labels and last-seen times; session/command names and running/waiting transitions; live traffic on any exposed preview port; and the wrapped workspace DK plus workspace ciphertext (unopenable without your account password). It does not get: any terminal input or output, past or future; workspace contents; the ability to forge a daemon’s identity to a browser that has already pinned it (TOFU); or the ability to forge a signed CMD that any daemon will accept (authz.Verify requires an allowlisted device’s Ed25519 key). It can drop, delay, or reorder any frame — that’s inherent to being the relay — which the sequence-number checks turn into “no readable output,” not “guaranteed delivery.”

A compromised paired machine (attacker gets root, or steals the daemon’s identity file plus bearer token) yields full plaintext of everything that machine ever handled, and the ability to add their own device key to its devices.json — durable control of that box’s terminals going forward. There is no farcrewd subcommand to revoke a single enrolled device; removing one means editing devices.json by hand. It does not retroactively expose sessions relayed through a different, uncompromised machine, and it does not grant account-level actions (pairing further machines, changing the account password) without also holding an account session or passkey.

A compromised enrolled browser (attacker gets its localStorage: device Ed25519 key, TOFU pins, and — if unlocked — the cached workspace DK) can sign valid commands for every machine that browser was ever enrolled on, for as long as it stays on those machines’ allowlists, and can decrypt cached workspace data. It cannot log in as the account (no session token, no account password) or register a new passkey without an active session.

Honest limits. The zero-knowledge property covers terminal content and workspace content — not all metadata. Machine names, session/command names, session status, and the timing/origin of your own connections are visible to whoever controls the server, by design, because the relay legitimately needs some of it (routing, push notifications, presence). Port previews are explicitly outside the encrypted boundary; treat anything exposed through them as server-visible. And encryption says nothing about availability — a relay that simply stops forwarding frames is a denial of service the crypto was never meant to prevent, only tamper-detect.

Last updated 2026-08-25