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) }