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.devzone 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
frpcbinary. The bundled client is SHA-256 verified at startup — a same-UID process that replacesfrpcinsideCelistra.appwith a trojan cannot launch. - A remote attacker with a bearer trying to extend the allow-list.
/v1/machine/shareis gated twice: by loopbackRemoteAddrand 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.jsondirectly. - 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:
- Legacy machine secret. A 256-bit hex value stored in
~/.celistra_auth.json(chmod 0600) and compared in constant time. - Firebase ID token. Verified offline against Google's JWKS (RS256,
iss = https://securetoken.google.com/<project>,aud = <project>,expin the future). The token'ssubclaim must equal the daemon's owner UID or be present inConfig.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:
- User clicks Pair new machine in the tray. The tray mints a
crypto/rand-backed challenge token (TTL 600 s, single-use). - The tray opens
https://app.celistra.dev/?t=<token>in the default browser. - The dashboard POSTs
{ firebaseIdToken, challenge, refreshToken }toPOST /v1/auth. - 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.
- 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/terminalruns 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: trueso Chrome lets the public app call into127.0.0.1.
4. Tunnel Surface
The daemon never listens on a public interface. When the tunnel is enabled:
celistradopens an outbound frp client connection tomail.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 viaUJEX_FRP_TOKEN) is presented on connect — this stops random internetfrpcclients from claiming subdomains on our relay.- nginx at the Ujex edge terminates TLS for
*.tunnel.ujex.devusing a Let's Encrypt wildcard (auto-renewed) and reverse-proxies to127.0.0.1:8080(the frps HTTP vhost).ufwdenies:8080externally; the Hetzner cloud firewall denies everything except22 / 25 / 80 / 443 / 465 / 587 / 7000plus ICMP. - Each machine claims
https://<uid6>-<machine-slug>.tunnel.ujex.dev, whereuid6is the first six hex chars of the Firebase UID. This guarantees that user A'smbpand user B'smbpcannot collide at the DNS layer. - CAA records on the
ujex.devzone pin certificate issuance to Let's Encrypt plus four other explicit CAs. A rogue CA cannot silently mint a trusted certificate fortunnel.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:
- Loopback RemoteAddr.
RemoteAddrcomes from the TCP socket, not client-controllable headers. A request that arrived via the frp tunnel has a non-loopbackRemoteAddrand is rejected with 403loopback only. We deliberately do not trustX-Forwarded-FororX-Real-IP— those are set by the relay. - Owner-UID. If the caller authenticated with a Firebase ID token, its
submust equalConfig.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_limitand 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
saveConfigon a blankConfig{}— alwaysloadConfig()first, mutate, then save. Overwriting loses the machine secret and bricks every paired dashboard. ~/.celistra_auth.jsonand~/.celistra_secrets.jsonare written0600and the mode is verified at load time.- Environment values of the form
KEY=@secret:NAMEare resolved at spawn time only; the literal@secret:NAMEsentinel 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.