wirecert

package
v0.10.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 11, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Overview

Package wirecert issues the certificates the node wire authenticates with.

THE PROBLEM IT SOLVES: a node names itself in the request path and, without this, nothing verifies the claim. Anything that could reach the listener could call itself any node, bind leases, take commands, and ask for a JIT registration — a credential that registers a runner against the organisation. Without it the wire is safe only on loopback.

The design is deliberately small. One CA per deployment, held by the control plane, and one certificate per node. There is no OCSP and no intermediate: a deployment with a compromised node revokes its certificate or re-issues the CA, which is a real cost and an honest one at this size. What matters is that the authenticated name in the certificate is the ONLY thing that decides which node a request is from.

Index

Constants

View Source
const CALifetime = 10 * 365 * 24 * time.Hour

CALifetime is how long a deployment's certificate authority is good for.

Long, because rotating it means re-issuing every node certificate by hand and a deployment that has to do that yearly will instead do it never. The CA key is the thing to protect; its lifetime is not the control that protects it.

View Source
const ClockSkew = time.Hour

ClockSkew is how far before its issuance a certificate becomes valid.

Two machines' clocks disagree, and a node whose clock is a few minutes behind the control plane's would otherwise reject a certificate it was just handed. An hour is far more than any sane deployment drifts and costs nothing.

NAMED, because a reader of the certificate has to undo it: NotBefore is an hour BEFORE the moment a certificate was issued, so anything comparing "when was this minted" against a wall clock has to add this back. Revocation by cutoff does exactly that, and reading NotBefore as the issuance time refused every replacement issued within an hour of a revocation.

View Source
const ExpiryWarning = 30 * 24 * time.Hour

ExpiryWarning is how long before expiry the control plane starts complaining.

View Source
const LeafLifetime = 365 * 24 * time.Hour

LeafLifetime is how long an issued node certificate is good for.

A YEAR IS A DEADLINE, NOT A DEFAULT. An expired node certificate takes that host out of the fleet with a TLS error, so the control plane warns while one is still working — see ExpiresSoon — rather than letting the first symptom be a node that cannot connect.

View Source
const MinIssuedLifetime = 20 * time.Minute

MinIssuedLifetime is the shortest leaf IssueNodeFor will mint.

A NODE RENEWS ON A FIVE-MINUTE SWEEP ONCE LESS THAN A THIRD OF THE LIFE REMAINS, so the final third has to be longer than a sweep or the renewal can fall between two sweeps and the node expires in place, which is a host that has to be re-enrolled by hand. Twenty minutes puts the window at six minutes forty seconds against a five-minute cadence; ten, the first value chosen, put it at three minutes twenty and would have been missed.

Variables

View Source
var AuthorityFiles = []AuthorityFile{
	{Name: "ca.key", Secret: true, Required: true},
	{Name: "ca.crt", Required: true},
	{Name: "ca-previous.key", Secret: true},
	{Name: "ca-previous.crt"},
	{Name: markerFile, Required: true},
}

AuthorityFiles is the ALLOWLIST: everything that belongs to a deployment's authority, and nothing that does not.

THE UNIT IS LARGER THAN "THE KEY AND THE CERTIFICATE", and each of the other three is here for a reason that cost something to learn:

  • authority-created lives OUTSIDE the ca directory deliberately, so that directory going missing is detectable. A backup that captured the ca directory alone would restore an authority with no witness, and the next loss would read as day one and mint a replacement — which is the whole failure ErrAuthorityLost exists to refuse.
  • ca-previous.crt and ca-previous.key are operationally REQUIRED while a rotation is running: the previous key is what signs the certificate the control plane presents, so an archive without it restores a deployment that every un-renewed node fails to verify.

An ALLOWLIST rather than "copy the ca directory", because Rotate leaves ca.crt.new / ca.key.new behind if it dies partway and ca.lock lives beside them; copying whatever is there would put a half-minted authority in an archive that says it is complete.

View Source
var ErrAuthorityChanging = errors.New("wirecert: the authority changed during the observation")

ErrAuthorityChanging means two reads of the authority files disagreed every time the snapshot tried, so nothing coherent can be said about them.

View Source
var ErrAuthorityLost = errors.New("wirecert: this deployment had a certificate authority and it is gone")

ErrAuthorityLost means this deployment had an authority and its files are gone.

