Aleph
Gateway RPC

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, operator immediately, never audited.
  • Remote — present one of the credentials below in connect, then call other methods; trusted_proxy resolves the real client from X-Forwarded-For (last entry) when the connection's peer is in trusted_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 by tools/scoped/'s three-tier permission merge.

The three credentials

resolve_connect_auth evaluates them in this order:

#CredentialSourceStorageRevocation
1Loopback (implicit)Same-host process
2Device token aleph-dt-<uuid>Exchanged for a bootstrap ticket at first connect; replayed thereafterSHA-256 hash in ~/.aleph/data/security.dbgateway.devices.revoke (precise socket close + drops every token for that device)
3Bootstrap ticket aleph-bt-<uuid>gateway.ticket.create or aleph-server pairSingle INSERT, consumed on exchangeSingle-use, default TTL 5 min (clamped 60s – 86 400s by gateway_ticket.rs)
4Shared Gateway token aleph-<uuid>SharedTokenManager::generate_token (first boot)HMAC hash in ~/.aleph/data/security.db; plaintext only in memorygateway.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 reconnect

Code facts:

  • Bootstrap-ticket format: device_token_manager.rs:88format!("aleph-bt-{}", Uuid::new_v4()).
  • Device-token format: device_token_manager.rs:148format!("aleph-dt-{}", Uuid::new_v4()), default TTL DEFAULT_DEVICE_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000 (10 years).
  • Device tokens are stored as SHA-256 hashes: device_token_manager.rs:240hex::encode(sha2::Sha256::digest(...)).
  • Shared-token hash: shared_token.rs:99-103HMAC-SHA256(secret, token) written via set_shared_token_with_secret.
  • exchange_bootstrap_ticket refuses a device_id that 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 and revoke_all_panel_devices cannot reach (device_token_manager.rs:113-126).

Audited events

Only remote connects that are rejected are audited; loopback is operator and never logs.

SourceEventBound
connect rejectedAuditEventType::AuthFailure (in src/security/audit.rs)Auth-scope rate limiter, ≤ 10/60s/IP
UnauthorizedFloodGuard closes a socketAuditEventType::RateLimitedOne 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.1

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 = true
your.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 IPs

src/gateway/tls.rs::load_or_generate:

  1. If the persisted cert.pem / key.pem / sans.txt exist and the desired SAN set is a subset of what's recorded → reuse.
  2. Drift or missing sidecar → delete sans.txt first, then write cert.pem, then key.pem, then sans.txt — atomic-regen marker commits the new pair only after every byte is on disk.
  3. 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_loopback from the resolved IP — XFF can never flip loopback (forge X-Forwarded-For: 127.0.0.1 and the real peer is still remote, so is_loopback() is false).
  • Per-IP concurrent-connection cap (gateway.max_connections_per_ip, default 64), Auth-scope rate-limit key, and the AuthFailure / RateLimited audit log all key on resolved.ip.
  • WS upgrade's secure field is tls_enabled || resolved.secure — a non-loopback client on an unencrypted leg is refused with 426 Upgrade Required.

Origin policy / DNS-rebinding defence

The browser-side gate (src/gateway/origin_policy.rs; browsers can't forge Origin):

OriginVerdictWhy
Missing / emptyallowNative callers (CLI, bots, tokio-tungstenite) send none; only browsers do.
Loopback / tauri: schemeallowSame-machine UI, desktop webview.
[gateway] allowed_origins exact matchallowOperator-configured extra origins.
Same-origin + Host is an IP literal or loopbackallowLAN-by-IP zero-config. A domain Host is not auto-allowed — DNS-rebinding defence.
Anything else (public web domain)deny403 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:

  • Serverload_or_generate keeps the SAN set in a sidecar; a newly-appearing interface IP triggers one atomic regen (new fingerprint, re-trust once). Drift detection lives in gateway/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-cert state with approve/reject Tauri commands. iOS Keychain is the second wired adapter.
  • Channel pairing (src/gateway/pairing_store.rs, methods pairing.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

ComponentPathRole
SharedTokenManagersrc/gateway/security/shared_token.rsCreates / rotates / validates the shared Gateway token; owns SecretVault.
DeviceTokenManagersrc/gateway/security/device_token_manager.rsBootstrap-ticket mint / consume, device-token issuance, per-device revocation.
SecurityStoresrc/gateway/security/store/SQLite backend with bootstrap_tickets / devices / tokens / identity / senders.
SecretVaultsrc/secrets/vault.rsEncrypted at-rest; the master key is the live shared token (the vault: RwLock<SecretVault> field in shared_token.rs:32).
SecretMaskersrc/exec/masker.rsRedacts 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 verify

Revocation

Revocation takes effect immediately, not at the next handshake:

  • gateway.token.rotate regenerates 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 and revoked_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 4001 device_revoked (handlers/gateway_devices.rs).
  • Loopback survives — token rotation never tears down same-host sessions (rotated_should_close_remote in server/handler.rs keys the close on is_loopback).
  • Failure-closed at the seam — exchange_bootstrap_ticket refuses a device_id that already exists with device_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, writes AuditEventType::RateLimited.
  • src/security/audit.rsAuthFailure / RateLimited rows, written from ws_upgrade_handler + the login-wall path.

See also

On this page