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.
This commit is contained in:
+13
-1
@@ -472,6 +472,15 @@ func enrol(ctx context.Context, opts options) error {
|
|||||||
}
|
}
|
||||||
fmt.Printf("generated this node's sealing key: %s\n", sealing.Public)
|
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
|
// 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
|
// 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.
|
// 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,
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -531,6 +540,9 @@ func enrol(ctx context.Context, opts options) error {
|
|||||||
[]byte(sealing.Private+"\n"), 0o600); err != nil {
|
[]byte(sealing.Private+"\n"), 0o600); err != nil {
|
||||||
return fmt.Errorf("cannot write this node's sealing key: %w", err)
|
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("\nenrolled as %s\n", reply.Node)
|
||||||
fmt.Printf(" identity %s\n", identityPath)
|
fmt.Printf(" identity %s\n", identityPath)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -40,6 +40,11 @@ type EnrolRequest struct {
|
|||||||
// nothing else can read, and it must never be able to read it either.
|
// nothing else can read, and it must never be able to read it either.
|
||||||
SealingKey string `json:"sealing_key,omitempty"`
|
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"`
|
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
|
// says once it is in, and the secret travels again because the control plane must not have to ask
|
||||||
// the broker who connected.
|
// the broker who connected.
|
||||||
func Enrol(ctx context.Context, address, pin, node, secret string, public []byte,
|
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)
|
config, err := PinnedConfig(pin)
|
||||||
if err != nil {
|
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,
|
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)
|
body, err := json.Marshal(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return EnrolReply{}, err
|
return EnrolReply{}, err
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ func TestWhatThisNodeSaysWhenItJoins(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
serving, err := identity.GenerateServingKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
request := EnrolRequest{
|
request := EnrolRequest{
|
||||||
Node: "workstation",
|
Node: "workstation",
|
||||||
@@ -44,6 +48,7 @@ func TestWhatThisNodeSaysWhenItJoins(t *testing.T) {
|
|||||||
PublicKey: mine.Public,
|
PublicKey: mine.Public,
|
||||||
OverlayKey: overlay.Public,
|
OverlayKey: overlay.Public,
|
||||||
SealingKey: sealing.Public,
|
SealingKey: sealing.Public,
|
||||||
|
ServingKey: serving.Public,
|
||||||
Profile: map[string]any{"seat": true},
|
Profile: map[string]any{"seat": true},
|
||||||
}
|
}
|
||||||
body, err := json.MarshalIndent(request, "", " ")
|
body, err := json.MarshalIndent(request, "", " ")
|
||||||
|
|||||||
Reference in New Issue
Block a user