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.