An operator key, a second seal on every own secret, and the vault keeps the export

novox/hq ADR 0085, amended: the mesh's root secrets — the store's superuser,
the broker's administrator, every secret a module holds for itself — were
sealed to a node key and nothing else, so a lost node took them with it.
Now the mesh records an operator's public sealing key and seals every own
secret to it as well, minted or accepted. The private half is written once
by `operator key new` to a file the operator keeps off the mesh; the mesh
holds one more blob per secret that it cannot open.

`secret recover` opens a secret with that key, to a 0600 file, from the
store or from an export; `secret export` writes every operator-sealed copy
as ciphertext. A module that `keeps` (the vault) is handed that export as a
declared file on its own disk, so recovery survives the store.

Secrets made before the key exists have no operator copy and are said so —
the plaintext was discarded — until each is issued again.
This commit is contained in:
2026-09-20 23:56:49 +02:00
parent 1c2e94e1a0
commit e140ed5d0b
13 changed files with 906 additions and 20 deletions
+72
View File
@@ -0,0 +1,72 @@
package secrets
import "testing"
// A secret sealed to the operator as well is opened by the operator's key and by nothing else.
func TestAThirdRecipientOpensWithItsOwnKeyOnly(t *testing.T) {
nodePub, nodePriv, err := Keypair()
if err != nil {
t.Fatal(err)
}
opPub, opPriv, err := Keypair()
if err != nil {
t.Fatal(err)
}
sealed, more, err := MakeAlso(nodePub, nodePub, opPub)
if err != nil {
t.Fatal(err)
}
if len(more) != 1 {
t.Fatalf("%d extra blobs for one extra key", len(more))
}
fromNode, err := Open(nodePriv, sealed.ForConsumer)
if err != nil {
t.Fatal(err)
}
fromOperator, err := Open(opPriv, more[0])
if err != nil {
t.Fatal(err)
}
if string(fromNode) != string(fromOperator) {
t.Fatal("the operator's copy is a different value from the node's")
}
if len(fromNode) != 40 {
t.Fatalf("a minted value is %d characters, not 40", len(fromNode))
}
if _, err := Open(nodePriv, more[0]); err == nil {
t.Fatal("the node's key opened the operator's blob")
}
if _, err := Open(opPriv, sealed.ForConsumer); err == nil {
t.Fatal("the operator's key opened the node's blob")
}
}
// An accepted value, sealed to the operator, comes back byte for byte.
func TestAnAcceptedValueRoundTripsThroughTheOperatorKey(t *testing.T) {
opPub, opPriv, err := Keypair()
if err != nil {
t.Fatal(err)
}
blob, err := Seal(opPub, []byte(" the-superuser's password, spaces and all "))
if err != nil {
t.Fatal(err)
}
got, err := Open(opPriv, blob)
if err != nil {
t.Fatal(err)
}
if string(got) != " the-superuser's password, spaces and all " {
t.Fatalf("got %q", got)
}
}
func TestAFingerprintNamesAKeyAndIsNotOne(t *testing.T) {
pub, _, _ := Keypair()
fp := Fingerprint(pub)
if len(fp) != len("sha256:")+16 || fp[:7] != "sha256:" {
t.Fatalf("fingerprint %q", fp)
}
if fp == Fingerprint(pub+"x") {
t.Fatal("two keys, one fingerprint")
}
}
+74 -5
View File
@@ -1,8 +1,11 @@
package secrets
import (
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
@@ -48,10 +51,22 @@ type Sealed struct {
// rather than reading the old one back — the only version of rotation that is honest about what
// the mesh knows.
func Make(consumerKey, providerKey string) (Sealed, error) {
sealed, _, err := MakeAlso(consumerKey, providerKey)
return sealed, err
}
// MakeAlso is Make with further recipients: the same fresh value, sealed once more to each key in
// `also`, returned in that order.
//
// **For the operator key, and nothing else so far** (novox/hq ADR 0085, amended). A secret a module
// holds for itself is sealed to its node and, when the mesh has an operator key, to that as well —
// so a person holding the key can recover it when the node cannot. The plaintext still exists only
// inside this call; a third blob is one more thing the mesh cannot open, not one more copy it can.
func MakeAlso(consumerKey, providerKey string, also ...string) (Sealed, []string, error) {
if consumerKey == "" || providerKey == "" {
// Sealing to an empty key would produce a blob nobody can open, stored as though it were
// a working credential. The caller knows which node is which and says so.
return Sealed{}, fmt.Errorf("both ends need a sealing key before a secret can be made")
return Sealed{}, nil, fmt.Errorf("both ends need a sealing key before a secret can be made")
}
// 30 bytes, not 32: base64url of 30 is exactly 40 characters, and 40 is the longest secret an
@@ -59,7 +74,7 @@ func Make(consumerKey, providerKey string) (Sealed, error) {
// "fit the tightest backend" rule ADR 0049 sets for the login, on the secret. 240 bits is ample.
value := make([]byte, 30)
if _, err := rand.Read(value); err != nil {
return Sealed{}, err
return Sealed{}, nil, err
}
// Base64 without padding, because it lands in a configuration file something else parses and
// a password containing a newline or a quote is a support call.
@@ -67,16 +82,24 @@ func Make(consumerKey, providerKey string) (Sealed, error) {
forConsumer, err := Seal(consumerKey, []byte(password))
if err != nil {
return Sealed{}, err
return Sealed{}, nil, err
}
forProvider, err := Seal(providerKey, []byte(password))
if err != nil {
return Sealed{}, err
return Sealed{}, nil, err
}
more := make([]string, 0, len(also))
for _, key := range also {
blob, err := Seal(key, []byte(password))
if err != nil {
return Sealed{}, nil, err
}
more = append(more, blob)
}
return Sealed{
ForConsumer: forConsumer, ForProvider: forProvider,
ConsumerKey: consumerKey, ProviderKey: providerKey,
}, nil
}, more, nil
}
// Accept seals a value somebody supplied, rather than one the mesh made.
@@ -115,6 +138,52 @@ func Accept(value string, consumerKey, providerKey string) (Sealed, error) {
}, nil
}
// Open is the other half of Seal, for the one holder of a private key this program ever acts for:
// the operator, recovering a secret with the key that never entered the mesh (novox/hq ADR 0085,
// amended). A node opens its own blobs in the host; the controller opens nothing of a node's, and
// cannot — it has no node's private key, which is the whole point of sealing.
func Open(privateKey string, sealed string) ([]byte, error) {
private, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privateKey))
if err != nil || len(private) != 32 {
return nil, fmt.Errorf("that is not a sealing key")
}
key, err := ecdh.X25519().NewPrivateKey(private)
if err != nil {
return nil, fmt.Errorf("that is not a usable sealing key: %w", err)
}
blob, err := base64.StdEncoding.DecodeString(sealed)
if err != nil {
return nil, fmt.Errorf("this is not a sealed value: %w", err)
}
var pub, priv [32]byte
copy(pub[:], key.PublicKey().Bytes())
copy(priv[:], private)
out, ok := box.OpenAnonymous(nil, blob, &pub, &priv)
if !ok {
return nil, fmt.Errorf("this was not sealed to that key")
}
return out, nil
}
// Keypair makes a sealing keypair for a holder outside the mesh — the operator. The private half is
// returned to be written where the caller says and nowhere else; the public half is what the mesh
// records and seals to.
func Keypair() (public, private string, err error) {
key, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return "", "", err
}
return base64.StdEncoding.EncodeToString(key.PublicKey().Bytes()),
base64.StdEncoding.EncodeToString(key.Bytes()), nil
}
// Fingerprint names a public key without being one: the first bytes of its hash, so two people can
// agree which key they mean out loud.
func Fingerprint(publicKey string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(publicKey)))
return "sha256:" + hex.EncodeToString(sum[:8])
}
// Seal closes a value to a node's public sealing key.
func Seal(publicKey string, value []byte) (string, error) {
public, err := base64.StdEncoding.DecodeString(publicKey)