Device Pairing
Pairing is the flow that bootstraps a mobile, watch, IDE plugin, or second-shell client onto a VibeCody daemon. Every cross-client deployment starts here.
This page covers the bearer-token pairing flow surfaced by the daemon’s /pair endpoint and the watch-specific P-256 ECDSA flow used by Apple Watch and Wear OS companions.
Two pairing surfaces
VibeCody uses two distinct pairing models depending on the client:
| Surface | Used by | Crypto | Endpoint |
|---|---|---|---|
| Bearer token | VibeMobile, IDE plugins, second-shell clients | 128-bit token from OS CSPRNG | /pair |
| P-256 ECDSA | VibeCodyWatch (watchOS), VibeCodyWear (Wear OS) | secp256r1 device-key challenge/response | /mobile/pairing/* and /watch/* |
The two are independent — a watch goes through ECDSA pairing because Apple’s Secure Enclave only supports P-256, while a phone or IDE plugin goes through bearer-token pairing because it’s faster and doesn’t need on-device key generation.
Bearer-token flow (mobile / IDE / second shell)
-
Daemon advertises the URL. Hit
GET /pairand the daemon returns:{ "url": "http://192.168.1.42:7878/pair?token=<32-hex-chars>", "token": "<32-hex-chars>", "instructions": "Open this URL in your device's browser to pair with this VibeCLI instance." }The
hostadvertised in the URL is taken from yourHost:header. Hit the daemon athttp://192.168.1.42:7878/pairfrom your phone and you get a LAN-reachable URL back, not a uselesshttp://localhost:...that only works on the daemon machine. -
Client sends the token in the
Authorization: Bearer <token>header on every subsequent request. -
Daemon validates by comparing against the configured token list. The
vibe-collaband IDE plugin clients use exactly this header.
Token security
- 128 bits of entropy sourced from the OS CSPRNG (
rand::rng()≡ThreadRng, internally seeded fromgetrandom(2)). - Hex-encoded as 32 lowercase ASCII characters.
- Bearer credential — anyone in possession of the token can connect. Tokens are NOT bound to a device id; if you need device-bound auth, use the watch P-256 flow.
The previous implementation used RandomState (HashMap DoS resistance), which is not cryptographically secure — the seed was roughly hash(unix_nanos) ^ hash(pid), predictable to anyone who could probe the pairing endpoint with timing. This was fixed. Tokens generated by current builds are statistically indistinguishable from random; see the regression test pairing::tests::back_to_back_tokens_diverge_quickly.
P-256 ECDSA flow (watch)
Watch pairing uses a four-step flow that proves the watch holds a private key the daemon can verify:
- Watch fetches
GET /watch/pairing-info— daemon returns a nonce and machine_id. - Watch displays a QR code containing the nonce, the daemon’s URL, and the pairing endpoint.
- Phone companion (or watch directly, on Wear OS) scans the QR, generates a fresh P-256 keypair in the Secure Enclave / Hardware-Backed KeyStore, and signs the nonce with the private key. The public key + signature are POSTed to
/watch/devices. - Daemon verifies the signature with the public key, registers the device, and responds with a device_id. From here on, every watch request is signed (in headers) and verified.
Why P-256, not Ed25519
Apple’s Secure Enclave only supports P-256 ECDSA (secp256r1). Ed25519 keys cannot live in the Enclave. Since the entire security story for watch pairing rests on the private key never leaving the secure hardware, P-256 is the only option. Wear OS (Hardware-Backed KeyStore) supports both algorithms but pins to P-256 for cross-platform parity.
Never reintroduce Ed25519 for device keys. The codebase has comments and review checklists guarding this; it’s a recurring trap because Ed25519 is faster and more popular in modern systems generally.
URL-only / Bearer-only / QR — three entry points
The pairing flow supports three entry mechanisms; clients pick whichever works for their environment:
| Entry | When to use |
|---|---|
| URL-only | The user types the URL directly into the client. Works everywhere — emulators with no camera, desktop browsers, web demos. The PRIMARY path. |
| URL + Bearer pre-fill | A trusted environment (e.g. a Tauri desktop app on the same machine) reads the daemon’s stored token directly and sends it to the client without manual entry. |
| QR code | Convenience for phone users who don’t want to type a 30+ char URL. Falls back to URL-only if the camera is unavailable. |
QR is never the only path. Emulators (Android Studio, iOS Simulator) have no camera, so a QR-only flow would be a P0 regression. Every shipping client must support URL entry.
/health declaration
features.pairing:
{
"available": true,
"transport": "daemon-http",
"endpoint": "/pair",
"token_bits": 128,
"rng": "os-csprng"
}
The rng field is the audit trail — it should always be os-csprng. If it ever reads hash-state or anything else, treat that as a security regression and refuse to pair.
Observability
Pairing operations emit structured tracing events under vibecody::pairing:
RUST_LOG=vibecody::pairing=info vibecli serve
Events:
INFO vibecody::pairing: pairing.url.generated
host=192.168.1.42 port=7878 token_len=32
Tokens are NEVER logged — only their length. That’s a hard rule: log statements must reject any field name containing the literal substring token if it carries a value other than length.
Watch-specific endpoints
For the watch ECDSA flow, the daemon exposes:
| Route | Purpose |
|---|---|
GET /watch/pairing-info |
Returns the current nonce + machine_id for the QR payload |
POST /watch/devices |
Register a new device (public key + signed nonce) |
GET /watch/devices |
List all registered watches (revoked + active) |
POST /watch/devices/:id/revoke |
Revoke a device — its signature is no longer accepted |
GET /watch/devices/:id/verify |
One-shot signature verification (used by middleware) |
The Tauri-side WatchManagementPanel is a thin wrapper around these — it lists registered watches, surfaces revoked devices in a separate section, and offers a Revoke button per row. Revocation is server-side; the device itself doesn’t know it was revoked until its next request fails.
Mobile-specific endpoints
Mobile pairing has a slightly richer flow with a 6-digit PIN displayed on the daemon side:
| Route | Purpose |
|---|---|
POST /mobile/pairing |
Mobile creates a pairing intent — daemon returns an id + PIN |
POST /mobile/pairing/:id/verify |
Mobile sends the PIN; daemon issues a bearer token |
POST /mobile/pairing/:id/accept |
Daemon-side accept (e.g. user clicks “Allow” in VibeUI) |
POST /mobile/pairing/:id/reject |
Daemon-side reject |
The PIN is a usability layer — it doesn’t add security beyond the bearer token, since the token is what’s actually checked on every request. It exists so the user explicitly types digits to confirm “yes this is my device” rather than silently accepting any inbound pairing request.
Cross-client matrix
| Client | Pairing flow | Crypto |
|---|---|---|
| VibeCLI / VibeUI / VibeApp | n/a — runs on the daemon machine | n/a |
| VibeMobile | /mobile/pairing/* with 6-digit PIN |
bearer token |
| VibeCodyWatch / VibeCodyWear | /watch/devices + Secure Enclave / HW KeyStore |
P-256 ECDSA |
| VS Code / JetBrains / Neovim | /pair with bearer token |
bearer token |
| Agent SDK | /pair with bearer token |
bearer token |
| vibe-collab | /pair with bearer token |
bearer token |
Whatever path a client uses, the daemon enforces the auth on every subsequent request via the Authorization header (or the watch signature header). Mobile / IDE clients store the bearer token in their platform-secure store (Keychain on iOS, Keystore on Android, OS credential locker on desktop).
Troubleshooting
“Cannot connect — pairing URL is http://localhost:...”
You hit the /pair endpoint from the daemon machine itself. The advertised URL is built from the Host: header — when you hit it from localhost, you get localhost back, which doesn’t reach external devices. Hit the daemon over the LAN address (e.g. http://192.168.1.42:7878/pair) from another device or use the daemon’s startup banner URL.
“Token validation failed”
The token in the URL doesn’t match what the daemon has stored. Either the daemon was restarted (tokens are regenerated on every boot today — persisted tokens are a future feature) or the URL was scraped between generation and use. Hit /pair again to rotate.
Watch pairing fails with “signature verification failed”
The watch generated a key in the Secure Enclave but the signature doesn’t validate. Most often this is a clock skew — the nonce embeds a timestamp, and the verifier checks it’s within ±60 seconds. Fix the watch clock (which usually means fixing the paired phone’s clock since watches sync from there).
“kernel.unprivileged_userns_clone: 0” on Linux during pairing
Unrelated — that’s a sandbox issue, not a pairing one. See docs/sandbox.
QR code scans but the URL won’t open in the browser
Either the URL contains localhost (see above) or your phone is on a different network than the daemon. Check the daemon’s startup banner — it logs the actual reachable URL.
Related
- Source:
vibecli/vibecli-cli/src/pairing.rs— bearer-token generationvibecli/vibecli-cli/src/serve.rs—/pair,/mobile/pairing/*,/watch/devices/*vibeui/src/components/RemoteControlPanel.tsx— the host-side server UIvibeui/src/components/WatchManagementPanel.tsx— registered watches + revocation
- Connectivity:
docs/connectivity— mDNS / Tailscale / ngrok / phone-relay races - Watch integration:
docs/watch-integration— companion-app architecture