package identity import ( "crypto/ecdh" "crypto/rand" "encoding/base64" "fmt" "os" "strings" "golang.org/x/crypto/nacl/box" ) // The key a secret is sealed to, so the mesh can carry one without ever holding a usable copy. // // **The fault this exists to avoid is documented, in another mesh, in its own tooling.** There, // credentials live in the control plane's database, encrypted at rest — which protects against // somebody reading the database file and nothing else. The same secret is also in each node's // environment file in plain text, and, worse, inside every connection string composed from it, so // the tool for finding copies has to search *by value* rather than by name. Its own documentation // says the copies inside composed URLs "are often the only copies actually in use". Encryption at // rest also cost the ability to audit: a query against the encrypted column returns zero rows and // proves nothing. // // So the arrangement here is the other one. **The node generates this key and the mesh only ever // sees the public half**, exactly as with the identity and overlay keys // (novox/hq ADR 0004). A secret is sealed to that public half before it is stored, so: // // - the control plane's database holds nothing usable, and a copy of it grants nothing // - the broker relays a blob it cannot read, which is the point of not trusting it // - *compromise of a node is compromise of that node* becomes true of secrets too, rather // than being true of identity and quietly false of everything that matters // // A third key rather than reusing one of the two that exist. The identity key signs and is // Ed25519; the overlay key is WireGuard's and is tied to being on the private network, which a // machine may not be. A key used for two purposes is one rotation away from breaking the other. // SealingKey is an X25519 keypair used only for receiving secrets. type SealingKey struct { // Public is what the mesh records. Public string `json:"public"` // Private never leaves this machine. Private string `json:"private"` } // GenerateSealingKey makes this node's key for receiving secrets. func GenerateSealingKey() (SealingKey, error) { private, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { return SealingKey{}, fmt.Errorf("cannot generate this node's sealing key: %w", err) } return SealingKey{ Public: base64.StdEncoding.EncodeToString(private.PublicKey().Bytes()), Private: base64.StdEncoding.EncodeToString(private.Bytes()), }, nil } // SealingKeyPath is where the private half lives. func SealingKeyPath(statePath string) string { return dirOf(statePath) + "/sealing.key" } // LoadSealingKey reads this node's sealing key. // // It does not make one. A key the mesh has never been told about is a key nothing can be sealed // to, so creating one here would produce a node that silently cannot receive any secret and looks // fine — the key is generated at enrolment, where its public half is reported in the same breath. func LoadSealingKey(path string) (SealingKey, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return SealingKey{}, fmt.Errorf( "this node has no sealing key at %s, so nothing can be sealed to it — it is made "+ "at enrolment, and a node that joined before secrets existed must join again", path) } return SealingKey{}, err } { private, decodeErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw))) if decodeErr != nil || len(private) != 32 { return SealingKey{}, fmt.Errorf( "%s is not a sealing key; move it aside to have a new one made", path) } key, keyErr := ecdh.X25519().NewPrivateKey(private) if keyErr != nil { return SealingKey{}, fmt.Errorf("%s is not a usable sealing key: %w", path, keyErr) } return SealingKey{ Public: base64.StdEncoding.EncodeToString(key.PublicKey().Bytes()), Private: base64.StdEncoding.EncodeToString(key.Bytes()), }, nil } } // Unseal opens something the mesh sealed to this node. // // Anonymous sealed boxes: the sender is not authenticated here, and does not need to be. What a // node applies is bounded by the declaration's signature, which is checked before any of this — // so a blob that arrives in a verified declaration came from the mesh, and this only has to // answer whether it was meant for this machine. func (s SealingKey) Unseal(sealed string) ([]byte, error) { blob, err := base64.StdEncoding.DecodeString(sealed) if err != nil { return nil, fmt.Errorf("this is not a sealed value: %w", err) } private, err := base64.StdEncoding.DecodeString(s.Private) if err != nil || len(private) != 32 { return nil, fmt.Errorf("this node's sealing key is unusable") } public, err := base64.StdEncoding.DecodeString(s.Public) if err != nil || len(public) != 32 { return nil, fmt.Errorf("this node's sealing key is unusable") } var pub, priv [32]byte copy(pub[:], public) copy(priv[:], private) out, ok := box.OpenAnonymous(nil, blob, &pub, &priv) if !ok { // Sealed to a different node, or to a key this one no longer has. Said as one thing // because from here they are indistinguishable, and both mean the same: this machine // cannot read it and applying it would write a file of rubbish. return nil, fmt.Errorf("this was not sealed to this node's current key") } return out, nil } // Seal closes a value to a node's public sealing key. Here so that a test can produce what the // mesh produces, rather than asserting against a blob nobody can regenerate. func Seal(publicKey string, value []byte) (string, error) { public, err := base64.StdEncoding.DecodeString(publicKey) if err != nil || len(public) != 32 { return "", fmt.Errorf("%q is not a sealing key", publicKey) } var pub [32]byte copy(pub[:], public) sealed, err := box.SealAnonymous(nil, value, &pub, rand.Reader) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(sealed), nil }