Files
mesh-host/internal/identity/sealing.go
T
mesh-tools bff1ac14a1
mesh/merge-gate head bff1ac14a198 merged into trunk 49c9002c04d3 beside mesh-controller#355@8c381172 — pass: builds mesh-host → ace, g14, novox, shanks; no…
mesh/repo-check head bff1ac14a198 merged into trunk 49c9002c04d3 beside mesh-controller#355@8c381172 — pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group release/57-config-and-provisioning delivered: every member is delivered
Feature 57 (config and provisioning): release mesh-host, the reviewed release branch on top of main
2026-10-16 08:24:13 +00:00

208 lines
8.3 KiB
Go

package identity
import (
"crypto/ecdh"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"strings"
"git.novox.be/novox/mesh-sdk/go/seal"
"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
}
// **The vault's box, opened with the same key** (novox/hq ADR 0349 §16, ADR 0206, issue 755). The core's own
// credentials travel in the declaration as values the vault sealed to this machine, which the controller carries and
// cannot open. The vault seals in the mesh's sealed box (mesh-sdk go/seal), whose keys travel as PEM: the public half
// as SPKI, the private as PKCS #8. They are this key's two halves in another wrapping, never another key.
// PublicPEM is this machine's sealing key's public half as SPKI PEM, the form the vault seals to.
func (s SealingKey) PublicPEM() (string, error) {
raw, err := base64.StdEncoding.DecodeString(s.Public)
if err != nil || len(raw) != 32 {
return "", fmt.Errorf("this node's sealing key is unusable")
}
pub, err := ecdh.X25519().NewPublicKey(raw)
if err != nil {
return "", fmt.Errorf("this node's sealing key is unusable: %w", err)
}
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", err
}
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil
}
// OpenVaultBox opens a value the vault sealed to this machine. It fails for a box sealed to another key, or changed.
func (s SealingKey) OpenVaultBox(b seal.Box) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(s.Private)
if err != nil || len(raw) != 32 {
return nil, fmt.Errorf("this node's sealing key is unusable")
}
priv, err := ecdh.X25519().NewPrivateKey(raw)
if err != nil {
return nil, fmt.Errorf("this node's sealing key is unusable: %w", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
opened, err := seal.Open(b, string(pem.EncodeToMemory(block)))
for i := range block.Bytes {
block.Bytes[i] = 0
}
if err != nil {
return nil, fmt.Errorf("this was not sealed by the vault to this node's current key")
}
return []byte(opened), nil
}
// Open opens anything the mesh sealed to this machine: the vault's box, given as its JSON (it begins with `{`), or
// the controller's sealed value, base64 (which never does).
func (s SealingKey) Open(sealed string) ([]byte, error) {
if strings.HasPrefix(strings.TrimSpace(sealed), "{") {
var b seal.Box
if err := json.Unmarshal([]byte(sealed), &b); err != nil {
return nil, fmt.Errorf("this is not the vault's sealed box: %w", err)
}
return s.OpenVaultBox(b)
}
return s.Unseal(sealed)
}