Device Pairing
Trust establishment via bootstrap tickets / device tokens (for new devices) and 6-character codes (for channel senders)
Overview
Device pairing is Aleph's trust-establishment protocol. New devices (macOS app, iOS client, web browser) or new chat senders (Telegram users, Discord members, iMessage unknowns) must complete a flow that verifies "the connection is authorized by the server owner" before interacting with Aleph.
Two trust-establishment paths:
| Type | For | Credential |
|---|---|---|
| Device pairing | Remote Panel / phone / CLI client | aleph-bt-* bootstrap ticket exchanged for aleph-dt-* device token |
| Channel sender pairing | Unknown Telegram / Discord / iMessage senders | 6-character alphanumeric code (single-source inbox store) |
Both are managed through RPC (pairing.list / pairing.approve /
pairing.reject / gateway.ticket.create) — not CLI commands.
Source locations:
- Device ticket + token manager:
src/gateway/security/device_token_manager.rs - Channel pairing store:
src/gateway/pairing_store.rs - Device registry (shared with cluster nodes):
src/gateway/security/store/devices.rs - Crypto helpers:
src/gateway/security/crypto.rs - Security storage:
src/gateway/security/store/ - CLI:
src/bin/aleph-server/commands/pair.rs,bootstrap_token.rs - Ticket / device RPC:
src/gateway/handlers/gateway_ticket.rs,src/gateway/handlers/gateway_devices.rs
Why pairing?
Aleph is a self-hosted AI assistant with access to your files, shell, and online accounts. Allowing unrestricted connections would be a major security risk. The pairing protocol addresses three problems:
- Authentication — ensure the connecting device or user is authorized by the server owner.
- Key exchange — devices submit or receive key material during pairing that authenticates future connections.
- Scope limiting — chat senders gain bounded permissions at pairing.
Device pairing (remote Panel / CLI)
Flow
Device Gateway Operator
│ │ │
│── gateway.ticket.create ──────►│ │
│ │ │
│◄── { ticket: "aleph-bt-x", ─│ │
│ urls: [...] } ────────────│ │
│ │ │
│ [device / QR scan / typed] │ │
│ [URLs are server-resolved, │ │
│ carry ?bt=aleph-bt-x] │ │
│ │ │
│── WS /ws connect { │ │
│ bootstrap_ticket: │ │
│ "aleph-bt-x" } ──────────►│ │
│ │ │
│ [exchange_bootstrap_ticket] │ │
│ [mint device_token] │ │
│ │ │
│◄── { device_token: ─│ │
│ "aleph-dt-…", │ │
│ role: "operator" } ────────│ │
│ │ │
│ [device persists token │ │
│ for subsequent reconnects] │ │Bootstrap ticket — aleph-bt-*
The aleph-bt-* ticket is a 5-minute TTL, single-use credential. Its
entire purpose is to keep long-lived credentials (device_token, shared
Gateway token) out of URLs and QR codes.
gateway.ticket.create returns:
{
"ticket": "aleph-bt-...",
"urls": [
"http://192.168.1.20:18790/?bt=aleph-bt-...",
"http://[fd00::1]:18790/?bt=aleph-bt-..."
],
"expires_at": "..."
}The urls field is assembled server-side (tls::discover_interface_ips)
— a Panel building it from its own window.location would emit
http://127.0.0.1:<port>/… whenever the operator generates the ticket
from the local desktop App, which a remote operator would never see.
Device token — aleph-dt-*
After successfully exchanging a bootstrap ticket, the handshake returns
an aleph-dt-* device token. The Panel persists it and submits it as
connect.device_token on every subsequent reconnect.
Device tokens:
- Long-lived, bound to one paired device.
- SHA-256-hashed at rest in
security.db. - Verified through
connect::resolve_connect_authin theconnecthandshake.
Headless cores: the CLI path
You can mint a ticket or print the recovery token without the daemon running:
# Mint a 5-minute bootstrap ticket
aleph-server pair # default TTL = 300s
aleph-server pair --ttl 3600 # 1 hour (clamped to 60..=86400)
# Print the shared Gateway token (recovery / manual entry)
aleph-server bootstrap-tokenaleph-server pair opens the 0600 security.db (WAL) directly — the
daemon does not need to be running. aleph-server bootstrap-token
prints the shared Gateway token for manual recovery (pasting into the
Panel / iOS app's "Authorize" dialog).
Revocation
Two granularities, both immediately effective:
gateway.token.rotate— regenerates the shared token, revokes every paired device, and closes every remote socket.gateway.devices.revoke {device_id}— revokes one device only: drops its live sessions back to the login wall synchronously, then closes its socket (WS 4001device_revoked).gateway.devices.listis the inventory, with a liveconnectedflag.
Both are scoped to device_type = 'panel' and never touch cluster
nodes. device_id is client-asserted and the devices table shares one
namespace with cluster nodes — the exchange refuses a device_id that
already names a non-Panel device (and cluster::admit_node refuses the
mirror case). Without that guard, one ticket could buy an operator
token the Panel roster cannot see and no revoke path can reach.
Channel sender pairing
A new chat sender (Telegram user, Discord member, iMessage unknown) takes
a lighter flow: a 6-character alphanumeric code in the single
inbox pairing store (src/gateway/pairing_store.rs) — channels no longer
carry their own pairing store.
Sender Gateway Operator
│ │ │
│── "Hello, Aleph" ─────────────►│ │
│ (from unknown sender) │ │
│ │── generate 6-char code ─► │
│◄── "Please verify with code: │ │
│ MRKV52" ──────────────────│ │
│ │── notify ──────────────────►│
│ │ "New sender on Telegram" │
│ │ "Code: MRKV52" │
│ │ │
│ │◄── pairing.approve ─────────│
│ │ { channel, code } │
│ │ │
│◄── "You're verified!" ─────────│ │6-character codes
Channel pairing codes are 6 alphanumeric characters (not 8-character Base32 — the latter was removed):
fn generate_code() -> String {
// 6 alphanumeric characters, ambiguous chars (0/O, 1/I/l) excluded
...
}Expiry and capacity
[routing] pairing_code_expiry_secs controls expiry (default 24 h,
0 = never expire). Codes are automatically rejected once expired;
periodic cleanup removes expired requests from the database.
The pending-request count is capped at MAX_PENDING_REQUESTS = 10 —
beyond that, new requests are refused with TooManyPending.
RPC management
# via aleph-server gateway call
aleph-server gateway call -p '{"channel":"telegram"}' pairing.list
aleph-server gateway call -p '{"channel":"telegram","code":"MRKV52"}' pairing.approve
aleph-server gateway call -p '{"channel":"telegram","code":"MRKV52"}' pairing.rejectOr through the Panel Settings → Pairing UI. There is no aleph pairing approve CLI command — it all goes through RPC.
Device vs channel pairing
| Device | Channel sender | |
|---|---|---|
| Credential | aleph-dt-* device token (long-lived) | Sender ID (inbound router store) |
| Bootstrap | aleph-bt-* ticket (5 min TTL, single-use) | 6-character alphanumeric code |
| Storage | devices table (Panel type) | pairing_requests table |
| Revocation granularity | Single device (gateway.devices.revoke) | Sender (pairing.reject or pairing.revoke) |
| Carrier | WS /ws connect handshake (device_token param) | Inbound message |
| Shared secret | Shared Gateway token for recovery only (never in URL/QR) | — |
Crypto primitives
Device tickets / tokens
device_token_manager generates:
- Bootstrap ticket:
uuid+ TTL stamp. - Device token:
uuid, SHA-256-hashed at rest.
Shared Gateway token
Generated at boot by SharedTokenManager, HMAC-SHA256-hashed,
constant-time verified. aleph-server bootstrap-token is the only
path that prints it; it must never travel in a URL, QR, or Referer
header.
Security guarantees
- 5-minute bootstrap tickets — single-use; limits credential-leak window.
- Per-device revocable tokens — no need to rotate the global shared token.
- Fail-closed — failed
exchange_bootstrap_ticketdoes not promote the connection. device_idnamespace isolation — Panel devices and cluster-node IDs do not share.- Immediate revocation — not at the next handshake.
See also
- Security Overview — trust boundary + Gateway token
- Execution Approval — permission system the paired device interacts with
- IPC — WebSocket control plane after pairing
- Sandboxing — OS-level enforcement