Skip to content

feat(worker): renew mTLS certificates automatically before they expire - #115

Merged
semics-tech merged 2 commits into
docs/security-architecturefrom
claude/worker-dashboard-security-jkyodv
Aug 2, 2026
Merged

feat(worker): renew mTLS certificates automatically before they expire#115
semics-tech merged 2 commits into
docs/security-architecturefrom
claude/worker-dashboard-security-jkyodv

Conversation

@semics-tech

@semics-tech semics-tech commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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:

  1. 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.

  2. 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.

  3. Protocol additions: New CertificateRenewalRequest and CertificateRenewalResponse message types in the worker↔hub stream.

  4. Auth posture review: New startup check that warns operators if the deployment still has workers on token mode, which is a bearer secret and weaker than the now-automatic mtls.

  5. Installer updates: Both bootstrap.sh and install.ps1 now accept --auth-mode / -AuthMode (defaulting to mtls), and enrol fails 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

  • Touches authentication or authorisation
  • Changes the wire contract (.proto)
  • Changes the database schemano schema change and no migration. Renewal reuses the existing worker_credentials table, which already supports multiple rows per worker with independent revoked_at. That is what makes the old and new certificates able to overlap without a migration.

What could go wrong:

  • A worker configured for mtls but enrolled with a token-mode token would receive no certificate and fail to authenticate. Mitigation: enrol now 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.
  • A renewal that succeeds on the server but fails to install on the worker leaves the old certificate valid and retries hourly. Mitigation: renewal happens at half-life, leaving ~45 days of runway.
  • Key and certificate are two files and cannot be replaced in one atomic step. A mismatched pair survives restarts and locks a worker out exactly as an expiry would. Mitigation: the pair is verified against each other before either is renamed into place, leaving only the gap between two renames, which contains no I/O.
  • Stolen worker keys can renew indefinitely. Mitigation: per-connection revocation check and an audit row on every renewal. The hub also refuses renewal from any worker that did not authenticate with mTLS, so a token- or Entra-mode worker cannot obtain a certificate its enrolment never established it holds a key for.

How it was tested

  • pnpm lint, pnpm typecheck, pnpm test:unit (670 tests), pnpm proto:check, pnpm audit --audit-level high
  • pnpm build:sea, and the bundle started from an empty directory (node rsagent-worker.mjs --rsagent-selftest)
  • Integration tests — not run locally (no Docker for the SQL Server container); green in CI on this branch

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 exceeding setTimeout'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-cert and parsed back through the worker's own zod schema. That caught a set -e abort 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 for token/entra without --ca-cert. Safe in the heredoc the pattern came from; not in an assignment.

Checklist

  • No SQL built by string concatenation or interpolation
  • New behaviour has a test
  • Error messages tell the reader what to do next
  • Generated protobuf regenerated and committed (pnpm proto:gen)
  • Docs updated — authentication.md (worker auth section rewritten around which mode to choose), security-architecture.md, security.md, deployment.md, README.md
  • docs/migration.md updated — closes the "worker certificate auto-rotation" known gap and reverses the "worker authentication defaults to an API key" decision

Known gaps this leaves behind

Both are recorded in docs/migration.md and neither is in scope here:

  • Nothing alerts on a worker that has stopped renewing. Renewal makes expiry unlikely, not impossible. certExpiresAt reaches the dashboard without being displayed; until it is, the signal is a worker.certificate.renewed audit 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, not main — it is stacked on the commit it was branched from. Worth confirming that is intended before merging.

https://claude.ai/code/session_0178yxB15VZFq9XU5x1ViCEY

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
@semics-tech semics-tech changed the title feat(worker,server): implement certificate renewal for mTLS workers feat(worker): renew mTLS certificates automatically before they expire Aug 2, 2026
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
@semics-tech
semics-tech merged commit fe59326 into docs/security-architecture Aug 2, 2026
8 checks passed
@semics-tech
semics-tech deleted the claude/worker-dashboard-security-jkyodv branch August 2, 2026 20:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants