neenv

A standby for the certificate authority

The problem

When every server proves who it is with a short-lived certificate, the certificate authority (CA) is the one machine everything else depends on. Here certificates live 24 hours and renew at two thirds of that, so a dead CA stops nothing at once — and then, about eight hours later, identities start to expire, and within a day most services refuse each other.

A backup of the CA's private key is not enough. The CA also holds a database: the ACME accounts every client registered, and the record of what it issued. Rebuild a CA from its key alone and every ACME client has to register again, by hand, on every machine. And the CA's own signing key usually exists in one place only: on the CA.

This pattern keeps a cold standby: a second machine in the other site holding a recent copy of the CA's whole directory, the software installed and stopped, and without the password that unlocks the signing key. One command turns it into the CA.

What runs here

The CA is step-ca. Its state is one directory, /etc/step-ca: ca.json, the certificates, the signing keys encrypted with a key password, its templates, and a badger database (the type is whatever ca.json's db.type says; the script reads it and checks only a badger directory). Two files in that directory hold passwords in clear (password.txt, provisioner-password.txt), written there from the password manager by configuration management. The root key is never in this directory: it lives in the password manager and is written back only to mint an intermediate. The script refuses to send a directory holding secrets/root_ca_key, and the receiver refuses a copy carrying one.

Before this pattern the directory existed on the CA's machine and nowhere else.

Setting up the two machines

Both machines get the same configuration-management role for the package, with one difference:

The CA The standby
package step-cli and step-ca from Smallstep's apt repository the same
account system user step, no shell, home /etc/step-ca the same
directory /etc/step-ca, owner step, mode 0700 the same, filled only by the copy
unit step-ca.service, enabled and running the same file, disabled and stopped
step ca init once, ever (it mints a root) never
password files written from the password manager deleted if found
group the CA group the standby group

The unit reads --password-file /etc/step-ca/password.txt. On the standby that file is not there, so neither a reboot nor a stray systemctl start can make it a second CA.

The copy

ca-standby.sh copy --from <CA> --to <standby>:

The check

ca-standby.sh check --from <CA> --to <standby> proves the copy is usable, not just present:

Run the copy and then the check every night.

The switch

ca-standby.sh switch --from <old CA> --to <standby>:

  1. Both password items are read and must be non-empty (their bytes counted, never printed) before anything is touched.
  2. Fence the old CA: stop and disable step-ca, delete its password files, record when it was fenced, and re-read that it is stopped, disabled, without a password and that its port (the address ca.json gives) refuses. If it does not answer, the switch refuses, because it may still be signing, unless --from-down says it is known to be down.
  3. A last copy from the fenced CA, which is now quiet, so nothing is lost. With --from-down the standby's last copy stands.
  4. Under one lock on the standby: compare the intermediate certificate that ca.json names, on disk, with the one you declared; only if it matches, unlock (each password from its own item, over stdin) and start step-ca. A stale or foreign copy is refused with nothing written.
  5. Probe: https://<CA name>/health at the address ca.json gives (or the standby's address when that is every address or loopback), and the intermediate it serves is the declared one.

From step 3 on, any failure rolls back: step-ca is stopped and disabled on the standby and its password files are deleted. The rollback then re-reads the standby and prints what it is; if it is not stopped, disabled and without a password, it says ROLLBACK FAILED and what to run by hand.

Two things are never done by the script, and are part of the switch all the same:

When the old CA comes back

After a --from-down switch, nothing has fenced the old CA. If it returns and its unit starts, there are two CAs. Before anything else touches it, run ca-standby.sh fence --host <old CA>. That makes it a standby: stopped, disabled, no password, port refusing. Then copy onto it from the new CA as usual.

Going back, and recovering from a failed switch

Going back is the same switch with the two machines exchanged, once the switch that brought you here succeeded: its last line is PASS switch.

If a switch fails, it does not leave a half-started standby (the rollback above), but by then it may have fenced the old CA. Do not run the reverse switch. If you do by mistake, its copy is refused when it carries older data than the old CA held when it was fenced (the copy's stamp against the fence's), but recovering in place is the way:

What it costs

The minutes the switch takes. If the old CA was down, ACME accounts registered after the last copy must register again. Certificates issued after it are missing from the database; that matters only for revoking one of them by serial before it expires within a day.

The drill

drill-ca-standby.sh needs Docker and builds its image once (Dockerfile): Debian with step-ca from Smallstep's repository and sshd. The ssh user's login shell is dash and it has passwordless sudo. Two stand-ins replace system tools: systemctl.sh, for the one unit (its stop sends the kill signal, then CONT, then SIGKILL after a grace, as systemd does), and cp.sh, which fails a database copy on demand, or hangs it so the sender can be killed mid-freeze. Container P runs a throwaway CA with two different passwords, each chosen by its item name. Container S is an empty standby. ca-standby.sh runs against them unchanged, through docker exec, and once through sshd.

The cases:

Then copies of the script, each with one guard removed, must each turn their own named case red:

Arm Guard removed Its case
no-exclude the sender leaves out the password files the copy (the receiver refuses it)
no-thaw the CA is thawed after the copy P not left stopped
no-error-thaw the trap thaws on an error a failed database copy leaves P thawed
no-signal-trap the traps thaw on a signal a sender terminated mid-freeze thaws P at once
no-watchdog the detached watchdog a sender killed mid-freeze is thawed
no-future-refusal the check refuses a stamp from the future the stamp from the future
no-run-refusal the receiver refuses while its CA runs copy refused while S runs
no-password-refusal the receiver refuses while it holds a password copy refused while S holds a password file
no-enabled-refusal the receiver refuses while its unit is enabled copy refused while S's unit is enabled
no-stale-refusal the receiver refuses older data a wrong-way copy onto a newer CA
no-trailer the receiver checks every file's sha256 refuses an altered file
no-unlisted the receiver refuses a file the trailer does not list refuses an unlisted file
no-recv-password the receiver refuses a password file in the copy refuses a password file
no-recv-cajson the receiver refuses a copy without ca.json refuses a stream with no ca.json
no-recv-rootkey the receiver refuses a root key refuses the root key
no-open-check the check opens the database a database that does not open
no-b64 the remote program travels base64 to bash copy through sshd, a dash login shell
no-pw-precheck both password items are checked first an empty item stops the switch before the fence
no-fence-disable the fence disables the unit and checks it P fenced: stopped, disabled, no password, port closed
no-fence-refusal an unanswering old CA stops the switch switch refused when the old CA does not answer
no-step3-lock step 3 holds the standby's lock step 3 refused while another run holds the lock
no-fence-delete the fence deletes the password files a returning host is fenced
no-rollback a failure after unlocking rolls back a failed start rolls back
no-rollback-stop the rollback stops and disables a failed probe after a start rolls back
no-disk-compare the intermediate is compared before unlocking a foreign intermediate is refused before unlock

Three guards have no arm:

They stay in the script as a second line.

$ ./drill-ca-standby.sh
== as published
  ok   P issues
  ...
ok    as-published: every case as expected
== arm no-exclude
ok    no-exclude: red on 'copy P -> S'
...
all checks passed
Exit Meaning
0 every case came out as expected and every arm was red on its own case
1 a case or an arm came out wrong; each BAD case prints the script's output
2 it could not run: no Docker, or the image did not build

What the drill cannot show:

What is still open