Authentication
Gateway connection authentication, token lifecycle, and remote transport encryption
Aleph's trust boundary is the network boundary: loopback is the implicit operator (zero config); a remote connection must pass the connect login wall with one of three Gateway credentials. Once authorized the connection is single-tier operator, identical to local.
Code anchors: src/gateway/handlers/connect.rs (handshake + authorization verdict), src/gateway/server/handler.rs::ws_upgrade_handler (upgrade gate + login wall), src/gateway/origin_policy.rs (browser cross-origin / DNS-rebinding defence), src/gateway/tls.rs (TLS material), src/gateway/security/{shared_token.rs, device_token_manager.rs, store/} (credentials + storage).
Trust model at a glance
+---------------------+ +--------------------+ +-------------------+
| Same host / browser | ──> | /ws upgrade (426) | ──> | resolve_connect_ |
| (loopback / tauri:) | | first frame must be connect | auth |
+---------------------+ +--------------------+ +-------------------+
|
+------------------------+------------------------+
| | |
v v v
loopback (params optional) device_token (*) bootstrap_ticket (*) / token
| | |
operator (implicit) bound to a device_id one-shot → fresh device_token- Loopback — same-host desktop / CLI / bridge process, zero config,
operatorimmediately, never audited. - Remote — present one of the credentials below in
connect, then call other methods;trusted_proxyresolves the real client fromX-Forwarded-For(last entry) when the connection's peer is intrusted_ips. - Single-tier
operator— there is no Chat / Config sub-tier; an authorized Panel has the same authority as a local user. Connection trust is orthogonal to what an agent may do, which is governed bytools/scoped/'s three-tier permission merge.
The three credentials
resolve_connect_auth evaluates them in this order:
| # | Credential | Source | Storage | Revocation |
|---|---|---|---|---|
| 1 | Loopback (implicit) | Same-host process | — | — |
| 2 | Device token aleph-dt-<uuid> | Exchanged for a bootstrap ticket at first connect; replayed thereafter | SHA-256 hash in ~/.aleph/data/security.db | gateway.devices.revoke (precise socket close + drops every token for that device) |
| 3 | Bootstrap ticket aleph-bt-<uuid> | gateway.ticket.create or aleph-server pair | Single INSERT, consumed on exchange | Single-use, default TTL 5 min (clamped 60s – 86 400s by gateway_ticket.rs) |
| 4 | Shared Gateway token aleph-<uuid> | SharedTokenManager::generate_token (first boot) | HMAC hash in ~/.aleph/data/security.db; plaintext only in memory | gateway.token.rotate — regenerates + revokes every paired Panel + force-closes every remote socket |
Bootstrap-ticket → device-token flow
[Operator, authorised on machine A]
| `gateway.ticket.create` (or `aleph-server pair`)
| -> server mints `aleph-bt-<uuid>` TTL=300s
| -> response also carries LAN-friendly pairing URLs
| (same-host bind ⇒ empty list; the Panel falls back to its own origin)
v
[New device] browser opens ?bt=<ticket>
| /ws first frame: connect { bootstrap_ticket, device_id }
v
[Server] consumes the ticket atomically
| creates a device row + `aleph-dt-<uuid>` (TTL 10y)
| response carries device_token
v
[Panel] persists device_token; replays it on every reconnectCode facts:
- Bootstrap-ticket format:
device_token_manager.rs:88—format!("aleph-bt-{}", Uuid::new_v4()). - Device-token format:
device_token_manager.rs:148—format!("aleph-dt-{}", Uuid::new_v4()), default TTLDEFAULT_DEVICE_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000(10 years). - Device tokens are stored as SHA-256 hashes:
device_token_manager.rs:240—hex::encode(sha2::Sha256::digest(...)). - Shared-token hash:
shared_token.rs:99-103—HMAC-SHA256(secret, token)written viaset_shared_token_with_secret. exchange_bootstrap_ticketrefuses adevice_idthat already names a non-Panel row (DeviceTokenError::DeviceIdConflict) — closes the namespace-collision vector where a one-shot ticket would mint an operator credential that the Panel roster cannot see andrevoke_all_panel_devicescannot reach (device_token_manager.rs:113-126).
Audited events
Only remote connects that are rejected are audited; loopback is operator and never logs.
| Source | Event | Bound |
|---|---|---|
connect rejected | AuditEventType::AuthFailure (in src/security/audit.rs) | Auth-scope rate limiter, ≤ 10/60s/IP |
UnauthorizedFloodGuard closes a socket | AuditEventType::RateLimited | One row per abusive connection, not per rejected request |
Remote transport encryption (TLS)
A remote connection is accepted only when one of these holds (insecure_exposure_refused in src/gateway/server/mod.rs):
# Remote plaintext is refused by default. Pick one:
[gateway]
allow_insecure_remote = true # only on a trusted LAN — you have been warned
[gateway]
tls.enabled = true # native TLS
tls.cert_path = "/etc/letsencrypt/.../fullchain.pem"
tls.key_path = "/etc/letsencrypt/.../privkey.pem"
[gateway]
tls.enabled = true # empty paths ⇒ self-signed
# (SAN auto-covers host's non-loopback IPs)
[gateway.trusted_proxy]
enabled = true
trusted_ips = ["127.0.0.1", "::1"] # upstream TLS-terminating reverse proxy
# + gateway.host still bound to 127.0.0.1A. Trusted reverse proxy (recommended; needs a domain)
Bind Aleph to loopback; Caddy / nginx terminates TLS in front:
[gateway]
host = "127.0.0.1"
allowed_origins = ["https://your.domain.com"]
[gateway.trusted_proxy]
enabled = trueyour.domain.com { reverse_proxy 127.0.0.1:18790 }trusted_proxy must be enabled in lock-step — without it every remote client looks like 127.0.0.1 to Aleph and so auto-passes the loopback-operator test, a full auth bypass.
B. Native self-signed (no domain)
[gateway]
host = "0.0.0.0"
[gateway.tls]
enabled = true
# cert_path / key_path left empty ⇒ auto-generate and persist under ~/.aleph/data/tls/
# san = [] # optional; default already covers all non-loopback interface IPssrc/gateway/tls.rs::load_or_generate:
- If the persisted
cert.pem/key.pem/sans.txtexist and the desired SAN set is a subset of what's recorded → reuse. - Drift or missing sidecar → delete
sans.txtfirst, then writecert.pem, thenkey.pem, thensans.txt— atomic-regen marker commits the new pair only after every byte is on disk. - Startup logs the SHA-256 fingerprint, so a Panel can pin it.
Client verification: macOS / iOS run a TOFU flow inside the App — shared decision core + pinned trust store + SHA-256 fingerprint + SAN parsing + approval splash (TOFU/change warning + approve/reject). Other clients follow the macOS reference.
C. Operator-provided certificate (domain, no proxy)
[gateway]
host = "0.0.0.0"
[gateway.tls]
enabled = true
cert_path = "/etc/letsencrypt/live/your.domain.com/fullchain.pem"
key_path = "/etc/letsencrypt/live/your.domain.com/privkey.pem"pub struct GatewayTlsConfig {
pub enabled: bool,
pub cert_path: String,
pub key_path: String,
pub san: Vec<String>,
}load_or_generate reads cert_path / key_path verbatim in the TlsMode::Provided branch; the self-signed branch ignores them (src/gateway/tls.rs:48-91).
Trusted-proxy / real client IP
src/gateway/trusted_proxy.rs::resolve_client is the spoof-safe single source:
ResolvedClient { ip: IpAddr, secure: bool } =
if enabled && trusted_ips.contains(&peer) {
last_forwarded_for(headers).unwrap_or(peer),
forwarded_proto_https(headers)
} else {
(peer, false) // untrusted peer → XFF ignored entirely
}Used by:
is_loopbackfrom the resolved IP — XFF can never flip loopback (forgeX-Forwarded-For: 127.0.0.1and the real peer is still remote, sois_loopback()isfalse).- Per-IP concurrent-connection cap (
gateway.max_connections_per_ip, default 64),Auth-scope rate-limit key, and theAuthFailure/RateLimitedaudit log all key onresolved.ip. - WS upgrade's
securefield istls_enabled || resolved.secure— a non-loopback client on an unencrypted leg is refused with426 Upgrade Required.
Origin policy / DNS-rebinding defence
The browser-side gate (src/gateway/origin_policy.rs; browsers can't forge Origin):
| Origin | Verdict | Why |
|---|---|---|
| Missing / empty | allow | Native callers (CLI, bots, tokio-tungstenite) send none; only browsers do. |
Loopback / tauri: scheme | allow | Same-machine UI, desktop webview. |
[gateway] allowed_origins exact match | allow | Operator-configured extra origins. |
Same-origin + Host is an IP literal or loopback | allow | LAN-by-IP zero-config. A domain Host is not auto-allowed — DNS-rebinding defence. |
| Anything else (public web domain) | deny | 403 origin not allowed. |
The escape hatch is [gateway] allow_any_origin = true — only for deployments fronting Aleph with their own reverse-proxy / auth layer (otherwise any web page the user visits can drive the agent).
TOFU / pairing-store single source
Self-signed TLS is trusted from inside the App, end-to-end:
- Server —
load_or_generatekeeps the SAN set in a sidecar; a newly-appearing interface IP triggers one atomic regen (new fingerprint, re-trust once). Drift detection lives ingateway/tls.rs. - Client — the macOS WKWebView cert-challenge adapter is the reference platform (proven end-to-end against a real self-signed remote): shared decision core + pinned TOFU trust store + SHA-256 fingerprint + SAN/subject parsing + approval splash (fingerprint + SAN + TOFU/change warning) +
pending-certstate with approve/reject Tauri commands. iOS Keychain is the second wired adapter. - Channel pairing (
src/gateway/pairing_store.rs, methodspairing.list/pairing.approve/pairing.reject) is a separate concept — it authorises unknown channel senders (iMessage / Telegram / Discord), not Panel device pairing. Channel access / pairing / allowlists are single-sourced on the inbound router.
Credential and vault components
| Component | Path | Role |
|---|---|---|
SharedTokenManager | src/gateway/security/shared_token.rs | Creates / rotates / validates the shared Gateway token; owns SecretVault. |
DeviceTokenManager | src/gateway/security/device_token_manager.rs | Bootstrap-ticket mint / consume, device-token issuance, per-device revocation. |
SecurityStore | src/gateway/security/store/ | SQLite backend with bootstrap_tickets / devices / tokens / identity / senders. |
SecretVault | src/secrets/vault.rs | Encrypted at-rest; the master key is the live shared token (the vault: RwLock<SecretVault> field in shared_token.rs:32). |
SecretMasker | src/exec/masker.rs | Redacts secrets in any human-visible or audit-string (approval cards, logs, receipts). Built-in patterns cover OpenAI / Anthropic / AWS / GitHub classic + fine-grained / generic passwords / PKCS#8 -----BEGIN PRIVATE KEY----- (regression-tested at masker.rs:99-123). |
Headless credential tools
All bypass the running daemon; they open ~/.aleph/data/security.db (mode 0600, WAL) directly under the Spec-C singleton-lock protocol:
# Mint a bootstrap ticket + LAN-friendly pairing URLs (headless counterpart of
# Settings → Security → "Pair new device")
aleph-server pair [--ttl SECONDS]
# Print the live shared Gateway token — one-shot, plaintext to stdout
aleph-server bootstrap-token
# Offline verify per-agent signing identities — independent of the daemon
aleph-server identity verifyRevocation
Revocation takes effect immediately, not at the next handshake:
gateway.token.rotateregenerates the shared token, revokes every paired Panel device, and closes every remote socket (handlers/gateway_token.rs::handle_token_rotate). The response carries the fresh token andrevoked_devices.gateway.devices.revoke {device_id}revokes one Panel device; its live sessions are dropped to the login wall synchronously, then the socket is closed with WS 4001device_revoked(handlers/gateway_devices.rs).- Loopback survives — token rotation never tears down same-host sessions (
rotated_should_close_remoteinserver/handler.rskeys the close onis_loopback). - Failure-closed at the seam —
exchange_bootstrap_ticketrefuses adevice_idthat already exists withdevice_type != "panel"; otherwise a one-shot ticket could mint an operator credential that the Panel roster cannot see.
Rate-limit and audit
src/gateway/rate_limiter.rs— keyed on(resolved_client_ip, method_scope); loopback exempt.src/gateway/server/flood_guard.rs::UnauthorizedFloodGuard— counts unauthorized requests per connection, trips on threshold, writesAuditEventType::RateLimited.src/security/audit.rs—AuthFailure/RateLimitedrows, written fromws_upgrade_handler+ the login-wall path.
See also
- Protocol — WebSocket JSON-RPC 2.0
- Security / Pairing — channel-side pairing flow
- Architecture / Gateway — internal structure