Unify trunk on main: initialization → main #3

Merged
jschoubben merged 58 commits from initialization into main 2026-09-05 01:13:33 +00:00
4 changed files with 124 additions and 3 deletions
Showing only changes of commit 5237944473 - Show all commits
+13 -1
View File
@@ -472,6 +472,15 @@ func enrol(ctx context.Context, opts options) error {
}
fmt.Printf("generated this node's sealing key: %s\n", sealing.Public)
// And the key it serves TLS with on its name inside the mesh. Generated here for the same
// reason as the others: the private half must never have been anywhere else, and the mesh
// only ever certifies the public one.
serving, err := identity.GenerateServingKey()
if err != nil {
return err
}
fmt.Printf("generated this node's serving key: %s\n", serving.Public)
// What this machine can be asked to do, gathered before joining rather than after. The
// control plane cannot decide what a node should run without it, so it travels with the
// request instead of being asked for in a second round trip.
@@ -482,7 +491,7 @@ func enrol(ctx context.Context, opts options) error {
}
reply, err := link.Enrol(ctx, token.Broker, token.Fingerprint, *name, token.Secret,
mine.Public, mine.Overlay.Public, sealing.Public, reported, opts.timeout)
mine.Public, mine.Overlay.Public, sealing.Public, serving.Public, reported, opts.timeout)
if err != nil {
return err
}
@@ -531,6 +540,9 @@ func enrol(ctx context.Context, opts options) error {
[]byte(sealing.Private+"\n"), 0o600); err != nil {
return fmt.Errorf("cannot write this node's sealing key: %w", err)
}
if err := identity.WriteServingKey(identity.ServingKeyPath(opts.state), serving); err != nil {
return fmt.Errorf("cannot write this node's serving key: %w", err)
}
fmt.Printf("\nenrolled as %s\n", reply.Node)
fmt.Printf(" identity %s\n", identityPath)
+98
View File
@@ -0,0 +1,98 @@
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)
}
+8 -2
View File
@@ -40,6 +40,11 @@ type EnrolRequest struct {
// nothing else can read, and it must never be able to read it either.
SealingKey string `json:"sealing_key,omitempty"`
// ServingKey is the public half of the key this node serves TLS with on its internal name.
// The mesh signs a certificate binding it; the private half never leaves the machine, so
// there is nothing to seal and nothing that could be stolen from the mesh's copy.
ServingKey string `json:"serving_key,omitempty"`
Profile map[string]any `json:"profile,omitempty"`
}
@@ -70,7 +75,8 @@ var ErrRefused = errors.New("the mesh refused this enrolment")
// says once it is in, and the secret travels again because the control plane must not have to ask
// the broker who connected.
func Enrol(ctx context.Context, address, pin, node, secret string, public []byte,
overlayKey, sealingKey string, profile map[string]any, timeout time.Duration) (EnrolReply, error) {
overlayKey, sealingKey, servingKey string, profile map[string]any,
timeout time.Duration) (EnrolReply, error) {
config, err := PinnedConfig(pin)
if err != nil {
@@ -116,7 +122,7 @@ func Enrol(ctx context.Context, address, pin, node, secret string, public []byte
}
request := EnrolRequest{Node: node, Secret: secret, PublicKey: public,
OverlayKey: overlayKey, SealingKey: sealingKey, Profile: profile}
OverlayKey: overlayKey, SealingKey: sealingKey, ServingKey: servingKey, Profile: profile}
body, err := json.Marshal(request)
if err != nil {
return EnrolReply{}, err
+5
View File
@@ -37,6 +37,10 @@ func TestWhatThisNodeSaysWhenItJoins(t *testing.T) {
if err != nil {
t.Fatal(err)
}
serving, err := identity.GenerateServingKey()
if err != nil {
t.Fatal(err)
}
request := EnrolRequest{
Node: "workstation",
@@ -44,6 +48,7 @@ func TestWhatThisNodeSaysWhenItJoins(t *testing.T) {
PublicKey: mine.Public,
OverlayKey: overlay.Public,
SealingKey: sealing.Public,
ServingKey: serving.Public,
Profile: map[string]any{"seat": true},
}
body, err := json.MarshalIndent(request, "", " ")