Status: implemented
Reviewed: 2026-08-27
A person can run a single-user self-hosted portal with:
wt roost start --https
open https://localhost:8443or run the gateway separately for a remote wing:
wt serve --local --httpsThe first invocation creates a localhost-only certificate authority on demand and asks the operating system to trust its public certificate for the current user. WT says what it is doing before the trust command runs. The CA private key never leaves the Wingthing profile.
browser ===== HTTPS :8443 ===== local gateway
|
wing ===== loopback HTTP :8080 =====+
The browser needs a trusted secure origin. A local wing, embedded wing, or remote wing arriving through an SSH reverse forward needs an endpoint it can reach without trusting the browser computer's private CA.
Both listeners use the same relay handler and bind only to loopback in local HTTPS mode. The ordinary HTTP endpoint is therefore a host-local transport, not a LAN service. For a remote wing, SSH authenticates and encrypts the segment between hosts before it reaches that loopback endpoint.
The two-listener design avoids copying the CA certificate or any private key to a
remote Linux or WSL machine. It also preserves wt start --local and the existing
reverse-forward recipe.
WT creates these files under WINGTHING_DIR/local-tls:
| File | Contents | Mode |
|---|---|---|
ca-key.pem |
ECDSA P-256 CA private key | 0600 |
ca.pem |
public self-signed CA certificate | 0644 |
localhost-key.pem |
ECDSA P-256 server private key | 0600 |
localhost.pem |
public server certificate | 0644 |
trusted |
successful, platform-verified user trust-store marker | 0600 |
The directory is mode 0700. Writes use a temporary file and atomic rename.
Existing CA material is never silently replaced: incomplete, corrupt, mismatched,
not-yet-valid, or expired CA state fails with an explicit error. A corrupt or
near-expiry leaf may be regenerated under the same CA.
The CA has a zero-length intermediate path and critical name constraints for:
localhost;127.0.0.0/8; and::1.
The leaf contains only localhost, 127.0.0.1, and ::1 SANs and server-auth
usage. The root is valid for ten years; the leaf rotates when fewer than thirty
days remain.
The explicit --https flag is consent to create and install this local material.
Before installation WT prints:
- the CA private-key path and mode;
- the public certificate path;
- the localhost-only constraint;
- that only the public certificate enters the trust store; and
- on macOS, that a native Certificate Trust Settings dialog may appear.
Platform destinations are:
| Platform | Current-user destination |
|---|---|
| macOS | explicitly selected ~/Library/Keychains/login.keychain-db user trust settings |
| Windows | current-user Root certificate store |
| Linux | Chromium NSS database at ~/.pki/nssdb |
Linux needs certutil from libnss3-tools or nss-tools. WT initializes a
missing Chromium NSS database without a password and without root.
WT checks the platform destination before writing the successful marker. On
macOS it evaluates the generated leaf with the SSL policy, the localhost name,
and the explicit login keychain. Windows and Linux confirm the precise root in
their current-user browser stores. A failed check is never recorded as trusted.
Markers made by older builds are reinstalled and upgraded once. The verified
marker then prevents a daemon or temporarily locked macOS login keychain from
reopening the ceremony on every start. wt local-cert remove removes this
precise public root and clears either marker version. On macOS removal clears
both the trust setting and the public certificate. It leaves the keys on the box
so a running listener is not broken and WT cannot silently replace an authority
that was previously trusted.
When --https is selected:
- the implicit
:8080default becomes127.0.0.1:8080; - both supplied addresses must be explicit loopback addresses with nonzero ports;
- wildcard, LAN, DNS, and public hosts are rejected before any key is created;
- HTTP and HTTPS may not resolve to the same loopback socket;
- the browser-facing base URL becomes
https://localhost:8443; and - a stale public
WT_BASE_URLcannot change that local origin.
The certificate ceremony is deliberately opt-in. Local listener hardening also applies to the HTTP-only form of the same no-login mode:
| Deployment | Result |
|---|---|
Existing wt serve / Fly edge or login node |
unchanged |
| Existing single-user local HTTP serve/roost | stays HTTP; implicit :8080 becomes 127.0.0.1:8080, explicit non-loopback binds are rejected |
| OAuth, organization, or public shared roost | keeps external HTTPS; local CA mode is rejected |
Single-user local serve/roost with --https |
dual loopback listeners and local trust ceremony |
Hosted WT_BASE_URL=https://... remains authoritative whenever local HTTPS is
not selected.
The relay independently rejects non-loopback Host headers in local mode, so a DNS
rebinding hostname cannot turn the loopback service into its own origin. Browser
WebSocket upgrades must be same-origin. Unsafe browser requests must carry the
same exact scheme, host, and port when they include Origin, and requests marked
cross-site by Sec-Fetch-Site are rejected. Origin-less native wing and CLI
requests remain compatible. Authenticated hosted and organization topologies keep
their existing cross-host WebSocket behavior.
HTTPS protects browser-to-local-gateway traffic and supplies a conventional secure browser origin. It does not replace Wingthing's browser-to-wing application encryption, wing authentication, SSH authentication, or the egg sandbox.
The CA private key has the power to issue another localhost certificate on this
one profile. Protecting the owning OS account and WINGTHING_DIR remains part of
the trust boundary. A compromised gateway still serves the browser JavaScript
and is therefore inside the client trust boundary even when terminal payloads
are application-encrypted.
The automated battery covers certificate constraints, SANs, chain verification, root reuse, leaf rotation, corrupt-state behavior, expiry behavior, permissions, symlink refusal, atomic trust markers, failed install and verification commands, legacy-marker migration, idempotence, macOS trust-rule and certificate removal, all platform command arguments, Linux NSS initialization, and the invariant that no trust command receives a private-key path.
Listener tests cover unsafe-address refusal before key creation, default address rewriting in both HTTP and HTTPS local modes, loopback alias collisions, ordinary HTTP and HTTPS handler parity, trusted and untrusted TLS clients, local passkey origins, Host-header/DNS-rebinding refusal, same-origin mutation and WebSocket rules, hosted base URL preservation, opt-in flags, and mode rejection.
The ordinary full repository gate and Docker-backed shared-roost browser battery remain required. The shared-roost battery exercises multiple users, role paths, ACL denial, persistent terminal replay, mobile views, and existing organization API behavior without selecting local HTTPS.