Skip to main content

Security Architecture

Celistra is zero-trust between the browser and the daemon: the daemon assumes every inbound request is hostile until proven otherwise. This document covers the threat model, the mechanisms that enforce it, and the operational surfaces (tunnel, tray, Cloud Functions) that hang off it.

0. Threat Model

We defend against:

  • A stolen bearer token. Either the legacy 256-bit machine secret or a Firebase refresh/ID token. The user has a one-click "Revoke all sessions" escape hatch that invalidates every refresh token on the account.
  • A hostile LAN / MITM on the tunnel. TLS is terminated at our nginx edge using Let's Encrypt certificates; the ujex.dev zone has CAA records that pin issuance to Let's Encrypt plus four other named CAs, so a rogue CA cannot silently issue for our domain.
  • A swapped frpc binary. The bundled client is SHA-256 verified at startup — a same-UID process that replaces frpc inside Celistra.app with a trojan cannot launch.
  • A remote attacker with a bearer trying to extend the allow-list. /v1/machine/share is gated twice: by loopback RemoteAddr and by owner-UID.
  • A compromised dashboard trying to social-engineer the daemon. The pairing challenge is minted by the tray (out-of-band from the browser), is crypto/rand-backed, constant-time compared, single-use, and expires in 600 s.

We do not defend against:

  • Malware running as the same user as celistrad — it can read ~/.celistra_auth.json directly.
  • Physical access to an unlocked machine.
  • The user handing over their Google password.

1. Two-Path Authentication

The daemon's withSecret middleware accepts either of two bearer formats on every authenticated endpoint:

  1. Legacy machine secret. A 256-bit hex value stored in ~/.celistra_auth.json (chmod 0600) and compared in constant time.
  2. Firebase ID token. Verified offline against Google's JWKS (RS256, iss = https://securetoken.google.com/<project>, aud = <project>, exp in the future). The token's sub claim must equal the daemon's owner UID or be present in Config.AllowedUIDs.

Clients prefer the Firebase ID token path whenever they have one. The dashboard no longer persists the machine secret to localStorage, and the daemon no longer syncs the machine secret to Firestore. A stolen Firestore record today does not yield a working bearer.

The machine secret stays on disk only as a backward-compatible fallback for already-paired dashboards during the Firebase-token rollout. Rotating the secret requires re-pairing.

2. Pairing (Challenge–Response)

Dashboards establish trust with a daemon through a transient handshake:

  1. User clicks Pair new machine in the tray. The tray mints a crypto/rand-backed challenge token (TTL 600 s, single-use).
  2. The tray opens https://app.celistra.dev/?t=<token> in the default browser.
  3. The dashboard POSTs { firebaseIdToken, challenge, refreshToken } to POST /v1/auth.
  4. The daemon verifies the Firebase ID token against the configured owner UID (or allow-list), checks the challenge in constant time, and — on success — returns the bearer secret plus the current tunnel URL. The challenge token is cleared immediately; a second attempt returns 401.
  5. The daemon persists the refresh token so it can rotate the ID token every 50 minutes without user interaction. Firebase does rotate refresh tokens; rotated values are written back to ~/.celistra_auth.json.

