Upgrading to mTLS¶
This guide covers adopting auth.mode: mtls — the client credential becomes a
short-lived certificate bound to a private key that never leaves the client's
device, issued over the same SSH _bootstrap channel that mints tokens today,
instead of a bearer token. It is additive, not a breaking config rename
like the v0.7.x auth patches: open and token are unaffected, mtls is a
new value alongside them, and token is retained (not deprecated) for
curl/scripts/CI/third-party callers that can't present a client certificate.
See Security › mTLS mode for the full
model.
Not a breaking config change
Unlike the v0.7.1–v0.7.4 auth patches, upgrading to a build with mTLS
support changes nothing for an existing open or token server — no
config key is removed or renamed. This guide is about adopting mtls
where you want it, on your own schedule.
Upgrade order: clients before servers¶
- Upgrade every client first — the CLI, host-agent, desktop app, mobile
app — to a build with mTLS support. A pre-mTLS client has no code path to
obtain a certificate at all, so it must be upgraded before any server it
talks to flips to
mtls. - Flip each server's
auth.mode: mtlsindependently, on your own schedule. There is no fleet-wide cutover requirement — mode is a per-server setting. - Clients re-bootstrap silently on next use, both directions. An
upgraded client holding a
token-mode entry against a server that just flipped tomtlshits an auth-shaped TLS failure on its next request, silently re-runs the SSH_bootstrapexchange, learns the new mode from the returned bundle, enrolls a certificate, and retries — no manualserver addneeded. The same happens in reverse (mtls→token): the existing certificate-holding entry gets a401, re-bootstraps, and migrates back to a bearer token. Both directions were live-verified with zero manual steps beyond the server-side config flip.
A pre-mTLS (old, released) client against a server already in mtls mode
gets an explicit, actionable error rather than a confusing failure — the
bootstrap channel replies this server requires auth.mode: mtls; upgrade
shed (client certificate support) when it sees a request with no csr=
argument. There is no silent lockout: the message names exactly what to do.
shed server add is now SSH-first, for every mode¶
shed server add no longer starts with an HTTP TOFU probe of /api/info
(that path survives only as the open-mode fallback). It goes SSH-first
for every mode:
- Connect over SSH to
--ssh-port(default2222— pass it explicitly if your server'sssh_portdiffers) and confirm the host key fingerprint, same TOFU-with-confirmation UX as before. - Run the
_bootstrapexchange over that connection. The returned bundle'sauth_modedecides what happens next:tokenwrites a bearer token,mtlsenrolls a certificate, and anopen-mode server's bootstrap rejection (bootstrap requires auth.ssh.mode: enforce) triggers the fallback to the old HTTP TOFU probe. - Write the entry + credential, then verify with an authenticated
GET /api/infoover the resulting transport.
Behavior change, stated plainly: shed server add against a token or
mtls server now requires an allowlisted SSH key at add time — previously
a token-mode add would succeed even before the caller's key was on the
allowlist, since the token was only needed lazily on the first API call.
Now the SSH _bootstrap step itself needs an authorized key, so an add
against an enforced server with an unlisted key fails immediately with "your
SSH key is not in this server's allowlist" rather than appearing to succeed.
Hand-pasting a ~/.shed/config.yaml entry no longer works against an
enforced server. Because the credential (token or certificate) is minted
per-client over an authenticated SSH exchange, there's no static value you
can copy into a config file and have it work — the entry must be produced by
a real shed server add (or a client's own silent re-bootstrap) against that
specific server. This was already effectively true for token mode's minted
tokens; mtls makes it unambiguous, since a hand-pasted entry with no
certificate/key on disk has nothing to present at the TLS handshake at all.
Desktop users: upgrade shed-host-agent before (or with) the app¶
The desktop app and shed-host-agent are separate release components
with independent version selectors (desktop/VERSION vs.
crates/shed-host-agent/VERSION — see the root CLAUDE.md release model).
That independence matters here: a new desktop app talking to an old
shed-host-agent cannot use mTLS at all.
- The app-to-host-agent protocol gained a new
credential.getmessage (the mode-agnostic successor to the oldtoken.get) plus a capability advertisement in the agent'shello_ack. An old agent'shello_acksimply carries noagent_capabilitiesfield, so a new app recognizes the gap immediately rather than sending acredential.getinto a switch with no case for it. - Concretely: point a new desktop app at an mtls server through an
old
shed-host-agent, and the app sees the old agent'shello_ackcarrying noagent_capabilitiesat all, recognizes it can't ask for a certificate, and every request against that mtls entry fails with an explicit "upgrade shed-host-agent" error. It fires on every request against that entry, not just the first — there's no silent degrade to token-mode behavior, because an mtls server has no token to fall back to. Atoken-mode entry through an old agent is unaffected (the legacytoken.getpath still works). The mirror-image mismatch — an old app talking through a new agent against an mtls server — is handled too: the old app still sends the legacytoken.get, and the agent, knowing the server requires a certificate the old app can't obtain, replies with its own explicit "upgrade the app" error rather than fabricating a bearer token that would never authenticate. - Fix: upgrade
shed-host-agentto a build withcredential.getsupport before (or in the same sitting as) upgrading the desktop app, whenever you plan to add or use an mtls server. If you only usetoken-mode servers, the old agent keeps working with a new app with no action needed — this ordering only matters for mtls. - The reverse mismatch (new agent, old app) degrades gracefully: an old app
never sends
credential.get, so a new agent simply never gets asked for a certificate on the app's behalf, and everything that worked before keeps working.
The CA story: rotation is manual, and it's fleet-wide¶
Each mtls-mode server generates a small internal CA the first time it starts
in that mode (ca_cert.pem / ca_key.pem, next to the existing TLS
cert/host key). It signs client certificates only — it has nothing to do with
the server's own TLS identity, which clients still pin the same way as
token mode.
Deleting the CA files and restarting the server invalidates every previously-issued client certificate at once. This is not a partial or per-client operation — it is fleet-wide by construction, since every issued certificate's signature no longer chains to anything the server trusts.
The recovery story is intentionally simple, and mostly self-driving:
- Every well-behaved client (CLI, host-agent, desktop, mobile) detects the
resulting TLS-level auth failure on its next request, silently re-runs the
SSH
_bootstrapexchange, and enrolls a fresh certificate against the new CA — the same reactive-migration mechanism that handles ordinary renewal and mode flips (see Upgrade order above). So the operator-facing cost of a CA rotation is "every client pays one extra SSH round-trip on its next command," not a coordinated maintenance window. - There is no
shed server ca rotate/ca statusCLI yet — rotation today means deleting the two files and restarting, and recovery means "wait for each client's next command" (or manually trigger one, e.g.shed -s <server> list, for anything you want back online immediately). A full CLI-assisted rotation story is future work. - The server helps you not get surprised by this: it logs the CA fingerprint and expiry at startup (same as the TLS fingerprint today) and warns when the CA is within 90 days of expiring, so a rotation is a planned event rather than an outage discovered in the field.
Checklist¶
- Upgrade every client (CLI, host-agent, desktop, mobile) that talks to a
server you plan to flip to
mtls. - Desktop users: confirm
shed-host-agentis upgraded, not just the app. - Flip
auth.mode: mtlson the server(s) you want hardened; leave others ontokenifcurl/CI/scripts need direct access. - Existing client entries re-bootstrap on their own on next use — no
manual
shed server addre-run needed after the flip. - Note the CA fingerprint/expiry from the server's startup log somewhere you'll see the 90-day warning.