0031 — Remote-consumer authentication and encryption (no SSH tunnel)
A remote consumer (the native mobile app) needs to reach a single phux server over a network without an SSH tunnel and without the full federation hub.
Full source summary
A remote consumer (the native mobile app) needs to reach a single phux server over a network without an SSH tunnel and without the full federation hub. Adopt TLS (rustls) over the existing WebSocket transport, authenticated by a pairing-issued bearer token carried in HELLO. No homegrown crypto, no new wire frames: auth and encryption stay a transport concern. Mutual TLS and SSH-envelope reuse are the rejected alternatives.
Status: Accepted Date: 2026-06-09
Context
phux speaks one wire over several transports behind the transport.rs frame
seam (Incoming/FrameReader/FrameWriter). UDS is the local default; trust
is the OS user, kernel-enforced by socket permissions
(operations.md “Local trust model”). The WebSocket
transport (WsListener: TCP + RFC 6455, one binary message per frame,
PHUX_WS_ADDR) exists for the local browser client but is plaintext and
unauthenticated — it stamps PeerIdentity { uid: 0, … } on every connection.
Safe on loopback; unsafe to expose to a phone over a network.
The only secure remote path today is SSH-tunnelled phux server --stdio
(operations.md “Federation trust model”): SSH supplies auth and encryption,
phux delegates entirely. A mobile app cannot reasonably carry an SSH-client UX
(key management, host-key TOFU, agent forwarding).
The documented long-arc remote path is QUIC + mutual TLS for v0.2+ federation (ADR-0007), satellite-framed and larger than this need. This ADR closes the nearer-term question the mobile driver forces: how does one remote consumer attach to one server, authenticated and encrypted, without SSH and without waiting for federation? It must not preclude the QUIC story — and may fold into it if QUIC lands first.
Constraint (CONTRIBUTING.md): no homegrown crypto. Lean on a vetted TLS stack and a token; do not invent a handshake.
Decision
Wrap the existing WebSocket transport in server-side TLS (tokio-rustls),
and authenticate the peer with an opaque bearer token established out-of-band by
a pairing step. Concretely:
- Encryption: TLS 1.3 via rustls terminated at
WsListenerbefore the RFC 6455 upgrade (wss://). The per-frame binary-message codec is unchanged underneath. This is a new dependency (see Tradeoffs). - The bind address is the toggle — zero remote-mode setup. A loopback
PHUX_WS_ADDRstays plaintext (the dev path); a routable address is treated as exposing the server, so phux auto-provisions a persisted self-signed certificate (rcgen) and reads the default token store. No openssl, no manual PEM. An operator-supplied cert overrides the generated one;PHUX_WS_SECURE=1forces the secure path on loopback for local testing. - Authentication: a pairing token. A
phux paircontrol verb mints a high-entropy token (32 bytes from the OS CSPRNG), shown once as a QR / short code together with the server’s certificate fingerprint so the pin is authenticated out-of-band, not blind-TOFU’d. The scannable form (phux-a9s) is a one-tap deep-link,phux://connect?url=<ws(s)-url>[&name=<n>][&fp=<sha256>]&token=<hex>, printed as text when the server address is known (--host, or a detected overlay address plus thePHUX_WS_ADDRport) and rendered as a Unicode half-block terminal QR byphux pair --qr. A remote consumer parsing the link must accept this exact shape (urlmandatory,name/fpoptional). The consumer presents the token in the WebSocket upgrade request (Authorization: Bearer <token>), where TLS already protects it; the server compares it in constant time and rejects the handshake (HTTP 401) before any phux frame is read. Verified at every connection attempt against the current contents of the store: the versioned owner-only store records a credential id, SHA-256 verifier (never the bearer secret), principal, scopes, issue/expiry/revocation times, and rotation generation. Ordinary pairing grants onlyterminal.control; it does not implicitly grant ADR-0092 work-plane authority. Every mutation holds an validated owner-controlled parent-directory lock plus an owner-only, no-follow-opened regular lock file across read, modify, temp-file sync, atomic rename, and directory sync. The lock path is checked against the opened inode after acquisition, so precreation or replacement cannot split concurrent CLI/server writers across lock inodes or lose updates. Every load verifies that the path is a regular, non-symlink file owned by the effective user with no group/world permissions; this applies equally to the default path andPHUX_WS_TOKENS, and an integrity failure denies admission. The file is stat’d per attempt and re-read only when its generation changed. After an observed generation change, malformed content, integrity failure, or exhaustion of the bounded stable-read retries denies admission against an empty snapshot; it never falls back to credentials from the prior generation. Pairing, rotation, and revocation therefore take effect at the next connection with no restart (auth::ReloadingTokenStore; see ADR-0081). An established session is not re-authorized and survives revocation until it drops. Rotation admits old and new generations for an explicit bounded overlap without extending an existing absolute expiry, and refuses an already-expired credential before generating a replacement secret.phux pairprints the stable credential ID used byphux pair rotate IDandphux pair revoke ID. Legacy anonymous token lines requirephux pair --migrate-legacy; conversion atomically preserves their bearer values as verifier-only generation-one credentials. Migration is idempotent and retains each legacy peer pseudonym (the first eight bytes of SHA-256 over its bearer) as the credential id. - Identity upgrade. A WebSocket peer that passes TLS + token is no longer
the anonymous
uid: 0stamp: its per-device record maps to aConsumerId(used in audit + capability scoping), whilePeerIdentitycarriestransport: WebSocket+ the already-populatedsource_addrand a token-attestation marker (mcp_host_keyis the existing attestation slot). The accepting transport also retains the complete non-secret credential attestation besidePeerIdentityfor the connection lifetime, ready for the separate scope-enforcement work without enforcing those scopes here. - No wire-spec change. The token rides the WebSocket handshake and TLS sits below the frame seam, so the phux frame catalog is untouched; this is transport + handshake policy, not protocol.
- No silent downgrade. Plaintext is reachable only on loopback; a routable bind always takes the TLS+token path. There is no configuration in which remote traffic crosses the wire in clear.
Amendment by ADR-0098
ADR-0098 supersedes the
claim that Phux defines no in-band authentication. TLS and the bearer gate
remain transport confidentiality, server identity, and admission for WSS.
Under paired policy they are necessary but insufficient: every stateful
transport, including UDS, also completes the channel-bound
phux-workload/v1 proof and receives closed per-operation scopes.
There is no bearer-only policy mode after ADR-0098’s cutover. Existing bearer
credentials remain an outer WSS admission gate only inside paired; they never
mint terminal authority and their former survive-until-drop rule is superseded.
The public-key registry is live truth: revocation, expiry, or a scope-ceiling
reduction terminates affected connections immediately.
Amendment — 2026-09-08: the connect link is an https Universal Link
The one-tap link phux pair prints and --qr encodes is
https://phux.phall.io/connect?<query>, with the query exactly as above.
The link carries the bearer token, and a custom URL scheme is not exclusive
on iOS: any installed app may register phux, and which app receives an
open is undefined, so phux:// could hand the token to a hostile or
careless app at the moment of the tap. A Universal Link opens only in the app
that proves ownership of the domain (applinks:phux.phall.io), which closes
that window for links emitted in this form. The phux://connect?<query>
spelling stays valid: --code parses both, and phux pair prints it on a
second line for app builds that predate the entitlement, until the App Store
build carrying it is the floor. Consumers must accept both prefixes.
Why
- Smallest trust-boundary move that is actually safe. TLS 1.3 gives confidentiality, integrity, and forward secrecy from a vetted stack; the bearer token gives authentication and revocation. Together they replace the SSH envelope for the one-server-one-phone case without an SSH-client UX. A passive observer cannot replay the token because it never crosses the wire in clear.
- It reuses the seam we already have. The frame codec, dispatch loop, and
PeerIdentityplumbing are transport-agnostic by design (ADR-0007 invariant 1). The change lives almost entirely intransport.rs; no domain code learns about it. - Pairing matches the device. A QR/short-code flow is the idiom a phone expects; SSH key distribution is not. The token is device-bound and independently revocable, which SSH-tunnel reuse cannot offer per-consumer.
- Forward-compatible with QUIC. TLS identity + token authorization is the
same conceptual model mutual-TLS QUIC will use; the pairing/token store and
the
PeerIdentityupgrade carry over. If QUIC (phux-84yt) lands first, this becomes thewssfallback, not wasted work.
Tradeoffs
- New crypto dependencies. Neither rustls nor quinn is in the tree today,
and
tokio-tungsteniteis pinned with TLS features off. This addsrustls/tokio-rustls(TLS),rustls-pemfile,subtle(constant-time compare),getrandom,sha2/hex(fingerprint), andrcgen(auto self-signed cert) — allring-backed to stay cmake-free. Justified because they are the vetted no-homegrown-crypto substrate, and rustls is the same stack ADR-0007’s QUIC will need. Owned as a dep-justification in the PR. - Bearer token = the secret is the credential. Anyone holding the token is
the device until revoked — weaker than a private key that never leaves the
device (mutual TLS). Mitigations: high entropy, TLS-only transport, one-time
display, per-device revocation, optional expiry. Accepted for v0.1-remote in
exchange for a pairing UX a phone can drive. Client-side storage is the OS
keychain/secure enclave; server-side token records are
0o600, like log sinks. - First-pair MITM window. With a self-signed cert and trust-on-first-use, an active MITM at the first connection can present its own cert and capture the token. Mitigation: the QR shows the cert fingerprint alongside the token, so the pin is verified out-of-band on first contact rather than blindly accepted. The cert is auto-generated and persisted (so the fingerprint is stable across restarts once pinned); an operator may substitute a CA-issued cert.
- Trust boundary widens past the OS user. UDS trust is “same UID, kernel enforced.” This admits a network peer whose proof is a token over TLS — a larger attack surface. It engages only on a routable bind, so the loopback default posture is unchanged.
Alternatives
Mutual TLS (client certificates). Each device gets a key/cert; the server verifies the client cert at the TLS layer; no application token. Strongest — the credential is a private key that never traverses the wire, and it is exactly where QUIC federation is going. Rejected as the first step because provisioning a client cert onto a phone is a heavier pairing UX than a token, and the token approach upgrades to mTLS later without a wire change. Recommended as the v0.2 hardening once QUIC’s cert model is settled.
Reuse the SSH envelope (embed an SSH client in the app). Rejected: it re-imports the entire SSH-client UX the mobile driver exists to avoid (key management, host-key TOFU), adds a large consumer dependency, and still leaves phux doing zero authentication of its own. It buys nothing TLS+token doesn’t, at higher consumer complexity.
Plaintext WS + token only. Rejected outright: token and terminal bytes would cross the network in clear — no confidentiality, and the token is trivially replayable. Violates the no-plaintext-remote requirement.
OutputMode guidance for remote consumers
A remote phone link is high-latency and may be lossy, so a remote consumer
SHOULD request OutputMode::StateSync (ADR-0018) at HELLO rather than the
default OutputMode::Raw: StateSync coalesces floods and paces per-consumer
RTT, which Raw (byte-faithful, lowest latency on a fast local link) does not.
Documented for operators in operations.md.
Related
- ADR-0007 — transport-as-trait, QUIC + mutual TLS as the v0.2+ federation path.
- ADR-0018 — lazy state synchronization; basis for the OutputMode guidance.
- operations.md — the remote-consumer trust boundary.
- phux-84yt — QUIC transport (federation epic phux-klxy); the option this defers to.