LOSING BOTH FILES IS INDISTINGUISHABLE FROM A FIRST RUN unless something remembers, and "mint a new one" is the wrong answer to exactly one of those. A restored backup that omitted the CA directory, a state directory recreated by a provisioning script, an operator clearing what they thought was cache — each looks like day one, and each would silently produce a NEW authority that every issued node bundle fails to verify against. The whole fleet drops off at once, and the control plane looks perfectly healthy.

View Source
var ErrForeignAuthority = errors.New("wirecert: this authority was issued for another deployment")

ErrForeignAuthority means the CA on disk belongs to a different deployment.

View Source
var ErrHalfInitialised = errors.New("wirecert: the CA directory holds only one of its two files")

ErrHalfInitialised means a CA directory holds one of its two files.

REFUSED RATHER THAN REPAIRED, and this is the most important error in the package. A missing key next to a present certificate looks like "just create it again", and creating it again mints a DIFFERENT authority: every node certificate ever issued stops verifying, the whole fleet drops off at once, and the operator is left with a control plane that looks healthy and a fleet that cannot reach it.

Functions

func AuthorityDeployment

func AuthorityDeployment(cert *x509.Certificate) (string, error)

AuthorityDeployment reads the deployment a CA certificate was issued for.

THE SUBJECT ORGANIZATION IS THE ANSWER, and it is what parseCA compares against a live control plane's identity: verifying against the CA is what decides which nodes may connect, so an authority carrying another installation's name would silently re-point that decision.

func AuthorityLockPath

func AuthorityLockPath(stateDir string) string

AuthorityLockPath is where that lock lives for a state directory.

EXPORTED SO NOBODY COPIES THE NAME. A privileged `billet local restore` CREATES this file as root inside a directory the service account owns, and what hands it back has to name the same file — a second literal somewhere else is a control plane that cannot take its own authority lock, discovered on the first start after a restore.

func AuthorityPath

func AuthorityPath(stateDir, name string) string

AuthorityPath is where one allowlisted file lives under a state directory.

func BootstrapTLS

func BootstrapTLS(b Bundle) (*tls.Config, error)

BootstrapTLS is the control plane's side of the ENROLLMENT wire: the small, separately bound listener serving /v1/ca and /v1/enroll.

NoClientCert, and that is the honest description rather than a relaxation. A machine reaching here has no certificate by definition, so asking for one buys nothing and costs an X.509 parse and a chain verification that a stranger chooses the inputs to. What secures these two routes is elsewhere and always was: the node compares this authority's fingerprint against a value an operator read off the control plane, asking requires a join token, and admission waits for a human to compare the node's own fingerprint back.

It presents the SAME server certificate as the operational wire, so whatever name a node dials this listener by must be among the certificate's subject names -- server.node_tls_hosts, or the concrete listen hosts it is derived from.

func CADir

func CADir(stateDir string) string

CADir is where a deployment's authority lives inside its state directory.

func ClientTLS

func ClientTLS(b Bundle) (*tls.Config, error)

ClientTLS is a node's side of the wire.

VERIFIES THE LEAF AGAINST ITS OWN CA, not merely that the certificate and key are a pair. A bundle whose node.crt came from one deployment and whose ca.crt came from another would otherwise load cleanly — and since the node adopts its DEPLOYMENT from the leaf, it would write the wrong identity permanently, trust a server that would reject it, and then refuse the correct bundle as a conflict.

func ExpiresSoon

func ExpiresSoon(cert *x509.Certificate) (time.Duration, bool)

ExpiresSoon reports whether a certificate is close enough to expiry to complain about, and how long it has left.

The control plane calls this on the certificate a node connected with, so the warning lands while that node is still WORKING. A check that only fires on failure would tell an operator their host is down, which they already know.

func Fingerprint

func Fingerprint(spki []byte) string

Fingerprint is a hash of a public key, in the shape a human compares.

OF THE PUBLIC KEY, NOT OF THE CERTIFICATE. A certificate changes every time it is renewed or re-issued; the key underneath it does not have to. Fingerprinting the certificate would mean the value an operator wrote down stops matching the moment anything is re-issued, and a check that goes stale is a check people learn to skip.

SHA256:base64 is OpenSSH's format, chosen because it is the one operators have already compared by eye a hundred times — and because a format nobody recognises invites pasting rather than reading.

func FingerprintOfCAPEM

