Security model
How Roost uses non-extractable device keys, pairing, revocation, audit, backups, and local-only telemetry.
Coordinator startup creates and validates one internal local account, personal
organization, and default dashboard automatically. That topology is not an
operator login identity: Roost has no hosted accounts and no login screen.
Device identity is a key, not a login account
Roost has no login-account provisioning and no shared tokens. Each browser mints its own Ed25519 key pair with WebCrypto, marks it non-extractable, and persists it through IndexedDB’s structured clone. A non-extractable private key cannot be exported by the page that created it, so there is no code path — including Roost’s own — that reads it out and sends it anywhere.
Every request carries an EdDSA JWT signed by that key. The coordinator verifies it with Bun’s WebCrypto:
- the header
algmust beEdDSA; anything else is rejected; - the
kidis the lowercase hex SHA-256 of the raw 32-byte public key, and that same value is the device’s fingerprint everywhere in the UI and the CLI; - the
audfor inbound browser tokens isroost-coordinator; - a
kidthat is not an authorized key is a 401, and the public-key cache is generation-checked on every lookup so a revoked key cannot be served from a warm cache; - tokens are short-lived — the default maximum accepted age is 300 seconds.
Key rotation keeps the old and the staged key until the coordinator’s commit state is unambiguous, so a rotation interrupted midway cannot lock a device out.
Three ways to authorize a browser
1. QR pairing. In Settings → Pair a device, Roost mints a one-shot browser
bootstrap token and renders a QR for the configured public origin. The token
rides in the URL fragment, which browsers never send to the server — so it
cannot land in the coordinator’s request log, a proxy or tunnel access log, or a
Referer header. Scan it with a phone camera and the device signs itself in.
2. Paste a bootstrap token. Mint a token in an already-authorized browser and paste it into the new one.
3. Tap-to-pair approval. The new browser posts its public key and polls for a decision. An already-authorized browser sees the request under Pending pair requests and approves or denies it; on approval the waiting browser reloads itself with an authorized token. Nothing is typed on either side.
Neither loopback nor a tailnet address authorizes a browser. Those addresses are transport metadata only; a fresh device still needs a scoped one-shot grant or an explicit pairing approval.
Bootstrap tokens are prefixed roost_bt_, single-use, and expire 24 hours after
minting whether or not they are redeemed. Redemption claims the row atomically —
matching only unused, unexpired tokens — so a token cannot be spent twice or
replayed after expiry.
Revoking a device
Delete the device’s row. Its kid stops verifying immediately, because the cache
is generation-checked rather than TTL-only. A device cannot revoke itself — that
request is refused with a pointer at key rotation, so a compromised browser cannot
lock you out by revoking the credential you are holding.
If you have lost every authorized browser, revoke from the coordinator host:
roost api device-revoke-local <fingerprint> --yes
It only accepts an http://127.0.0.1:<port> coordinator URL with no credentials,
path, query, or fragment. See the CLI.
Enrollment boundaries
Worker enrollment for supported macOS and Linux hosts uses the same one-shot
bootstrap tokens, minted by roost add-machine or Settings → Machines → Add
machine. The machine joins by pulling; the coordinator never SSHes out to
push a credential.
Windows host enrollment is unavailable in v0.5.0. The release publishes no
Windows worker, package, installer, or signed join script, so the paused Windows
implementation provides no current enrollment path. Windows remains supported
as a browser client.
Workers dial the coordinator outbound and never listen, so a worker machine exposes no inbound port to attack.
The audit log
Every Connect RPC is audited in the authentication interceptor—the only layer
that has both the verified caller fingerprint and response status. Each row
records method, path, status, trace id, and caller fingerprint, never request
payloads. A SessionsPrompt row therefore proves that the RPC occurred but
contains no prompt text. Agent status messages are likewise excluded from
audit rows and operational logs. Non-Connect paths are audited in the outer
request wrapper with a null caller, because there is no JWT context there.
High-frequency, zero-signal methods are skipped only when they succeed: health probes, worker heartbeats, pair-list polling, resize and cursor chatter, the non-mutating list reads the app polls, and the receipt for the app uploading its own debug logs. A non-200 for any of them is always written, because a failing heartbeat or a rejected health probe is exactly the anomaly worth keeping.
Retention is an explicit allowlist, not a blanket age cutoff. A sweep runs at
startup and every 24 hours and deletes only SessionsInput rows — “who typed
into which session”, genuine audit data but by far the highest volume — older than
the retention window, which defaults to 90 days and is configurable through
ROOST_COORDINATOR_AUDIT_RETENTION_DAYS. Deletion runs in 10,000-row batches with
a yield between statements, so a large backlog cannot block live RPCs on the
coordinator’s single write thread.
Everything with forensic value is kept indefinitely: PairApprove,
AuthRedeemBrowser, WorkersDelete, WorkspacesDelete, SessionsKill, and
SessionsSpawn. “When was this device authorized, and by whom” is precisely the
question the log exists to answer.
Backups
The coordinator writes a verified SQLite snapshot before applying pending
migrations to an existing database, and again on a 24-hour interval. Each snapshot
is integrity-checked as a standalone database before being compressed, and the 14
newest coord_v2.<timestamp>.db.gz archives are retained in a backups/
directory beside the database, created with owner-only permissions.
These are same-host rollback material. They do not survive the loss of the coordinator’s disk and are not off-host disaster recovery; copy them to storage with an independent failure domain if host-loss recovery matters.
What stays off the front door
Deny /internal/* and /api/db-export at your front door; the coordinator
additionally refuses /api/db-export for any caller it does not resolve as
on-host. The worker link /ws/coord-worker/* passes by default, because
workers dial the same origin browsers use unless you declare a separate
ROOST_COORDINATOR_PUBLIC_URL for them. Whatever authentication the front door
performs authenticates a human; a browser still needs a scoped one-shot grant
or an approved pairing request. Details in networking.
Telemetry behavior
Roost has no analytics, crash reporting, or phone-home, and no
Roost vendor account exists. Diagnostics are local files: always-on signal
events land in the coordinator’s and worker’s own error logs, and
roost doctor --since <window> summarizes them from disk. Agent status is not
persisted at all, and neither status messages nor guarded-prompt text are
logged, audited as payload, or stored. Data leaves your hardware only through
integrations you deliberately configure—for example dictation sent to
Deepgram or a Cloudflare tunnel you operate.
Next
- Networking — the loopback listener and the private paths
- The CLI —
api device-revoke-local,status,doctor - Fleet — event log, backups, and fleet updates
- Quickstart — pairing in practice