If the Firebase UID doesn't match the daemon's expected owner (and isn't in the allow-list), the request is rejected regardless of the challenge.

3. Transport

  • REST / SSE: Authorization: Bearer <token>.
  • WebSocket: browsers can't set headers on upgrade, so /v1/terminal runs a first-frame handshake where the client sends the bearer once the socket is open. Neither the ID token nor the secret is passed as a ?token= query parameter (query strings end up in proxy access logs).
  • CORS: allowed origins are the app domain and null / file:// for the tray's local dashboard. Credentials are not required.
  • Private Network Access: the daemon replies with Access-Control-Allow-Private-Network: true so Chrome lets the public app call into 127.0.0.1.

4. Tunnel Surface

The daemon never listens on a public interface. When the tunnel is enabled:

  • celistrad opens an outbound frp client connection to mail.ujex.dev:7000 (the frps control port, co-hosted with the Ujex mailserver). An admission token (built into the daemon at ldflags time, or supplied via UJEX_FRP_TOKEN) is presented on connect — this stops random internet frpc clients from claiming subdomains on our relay.
  • nginx at the Ujex edge terminates TLS for *.tunnel.ujex.dev using a Let's Encrypt wildcard (auto-renewed) and reverse-proxies to 127.0.0.1:8080 (the frps HTTP vhost). ufw denies :8080 externally; the Hetzner cloud firewall denies everything except 22 / 25 / 80 / 443 / 465 / 587 / 7000 plus ICMP.
  • Each machine claims https://<uid6>-<machine-slug>.tunnel.ujex.dev, where uid6 is the first six hex chars of the Firebase UID. This guarantees that user A's mbp and user B's mbp cannot collide at the DNS layer.
  • CAA records on the ujex.dev zone pin certificate issuance to Let's Encrypt plus four other explicit CAs. A rogue CA cannot silently mint a trusted certificate for tunnel.ujex.dev.

The frpc binary itself is bundled inside /Applications/Celistra.app (macOS) and verified by SHA-256 at startup: the expected digest is baked into the Go binary via -ldflags, and VerifyFrpcBinary() refuses to exec if the on-disk binary's digest drifts.

5. /v1/machine/share — Double-Gated

/v1/machine/share lets the owner write additional Firebase UIDs into Config.AllowedUIDs so collaborators can pair without the tray challenge. It is guarded by:

  1. Loopback RemoteAddr. RemoteAddr comes from the TCP socket, not client-controllable headers. A request that arrived via the frp tunnel has a non-loopback RemoteAddr and is rejected with 403 loopback only. We deliberately do not trust X-Forwarded-For or X-Real-IP — those are set by the relay.
  2. Owner-UID. If the caller authenticated with a Firebase ID token, its sub must equal Config.UID (the owner), not just be present in the allow-list. An existing allow-listed viewer cannot self-extend the list.

The legacy machine-secret path also satisfies (2) implicitly, since the secret is owner-held.

6. Revoke All Sessions

A signed-in user who suspects a device is compromised can click Revoke all sessions in the dashboard account menu or in the tray. This calls a Firebase Cloud Function (revokeAllSessions in us-central1) which invokes admin.auth().revokeRefreshTokens(uid), forcing every device to re-authenticate. The tray item and the dashboard button both surface the same function; either one recovers from a stolen bearer. After revocation, the user signs back in and re-pairs any paired daemons.

7. Local-Only Dashboard

GET / on 127.0.0.1:33120 serves dashboard_html.go without the bearer check because it runs on loopback and embeds the bearer into its own <script> block at render time. It is not reachable through the frp tunnel: the dashboard handler rejects any request whose Host is not 127.0.0.1 / localhost.

8. Agent IDs

Agent IDs are four hex characters drawn from crypto/rand (ag-XXXX). A legacy math/rand path has been removed — IDs are now unpredictable, which reduces the utility of enumeration attacks on any log that leaks an agent ID.

9. Auto-Restart Safety

autoRestart: true is gated by the restart supervisor:

  • Max 5 restarts in a 60-second window.
  • On breach, the agent is terminated with exit reason restart_limit and written to history.
  • Prevents fork-bomb-on-exit scenarios.

handleRestart holds the agent mutex for the full duration of closing the PTY and reaping the process — a prior window where two concurrent restart requests could race on the same PTY file descriptor is closed.

10. Secret Hygiene

  • Never call saveConfig on a blank Config{} — always loadConfig() first, mutate, then save. Overwriting loses the machine secret and bricks every paired dashboard.
  • ~/.celistra_auth.json and ~/.celistra_secrets.json are written 0600 and the mode is verified at load time.
  • Environment values of the form KEY=@secret:NAME are resolved at spawn time only; the literal @secret:NAME sentinel is what lands on disk, in history, and on the wire. The secret value itself is never serialised.
  • Factory Reset in the dashboard clears only the browser's local state (Zustand persist). The daemon's secret is untouched; re-pair to reconnect.
  • RevenueCat App User ID = Firebase UID, so anonymous purchases can't carry Pro entitlements across accounts.

11. What Was Removed During Audit

The client-side isSafeCommand / dangerousPatterns blocklist is gone. It was security theater: a user who could reach the spawn endpoint could already run arbitrary processes as their own user, and the pattern list gave a false impression of containment while annoying legitimate use cases. The honest posture is that celistrad executes under your user account and can do anything you can do — protect the bearer, not the command line.