diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 6eadb33..2eb9eac 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -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) diff --git a/internal/identity/serving.go b/internal/identity/serving.go new file mode 100644 index 0000000..1e0a2b4 --- /dev/null +++ b/internal/identity/serving.go @@ -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) +} diff --git a/internal/link/enrol.go b/internal/link/enrol.go index 35a1d21..5999395 100644 --- a/internal/link/enrol.go +++ b/internal/link/enrol.go @@ -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 diff --git a/internal/link/enrol_shape_test.go b/internal/link/enrol_shape_test.go index 3100a4a..7b53f95 100644 --- a/internal/link/enrol_shape_test.go +++ b/internal/link/enrol_shape_test.go @@ -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, "", " ")