feat(worker): renew mTLS certificates automatically before they expire - #115
Merged
semics-tech merged 2 commits intoAug 2, 2026
Merged
Conversation
Enrolment was the only path that ever issued a client certificate, so RSAGENT_WORKER_CERT_VALIDITY_DAYS was an outage timer rather than a security control: at day 90 every mTLS worker stopped connecting, and each one needed a human to mint a fresh single-use token for that host. Nothing warned first. Short certificate lifetimes are only defensible once something renews them. Workers now re-request a certificate at half its lifetime over the session their current certificate already authenticated — possession of a working credential authorises the reissue, as in EST simplereenroll (RFC 7030) and kubelet client-certificate rotation. No new bootstrap secret has to exist for a worker to stay enrolled. What widens if this is wrong: renewal-by-possession means a stolen worker key can renew itself indefinitely. That is the accepted trade in every comparable system, and the bounds on it are the audit row written per renewal (worker.certificate.renewed) and the per-connection revocation check, not an expiry the legitimate worker would trip over first. The hub refuses renewal from any worker that did not authenticate with mTLS, so a token- or Entra-mode worker cannot talk itself into a certificate its enrolment never established it holds a key for. The CSR subject is discarded and the certificate is named from the enrolled worker id, so a worker cannot rename itself into another's identity. Deliberate choices worth knowing: - The certificate in use is NOT revoked by its own renewal. Doing so would kill the session carrying the response. Older spares are swept, so a worker accumulates at most two live certificates rather than one per renewal. - The worker reconnects immediately after installing. A new certificate is only exercised by a handshake, and discovering it does not work is worth far more now, with weeks of validity left, than at expiry. - Key and certificate are checked against each other before either is renamed into place. A mismatched pair survives restarts and locks a worker out exactly as an expiry would. - A worker offline past its expiry still needs re-enrolling. Recovering automatically would require a second standing credential whose only purpose is to be valid after the first stopped being. Also here, because the renewal gap was what made mTLS expensive to run: - Installers take --auth-mode/-AuthMode and default to mtls. They previously hardcoded token, which is why it was the de facto default; there was never a PKI cost, since the control plane runs its own CA created on demand. The dashboard's install command now carries the mode the token was minted for, and enrol fails with a clear message on a mismatch instead of writing an empty certificate file. - Both installers detect an available Azure managed identity and recommend entra, but never select it. The same command should not mean different things on different hosts. - The control plane reviews auth posture at startup and warns when a real deployment still has workers on API keys. Derived from whether hub TLS is required and whether RSAGENT_PUBLIC_URL is non-local, not from NODE_ENV -- nothing else here reads it, it is unset in most images, and a warning that switches itself off when a variable is missing is not one to rely on. Verified: lint, typecheck, 670 unit tests, proto:check, pnpm audit, build:sea, and the bundle started from an empty directory. Both installers' config templating was rendered for all three modes with and without --ca-cert and parsed back through the worker's own schema; that caught a set -e abort in the bash templating, where a failing command substitution inside a variable assignment ends the script (safe in the heredoc it was copied from, not in an assignment). Integration tests were not run: they need a SQL Server container and Docker is unavailable here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178yxB15VZFq9XU5x1ViCEY
migration.md still described the state this branch replaced. Two entries had become false rather than merely stale, which is worse in a file whose whole purpose is telling the next reader what is decided and what is missing: - "Worker authentication defaults to an API key, not mTLS" recorded a decision the installers no longer implement. Its reasoning is worth keeping visible though, because the premise is what was wrong: mTLS was rejected for CA custody and rotation load, and this product has no CA custody at all. The rotation load was real and was self-inflicted — the absence of a renewal path was the defect, not the saving. - The "worker certificate auto-rotation" known gap is closed. Replaced that row with the two gaps the change leaves behind, both of which are now the weakest link and neither of which is in scope here: - Nothing alerts on a worker that has stopped renewing. Renewal makes expiry unlikely, not impossible, and certExpiresAt reaches the dashboard without being displayed. - RSAGENT_GRPC_TLS_CLIENT_CA accepts an operator's CA at the TLS layer and then authentication rejects the certificate, because identity is a fingerprint row only enrolment and renewal create. A setting that promises what it cannot do should fail at startup, not per connection. Also corrects deployment.md, which explained the hub's TLS requirement purely in terms of token mode. It is now the default mode's precondition too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178yxB15VZFq9XU5x1ViCEY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
Implements automatic certificate renewal for mTLS-authenticated workers. Previously, the 90-day client certificates issued at enrolment were the only issuance path, making certificate expiry an outage timer: every worker would lose connectivity at day 90 and require manual re-enrolment with a fresh token.
This change adds:
Worker-side renewal (
CertificateRenewer): Monitors the client certificate's validity, generates a new CSR at half-life (45 days), and sends it to the control plane over the authenticated session. On success, installs the new certificate and reconnects to exercise it while the old one still works.Server-side renewal (
renewWorkerCertificate): Issues a fresh certificate over the CSR, stores it with label "Renewed", keeps the current credential valid (so the session carrying the response doesn't break), and revokes older spare credentials to prevent accumulation.Protocol additions: New
CertificateRenewalRequestandCertificateRenewalResponsemessage types in the worker↔hub stream.Auth posture review: New startup check that warns operators if the deployment still has workers on
tokenmode, which is a bearer secret and weaker than the now-automaticmtls.Installer updates: Both
bootstrap.shandinstall.ps1now accept--auth-mode/-AuthMode(defaulting tomtls), andenrolfails with a clear error if the configured mode does not match the mode the enrolment token was minted for.The renewal basis is possession of a working credential (proven by the TLS handshake that opened the session), matching EST
simplereenroll(RFC 7030) and kubelet client-certificate rotation. This means a stolen key can renew itself indefinitely, but that is addressed by the per-connection revocation check and audit trail, not by an expiry the legitimate worker would hit first.Blast radius
.proto)worker_credentialstable, which already supports multiple rows per worker with independentrevoked_at. That is what makes the old and new certificates able to overlap without a migration.What could go wrong:
mtlsbut enrolled with atoken-mode token would receive no certificate and fail to authenticate. Mitigation:enrolnow validates the mode match and fails with a clear error instead of writing an empty certificate file; the dashboard's install command carries the token's own mode.How it was tested
pnpm lint,pnpm typecheck,pnpm test:unit(670 tests),pnpm proto:check,pnpm audit --audit-level highpnpm build:sea, and the bundle started from an empty directory (node rsagent-worker.mjs --rsagent-selftest)New test suites:
packages/worker/test/cert-renewal.test.ts(12): state machine with faked timers — renewal at half-life, immediate renewal when already past it, jitter bounds, delays exceedingsetTimeout's 24.9-day ceiling, refusal and timeout retries, mismatched-key rejection, and abandonment on disconnect.packages/server/test/worker-cert-renewal.test.ts(14): against a real Postgres — the certificate in use stays valid across its own renewal, older spares are revoked, other workers are untouched, the CSR subject is discarded in favour of the enrolled worker id, and the hub refuses renewal for token/entra workers.packages/server/test/auth-posture.test.ts(12): the startup review, including that it fails on rather than off.Both installers' config templating was rendered for all three auth modes with and without
--ca-certand parsed back through the worker's own zod schema. That caught aset -eabort in the bash templating: a failing command substitution inside a variable assignment takes the assignment's exit status, which would have silently killed the installer fortoken/entrawithout--ca-cert. Safe in the heredoc the pattern came from; not in an assignment.Checklist
pnpm proto:gen)authentication.md(worker auth section rewritten around which mode to choose),security-architecture.md,security.md,deployment.md,README.mddocs/migration.mdupdated — closes the "worker certificate auto-rotation" known gap and reverses the "worker authentication defaults to an API key" decisionKnown gaps this leaves behind
Both are recorded in
docs/migration.mdand neither is in scope here:certExpiresAtreaches the dashboard without being displayed; until it is, the signal is aworker.certificate.renewedaudit row per worker per half-lifetime.RSAGENT_GRPC_TLS_CLIENT_CA(bring-your-own CA) is half-wired. TLS accepts the operator's certificate and then authentication rejects it, because identity is a fingerprint row that only enrolment and renewal create. Cheap to finish given identity is the fingerprint rather than the chain, but until then it should fail at startup rather than per connection.Note on the base branch
This PR targets
docs/security-architecture, notmain— it is stacked on the commit it was branched from. Worth confirming that is intended before merging.https://claude.ai/code/session_0178yxB15VZFq9XU5x1ViCEY