func FingerprintOfCAPEM(caPEM []byte) (string, error)

FingerprintOfCAPEM is the fingerprint of an authority, from its PEM.

What a node checks the control plane against before it will send anything: the operator reads this off the server with `billet ca show` and gives it to the node, so the first connection is verified against a value that travelled by a channel an attacker on the network does not control.

func FingerprintOfCSR

func FingerprintOfCSR(csrPEM []byte) (string, error)

FingerprintOfCSR is the fingerprint of the key a certificate request is for.

THE SAME VALUE THE ISSUED CERTIFICATE WILL HAVE, which is the whole point: an operator approves a fingerprint they read off the node's console, and that fingerprint has to survive being signed or the approval means nothing.

func FingerprintOfCert

func FingerprintOfCert(cert *x509.Certificate) string

FingerprintOfCert is the fingerprint of a certificate's public key.

func InstallAuthority

func InstallAuthority(stateDir, deployment string, files map[string][]byte) error

InstallAuthority writes a whole authority into a state directory that has none.

EXPORTED SO A SECOND CONTROLLER CAN ADOPT ONE, which is the whole of what an active/passive pair needs from the identity store: not a shared mutable authority, but the ability for a host with nothing to end up holding exactly what the deployment already has. `billet ca issue` and a copy do the same job by hand; this is what does it without a person.

THE CALLER MUST HOLD LockAuthority, for the reason ReadAuthority states: these are the files `billet ca rotate` mutates in sequence.

IT CREATES AND NEVER REPLACES. Every write is O_EXCL, so a directory that already holds any part of an authority refuses rather than being merged into — the rule `billet local restore` states as "absent, byte-identical, or preserved and refused", and the reason it is absolute here is that the thing being written over would be the key every node in a fleet verifies against.

AND IT VERIFIES WHAT IT WROTE. The bytes arrived over a network from a store, so nothing about them is billet's until they have been parsed, proved to hold together as a pair, and proved to name this deployment.

func LeafOf

func LeafOf(b Bundle) (*x509.Certificate, error)

LeafOf parses the leaf certificate out of a bundle.

func NewNodeCSR

func NewNodeCSR(name string) ([]byte, []byte, error)

NewNodeCSR generates a key and a certificate request for a node name.

Returns the request to send and the key to keep. The key is PEM and is written 0600 by the caller; it is the node's identity and never leaves the machine.

func ParseAuthorityPair

func ParseAuthorityPair(keyPEM, certPEM []byte) (*x509.Certificate, error)

ParseAuthorityPair validates a CA key and certificate that are not on disk yet, returning the certificate.

EXPORTED so a restore applies the same rules to an ARCHIVE's bytes before it publishes them, rather than discovering on the next control-plane start that what it installed does not hold together.

func ParseCertificates added in v0.10.0

func ParseCertificates(body []byte) ([]*x509.Certificate, error)

ParseCertificates parses every CERTIFICATE block in a PEM bundle, in order, and refuses a bundle with none, with a block that is not a certificate or does not decode, or with any bytes between, before or after the blocks that are not whitespace. pem.Decode skips such bytes, and a malformed block, to reach the next BEGIN, which is the trap decodeFirstPEM exists to refuse.

func PreviousCA

func PreviousCA(stateDir string) (*x509.Certificate, []byte, error)

PreviousCA is the authority being retired, or nil when no rotation is running.

func ReadSecret

func ReadSecret(path string) ([]byte, error)

readSecret reads a private key, refusing anything a private key must not be.

FAIL CLOSED ON THE FILE ITSELF, because creation's 0600 says nothing about what is there NOW. A backup that restored ca.key as 0644 into a traversable directory starts billet perfectly happily while any local user copies the authority and mints node identities at will. A symlink is refused for the same reason: the path billet was told to read is the only one it should read. ReadSecret is readSecret for callers outside this package that hold a private key of their own — the staged enrollment key, which is a node identity waiting to be signed and has to meet the same bar as one that already is.

func RenewalDue

func RenewalDue(cert *x509.Certificate) (time.Duration, bool)

RenewalDue reports whether a certificate is far enough through its life to replace, and how long it has left.

AT A THIRD OF THE WAY FROM THE END, deliberately earlier than ExpiryWarning. A warning is for a human who may be on holiday; this is for the node itself, and the window has to be wide enough that a control plane which is down for a week, or a node powered off for a month, still has time to renew when it comes back. A node that lets its certificate expire cannot renew — renewal is authenticated by the certificate being renewed — so it has to be re-enrolled by hand, which is the outcome this width exists to avoid.

