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
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.sidecars/ignite — a small Go daemon, always on, bound to the machine's tailnet IP on :7701./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 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. - 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. - The server mints a token, registers the device with id = first 8 bytes of
sha256(pubkey), and deletespair.id. - 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:
Bearer <token> — 64 hex, minted at pairing./api/nonce.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
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.~/.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
remote_access_token must be set — the config refuses a non-localhost addr without one. iOS is not planned.