The controller plans config, templates and moved providers' provisions, hands bus accounts to the vault and judges the new manifest words, on the feature's SDK and node-engine validator (feature 57, config and provisioning).
298 lines
11 KiB
Go
298 lines
11 KiB
Go
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.<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)
|
|
}
|
|
}
|