cerveau docs site github
Interfaces

Pocket (Android)

A native Android client that reaches your machine over your own tailnet. The agent stays on the workstation; the phone is a way in. Pairing grants a credential, not a route.

What it is

The app
android/ — native Java, no Gradle, built with raw SDK tools via android/build.sh. Two activities: MainActivity (portal and unlock) and PanelActivity (fullscreen WebView onto the panel). About 29 KB.
The doorbell
sidecars/ignite — a small Go daemon, always on, bound to the machine's tailnet IP on :7701.
The core
Binds to the tailnet IP rather than loopback when remote access is configured. Every /api/ route is gated; static assets and /api/health stay open so the panel can render its lock screen.

Wake on demand

The machine does not have to be running. Any request to the doorbell starts the whole stack — core, engine, embedder — through systemd user units, then reverse-proxies to the core. While the core boots it serves a self-reloading waking cerveau page, so opening the app on a sleeping machine shows progress rather than a connection error.

cerveau.service        the core
cerveau-llama.service  the engine
cerveau-embed.service  the embedder
cerveau-ignite.service the doorbell

The pairing handshake

The pairing handshake The machine prints a one-use code. The phone mints a keypair inside the Keystore and sends the code with its public key. The machine returns a token and deletes the code, after which every request carries a fresh signature. SIX-CHARACTER CODE CODE + PUBLIC KEY TOKEN + DEVICE ID EVERY REQUEST, SIGNED your machine the console is the trust root the phone keypair inside the Keystore ONE USE · FIVE MINUTES · FIVE ATTEMPTS PER MINUTE · CODE DELETED ON USE
The private half never leaves the secure element, so copying the app to another phone yields an identity that cannot sign.
  1. The core prints a six-character pairing id to its console, also written to ~/.crv/pair.id. Physical access to that console is the root of trust — nothing else can start this.
  2. The phone generates a P-256 keypair inside the Android Keystore. The private key never leaves the TEE. It posts the id and its public key to /api/pair.
  3. The server mints a token, registers the device with id = first 8 bytes of sha256(pubkey), and deletes pair.id.
  4. The app receives {token, device_id}.

On a headless box, crvcli pair --qr renders the QR as terminal characters over SSH.

Every request after that

A bearer token alone was judged too thin. Each request carries three things:

Authorization
Bearer <token> — 64 hex, minted at pairing.
X-Cerveau-Nonce
A one-shot nonce fetched from /api/nonce.
X-Cerveau-Sig
That nonce signed with the device key, SHA256withECDSA, alongside X-Cerveau-Device.

The server verifies against the registered public key and deletes the nonce, so a replayed request is rejected. Verified server-side: no auth → 401, token only → 403, token + valid signature → 200, replayed nonce → 403.

At rest

Phone
The token is AES-GCM encrypted by a Keystore key flagged setUserAuthenticationRequired(true)the fingerprint is the decrypt trigger, not a screen you can skip. Cold start means unlock, then panel. Five wrong pairing ids is a 15-minute app lockout.
Machine
The token is plaintext in ~/.config/cerveau/config.json, mode 0600. Acceptable because the machine is the trust anchor; the phone is the portable attack surface.

The loopback proxy

The WebView's JavaScript cannot reach the Keystore, so the app runs a localhost HTTP server on an ephemeral port. The WebView loads http://127.0.0.1:<port>/ and the proxy forwards every request to the real core, adding the token and a fresh device signature.

The panel's JavaScript stays completely dumb — it never sees a secret. Navigation is jailed to the loopback, so a page cannot navigate the WebView somewhere the proxy is not in front of.

Revocation

POST /api/devices/revoke, or the panel's device list. The machine stops answering that device immediately, wherever it is. A trusted device can vouch for a new one, and revocation cascades through the chain — revoking a voucher revokes what it vouched for.

curl localhost:7700/api/devices        # id, added_at, last_seen

Starting over

rm ~/.crv/devices.json
systemctl --user restart cerveau.service
# read the new pairing id from the console
Requirements A tailnet you control. The core must bind to the tailnet IP and remote_access_token must be set — the config refuses a non-localhost addr without one. iOS is not planned.