Computed from the certificate's OWN lifetime rather than from LeafLifetime, so a leaf shortened by the CA's own expiry still renews proportionally rather than being judged against a year it never had.

THE BACKDATED HOUR IS NOT LIFE. leafTemplate sets NotBefore an hour before issue (ClockSkew) so a host whose clock runs behind can use the certificate at once; that hour was never time the certificate had left. Counting it made a twenty-minute leaf look eighty minutes long and due the moment it was issued, which a rehearsal with short leaves found. A certificate that was not backdated (nothing billet issues) is measured from its NotBefore as before.

func Retire

func Retire(stateDir, deployment string) error

Retire drops the authority a rotation replaced.

AFTER THE FLEET HAS MOVED, which only an operator can judge: a node that has not renewed still trusts only the old authority, and retiring it makes that node unable to verify the control plane. `billet ca show` reports how many nodes are still on the old one.

func RotationAge

func RotationAge(stateDir string) (time.Duration, bool)

RotationAge is how long ago a rotation was started.

THE CERTIFICATE'S FACT, NOT THE KEY'S, and that is the right one for an operator: `billet ca retire` asks whether there is an overlap to finish, and a rotation interrupted before its key was written still left one to clean up.

func RotationLeftovers

func RotationLeftovers(unexpected []string) []string

RotationLeftovers reports whether an unexpected entry looks like an interrupted rotation, which is the one an operator can act on directly.

func SameFingerprint

func SameFingerprint(a, b string) bool

SameFingerprint compares two fingerprints, tolerating the ways a human transcribes one.

CASE AND SURROUNDING SPACE ONLY. It deliberately does NOT tolerate a missing "SHA256:" prefix or a different separator: those are the shapes a DIFFERENT hash function's output takes, and quietly accepting them would mean comparing values that were never the same kind of thing.

func Serial

func Serial(cert *x509.Certificate) string

Serial is a certificate's serial number as the ledger stores it.

Hex, because a serial is a 128-bit integer and every other rendering of one — decimal, base64 — makes it harder to match against what `openssl x509` prints when somebody is trying to work out which credential they are looking at.

func ServerTLS

func ServerTLS(b Bundle) (*tls.Config, error)

ServerTLS is the control plane's side of the OPERATIONAL wire.

RequireAndVerifyClientCert, and the whole of the two-listener split turns on it. This was VerifyClientCertIfGiven so that a machine with no certificate could reach /v1/ca and /v1/enroll, with every other route refused in the handler -- which authenticated correctly and cost the fleet its availability. A certless caller completed a handshake, asked for the authority, and held a connection in keep-alive until the listener's idle timeout, out of a connection budget it SHARED with real nodes; once that budget was full, Accept blocked before the kernel accept and a healthy node was never admitted at all.

A cap cannot separate the two, because the permit is taken BEFORE the handshake -- the handshake is itself work an anonymous caller can ask for -- and which caller holds a certificate is not known until after it. So the separation has to be the handshake itself: those two routes moved to their own listener (BootstrapTLS, nodeplane.BootstrapHandler) and this one refuses a connection that presents nothing, before Go's HTTP server ever sees it.

nodeplane.authorise still demands a verified chain on every route. It is no longer the only thing standing there, and it is kept because Handler can be built without TLS at all -- a loopback wire, and every handler-level test.

func WriteFileAtomic

func WriteFileAtomic(path string, data []byte, mode os.FileMode) error

WriteFileAtomic replaces a file with data, giving it exactly mode, and leaves either the old contents or the new ones behind.

NOT os.WriteFile, FOR TWO REASONS THAT BOTH MATTER FOR A PRIVATE KEY.

It applies its mode only when it CREATES the file, so writing a fresh key over an existing node.key that happened to be 0644 left a new secret world-readable and reported success. And it FOLLOWS SYMLINKS: with a temporary name derived from the destination, anyone able to write the directory can plant that name pointing somewhere they can read, and the key is written there before the rename puts it back. The approval wait makes that window minutes long and entirely predictable.

So the temporary is created with a random name by CreateTemp — which uses O_EXCL, so it cannot be an attacker's symlink — chmodded explicitly rather than inheriting anything, and fsynced before the rename. The directory is synced afterwards, so the rename survives a power cut rather than leaving a name pointing at nothing.

