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
+51
View File
@@ -14,6 +14,7 @@ import (
"sort"
"strconv"
"strings"
"time"
)
// SettingsBy is the layers that apply to each module, keyed by module name.
@@ -89,6 +90,10 @@ type Rendering struct {
// a fact about the mesh, and resolution answers questions about one machine.
Mesh []string
// Kept is every operator-sealed secret in the mesh, for a module that `keeps` them. Nil when
// nothing on this node keeps them, or the mesh has no operator key.
Kept *KeptExport
// Foundation is the ports the mesh itself needs reachable on every machine, which no module
// declares because the foundation is not a module (novox/hq 04-ISSUES/051 and 052). The broker
// is the one that matters: a machine dials it to enrol, and a firewall derived only from
@@ -384,6 +389,13 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
}
first = append(first, file)
}
if m.Keeps != "" && with.Kept != nil {
file, err := keptFile(m.Keeps, with.Kept)
if err != nil {
return nil, err
}
first = append(first, file)
}
if m.Computed != "" {
generator, known := with.Generators[m.Computed]
if !known {
@@ -725,6 +737,45 @@ func receivedFile(requirement, path string, given []Contribution) (map[string]an
}, nil
}
// Kept is one secret as the operator can recover it: where it belongs, and the value sealed to the
// operator's key. Never a node's blob, and never a value.
type Kept struct {
Node string `json:"node"`
Module string `json:"module"`
Name string `json:"name"`
// Origin is `made` or `accepted` — whether the mesh minted it or a person supplied it.
Origin string `json:"origin"`
// Sealed is the value, sealed to the operator key named in Key.
Sealed string `json:"sealed"`
Key string `json:"key"`
MadeAt time.Time `json:"made-at"`
}
// KeptExport is what a person keeps beside the operator key, and what a vault keeps on its disk:
// every operator-sealed copy, and the honest list of what has none.
type KeptExport struct {
Export int `json:"export"`
OperatorKey string `json:"operator-key"`
Fingerprint string `json:"fingerprint"`
Kept []Kept `json:"kept"`
Unrecoverable []Kept `json:"unrecoverable,omitempty"`
}
// KeptID is the resource that carries the export to a module that keeps it.
func KeptID() string { return "kept" }
// keptFile is the export, written where the module said. Ciphertext throughout — see Keeps.
func keptFile(dir string, kept *KeptExport) (map[string]any, error) {
body, err := json.MarshalIndent(kept, "", " ")
if err != nil {
return nil, err
}
return map[string]any{
"id": KeptID(), "type": "file", "path": strings.TrimRight(dir, "/") + "/export.json",
"mode": "0600", "content": string(body) + "\n",
}, nil
}
// sortedKeys is map iteration made repeatable, which everything written to a machine needs.
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
+68
View File
@@ -0,0 +1,68 @@
package catalogue
import (
"strings"
"testing"
)
// A module that keeps the operator-sealed secrets is handed the export as a file, at 0600, and a
// module that does not keep them is handed nothing — the export goes to the vault and nowhere else.
func TestOnlyAModuleThatKeepsGetsTheExport(t *testing.T) {
vault := Manifest{Module: "mesh-vault", Version: "1", Provides: FromAnywhere("secret"),
Keeps: "/var/lib/mesh-vault/root"}
other := Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")}
got, err := Resolve(shelf(vault, other), []string{"mesh-vault", "zsh"},
Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, World{})
if err != nil {
t.Fatal(err)
}
kept := &KeptExport{Export: 1, OperatorKey: "OPERATOR", Fingerprint: "sha256:abcd",
Kept: []Kept{{Node: "anchor", Module: "postgres", Name: "superuser", Origin: "accepted", Sealed: "CIPHERTEXT", Key: "OPERATOR"}}}
out, err := got.Declaration(Rendering{Kept: kept})
if err != nil {
t.Fatal(err)
}
var files int
for _, r := range out {
if r["path"] != "/var/lib/mesh-vault/root/export.json" {
continue
}
files++
if r["mode"] != "0600" {
t.Errorf("the export is written at mode %v, and it is the mesh's root secrets, sealed or not", r["mode"])
}
content, _ := r["content"].(string)
for _, want := range []string{`"operator-key": "OPERATOR"`, `"sealed": "CIPHERTEXT"`, `"module": "postgres"`} {
if !strings.Contains(content, want) {
t.Errorf("the export lacks %s:\n%s", want, content)
}
}
if id, _ := r["id"].(string); !strings.Contains(id, "mesh-vault") {
t.Errorf("the export's resource id %q does not carry its module", id)
}
}
if files != 1 {
t.Fatalf("%d export files; one module keeps them", files)
}
// No operator key yet: the vault is declared without the file, not with an empty one.
out, err = got.Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
for _, r := range out {
if r["path"] == "/var/lib/mesh-vault/root/export.json" {
t.Fatal("an export was written with nothing to export")
}
}
}
func TestKeepsMustBeAnAbsolutePath(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"root"}`))
if err == nil || !strings.Contains(err.Error(), "keeps") {
t.Fatalf("a relative keeps path was not refused: %v", err)
}
if _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"/var/lib/mesh-vault/root"}`)); err != nil {
t.Fatalf("an absolute keeps path was refused: %v", err)
}
}
+15
View File
@@ -297,6 +297,17 @@ type Manifest struct {
// module, in a file anybody can read, for ever.
OwnSecrets map[string]string `json:"own-secrets,omitempty"`
// Keeps is where this module wants every operator-sealed secret in the mesh written — the
// vault's field, and so far nobody else's (novox/hq ADR 0085, amended).
//
// A directory. The mesh writes one file into it, `export.json`: every secret a module holds for
// itself, sealed to the operator's key, with the key's public half and the list of what is NOT
// in it. Ciphertext to the machine that holds it and to everything on the bus it crossed —
// only the operator, holding the private half off the mesh, can open a line of it. That is
// what lets a vault's disk stand in for the store when the store is gone: recovery needs the
// export and the key, and the mesh holds neither in a form it can use.
Keeps string `json:"keeps,omitempty"`
// Listens is what this module accepts connections on, and from where.
//
// **A rule names its source** ([ADR 0007](novox/hq)). A port with no source is open to
@@ -918,6 +929,10 @@ func ParseManifest(raw []byte) (Manifest, error) {
"%s grants %q to its consumers and does not provide it", m.Module, to))
}
}
if m.Keeps != "" && !strings.HasPrefix(m.Keeps, "/") {
problems = append(problems, fmt.Sprintf(
"%s keeps the operator-sealed secrets at %q, which is not an absolute path", m.Module, m.Keeps))
}
for to, where := range m.Receives {
if !name.MatchString(to) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to receive", to))