Files
mesh-host/internal/identity/serving.go
T
jschoubben 5237944473 A node generates the key it serves TLS with
A fourth key, reported at enrolment like the others. 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 private half never leaves the machine. The mesh is told the public
half and signs a certificate binding it to this node's name inside the
mesh — so there is nothing to seal, and a copy of what the mesh holds
certifies nothing it did not already certify.

It does not make one on demand, for the same reason the sealing key 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.
2026-08-31 00:09:15 +02:00

99 lines
3.8 KiB
Go

package identity
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"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.
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
}
private, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
if err != nil || len(private) != ed25519.PrivateKeySize {
return ServingKey{}, fmt.Errorf("%s is not a serving key", path)
}
key := ed25519.PrivateKey(private)
return ServingKey{
Public: base64.StdEncoding.EncodeToString(key.Public().(ed25519.PublicKey)),
Private: base64.StdEncoding.EncodeToString(private),
}, nil
}
// WriteServingKey puts the private half where configuration can point at it.
func WriteServingKey(path string, key ServingKey) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
return os.WriteFile(path, []byte(key.Private+"\n"), 0o600)
}