Types

type Authority

type Authority struct {
	// Present maps an allowlisted name to its bytes.
	Present map[string][]byte
	// Unexpected names anything in the ca directory that is not allowlisted.
	// REPORTED RATHER THAN COPIED: a leftover ca.crt.new from an interrupted
	// rotation is worth an operator's attention and must not travel in an
	// archive as though it were authority state.
	Unexpected []string
}

Authority is a deployment's authority as it stands on disk.

func ReadAuthority

func ReadAuthority(stateDir string) (Authority, error)

ReadAuthority collects the allowlisted authority state and refuses an incomplete one.

THE CALLER MUST ALREADY HOLD LockAuthority. This reads five files that `billet ca rotate` mutates in sequence, so without the lock it can return a key from one generation beside a certificate from another — an archive that loads cleanly and verifies nothing.

INCOMPLETE IS A REFUSAL, NOT A SHORTER ANSWER. A backup of a half-initialised authority is the trap ErrHalfInitialised exists to stop one layer down: it restores as a directory holding one of its two files, which billet then refuses to repair, on a host where nobody is expecting it.

func (Authority) Rotating

func (a Authority) Rotating() bool

Rotating reports whether a rotation is running.

type AuthorityFile

type AuthorityFile struct {
	// Name is the archive-stable name, which is also the basename on disk for
	// everything except the marker.
	Name string
	// Secret says whether it is a private key. A backup writes everything 0600
	// regardless; this decides what an ERROR may say about it and how it is read.
	Secret bool
	// Required says whether an authority is incomplete without it. The previous
	// generation exists only while a rotation is running.
	Required bool
}

AuthorityFile names one file that is part of a deployment's authority.

type AuthorityLock

type AuthorityLock struct {
	// contains filtered or unexported fields
}

AuthorityLock is an exclusive hold on a deployment's certificate authority.

WHAT IT PREVENTS is a reader capturing half a rotation. `billet ca rotate` mutates five files in sequence — it copies the old pair aside as ca-previous.*, then renames a freshly minted key and certificate into place — so anything reading the directory while that runs can come away with a key from one generation beside a certificate from another, or with only half of the previous pair. A backup is exactly such a reader, and the archive it writes would load cleanly and verify nothing.

WHAT IT DOES NOT COVER, AND WHY THAT IS NOW SAFE. `LoadServing` — which is the whole of the control plane's read, and runs ONCE while it starts — does not take it, and neither does `LoadOrCreateCA` behind it. The renewal signer is not in the set at all: `SignNodeCSR` signs with the in-memory *CA and reads nothing from disk.

So the only thing a rotation can collide with is a control plane STARTING, and that is closed by publication ORDER rather than by this lock. Rotate writes ca-previous.crt before ca-previous.key and Retire removes them the other way round, so a certificate with no key beside it always means "started, not committed" and the reader presents with the current authority; and the one torn read the two renames of the current pair can produce is repaired from ca-previous.key, which is durable before the first rename. Every instant of a rotation is therefore a state a reader answers correctly. See LoadServing.

TAKING IT IN THE READER WAS THE OTHER CANDIDATE AND WAS REJECTED. It is non-blocking on purpose — every holder is a command somebody is waiting on — so a reader would need a waiting acquisition, and `billet local backup` holds this across the whole ledger snapshot. A control plane that refuses to start because a backup is running is a worse failure than the diagnostic being fixed, and a startup path that took this lock could not call anything else that takes it: a second flock on a separate descriptor in one process is denied, so it would deadlock against itself and report another billet.

func LockAuthority

func LockAuthority(stateDir string) (*AuthorityLock, error)

LockAuthority takes the lock, or names what already holds it.

NON-BLOCKING, on the same argument the lifecycle lock makes: the operator who started a second command wants to be told what is already running, not queued silently behind a rotation.

It is a real exclusion between PROCESSES and also within one — measured on darwin, a second flock on a separate descriptor in the same process is denied with EWOULDBLOCK — so a caller holding this must not call anything that takes it again.

func (*AuthorityLock) Release

func (l *AuthorityLock) Release() error

Release drops the lock. Closing the descriptor releases it, so a process that exits cannot leave one held.

type AuthoritySnapshot added in v0.10.0

