mesh-vault (mesh-catalog f3b13de2) answers a move it refuses with `refused: {reason, held}` and `why`, and
no read-back. The engine read only the read-back, so every such row failed "answered with no read-back"
and the controller asked it again on every pass.
Quick fix: treat any answer without a read-back as done. Thorough fix (chosen): read `refused`. For
holds-another-value the vault's value is the live one: the move answers done, superseded, at the held
generation with the held record in its words, so the controller records the row and stops asking; the
journal says it once per secret. Any other reason fails in the vault's own words.
185 lines
7.2 KiB
Go
185 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/nacl/box"
|
|
|
|
"github.com/novox/mesh-host/internal/identity"
|
|
"github.com/novox/mesh-host/internal/link"
|
|
)
|
|
|
|
// The move of a value sealed to this machine into the vault (design 57's V3, issue 676; internal/link
|
|
// movesecret.go). The controller cannot open what it holds; this machine can open what was sealed to it, and so
|
|
// hands it to the vault, sealed to a key the vault made for the call. The value is in this process for that one
|
|
// call and written nowhere.
|
|
|
|
// vaultAsk asks one of the vault's verbs in this engine's own name and answers its result.
|
|
type vaultAsk func(verb string, args any) (json.RawMessage, error)
|
|
|
|
// moveSecretFrom is the engine's move: this machine's sealing key, read for each ask, and the vault asked over
|
|
// the link open now.
|
|
func moveSecretFrom(statePath string, queue *link.Session) link.MoveSecretFunc {
|
|
return func(m link.MoveSecret) (link.MovedSecret, error) {
|
|
key, err := identity.LoadSealingKey(identity.SealingKeyPath(statePath))
|
|
if err != nil {
|
|
return link.MovedSecret{}, err
|
|
}
|
|
ask := func(verb string, args any) (json.RawMessage, error) {
|
|
return queue.AskVault(context.Background(), verb, args)
|
|
}
|
|
moved, err := moveIntoTheVault(ask, key, m)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "mesh-host: a value was not moved into the vault: %v\n", err)
|
|
}
|
|
if moved.Superseded != "" {
|
|
if _, said := supersededSaid.LoadOrStore(moved.Secret, true); !said {
|
|
fmt.Fprintf(os.Stderr, "mesh-host: %s\n", moved.Superseded)
|
|
}
|
|
}
|
|
return moved, err
|
|
}
|
|
}
|
|
|
|
// moveIntoTheVault opens the row's value with this machine's sealing key, hands it to the vault's move-in in its two
|
|
// steps, and answers what the vault kept once its read-back — sealed to a key made here for this one call — opens
|
|
// to the same value.
|
|
func moveIntoTheVault(ask vaultAsk, key identity.SealingKey, m link.MoveSecret) (link.MovedSecret, error) {
|
|
if m.SealedTo != "" && m.SealedTo != key.Public {
|
|
return link.MovedSecret{}, errors.New("the controller's store says this value was sealed to a sealing key this " +
|
|
"machine no longer has, so it cannot be opened here")
|
|
}
|
|
value, err := key.Unseal(m.Sealed)
|
|
if err != nil {
|
|
return link.MovedSecret{}, fmt.Errorf("the value does not open with this machine's sealing key: %w", err)
|
|
}
|
|
defer wipe(value)
|
|
|
|
// This machine's sealing key, stated in its own name: the key the vault seals the core's credentials to for the
|
|
// controller to carry here (novox/hq ADR 0349 §16, issue 763), never a key the controller names.
|
|
first := map[string]any{"origin": m.Origin, "generation": m.Generation, "sealing_key": key.Public}
|
|
if !m.MadeAt.IsZero() {
|
|
first["made_at"] = m.MadeAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
if m.Own() {
|
|
first["node"], first["module"], first["name"] = m.Node, m.Module, m.Name
|
|
} else {
|
|
first["provider"], first["provision"] = m.Provider, m.Provision
|
|
first["consumer"], first["consumer_module"] = m.Consumer, m.ConsumerModule
|
|
if m.Local != "" {
|
|
first["local"] = m.Local
|
|
}
|
|
}
|
|
raw, err := ask("move-in", first)
|
|
if err != nil {
|
|
return link.MovedSecret{}, fmt.Errorf("the vault did not open the move: %w", err)
|
|
}
|
|
var opened struct {
|
|
Call string `json:"call"`
|
|
SealTo string `json:"seal_to"`
|
|
Secret string `json:"secret"`
|
|
}
|
|
if err := json.Unmarshal(raw, &opened); err != nil || opened.Call == "" || opened.SealTo == "" {
|
|
return link.MovedSecret{}, errors.New("the vault answered no call and no key to seal to")
|
|
}
|
|
sealed, err := identity.Seal(opened.SealTo, value)
|
|
if err != nil {
|
|
return link.MovedSecret{}, fmt.Errorf("the vault's key for the call is not a sealing key: %w", err)
|
|
}
|
|
backPub, backPriv, err := box.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return link.MovedSecret{}, err
|
|
}
|
|
defer wipe(backPriv[:])
|
|
raw, err = ask("move-in", map[string]any{"call": opened.Call, "sealed": sealed,
|
|
"read_back_to": base64.StdEncoding.EncodeToString(backPub[:])})
|
|
if err != nil {
|
|
return link.MovedSecret{}, fmt.Errorf("the vault did not take %s: %w", opened.Secret, err)
|
|
}
|
|
var kept struct {
|
|
Name string `json:"name"`
|
|
Generation int `json:"generation"`
|
|
Unchanged bool `json:"unchanged"`
|
|
ReadBack string `json:"read_back"`
|
|
// Refused is the vault's refusal as fields (mesh-vault's MoveRefused, mesh-catalog f3b13de2): nothing was taken.
|
|
Refused *struct {
|
|
Reason string `json:"reason"`
|
|
Held *heldValue `json:"held"`
|
|
} `json:"refused"`
|
|
Why string `json:"why"`
|
|
}
|
|
if err := json.Unmarshal(raw, &kept); err != nil {
|
|
return link.MovedSecret{}, fmt.Errorf("the vault's answer for %s does not read: %v", opened.Secret, err)
|
|
}
|
|
if r := kept.Refused; r != nil {
|
|
// The vault holds another value for the name, and the move never replaces one: the vault's value is the live
|
|
// one, and this machine's older value is superseded, never asked again (the sweep of 2026-10-30: it failed
|
|
// "answered with no read-back" on every ask).
|
|
if r.Reason == vaultHoldsAnother && r.Held != nil && r.Held.Name != "" {
|
|
return link.MovedSecret{Secret: r.Held.Name, Generation: r.Held.Generation, Superseded: r.Held.words()}, nil
|
|
}
|
|
why := kept.Why
|
|
if why == "" {
|
|
why = r.Reason
|
|
}
|
|
return link.MovedSecret{}, fmt.Errorf("the vault refused %s: %s", opened.Secret, why)
|
|
}
|
|
if kept.Name == "" || kept.ReadBack == "" {
|
|
return link.MovedSecret{}, fmt.Errorf("the vault answered %s with no read-back", opened.Secret)
|
|
}
|
|
blob, err := base64.StdEncoding.DecodeString(kept.ReadBack)
|
|
if err != nil {
|
|
return link.MovedSecret{}, fmt.Errorf("the vault's read-back of %s is not sealed", kept.Name)
|
|
}
|
|
back, ok := box.OpenAnonymous(nil, blob, backPub, backPriv)
|
|
if !ok {
|
|
return link.MovedSecret{}, fmt.Errorf("the vault's read-back of %s does not open with the key made for it", kept.Name)
|
|
}
|
|
defer wipe(back)
|
|
if subtle.ConstantTimeCompare(back, value) != 1 {
|
|
return link.MovedSecret{}, fmt.Errorf("the vault keeps %s, but it read back another value than the one handed "+
|
|
"in: the move of it did not hold", kept.Name)
|
|
}
|
|
return link.MovedSecret{Secret: kept.Name, Generation: kept.Generation, Unchanged: kept.Unchanged}, nil
|
|
}
|
|
|
|
// vaultHoldsAnother is the vault's reason when it holds another value for the name (mesh-vault RefusedHeldAnother).
|
|
const vaultHoldsAnother = "holds-another-value"
|
|
|
|
// heldValue is the record of the value the vault holds instead, never the value (mesh-vault HeldAnother).
|
|
type heldValue struct {
|
|
Name string `json:"name"`
|
|
Generation int `json:"generation"`
|
|
ChangedBy string `json:"changed_by"`
|
|
ChangedAt time.Time `json:"changed_at"`
|
|
Origin string `json:"origin"`
|
|
}
|
|
|
|
// words say the held value's record, for the journal and the controller.
|
|
func (h heldValue) words() string {
|
|
w := fmt.Sprintf("the vault holds another value for %s, generation %d, changed by %s at %s", h.Name, h.Generation,
|
|
h.ChangedBy, h.ChangedAt.UTC().Format(time.RFC3339))
|
|
if h.Origin != "" {
|
|
w += ", origin " + h.Origin
|
|
}
|
|
return w + ": that value is the live one, and this machine's older value is superseded and not moved"
|
|
}
|
|
|
|
// supersededSaid holds the names a superseded move was said of, so the journal says each once.
|
|
var supersededSaid sync.Map
|
|
|
|
func wipe(b []byte) {
|
|
for i := range b {
|
|
b[i] = 0
|
|
}
|
|
}
|