Files
mesh-host/internal/identity/serving.go
jschoubben c83ed4eca9 A node's serving key is stored in the format a server reads
PKCS#8 PEM, not this host's own base64. The mesh delivers a PEM certificate
beside it and every TLS server there is reads PEM: nginx's ssl_certificate_key,
Go's LoadX509KeyPair, openssl s_server. Stored the other way the file was
intact, present, correctly permissioned, and unusable — the machine failed at
the moment something connected, which the lab found by connecting.

A key in the old encoding is refused by name rather than called corrupt: it is
replaced by enrolling again, and that is a different remedy from a damaged
file.
2026-08-31 00:41:10 +02:00

133 lines
5.4 KiB
Go

package identity
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"strings"
)
// The key a node serves TLS with, on its name inside the mesh.
//
// A fourth key, and the reasoning is the one this file's neighbours already give twice: **a key
// used for two purposes is one rotation away from breaking the other**. The identity key signs
// messages to the mesh and would do for TLS — Ed25519 works in TLS 1.3 — and reusing it would
// mean rotating a node's identity every time its certificate is replaced, or the reverse.
//
// **The private half never leaves the machine.** The mesh is told the public half at enrolment
// and signs a certificate binding it to this node's internal name, which is the whole of what a
// certificate authority does. There is no request to send and nothing to seal: the mesh issues
// something public, about a key it cannot use.
//
// novox/hq 08-connectivity: the mesh CA certifies internal names, and it is not a bootstrap
// concern — a joining node verifies the control plane against the fingerprint in its token, so
// nothing needs the CA before membership.
// ServingKey is an Ed25519 keypair a node presents when something connects to it by name.
type ServingKey struct {
// Public is what the mesh records and certifies.
Public string `json:"public"`
// Private never leaves this machine.
Private string `json:"private"`
}
// GenerateServingKey makes this node's key for serving on its internal name.
func GenerateServingKey() (ServingKey, error) {
public, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return ServingKey{}, fmt.Errorf("cannot generate this node's serving key: %w", err)
}
return ServingKey{
Public: base64.StdEncoding.EncodeToString(public),
Private: base64.StdEncoding.EncodeToString(private),
}, nil
}
// ServingKeyPath is where the private half lives.
//
// A file of its own, named by whatever configuration needs it — the same arrangement the overlay
// key has, and for the same reason: the mesh can compose a service's configuration without ever
// holding the key that configuration points at.
//
// **PKCS#8 PEM**, because that is the only reason the file exists. A key stored in this host's own
// encoding is a key nothing can serve with: the mesh delivers a PEM certificate beside it, and
// every TLS server there is — a web server's `ssl_certificate_key`, Go's `LoadX509KeyPair`,
// `openssl s_server -key` — reads PEM and nothing else. It was base64 once, and the certificate
// arrived, and the file was there, and nothing could start.
func ServingKeyPath(statePath string) string {
return dirOf(statePath) + "/serving.key"
}
// CertificatePath is where the certificate the mesh issued lives.
//
// Beside the key, and written by the host from an ordinary declaration — it is public, so it
// travels in the open like any other file.
func CertificatePath(statePath string) string {
return dirOf(statePath) + "/serving.crt"
}
// LoadServingKey reads this node's serving key.
//
// It does not make one, for the same reason LoadSealingKey does not: a key the mesh has never
// certified is a key nothing will trust, so a node that quietly generated one would serve a
// certificate for a key it no longer has and fail in a way that names neither.
func LoadServingKey(path string) (ServingKey, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return ServingKey{}, fmt.Errorf(
"this node has no serving key at %s, so nothing can be certified for it — it is "+
"made at enrolment, and a node that joined before had none", path)
}
return ServingKey{}, err
}
block, _ := pem.Decode(raw)
if block == nil {
// Distinguished from a corrupt key, because the remedy is different and the difference is
// invisible otherwise. A key this host wrote before it stored PEM is intact and unusable:
// nothing serving TLS can read it, and the machine fails at the moment something connects.
if _, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw))); err == nil {
return ServingKey{}, fmt.Errorf(
"%s holds a serving key in this host's old encoding, which nothing serving TLS "+
"can read. It is replaced by enrolling again, which generates one and tells "+
"the mesh about it", path)
}
return ServingKey{}, fmt.Errorf("%s is not a serving key", path)
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return ServingKey{}, fmt.Errorf("%s is not a serving key: %w", path, err)
}
key, isEd25519 := parsed.(ed25519.PrivateKey)
if !isEd25519 {
return ServingKey{}, fmt.Errorf(
"%s holds a %T, and a node serves with an Ed25519 key", path, parsed)
}
return ServingKey{
Public: base64.StdEncoding.EncodeToString(key.Public().(ed25519.PublicKey)),
Private: base64.StdEncoding.EncodeToString(key),
}, nil
}
// WriteServingKey puts the private half where configuration can point at it, as PKCS#8 PEM.
func WriteServingKey(path string, key ServingKey) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
raw, err := base64.StdEncoding.DecodeString(key.Private)
if err != nil || len(raw) != ed25519.PrivateKeySize {
return fmt.Errorf("this is not a serving key to write")
}
encoded, err := x509.MarshalPKCS8PrivateKey(ed25519.PrivateKey(raw))
if err != nil {
return err
}
return os.WriteFile(path,
pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}), 0o600)
}