type AuthoritySnapshot struct {
	Current     *x509.Certificate
	CurrentPEM  []byte
	Previous    *x509.Certificate
	PreviousPEM []byte
	Created     bool
}

AuthoritySnapshot is what a read-only observer can say about a deployment's authority from its files alone: the current CA certificate, the predecessor while a rotation is in progress, and whether the creation marker is present. It carries no key material.

func SnapshotAuthority added in v0.10.0

func SnapshotAuthority(stateDir string) (AuthoritySnapshot, error)

SnapshotAuthority reads the PUBLIC half of a deployment's authority without taking a lock and without creating anything, and answers only when two consecutive reads agree.

LoadServing takes the same confirming-read approach because a rotation or a retirement can cross a single read; an observer that took LockAuthority instead would create the lock file and its directory on a controller that has neither, which is exactly what a read-only inspection must not do. The files are read as readPublic reads them (no symlink, a regular file, capped) and the key files are never opened.

func (AuthoritySnapshot) Rotating added in v0.10.0

func (s AuthoritySnapshot) Rotating() bool

Rotating reports whether a predecessor authority is still present.

type Bundle

type Bundle struct {
	// CertPEM is the holder's certificate.
	CertPEM []byte
	// KeyPEM is the holder's private key. A secret: written 0600, never logged.
	KeyPEM []byte
	// CAPEM is the authority both sides verify against.
	CAPEM []byte
}

Bundle is everything one party needs to speak on the wire.

func LoadBundle

func LoadBundle(certPath, keyPath, caPath string) (Bundle, error)

LoadBundle reads a bundle a node was given.

The node's key is held to the same standard as the control plane's: it is the credential that lets this host act as this node, and a host whose key any local user can read is a host any local user can impersonate.

func (Bundle) Deployment

func (b Bundle) Deployment() (string, error)

Deployment reads the installation a bundle was issued for.

THE CERTIFICATE IS WHERE A NODE'S DEPLOYMENT COMES FROM, and that removes the only step of enrollment an operator could not perform. A node's state directory mints a random identity when it has none — right for a control plane, which is where an installation BEGINS, and wrong for a node, which joins one. A freshly enrolled node invented an identity, the control plane compared it with its own and refused the registration, and no amount of copying certificates around could have fixed it.

It is also one authority rather than two. The certificate already decides which deployment may connect at all — it is verified against that deployment's CA — so reading the identity from the same object cannot disagree with it.

func (Bundle) NodeName

func (b Bundle) NodeName() (string, error)

NodeName reads the node a bundle was issued for.

func (Bundle) Write

func (b Bundle) Write(dir string) error

Write puts a bundle on disk for an operator to copy to a node.

REFUSES TO OVERWRITE. Re-issuing over a live node's directory would leave that host with a key it never loaded and a certificate it cannot use until someone restarts it — and if the write half-fails, with neither.

type CA

type CA struct {
	// contains filtered or unexported fields
}

CA is a deployment's certificate authority.

func LoadOrCreateCA

func LoadOrCreateCA(stateDir, deployment string) (*CA, error)

LoadOrCreateCA reads the deployment's authority, minting it on first use.

The deployment id is recorded in the CA's subject so a certificate found on a host can be attributed to the installation that issued it. It is NOT a second authority for which deployment a node belongs to — verifying against this CA is that answer, and registration checks the id it was told for the same reason it always did.

func Rotate

func Rotate(stateDir, deployment string) (*CA, error)

Rotate replaces the issuing authority, keeping the old one trusted.

AN OVERLAP, NOT A SWITCH, and the ordering is the whole design. A node trusts the authority it was given, so the moment the control plane starts PRESENTING a certificate from a new one, every node that has not yet heard about it fails to verify the server and drops out of the fleet. There is no way back from that over the wire, because the wire is what broke.

So rotation runs in two phases:

billet ca rotate   new authority issues NODE certificates; the OLD one still
                   signs what the server presents, and both are trusted. Nodes
                   pick up the new one through ordinary renewal, which already
                   carries the authority alongside the certificate.
billet ca retire   once every node has renewed, the old authority is dropped
                   and the server presents a certificate from the new one.

A node that misses the whole overlap has to be re-enrolled, which is why retiring is a separate command an operator runs when they can see the fleet has moved rather than something that happens on a timer.

func (*CA) Capping

