MTLS ENROLLMENT
mTLS enrollment (v0.1.4)
auth.mode = "mtls" moves API authentication to the TLS layer itself: the
daemon terminates TLS using its own issued certificate and (when
require_client_cert = true) rejects any connection that doesn't present a
client certificate signed by a trusted CA - before the request ever reaches
application code.
Scope: client side only¶
Device Agent implements only its side of this handshake: generating a
keypair and CSR, submitting them to a configured enrollment endpoint, and
persisting whatever certificate comes back. It does not implement a
certificate authority. Today, zyvorai/fleet's enrollment-token system
issues an opaque bearer-style token, not a signed certificate - there is no
CSR-signing endpoint anywhere in this stack yet. Building a production CA
(key custody, revocation, rotation policy) is a separate, security-sensitive
project of its own and deliberately isn't improvised here.
Enrollment protocol¶
zyvor-device-agent enroll does the following:
- Reads a single-use token from
enrollment.token_file. - Generates a keypair and a CSR (
Common Name=enrollment.common_name, ordevice.serialif that's empty). POSTs toenrollment.server_urlwithAuthorization: Bearer <token>and a JSON body{"csr_pem": "...", "common_name": "..."}.- Expects a 200 response
{"certificate_pem": "...", "ca_bundle_pem": "..."}. - Writes the certificate to
auth.mtls.cert_file, the private key toauth.mtls.key_file(mode0600, never transmitted), and - if returned andauth.mtls.client_ca_fileis set - the CA bundle to that path. - Deletes the (now-consumed) token file.
Re-running enroll refuses to overwrite an existing certificate unless
--force is passed, so a stray re-run can't silently replace a working
identity.
Serving auth.mode = "mtls"¶
serve refuses to start in mtls mode if auth.mtls.cert_file/key_file
don't exist yet - run enroll first. When they're present, the TCP listener
is served via axum-server's rustls integration instead of plain axum::serve,
with the device's own certificate as the server identity. Set
auth.mtls.require_client_cert = true and point client_ca_file at the CA
that signs client certificates to require mutual TLS; leave it false for
server-side TLS only (the daemon still proves its own identity, but accepts
any client).
zyvor-device-agent identity prints the current certificate's subject,
issuer, validity window and key backend (software, or tpm - see
docs/TPM2_IDENTITY.md for keeping the private key inside a TPM2 instead
of a plain file).
auth.mode only governs the TCP listener. server.unix_socket, if enabled,
is a separate plain listener authenticated by kernel peer credentials (see
the README's "API auth and Unix socket" section) - it bypasses mTLS
entirely by design, the same way it already bypasses bearer auth.
The bundled dashboard is a plain browser static page with no client
certificate to present, so with require_client_cert = true a browser
can't load it at all over the mTLS listener (the TLS handshake itself
fails before any HTTP request, dashboard shell included) - use the Unix
socket, curl/a script with the right certificate, or require_client_cert
= false if you need browser access to a device configured for mTLS.
Verifying locally with a throwaway test CA¶
Nothing in this repository stands up a CA - for local verification, run any
minimal HTTPS server that accepts the POST above and signs the CSR with a
throwaway root (e.g. openssl ca or a few lines of cryptography/rcgen).
Never point enrollment.server_url at a throwaway CA in a real deployment;
it exists purely to exercise this client-side flow end to end.
sudo zyvor-device-agent --config /etc/zyvor/device-agent.toml enroll
sudo zyvor-device-agent --config /etc/zyvor/device-agent.toml identity
curl --cacert client-ca.pem --cert client.pem --key client-key.pem \
https://device:9188/api/v1/health # succeeds with a valid client cert
curl --cacert client-ca.pem https://device:9188/api/v1/health
# rejected at the TLS handshake with require_client_cert = true
Non-goals¶
Automatic certificate rotation and CRL/OCSP revocation checking are
explicitly out of scope for this milestone - reissue manually with
enroll --force when a certificate needs replacing.