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
208 lines
8.3 KiB
Go
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)
|
|
}
|