func (c *CA) Capping() (time.Duration, bool)

Capping reports whether this authority is close enough to its own expiry that the certificates it issues are being shortened, and how long it has left.

THE FAILURE IT NAMES IS A SLOW ONE. A leaf may not outlive its authority, so from one leaf-lifetime out every certificate issued is shorter than a full life: renewals come round faster and faster, nothing errors, and then the whole fleet expires on the same day the authority does.

Rotating is an overlap, not a switch: issue a new authority, keep trusting the old one while nodes pick the new one up through renewal, then retire it. That is why renewal returns the CA alongside the certificate.

func (*CA) CertPEM

func (c *CA) CertPEM() []byte

CertPEM is the authority nodes verify the control plane against.

func (*CA) Fingerprint

func (c *CA) Fingerprint() string

Fingerprint is this authority's own fingerprint.

func (*CA) IssueNode

func (c *CA) IssueNode(name string) (Bundle, error)

IssueNode mints a node's certificate.

THE COMMON NAME IS THE NODE'S IDENTITY, and it is the only one. The wire's handlers take the name from the verified certificate rather than from the request path, so a host holding this certificate can act as this node and as nothing else.

func (*CA) IssueNodeFor added in v0.6.1

func (c *CA) IssueNodeFor(name string, lifetime time.Duration) (Bundle, error)

IssueNodeFor mints a node's certificate good for the given lifetime rather than LeafLifetime.

A SHORT LEAF IS HOW A ROTATION IS REHEARSED. A node renews once less than a third of its certificate's life remains, so with year-long leaves nothing renews inside any rehearsal; a twenty-minute leaf renews inside the run. The bounds are enforced HERE, not only by the command that asks, because an exported entry point that trusts its caller is a second place the rule can be missing (the alloc.New argument). The same cap as every leaf applies: nothing outlives the authority.

func (*CA) IssueServer

func (c *CA) IssueServer(hosts []string) (Bundle, error)

IssueServer mints the certificate the control plane serves with.

KEPT IN MEMORY BY ITS CALLER, deliberately. Nothing verifies the control plane's certificate except against this CA, so re-minting it every boot costs nothing and removes a whole failure mode: a server certificate on disk is one more thing that expires, and its expiry takes the entire fleet offline at an hour nobody chose.

func (*CA) NotAfter

func (c *CA) NotAfter() time.Time

NotAfter is when this authority stops working.

func (*CA) SignNodeCSR

func (c *CA) SignNodeCSR(name string, csrPEM []byte) (Bundle, error)

SignNodeCSR issues a node certificate for a key the node generated itself.

THE PRIVATE KEY NEVER CROSSES THE WIRE, which is the whole reason renewal takes a CSR rather than returning a fresh bundle. A renewal endpoint that minted the key server-side would put a node's identity on the network once a year, and into the control plane's memory and logs on the way.

THE NAME COMES FROM THE CALLER, NOT FROM THE CSR. The subject in a CSR is whatever the requester typed; the authenticated identity is what the wire proved. Signing the former would let any node with a valid certificate mint one for any name it liked — which is every node able to impersonate every other, through the endpoint meant to keep them working.

type Rotating

type Rotating struct {
	// contains filtered or unexported fields
}

Rotating is a node's TLS identity that can be replaced without a restart.

A CALLBACK RATHER THAN A tls.Config FIELD, because a Config's Certificates slice is read when a handshake starts and a node holds long-lived connections either side of a renewal. Swapping the slice under a live Config is a data race; GetClientCertificate is the supported way to answer "what am I, right now" per handshake.

func NewRotating

func NewRotating(certPath, keyPath, caPath string) (*Rotating, error)

NewRotating builds a rotating identity from a bundle on disk, falling back to the generation a renewal replaced if the current one is incomplete.

A GENERATION IS THREE FILES AND THREE RENAMES, and no amount of care makes that one operation. Between any two of them the process can die, and what is left is a new key beside an old certificate — a pair that verifies as nothing. The node then cannot start, and cannot renew its way out either, because renewal is authenticated by the certificate being renewed and the key that certificate belonged to has already been overwritten. That machine has to be enrolled again by hand, which is the outcome renewal exists to avoid.

So the answer is not to make the write atomic — it cannot be — but to keep the predecessor until the successor is known to load, and to come back to it when the successor does not. Recovery is silent to the wire and loud in the log: RolledBack reports it so the caller can say so.

