package main import ( "context" "crypto/ecdh" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" "sort" "strings" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // The core's own credentials, sealed by the vault (novox/hq ADR 0349 §16, issue 755; the lead's decision on issue 755, // notes 4 and 5). // // For a machine whose engine opens the vault's box (link.VaultBoxContract), each own secret of a module on the core's // list (catalogue.CoreCredentialModules) travels as the vault sealed it to that machine's key, which the controller // asks for on the send path (`get`, answered to the controller alone and sealed only to the key that machine's engine // stated: mesh-vault's core.go) and carries without being able to open it. A machine below that contract is sent the // controller's own sealed value, exactly as before. // // **The vault seals every answer afresh**, so the box last got is kept (inventory.CoreBox) and carried again while the // vault's generation and the machine's key are the same: a declaration that changed with every answer would never be // current. Every question but a send reads the kept box and asks nothing. // // **When the vault does not answer, the controller never falls back to its own seal.** A credential with a kept box // keeps it — the machine keeps the file it has — and one with none leaves its module out of the declaration (the // machine keeps what it holds of it, and a module not there yet is not placed). Either way the send raises a // condition naming the vault and the machine, cleared by the first send the vault answers whole. // engineOpensVaultBox says whether a machine's node-engine opens the vault's box, by its own newest statement. func engineOpensVaultBox(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) { stated, had, err := inv.HealthOf(ctx, node) if err != nil { return false, err } return had && stated.Contract >= link.VaultBoxContract, nil } // coreAnswer is the vault's `get` answer as the controller reads it: the name, the generation and the box, never // opened. type coreAnswer struct { Name string `json:"name"` Generation int `json:"generation"` Sealed json.RawMessage `json:"sealed"` } // coreAsk asks the vault for one secret sealed to the given key. type coreAsk func(name, publicKey string) (coreAnswer, error) // coreBoxes is what the vault's boxes make of one machine's composition. type coreBoxes struct { // sealed is each core credential's box, by module and name (catalogue.Rendering.VaultSealed). sealed map[string]map[string]json.RawMessage // notPlaced is each core module left out, with why (catalogue.Rendering.NotPlaced). notPlaced map[string]string // keep is each box newly answered, to keep. keep []inventory.CoreBox // held is each credential the vault did not answer for whose kept box is carried again; silent is why the vault // did not answer, per credential. held, silent []string } // coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, // with their plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the // client, and one issued outside the mesh is never the vault's to seal here. func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string { out := map[string][]string{} for _, m := range modules { if !catalogue.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) { continue } var names []string for name, own := range m.OwnSecrets.Plain() { if own.IssuedBy != catalogue.IssuedOutside { names = append(names, name) } } if len(names) > 0 { sort.Strings(names) out[m.Module] = names } } return out } // sealingKeyPEM is a machine's sealing key, recorded raw in base64, as the SPKI PEM the vault seals to. func sealingKeyPEM(raw string) (string, error) { b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw)) if err != nil || len(b) != 32 { return "", errors.New("the machine's sealing key is not a raw X25519 key") } pub, err := ecdh.X25519().NewPublicKey(b) if err != nil { return "", err } der, err := x509.MarshalPKIXPublicKey(pub) if err != nil { return "", err } return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil } // coreBoxesOf decides each core credential of a machine: on a send, the vault's answer (the kept box while its // generation and key are the same), the kept box where the vault did not answer, or the module left out where nothing // was kept; on any other question, the kept box, or a stand-in the next send asks for. Pure: what to keep and what to // say is answered, not done. func coreBoxesOf(node, sealingKey string, core map[string][]string, kept map[string]map[string]inventory.CoreBox, choosing Choosing, ask coreAsk) coreBoxes { out := coreBoxes{sealed: map[string]map[string]json.RawMessage{}, notPlaced: map[string]string{}} modules := make([]string, 0, len(core)) for m := range core { modules = append(modules, m) } sort.Strings(modules) for _, module := range modules { if sealingKey == "" { out.notPlaced[module] = fmt.Sprintf("%s has no sealing key, so the vault can seal %s's credentials to "+ "nothing there (novox/hq ADR 0349 §16)", node, module) continue } key, err := sealingKeyPEM(sealingKey) if err != nil { out.notPlaced[module] = fmt.Sprintf("%s's credentials cannot be asked of the vault: %v", module, err) continue } boxes := map[string]json.RawMessage{} for _, name := range core[module] { k, has := kept[module][name] valid := has && k.SealedTo == sealingKey && len(k.Box) > 0 if choosing != Allocating { if valid { boxes[name] = k.Box } else { boxes[name] = catalogue.ForeseenVaultBox } continue } secret := "own/" + node + "/" + module + "/" + name got, err := ask(secret, key) if err == nil && (got.Generation <= 0 || canonicalBox(got.Sealed) == nil) { err = errors.New("it answered no box") } if err != nil { out.silent = append(out.silent, fmt.Sprintf("%s: %v", secret, err)) if valid { boxes[name] = k.Box out.held = append(out.held, module+"/"+name) continue } out.notPlaced[module] = fmt.Sprintf("the vault did not give %s's credential %s for %s (%v), and the "+ "controller never seals it itself (novox/hq ADR 0349 §16): not placed until the vault answers", module, name, node, err) break } if valid && k.Generation == got.Generation { boxes[name] = k.Box continue } box := canonicalBox(got.Sealed) boxes[name] = box out.keep = append(out.keep, inventory.CoreBox{Module: module, Name: name, Generation: got.Generation, SealedTo: sealingKey, Box: box}) } if _, left := out.notPlaced[module]; !left { out.sealed[module] = boxes } } return out } // canonicalBox is the vault's box in the one form the declaration carries — the sealed box's fields, in their order, // and nothing else — or nil when it is not one. func canonicalBox(raw json.RawMessage) json.RawMessage { var b struct { V int `json:"v"` Eph string `json:"eph"` IV string `json:"iv"` Tag string `json:"tag"` Ct string `json:"ct"` } if err := json.Unmarshal(raw, &b); err != nil || b.V <= 0 || b.Eph == "" || b.Ct == "" { return nil } out, _ := json.Marshal(b) return out } // coreVault is how a send asks the vault for a core credential: its `get`, in the controller's name. A test stands // in for it. var coreVault = func(ctx context.Context) coreAsk { vault := theVault(ctx) return func(name, publicKey string) (coreAnswer, error) { raw, err := vault("get", map[string]any{"name": name, "public_key": publicKey}) if err != nil { return coreAnswer{}, err } var a coreAnswer if err := json.Unmarshal(raw, &a); err != nil { return coreAnswer{}, fmt.Errorf("the vault's answer is not one: %w", err) } return a, nil } } // coreBoxesFor is coreBoxesOf for one machine, read from and kept in the store, and said on a send. func coreBoxesFor(ctx context.Context, inv *inventory.Inventory, node string, modules []catalogue.Manifest, handed func(catalogue.Manifest) bool, choosing Choosing) (coreBoxes, error) { core := coreModulesOf(modules, handed) if len(core) == 0 { if choosing == Allocating { settleVaultSilent(ctx, node) } return coreBoxes{}, nil } key, err := inv.SealingKeyOf(ctx, node) if err != nil { return coreBoxes{}, err } kept, err := inv.KeptCoreBoxes(ctx, node) if err != nil { return coreBoxes{}, err } var ask coreAsk if choosing == Allocating { ask = coreVault(ctx) } out := coreBoxesOf(node, key, core, kept, choosing, ask) for _, b := range out.keep { if err := inv.KeepCoreBox(ctx, node, b); err != nil { return coreBoxes{}, err } } if choosing == Allocating { sayVaultSilent(ctx, node, out) } return out, nil } // kindVaultSilent is a send that did not have the vault's box for a core credential of a machine. const kindVaultSilent = "vault-silent" // vaultSilentObservation is that, as a condition: `machine..vault-silent`. func vaultSilentObservation(node string, b coreBoxes) conditions.Observation { o := conditions.Observation{Scope: conditions.ScopeMachine, ID: node, Kind: kindVaultSilent, Token: kindVaultSilent, Machine: node, Severity: conditions.Warning, Source: coreDowngradeSource} var said []string if len(b.notPlaced) > 0 { o.Severity = conditions.Urgent var left []string for m := range b.notPlaced { left = append(left, m) } sort.Strings(left) said = append(said, "not placed: "+strings.Join(left, ", ")) } if len(b.held) > 0 { said = append(said, "kept as last sent: "+strings.Join(b.held, ", ")) } o.Summary = fmt.Sprintf("the vault did not answer for the core's credentials on %s; %s", node, strings.Join(said, "; ")) o.Said = o.Summary + " — " + strings.Join(b.silent, "; ") return o } // sayVaultSilent raises the condition for a send the vault did not answer whole, and clears it for one it did. func sayVaultSilent(ctx context.Context, node string, b coreBoxes) { if len(b.silent) == 0 && len(b.notPlaced) == 0 { settleVaultSilent(ctx, node) return } fmt.Printf("HELD: %s\n", vaultSilentObservation(node, b).Said) if conditionsFrom == nil { return } if _, err := conditionsFrom.Observe(ctx, vaultSilentObservation(node, b)); err != nil { fmt.Printf("the condition for the vault's silence on %s could not be raised: %v\n", node, err) } } // settleVaultSilent clears the condition once a send had every core credential of the machine from the vault. func settleVaultSilent(ctx context.Context, node string) { if conditionsFrom == nil { return } key := vaultSilentObservation(node, coreBoxes{}).Key() if _, err := conditionsFrom.Clear(ctx, key, "the vault answered for every core credential of "+node); err != nil { fmt.Printf("the condition %s could not be cleared: %v\n", key, err) } }