Cluster Certificates
A March cluster can admit nodes by certificate instead of by one shared secret. Each node gets its own ed25519 key and a certificate for that key, signed by an operator key you keep offline. A node joins only if its certificate verifies under the operator key, has not expired and has not been revoked. A leaked certificate or key affects one node, and you can revoke it without rotating anything else.
This page is the operator’s guide: making keys, issuing and renewing certificates, configuring nodes, and revoking. How the handshake and the per-frame MAC work is in Clustering.
What you get, and what you don’t
- Who may join. Only nodes holding a certificate from your operator key. A node cannot claim another node’s name: the certificate names it, and the node proves in every handshake that it holds the certificate’s key.
- Frame integrity. Every frame after the handshake carries a MAC. A frame changed, injected or replayed on the wire is dropped and counted.
- Expiry and revocation. A peer is disconnected when its certificate
expires or is revoked, and is reported as
NodeDead(_, "certificate expired")orNodeDead(_, "certificate revoked"). - Not encryption. Frames are readable by anyone on the network path. Run the cluster on a private network, or under a service mesh that encrypts. Node names are SPIFFE-style URIs so that a mesh can carry the same identities.
- Not yet: role enforcement. A certificate lists the protocol roles a node
may offer or initiate and whether it may use raw sends. Nodes can read those
fields (
ClusterNode.peer_cert), but nothing refuses an offer or a send on their basis yet.
1. Make the operator key
forge cluster keygen --out ./pki
This writes pki/operator.key (the secret key, hex, mode 0600) and
pki/operator.pub (the public key, hex), and prints the line to give every
node:
MARCH_CLUSTER_OPERATOR_PUBKEY=3d5b4133b0c5f733...
Keep operator.key off the nodes: whoever holds it can issue certificates
and revocations. It is a different key from the one forge hot-reload keygen
makes for signing deploys, on purpose. keygen refuses to overwrite an
existing operator.key unless you pass --force, because replacing it
invalidates every certificate it signed.
2. Issue a certificate per node
forge cluster cert web-1 \
--roles Checkout.Ledger:offer,Checkout.Client:initiate \
--flags raw_send \
--days 30 \
--trust-domain prod.example --pool web \
--operator-key ./pki/operator.key --out ./pki
This writes pki/web-1.key (the node’s secret key, mode 0600) and
pki/web-1.cert, and prints the certificate’s URI, serial and expiry:
node spiffe://prod.example/pool/web/node/web-1
serial 1759598414123-9944755218842a7914166be67a8bc8da
not_after 1792849214 (unix seconds)
NODEmust be the node’sMARCH_NODE_NAME: the handshake checks that the certificate names the node that presents it.--rolestakesProto.Role:offerandProto.Role:initiate; anything else is refused.--daysdefaults to 90.--secondssets a short life (tests, or very short-lived certificates reissued by automation).- Keep the serial: a revocation of this one certificate names it.
- The serial starts with the time the certificate was issued, in unix milliseconds. Nodes use it to order a node’s certificates: a node never replaces its certificate with one issued before it (see section 4). A certificate from a forge older than this has a plain random serial and no issue time. Nodes still accept it, but it cannot replace a certificate that has one.
3. Configure the nodes
Each node needs its key, its certificate and the operator public key:
MARCH_NODE_NAME=web-1
MARCH_NODE_PORT=4001
MARCH_CLUSTER_NODES=10.0.0.2:4001
MARCH_NODE_KEY=/run/secrets/web-1.key
MARCH_NODE_CERT=/run/secrets/web-1.cert
MARCH_CLUSTER_OPERATOR_PUBKEY=/run/secrets/operator.pub
Each of the three may hold the value itself or name a file holding it. With
MARCH_NODE_CERT set, ClusterNode.config_from_env uses certificate mode and
ignores MARCH_CLUSTER_SECRET. At startup the node checks its own certificate:
it must verify under the operator key, be unexpired, name MARCH_NODE_NAME,
and name the key in MARCH_NODE_KEY. A misconfigured node stops with the
reason instead of failing every handshake later.
Every node of a cluster must run in the same mode. A certificate node refuses a shared-secret node and the other way round, with a message naming the variables to set, so move a cluster to certificates all at once rather than one node at a time.
4. Renew before expiry, without a restart
A certificate is checked in every handshake and rechecked on every tick. When it expires, its peers disconnect the node. Before that, issue a new certificate and put it where the node reads it. The node replaces its certificate while it runs: there is no restart, and the node’s links and the sessions on them stay up.
forge cluster cert web-1 --node-key ./pki/web-1.key --days 30 \
--roles ... --operator-key ./pki/operator.key --out ./pki
--node-key reuses the node’s existing key, so only web-1.cert changes.
Copy it over the file MARCH_NODE_CERT names on the node. The node re-reads
that file (and MARCH_NODE_KEY’s, when the key is a file) every
MARCH_NODE_CERT_POLL_MS milliseconds, 10 s by default, and takes the new
certificate once it:
- verifies under the operator key and has not expired,
- names this node (
MARCH_NODE_NAME) and the node’s key, - has not been revoked,
- was not issued before the certificate the node holds (the issue time in its serial, section 2).
The last rule means an old certificate cannot be put back, whether by a stale file or a replayed release. It holds across restarts too, because the node starts on the certificate it last took. It orders by issue time, not by expiry, so a newer certificate that lives shorter, or carries fewer roles, still replaces an older one. To go back to an older certificate’s roles on purpose, issue a new certificate with those roles. To force a particular certificate regardless, restart the node on it: the order is only checked when a running node replaces its certificate.
Otherwise the node keeps the certificate it has and reports why (below). This is the usual path for a Kubernetes secret volume, cert-manager, a Vault agent or a CI job copying files: they rewrite the file and signal no one, so the node watches the file rather than waiting for a signal.
To change the key as well, leave out --node-key. forge cluster cert then
makes a new key, and you replace both files. Write the key first: until the
certificate that names it arrives, the node refuses the pair and keeps the old
one.
Code that manages certificates itself can call the same replacement directly:
match ClusterNode.replace_cert(node, cert_text, "") do -- "" keeps the key
Ok(c) -> println("now serving " ++ c.serial)
Err(e) -> println("certificate refused: " ++ e)
end
The second argument is a new key in hex, for a certificate that names one.
What happens on the wire. New handshakes present the new certificate at
once. Links already up are not redialled, because a new connection would
cancel every session on the old one. Instead the node sends each linked peer
the new certificate over the existing, authenticated link, with a signature
made by the certificate’s own key to prove it holds that key. The signature
also covers that link’s handshake transcript and a counter for the link, so
the update is good on that one link, once. Someone who records it off the wire
cannot replay it on a link of their own, for example one made with a leaked
old key, to keep that link alive past the old certificate’s revocation. The
peer checks the update as it would a handshake: it must name the same node and
must not be issued before the certificate it replaces. From
then on it holds that certificate for the link: the expiry recheck, revocation
and peer_cert all use the new one, so the link outlives the old
certificate’s expiry. After that expiry nobody can join with the old
certificate. A peer running a March from before live replacement, or from
before updates were bound to their link, refuses the update and drops the
link when the old certificate expires. The node redials
it at once under the new certificate, but sessions on that link are lost.
run_<Role> direct connections read the environment’s files at every
connect, so they present the new certificate from their next connection.
Revoke the old certificate’s serial once the new one is in place if it must stop working before it expires.
Through the control plane
A cluster that runs the control plane (a [control] section in its
topology) can take the renewal from forge directly, with no file to copy:
forge cluster cert web-1 --node-key ./pki/web-1.key --days 30 \
--roles ... --operator-key ./pki/operator.key --out ./pki \
--deliver ctl-1.prod.example:7947
--deliver names the control API of any control candidate (several, comma
separated, if you like). forge writes the certificate as usual, then sends a
release carrying it, signed by the deploy key (--deploy-key PATH, default
forge’s own from forge hot-reload keygen), the way forge deploy sends its
releases, and follows it until the node reports presenting the new
certificate. The node checks two signatures before it takes anything: the
deploy key’s over the release and the operator key’s over the certificate. A
control-plane node therefore cannot forge either; it can only delay them.
The node then replaces its certificate live, exactly as above, and writes it
back over its MARCH_NODE_CERT file so a restart comes back on it.
--deliver needs --node-key: a release never carries a secret key, so the
node keeps the key it has. To change the key, use the files. Issuance stays
with you, offline: the control plane holds no operator key and issues
nothing.
A node refuses a certificate item, and the release halts with its reason in
forge’s output and the control API’s STATUS, when the certificate:
- names another node,
- is signed by another operator key,
- has expired or been revoked,
- names a key the node does not hold,
- was issued before the certificate the node holds (section 3), or
- comes in a release older than one the node already took certificates or revocations from.
The node keeps that last bound, its certificate floor, on disk at
$MARCH_CONTROL_DIR/cert-floor-<node> (written to a temporary file and
renamed), and reads it back when it restarts. A cert-only release does not
advance the reload server’s release head, so without the floor file a
restarted node would take an older, genuinely signed release again. Point
MARCH_CONTROL_DIR at a directory that persists and that only the node’s user
can write. The default, /tmp/march-control, is cleared at reboot. In that
case the issue-time rule above still refuses an older certificate, as long as
the node’s certificate is a file (the delivered certificate is written back to
it). A revocation release advances the floor too, so a replay of the release
that delivered a since-revoked certificate is refused.
forge also refuses to deliver while an earlier release is still rolling out, since a new release would supersede it.
5. Revoke
To cut off one certificate (a node was compromised, or decommissioned early):
forge cluster revoke --serial 9944755218842a7914166be67a8bc8da --operator-key ./pki/operator.key
To cut off every certificate of a node:
forge cluster revoke --node web-1 --trust-domain prod.example --pool web --operator-key ./pki/operator.key
Either prints a token. Give it to any running node:
match ClusterNode.revoke(node, token) do
Ok(_) -> ()
Err(e) -> println("revoke refused: " ++ e)
end
The node passes it on to every peer, and each drops links to the revoked
certificate and refuses its handshakes. A node started later learns it from
its peers when it links, or from MARCH_CLUSTER_REVOCATIONS (tokens separated
by commas or whitespace, or a file of them), so add the token there too.
ClusterNode.revocations(node) lists what a node knows. A token counts only
if the operator key signed it, so one node cannot revoke another.
With the control plane, --deliver does the handing out for you:
forge cluster revoke --node web-1 --trust-domain prod.example --pool web \
--operator-key ./pki/operator.key --deliver ctl-1.prod.example:7947
The release reaches every node, not just those linked to whoever saw the
token first, and forge follows it until every node reports knowing the
revocation. The revoked node itself is not waited for, since its peers cut
it off. Each node checks the operator’s signature on the token itself.
Still add the token to MARCH_CLUSTER_REVOCATIONS for nodes started later.
Watching for trouble
let _ = ClusterNode.on_security_event(node, fn ev ->
println(ClusterNode.security_text(ev)))
reports each refused handshake with its reason (an expired, revoked or foreign
certificate; a peer in the other mode), each frame dropped for a bad MAC, and
each certificate replacement: CertReplaced(node_id, serial) for this node’s
own or a peer’s, and CertRefused(node_id, why) when a new certificate did not
check out (a half-written file, a certificate for a key that is not there yet,
an expired or revoked one) and the old one was kept.
ClusterNode.frames_rejected(node) counts the dropped frames. A connection is
closed after three.
Files
| file | written by | holds | where it goes |
|---|---|---|---|
operator.key |
forge cluster keygen |
operator secret key | offline |
operator.pub |
forge cluster keygen |
operator public key | every node |
<node>.key |
forge cluster cert |
the node’s secret key | that node only |
<node>.cert |
forge cluster cert |
the node’s certificate (base64) | that node |