func (*Rotating) ClientTLS

func (r *Rotating) ClientTLS(serverName string) *tls.Config

ClientTLS is this identity as a dialable config, verifying the control plane against serverName.

BOTH HALVES ARE ANSWERED PER HANDSHAKE, because a node builds this once and keeps it for the life of the process. GetClientCertificate does that for what the node presents; RootCAs cannot, because it is a value captured when the config is built — so verification is done here instead, against the pool as it is at the handshake. That is what lets a renewal widen a running node's trust, which is the entire mechanism by which a CA rotation reaches the fleet.

THE NAME IS PASSED IN RATHER THAN READ OFF THE CONNECTION. tls.ConnectionState carries only what went out in SNI, and SNI is not sent for an IP literal — so a node dialling its control plane by address would arrive here with nothing to check the certificate against, and billet supports exactly that.

func (*Rotating) Leaf

func (r *Rotating) Leaf() *x509.Certificate

Leaf is the certificate in force right now.

func (*Rotating) Replace

func (r *Rotating) Replace(certPEM, keyPEM, caPEM []byte) error

Replace installs a renewed certificate, writing it down before using it.

TO DISK FIRST, AND ATOMICALLY. A node that adopted a renewal in memory and then failed to persist it would keep working until it restarted and then come back with the old certificate — which by then may be closer to expiry, or past it. Writing first means the worst case is a renewal that is durable but not yet live, which the next start picks up.

The key is written 0600 and the certificate 0644: one is a secret and the other is public, and giving them the same mode teaches the wrong lesson to whoever copies this next.

func (*Rotating) RolledBack

func (r *Rotating) RolledBack() bool

RolledBack reports whether this identity was recovered from the generation a renewal replaced — which means a renewal was interrupted partway through installing itself, and is worth an operator's attention even though nothing is broken.

func (*Rotating) StaleCopies

func (r *Rotating) StaleCopies() error

StaleCopies reports a superseded generation that could not be deleted — a second copy of this node's private key, left on disk.

type Serving

type Serving struct {
	// Issuing signs node certificates and renewals: the NEW authority during an
	// overlap, which is how nodes adopt it.
	Issuing *CA
	// Presents signs the certificate the control plane serves: the PREVIOUS
	// authority during an overlap, because a node that has not renewed trusts
	// only that one. The server follows the fleet rather than leading it.
	Presents *CA
	// Trust is every authority a node should accept, newest first — a
	// concatenated PEM, which is what x509.CertPool reads and what a node writes
	// to its ca.crt. During an overlap it holds two.
	Trust []byte
	// Rotating reports that an overlap was started, and RotationAge how long ago.
	Rotating    bool
	RotationAge time.Duration
}

Serving is everything a control plane needs from its authority, read once.

ONE READ, BECAUSE FOUR WERE A RACE OF THEIR OWN. This used to be four separate walks of the ca directory during startup — LoadOrCreateCA, ServingCA, TrustBundle and RotationAge — and a `billet ca retire` landing between the second and the third produced a control plane that PRESENTS a certificate signed by the retired authority while trusting only the new one. It starts, it looks healthy, and not one node can verify it. That failure is worse than anything a half-published rotation can cause, because the others all refuse.

func LoadServing

func LoadServing(stateDir, deployment string) (Serving, error)

LoadServing reads a deployment's authority as one consistent picture, minting it on first use.

THE READ ORDER IS PART OF THE ANSWER, and it is chosen against the order a rotation writes in so that every instant of a rotation is a state this returns something correct for. ca.crt then ca.key (LoadOrCreateCA), then ca-previous.crt then ca-previous.key:

  • Rotate writes ca-previous.crt then ca-previous.key. Landing between them finds a certificate and no key, so there is nothing to present the previous authority with — and the current pair IS the old one at that point, because the renames have not run. Presenting with it is correct.
  • Retire removes ca-previous.key then ca-previous.crt. Landing between them finds the same shape and presents with the current authority, which is the new one, while still trusting the old certificate. Wider than needed, and safe.
  • Rotate renames ca.key then ca.crt. Landing between them is the one torn read the ordering can produce and LoadOrCreateCA repairs it; see there.

Both fallbacks go the same way: an absent previous KEY means present with the current authority, and a present previous CERTIFICATE means trust one more thing. Neither can serve an authority the fleet does not have.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL