mesh/merge-gate head 5a7ec3775b98 merged into trunk 53fd32618916 beside mesh-controller#346@c711e622 — pass: builds mesh-host → ace, g14, novox, shanks; no…
mesh/repo-check head 5a7ec3775b98 merged into trunk 53fd32618916 beside mesh-controller#346@c711e622 — pass: [release feat/57-config-and-provisioning, judg…
mesh/delivery stopped: its walk plan-1792114347281764422 was stopped by a person (mesh-controller): mesh-host#114 merged into the release branch feat/57-…
mesh/delivery-group group 57-config-and-provisioning closed: a member was closed before the group merged
The node-engine renders the feature's templates, collections and moved providers' files, on the SDK the feature's modules build on (feature 57, config and provisioning).
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)
|
|
}
|