Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb3478462 |
@@ -67,9 +67,6 @@ type deskGive struct {
|
|||||||
// nil keeps no record (a test that does not look). end closes it with how it ended.
|
// nil keeps no record (a test that does not look). end closes it with how it ended.
|
||||||
open func(node, module, name, desk string) (int64, error)
|
open func(node, module, name, desk string) (int64, error)
|
||||||
end func(id int64, outcome string) error
|
end func(id int64, outcome string) error
|
||||||
// vault, when set, is the path of a module whose secrets are filled the new way (vault_secrets.go): the vault
|
|
||||||
// records the ask, holds its bounds and opens what is typed; open, end and accept are not used.
|
|
||||||
vault vaultAsker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// askedByName is the caller as the prompt names it: the first clause of what the bus said, in the characters
|
// askedByName is the caller as the prompt names it: the first clause of what the bus said, in the characters
|
||||||
@@ -136,9 +133,6 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
|
|||||||
"bus, where an agent may answer first (novox/hq ADR 0259 §10)", module, name, node, module, name)
|
"bus, where an agent may answer first (novox/hq ADR 0259 §10)", module, name, node, module, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if d.vault != nil {
|
|
||||||
return d.giveToVault(node, module, name, desk)
|
|
||||||
}
|
|
||||||
// The bounds, read and kept before anybody is asked to type (novox/hq ADR 0277): one open prompt per secret,
|
// The bounds, read and kept before anybody is asked to type (novox/hq ADR 0277): one open prompt per secret,
|
||||||
// few an hour. How the ask ends is recorded whatever happens below.
|
// few an hour. How the ask ends is recorded whatever happens below.
|
||||||
outcome := "failed"
|
outcome := "failed"
|
||||||
@@ -281,11 +275,6 @@ func askAtDesk(ctx context.Context, node, module, name, desk string) error {
|
|||||||
},
|
},
|
||||||
announce: func(node, module, name, how string) error { return announceSecretGiven(ctx, node, module, name, how) },
|
announce: func(node, module, name, how string) error { return announceSecretGiven(ctx, node, module, name, how) },
|
||||||
}
|
}
|
||||||
// A module whose secrets are filled the new way is asked for by the vault (ADR 0349 clause 9); every other
|
|
||||||
// keeps today's path (clause 17).
|
|
||||||
if secretsOfTheVault(module) {
|
|
||||||
d.vault = theVault(ctx)
|
|
||||||
}
|
|
||||||
words, err := d.give(node, module, name, desk)
|
words, err := d.give(node, module, name, desk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ type issuedTaker struct {
|
|||||||
keep func(provision, provider, value string) (inventory.IssuedKept, error)
|
keep func(provision, provider, value string) (inventory.IssuedKept, error)
|
||||||
record func(act link.HandAct) error
|
record func(act link.HandAct) error
|
||||||
send func(machines []string) error
|
send func(machines []string) error
|
||||||
// ofTheVault says whether a provider module's secrets are filled the new way; for one that is, the value goes
|
|
||||||
// into the vault (vault, vault_secrets.go) and nothing is kept, sealed or sent here. Nil is none.
|
|
||||||
ofTheVault func(module string) bool
|
|
||||||
vault vaultAsker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// take asks every provider of the provision for its software's value and keeps each; it answers what it did, in
|
// take asks every provider of the provision for its software's value and keeps each; it answers what it did, in
|
||||||
@@ -94,9 +90,6 @@ func (t issuedTaker) take(provision string) (string, error) {
|
|||||||
|
|
||||||
// takeFrom is one provider: asked, opened, kept, recorded and sent when it changed.
|
// takeFrom is one provider: asked, opened, kept, recorded and sent when it changed.
|
||||||
func (t issuedTaker) takeFrom(provision string, p issuedProvider) (string, error) {
|
func (t issuedTaker) takeFrom(provision string, p issuedProvider) (string, error) {
|
||||||
if t.ofTheVault != nil && t.ofTheVault(p.Module) {
|
|
||||||
return t.takeIntoVault(provision, p)
|
|
||||||
}
|
|
||||||
public, private, err := secrets.Keypair()
|
public, private, err := secrets.Keypair()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("no key could be made to take the value: %w", err)
|
return "", fmt.Errorf("no key could be made to take the value: %w", err)
|
||||||
@@ -269,9 +262,7 @@ func secretIssued(ctx context.Context, args []string) error {
|
|||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
send: func(machines []string) error { return sendTo(ctx, open, machines) },
|
send: func(machines []string) error { return sendTo(ctx, open, machines) },
|
||||||
ofTheVault: secretsOfTheVault,
|
|
||||||
vault: theVault(ctx),
|
|
||||||
}
|
}
|
||||||
words, err := t.take(provision)
|
words, err := t.take(provision)
|
||||||
if words != "" {
|
if words != "" {
|
||||||
|
|||||||
@@ -108,12 +108,7 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return errors.New("there is nothing to seal")
|
return errors.New("there is nothing to seal")
|
||||||
}
|
}
|
||||||
// The provider's module decides the path: filled the new way, the vault keeps the provision's credential.
|
return acceptForProvision(ctx, node, module, name, value)
|
||||||
return bySwitch(module, secretsOfTheVault,
|
|
||||||
func() error { return acceptForProvision(ctx, node, module, name, value) },
|
|
||||||
func() error {
|
|
||||||
return said(acceptIntoVault(theVault(ctx), provisionArgs(node, name), value, actingAs()))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if *desk != "" {
|
if *desk != "" {
|
||||||
if *from != "" || *provider != "" {
|
if *from != "" || *provider != "" {
|
||||||
@@ -130,50 +125,23 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return errors.New("there is nothing to seal")
|
return errors.New("there is nothing to seal")
|
||||||
}
|
}
|
||||||
return bySwitch(module, secretsOfTheVault,
|
|
||||||
func() error { return acceptKept(ctx, node, module, name, *provider, *local, value) },
|
|
||||||
func() error {
|
|
||||||
// Sealed here to the vault's key for this one call; only the sealed box leaves this process.
|
|
||||||
target := ownSecretArgs(node, module, name)
|
|
||||||
if *provider != "" {
|
|
||||||
if *local != "" {
|
|
||||||
return errors.New("--local names a credential inside the module; the vault keeps one credential " +
|
|
||||||
"per provider and provision, so it takes no --local: nothing was sealed")
|
|
||||||
}
|
|
||||||
target = provisionArgs(*provider, name)
|
|
||||||
}
|
|
||||||
return said(acceptIntoVault(theVault(ctx), target, value, actingAs()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// said prints what was done, or answers why not.
|
|
||||||
func said(words string, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Println(words)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// acceptKept is today's `secret accept`: the value sealed into the controller's store, for a module whose secrets
|
|
||||||
// are filled the old way.
|
|
||||||
func acceptKept(ctx context.Context, node, module, name, provider, local, value string) error {
|
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer open.Close()
|
defer open.Close()
|
||||||
|
|
||||||
if provider != "" {
|
if *provider != "" {
|
||||||
// Into the pair, not into the module's own secrets: what the provider is asked to create
|
// Into the pair, not into the module's own secrets: what the provider is asked to create
|
||||||
// and what the consumer reads are the same value, and neither end can be told a different
|
// and what the consumer reads are the same value, and neither end can be told a different
|
||||||
// one later without the other (novox/hq 04-ISSUES/070).
|
// one later without the other (novox/hq 04-ISSUES/070).
|
||||||
if err := open.inventory.AcceptSecretForPair(ctx, name, node, module, provider, local, value); err != nil {
|
if err := open.inventory.AcceptSecretForPair(ctx, name, node, module, *provider, *local, value); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s on %s now holds %q from %s%s, sealed to both machines.\n", module, node, name, provider, asLocal(local))
|
fmt.Printf("%s on %s now holds %q from %s%s, sealed to both machines.\n", module, node, name, *provider, asLocal(*local))
|
||||||
fmt.Printf(" the mesh cannot read it back, will not replace it with one of its own, and will not rotate it\n")
|
fmt.Printf(" the mesh cannot read it back, will not replace it with one of its own, and will not rotate it\n")
|
||||||
fmt.Printf(" run `push %s` and `push %s` to send it\n", provider, node)
|
fmt.Printf(" run `push %s` and `push %s` to send it\n", *provider, node)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// A trusted party's secret is announced before it is kept (the confirmation review of 2026-10-09, N1-give):
|
// A trusted party's secret is announced before it is kept (the confirmation review of 2026-10-09, N1-give):
|
||||||
@@ -517,22 +485,6 @@ func secretRotate(ctx context.Context, args []string) error {
|
|||||||
return errors.New(secretUsage)
|
return errors.New(secretUsage)
|
||||||
}
|
}
|
||||||
node, module, name := rest[0], rest[1], rest[2]
|
node, module, name := rest[0], rest[1], rest[2]
|
||||||
return bySwitch(module, secretsOfTheVault,
|
|
||||||
func() error { return rotateKept(ctx, node, module, name, why) },
|
|
||||||
func() error {
|
|
||||||
words, err := rotateInVault(theVault(ctx), ownSecretArgs(node, module, name), actingAs())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
why.record(ctx, "secret rotate", []string{node, module, name})
|
|
||||||
fmt.Println(words)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// rotateKept is today's `secret rotate`: made anew in the controller's store and sent, for a module whose
|
|
||||||
// secrets are filled the old way.
|
|
||||||
func rotateKept(ctx context.Context, node, module, name string, why handActFlags) error {
|
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
"github.com/novox/mesh-controller/internal/secrets"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The vault's verbs, reached from the controller's (novox/hq ADR 0349 clauses 8 to 10; design 57's V2, issue 675).
|
|
||||||
//
|
|
||||||
// **Each module is filled the old way or the new way, by its form** (ADR 0349 clause 17, the release lead's
|
|
||||||
// decision of 2026-10-14 on issue 675). `secret accept`, `secret ask` (and `accept --at-desk`), `secret issued`
|
|
||||||
// and `secret rotate` go to the vault only for a module whose secrets are filled the new way — its machine asks
|
|
||||||
// the vault for them — and keep today's path, the controller's store and the declaration it seals, for every other
|
|
||||||
// module. Forwarding a module still filled the old way would report success and change nothing on its machine:
|
|
||||||
// the vault hands it nothing, and its machine never asks.
|
|
||||||
//
|
|
||||||
// **What the controller carries, it cannot open.** The vault takes a value in two steps of one verb: the first
|
|
||||||
// names the secret and answers a key the vault made for that one call; the second carries the value sealed to it.
|
|
||||||
// The value given at this terminal is sealed here before it leaves the process (clause 8); what a desk's prompt or
|
|
||||||
// a provider's tool answers is sealed there to the vault's key, and the controller only passes it on (clauses 9
|
|
||||||
// and 10). The controller still asks the desk and the provider, because the bus lets only the controller ask
|
|
||||||
// them, and asks the vault in its own name, so the vault knows the call is the controller's.
|
|
||||||
|
|
||||||
// secretsOfTheVault says whether a module's secrets are filled the new way: the switch the controller's secret
|
|
||||||
// verbs turn on. **No module's are until design 57's V4** (issue 647), which fills a moved module's
|
|
||||||
// `${secret:}` from the vault and sets this; until then it is false for every module, so every verb keeps today's
|
|
||||||
// path.
|
|
||||||
func secretsOfTheVault(module string) bool { return false }
|
|
||||||
|
|
||||||
// bySwitch runs the vault's path for a module whose secrets are filled the new way, and today's for every other.
|
|
||||||
func bySwitch(module string, ofTheVault func(string) bool, today, vault func() error) error {
|
|
||||||
if ofTheVault != nil && ofTheVault(module) {
|
|
||||||
return vault()
|
|
||||||
}
|
|
||||||
return today()
|
|
||||||
}
|
|
||||||
|
|
||||||
// vaultAsker asks one of the vault's verbs in the controller's name and answers its result, or its refusal.
|
|
||||||
type vaultAsker func(verb string, args map[string]any) (json.RawMessage, error)
|
|
||||||
|
|
||||||
// vaultWithin is how long the vault is given to answer one step.
|
|
||||||
const vaultWithin = 20 * time.Second
|
|
||||||
|
|
||||||
// theVault asks the vault's seat on this controller's bus, on the subject that names the controller.
|
|
||||||
func theVault(ctx context.Context) vaultAsker {
|
|
||||||
return func(verb string, args map[string]any) (json.RawMessage, error) {
|
|
||||||
var result json.RawMessage
|
|
||||||
err := onTheBus(func(conn *nats.Conn) error {
|
|
||||||
answer, err := link.AskMeshSeatToolAs(ctx, conn, broker.ControllerName, "mesh-vault", verb, args, vaultWithin)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if answer.Error != "" {
|
|
||||||
return errors.New(answer.Error)
|
|
||||||
}
|
|
||||||
result = answer.Result
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// vaultOpened is a first step's answer: the call, the key made for it, the secret's name in the vault, and who
|
|
||||||
// asked as the vault recorded it. Never a value.
|
|
||||||
type vaultOpened struct {
|
|
||||||
Call string `json:"call"`
|
|
||||||
SealTo string `json:"seal_to"`
|
|
||||||
Secret string `json:"secret"`
|
|
||||||
AskedBy string `json:"asked_by"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// vaultChanged is what the vault kept: never the value.
|
|
||||||
type vaultChanged struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Generation int `json:"generation"`
|
|
||||||
By string `json:"by"`
|
|
||||||
Unchanged bool `json:"unchanged"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func openVaultCall(ask vaultAsker, verb string, args map[string]any) (vaultOpened, error) {
|
|
||||||
raw, err := ask(verb, args)
|
|
||||||
if err != nil {
|
|
||||||
return vaultOpened{}, fmt.Errorf("the vault did not open the call: %w", err)
|
|
||||||
}
|
|
||||||
var o vaultOpened
|
|
||||||
if err := json.Unmarshal(raw, &o); err != nil || o.Call == "" || o.SealTo == "" {
|
|
||||||
return vaultOpened{}, errors.New("the vault answered no call and no key to seal to")
|
|
||||||
}
|
|
||||||
return o, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func endVaultCall(ask vaultAsker, verb string, args map[string]any) (vaultChanged, error) {
|
|
||||||
raw, err := ask(verb, args)
|
|
||||||
if err != nil {
|
|
||||||
return vaultChanged{}, err
|
|
||||||
}
|
|
||||||
var c vaultChanged
|
|
||||||
if err := json.Unmarshal(raw, &c); err != nil {
|
|
||||||
return vaultChanged{}, errors.New("the vault answered something that is not what it kept")
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownSecretArgs names a module's own secret on one machine, as the vault's verbs take it.
|
|
||||||
func ownSecretArgs(node, module, name string) map[string]any {
|
|
||||||
return map[string]any{"node": node, "module": module, "name": name}
|
|
||||||
}
|
|
||||||
|
|
||||||
// provisionArgs names the one credential of a provider assignment and provision.
|
|
||||||
func provisionArgs(provider, provision string) map[string]any {
|
|
||||||
return map[string]any{"provider": provider, "provision": provision}
|
|
||||||
}
|
|
||||||
|
|
||||||
// acceptIntoVault is `secret accept` for a module filled the new way: the value read at this terminal is sealed
|
|
||||||
// here to the key the vault made for this call, and only the sealed box crosses the bus (ADR 0349 clause 8).
|
|
||||||
func acceptIntoVault(ask vaultAsker, target map[string]any, value, by string) (string, error) {
|
|
||||||
first := map[string]any{"by": by}
|
|
||||||
for k, v := range target {
|
|
||||||
first[k] = v
|
|
||||||
}
|
|
||||||
opened, err := openVaultCall(ask, "accept", first)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("nothing was read or sealed: %w", err)
|
|
||||||
}
|
|
||||||
sealed, err := secrets.Seal(opened.SealTo, []byte(value))
|
|
||||||
value = ""
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("the vault's key for this call is not a sealing key; nothing was sent: %w", err)
|
|
||||||
}
|
|
||||||
changed, err := endVaultCall(ask, "accept", map[string]any{"call": opened.Call, "sealed": sealed})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("the vault did not take it: %w", err)
|
|
||||||
}
|
|
||||||
if changed.Unchanged {
|
|
||||||
return fmt.Sprintf("the vault already holds this value as %s (generation %d); nothing changed", opened.Secret,
|
|
||||||
changed.Generation), nil
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("the vault now holds %s (generation %d), accepted: sealed at this terminal to the vault's key "+
|
|
||||||
"for this call, opened in the vault alone and never remade by it; its machine takes it from the vault",
|
|
||||||
opened.Secret, changed.Generation), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rotateInVault is `secret rotate` for a module filled the new way: the vault makes the value anew and says it
|
|
||||||
// changed; the module's machine takes it from the vault, with no declaration sent.
|
|
||||||
func rotateInVault(ask vaultAsker, target map[string]any, by string) (string, error) {
|
|
||||||
args := map[string]any{"by": by}
|
|
||||||
for k, v := range target {
|
|
||||||
args[k] = v
|
|
||||||
}
|
|
||||||
changed, err := endVaultCall(ask, "rotate", args)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("not rotated: %w", err)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("rotated %s in the vault (generation %d), asked by %s; the value is sealed and not shown, and "+
|
|
||||||
"its machine takes it from the vault", changed.Name, changed.Generation, changed.By), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// giveToVault is the desk's path for a module filled the new way (ADR 0349 clause 9, ADR 0277 §22–§27 in the
|
|
||||||
// vault): the vault records the ask and holds its bounds before the prompt opens and answers the key the prompt
|
|
||||||
// seals to and who asked as it recorded them; the prompt names that asker; what is typed comes back sealed to
|
|
||||||
// the vault's key and is passed on unopened; how the ask ended is the vault's record.
|
|
||||||
func (d deskGive) giveToVault(node, module, name, desk string) (string, error) {
|
|
||||||
opened, err := openVaultCall(d.vault, "secret-ask", map[string]any{"node": node, "module": module, "name": name,
|
|
||||||
"desk": desk, "asked_by": askedByName(d.askedBy)})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
|
|
||||||
}
|
|
||||||
end := func(outcome string) {
|
|
||||||
_, _ = endVaultCall(d.vault, "secret-ask", map[string]any{"call": opened.Call, "outcome": outcome})
|
|
||||||
}
|
|
||||||
raw, err := d.ask(desk, map[string]any{
|
|
||||||
"module": module,
|
|
||||||
"secret": name,
|
|
||||||
"node": node,
|
|
||||||
"asked_by": opened.AskedBy,
|
|
||||||
"seal_to": opened.SealTo,
|
|
||||||
"timeout_seconds": deskPromptWithin,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
end("refused")
|
|
||||||
return "", fmt.Errorf("the desk on %s could not be asked: %w", desk, err)
|
|
||||||
}
|
|
||||||
var answer struct {
|
|
||||||
Sealed string `json:"sealed"`
|
|
||||||
Cancelled bool `json:"cancelled"`
|
|
||||||
TimedOut bool `json:"timed_out"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
|
||||||
end("failed")
|
|
||||||
return "", fmt.Errorf("the desk on %s answered something that is not the prompt's answer", desk)
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case answer.TimedOut:
|
|
||||||
end("timed-out")
|
|
||||||
return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin)
|
|
||||||
case answer.Cancelled:
|
|
||||||
end("dismissed")
|
|
||||||
return "", fmt.Errorf("%w: the prompt on %s was dismissed", errNothingGiven, desk)
|
|
||||||
case answer.Sealed == "":
|
|
||||||
end("failed")
|
|
||||||
return "", fmt.Errorf("the desk on %s answered no sealed value", desk)
|
|
||||||
}
|
|
||||||
changed, err := endVaultCall(d.vault, "secret-ask", map[string]any{"call": opened.Call, "sealed": answer.Sealed})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("the vault did not take what was typed at the desk on %s: %w", desk, err)
|
|
||||||
}
|
|
||||||
act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name, "--at-desk", desk},
|
|
||||||
Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s, asked by %s; kept in the vault", name,
|
|
||||||
module, node, desk, opened.AskedBy),
|
|
||||||
Cause: "given-at-the-desk"}
|
|
||||||
recorded := ""
|
|
||||||
if err := d.record(act); err != nil {
|
|
||||||
recorded = fmt.Sprintf("\n this act could NOT be recorded in the hand-act log, and is done anyway: %v", err)
|
|
||||||
}
|
|
||||||
if d.announce != nil {
|
|
||||||
if err := d.announce(node, module, name, "at the desk on "+desk); err != nil {
|
|
||||||
recorded += fmt.Sprintf("\n this change could NOT be announced on the operator's channels: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s on %s now holds %q, given at the desk on %s and kept in the vault as %s (generation %d); "+
|
|
||||||
"it opened in the vault alone, and %s takes it from there", module, node, name, desk, opened.Secret,
|
|
||||||
changed.Generation, node) + recorded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// takeIntoVault is `secret issued` for a provider filled the new way (ADR 0349 clause 10): the vault opens a call;
|
|
||||||
// the provider's tool, asked by the controller as today — the one caller the bus lets ask it — seals its
|
|
||||||
// software's value to the vault's key; the controller passes it on unopened. Nothing is sealed or sent here: each
|
|
||||||
// holder takes it from the vault.
|
|
||||||
func (t issuedTaker) takeIntoVault(provision string, p issuedProvider) (string, error) {
|
|
||||||
opened, err := openVaultCall(t.vault, "issued", map[string]any{"provision": provision, "provider": p.Node,
|
|
||||||
"module": p.Module})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
answer, err := t.ask(p, map[string]any{"provision": provision, "seal_to": opened.SealTo})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("its tool %s could not be asked: %w", p.Tool, err)
|
|
||||||
}
|
|
||||||
if answer.Error != "" {
|
|
||||||
return "", fmt.Errorf("its tool %s refused: %s", p.Tool, answer.Error)
|
|
||||||
}
|
|
||||||
var given struct {
|
|
||||||
Sealed string `json:"sealed"`
|
|
||||||
}
|
|
||||||
if err := unmarshalAnswer(answer, &given); err != nil || given.Sealed == "" {
|
|
||||||
return "", fmt.Errorf("its tool %s answered no sealed value", p.Tool)
|
|
||||||
}
|
|
||||||
changed, err := endVaultCall(t.vault, "issued", map[string]any{"call": opened.Call, "sealed": given.Sealed})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("the vault did not take what its tool %s answered: %w", p.Tool, err)
|
|
||||||
}
|
|
||||||
if changed.Unchanged {
|
|
||||||
return fmt.Sprintf("%s from %s on %s: the vault already holds the value its software issued (generation %d); "+
|
|
||||||
"nothing was written", provision, p.Module, p.Node, changed.Generation), nil
|
|
||||||
}
|
|
||||||
recorded := ""
|
|
||||||
if err := t.record(link.HandAct{Verb: handActIssued, Args: []string{provision},
|
|
||||||
Why: fmt.Sprintf("%s on %s gave the credential its software issued for %s; kept in the vault as %s", p.Module,
|
|
||||||
p.Node, provision, opened.Secret),
|
|
||||||
Cause: "issued-by-software"}); err != nil {
|
|
||||||
recorded = fmt.Sprintf("\n this was NOT recorded in the hand-act log, and is done anyway: %v", err)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s from %s on %s: the value its software issued is kept in the vault as %s (generation %d); "+
|
|
||||||
"each holder takes it from there%s", provision, p.Module, p.Node, opened.Secret, changed.Generation,
|
|
||||||
recorded), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// actingAs is who acts in this process, as the vault records it beside the controller that carries the act.
|
|
||||||
func actingAs() string {
|
|
||||||
return strings.TrimSpace(askedByName(link.Caller()))
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
"github.com/novox/mesh-controller/internal/secrets"
|
|
||||||
"github.com/novox/mesh-controller/internal/testbus"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The controller's secret verbs reach the vault only for a module filled the new way (design 57's V2, issue 675,
|
|
||||||
// the release lead's decision on it; novox/hq ADR 0349 clauses 8 to 10 and 17).
|
|
||||||
|
|
||||||
// standInVault answers the vault's two-step verbs as mesh-vault does: a key made for each call, the value opened
|
|
||||||
// with it here and nowhere else. It keeps what it took and every argument it was sent, as the bus carried them.
|
|
||||||
type standInVault struct {
|
|
||||||
t *testing.T
|
|
||||||
mu sync.Mutex
|
|
||||||
keys map[string]string // call → private key
|
|
||||||
taken map[string]string // secret → value
|
|
||||||
sent []string // every argument, as JSON
|
|
||||||
refuse error // refuses every first step when set
|
|
||||||
outcome []string
|
|
||||||
again bool // the value is held already
|
|
||||||
}
|
|
||||||
|
|
||||||
func newStandInVault(t *testing.T) *standInVault {
|
|
||||||
return &standInVault{t: t, keys: map[string]string{}, taken: map[string]string{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *standInVault) ask(verb string, args map[string]any) (json.RawMessage, error) {
|
|
||||||
v.mu.Lock()
|
|
||||||
defer v.mu.Unlock()
|
|
||||||
raw, _ := json.Marshal(map[string]any{"verb": verb, "args": args})
|
|
||||||
v.sent = append(v.sent, string(raw))
|
|
||||||
if verb == "rotate" {
|
|
||||||
return json.Marshal(vaultChanged{Name: "own/g14/dunst/token", Generation: 2, By: fmt.Sprint(args["by"])})
|
|
||||||
}
|
|
||||||
call, _ := args["call"].(string)
|
|
||||||
if call == "" {
|
|
||||||
if v.refuse != nil {
|
|
||||||
return nil, v.refuse
|
|
||||||
}
|
|
||||||
public, private, err := secrets.Keypair()
|
|
||||||
if err != nil {
|
|
||||||
v.t.Fatal(err)
|
|
||||||
}
|
|
||||||
call = fmt.Sprintf("call-%d", len(v.keys)+1)
|
|
||||||
v.keys[call] = private
|
|
||||||
secret := fmt.Sprintf("%s/%v", verb, args["name"])
|
|
||||||
return json.Marshal(vaultOpened{Call: call, SealTo: public, Secret: secret, AskedBy: "as-the-vault-recorded"})
|
|
||||||
}
|
|
||||||
private, ok := v.keys[call]
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("no such call")
|
|
||||||
}
|
|
||||||
delete(v.keys, call)
|
|
||||||
if o, _ := args["outcome"].(string); o != "" {
|
|
||||||
v.outcome = append(v.outcome, o)
|
|
||||||
return json.Marshal(vaultChanged{Unchanged: true})
|
|
||||||
}
|
|
||||||
value, err := secrets.Open(private, fmt.Sprint(args["sealed"]))
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("not sealed to this call")
|
|
||||||
}
|
|
||||||
// As mesh-vault takes a typed value: its line ending off (intake.go AskEnd).
|
|
||||||
v.taken[verb] = strings.TrimRight(string(value), "\r\n")
|
|
||||||
return json.Marshal(vaultChanged{Name: verb, Generation: 1, Unchanged: v.again})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *standInVault) said() string {
|
|
||||||
v.mu.Lock()
|
|
||||||
defer v.mu.Unlock()
|
|
||||||
return strings.Join(v.sent, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// No module's secrets are filled the new way before V4: every verb keeps today's path for every module.
|
|
||||||
func TestNoModulesSecretsAreTheVaultsBeforeV4(t *testing.T) {
|
|
||||||
for _, module := range []string{"telegram", "dunst", "plex", "postgres", "mesh-vault", "kometa", ""} {
|
|
||||||
if secretsOfTheVault(module) {
|
|
||||||
t.Errorf("%q is filled the new way before V4", module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The switch: today's path for a module filled the old way, the vault's for one filled the new way, and never
|
|
||||||
// both.
|
|
||||||
func TestTheSwitchRunsOnePathByTheModulesForm(t *testing.T) {
|
|
||||||
moved := func(m string) bool { return m == "dunst" }
|
|
||||||
for module, want := range map[string]string{"telegram": "today", "dunst": "vault"} {
|
|
||||||
var ran []string
|
|
||||||
err := bySwitch(module, moved,
|
|
||||||
func() error { ran = append(ran, "today"); return nil },
|
|
||||||
func() error { ran = append(ran, "vault"); return nil })
|
|
||||||
if err != nil || strings.Join(ran, ",") != want {
|
|
||||||
t.Errorf("%s ran %v (%v), not %s", module, ran, err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var ran []string
|
|
||||||
_ = bySwitch("dunst", nil, func() error { ran = append(ran, "today"); return nil },
|
|
||||||
func() error { ran = append(ran, "vault"); return nil })
|
|
||||||
if strings.Join(ran, ",") != "today" {
|
|
||||||
t.Errorf("no switch ran %v", ran)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `secret accept` for a module filled the new way: the value read at the terminal is sealed there to the key the
|
|
||||||
// vault made for this one call; no clear value crosses the bus or reaches the controller's log. Over a real bus,
|
|
||||||
// with a tap on every subject.
|
|
||||||
func TestAnAcceptedValueCrossesTheBusOnlySealedToTheVault(t *testing.T) {
|
|
||||||
conn, err := nats.Connect(testbus.URL(t))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(conn.Close)
|
|
||||||
before := handActConn
|
|
||||||
handActConn = conn
|
|
||||||
t.Cleanup(func() { handActConn = before })
|
|
||||||
|
|
||||||
var tapped bytes.Buffer
|
|
||||||
var tapMu sync.Mutex
|
|
||||||
tap, err := conn.Subscribe(">", func(m *nats.Msg) {
|
|
||||||
tapMu.Lock()
|
|
||||||
defer tapMu.Unlock()
|
|
||||||
tapped.WriteString(m.Subject + " " + string(m.Data) + "\n")
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer tap.Unsubscribe()
|
|
||||||
|
|
||||||
vault := newStandInVault(t)
|
|
||||||
var subjects []string
|
|
||||||
holder, err := conn.Subscribe(broker.CalledPattern(link.SeatToolSubject("mesh-vault", "accept")), func(m *nats.Msg) {
|
|
||||||
subjects = append(subjects, m.Subject)
|
|
||||||
var args map[string]any
|
|
||||||
_ = json.Unmarshal(m.Data, &args)
|
|
||||||
result, err := vault.ask("accept", args)
|
|
||||||
answer := link.Answer{Result: result}
|
|
||||||
if err != nil {
|
|
||||||
answer = link.Answer{Error: err.Error()}
|
|
||||||
}
|
|
||||||
raw, _ := json.Marshal(answer)
|
|
||||||
_ = m.Respond(raw)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer holder.Unsubscribe()
|
|
||||||
|
|
||||||
var logged bytes.Buffer
|
|
||||||
log.SetOutput(&logged)
|
|
||||||
t.Cleanup(func() { log.SetOutput(nil) })
|
|
||||||
|
|
||||||
const value = "pw-of-last-year-that-must-not-show"
|
|
||||||
words, err := acceptIntoVault(theVault(context.Background()), provisionArgs("novox", "postgres"), value, "jochen")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if vault.taken["accept"] != value {
|
|
||||||
t.Fatalf("the vault opened %q", vault.taken["accept"])
|
|
||||||
}
|
|
||||||
if err := conn.Flush(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
tapMu.Lock()
|
|
||||||
crossed := tapped.String()
|
|
||||||
tapMu.Unlock()
|
|
||||||
if !strings.Contains(crossed, "mesh.seat.mesh-vault.call.accept.controller") {
|
|
||||||
t.Fatalf("the tap heard no call to the vault: %s", crossed)
|
|
||||||
}
|
|
||||||
for _, s := range subjects {
|
|
||||||
if !strings.HasSuffix(s, ".controller") {
|
|
||||||
t.Errorf("the vault was asked on %s, not in the controller's name", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for where, text := range map[string]string{"the bus": crossed, "the log": logged.String(), "the answer": words} {
|
|
||||||
if strings.Contains(text, value) || strings.Contains(text, "must-not-show") {
|
|
||||||
t.Errorf("the value crossed %s in the clear", where)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !strings.Contains(words, "sealed at this terminal") {
|
|
||||||
t.Errorf("the answer: %q", words)
|
|
||||||
}
|
|
||||||
// A vault that refuses the first step: nothing is sealed or sent.
|
|
||||||
vault.refuse = errors.New("the vault has no operator key")
|
|
||||||
if _, err := acceptIntoVault(vault.ask, ownSecretArgs("g14", "dunst", "token"), value, "jochen"); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "nothing was read or sealed") {
|
|
||||||
t.Errorf("a refused call said %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The desk's path for a module filled the new way: the vault records the ask and holds its bounds before the
|
|
||||||
// prompt; the prompt names the asker as the vault recorded it; what is typed passes the controller sealed and
|
|
||||||
// unopened; today's store is not touched. For a module filled the old way, the vault is never asked.
|
|
||||||
func TestAnAskForAModuleFilledTheNewWayIsTheVaults(t *testing.T) {
|
|
||||||
d, accepted, acts, asked := aDesk(t, sealedTo(t, typed))
|
|
||||||
d.askedBy = "g14/claude-code"
|
|
||||||
d.open = func(node, module, name, desk string) (int64, error) {
|
|
||||||
t.Fatal("today's record was asked")
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
vault := newStandInVault(t)
|
|
||||||
d.vault = vault.ask
|
|
||||||
words, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(*accepted) != 0 {
|
|
||||||
t.Fatal("the controller opened and kept what was typed")
|
|
||||||
}
|
|
||||||
if vault.taken["secret-ask"] != typed {
|
|
||||||
t.Fatalf("the vault opened %q", vault.taken["secret-ask"])
|
|
||||||
}
|
|
||||||
if p := (*asked)[0]; p["asked_by"] != "as-the-vault-recorded" || p["seal_to"] == "" {
|
|
||||||
t.Errorf("the prompt was asked %v", p)
|
|
||||||
}
|
|
||||||
if !strings.Contains(vault.said(), `"asked_by":"g14/claude-code"`) {
|
|
||||||
t.Errorf("the vault was not told who asked: %s", vault.said())
|
|
||||||
}
|
|
||||||
if len(*acts) != 1 || (*acts)[0].Cause != "given-at-the-desk" || !strings.Contains((*acts)[0].Why, "kept in the vault") {
|
|
||||||
t.Errorf("the act: %+v", *acts)
|
|
||||||
}
|
|
||||||
raw, _ := json.Marshal(struct {
|
|
||||||
Words string
|
|
||||||
Acts []link.HandAct
|
|
||||||
Asked []map[string]any
|
|
||||||
}{words, *acts, *asked})
|
|
||||||
if strings.Contains(string(raw)+vault.said(), typed) || strings.Contains(string(raw)+vault.said(), "AAEhBP0") {
|
|
||||||
t.Fatal("the value appears in what was said, asked, recorded or sent to the vault")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dismissed: the vault's record ends the ask so.
|
|
||||||
d2, _, _, _ := aDesk(t, func(map[string]any) (json.RawMessage, error) { return json.RawMessage(`{"cancelled":true}`), nil })
|
|
||||||
v2 := newStandInVault(t)
|
|
||||||
d2.vault = v2.ask
|
|
||||||
if _, err := d2.give("anchor", "telegram", "telegram-token", "laptop"); !errors.Is(err, errNothingGiven) {
|
|
||||||
t.Fatalf("%v", err)
|
|
||||||
}
|
|
||||||
if strings.Join(v2.outcome, ",") != "dismissed" {
|
|
||||||
t.Errorf("the vault was told %v", v2.outcome)
|
|
||||||
}
|
|
||||||
// The vault's bounds refuse: nobody is asked to type.
|
|
||||||
d3, _, _, asked3 := aDesk(t, sealedTo(t, typed))
|
|
||||||
v3 := newStandInVault(t)
|
|
||||||
v3.refuse = errors.New("an ask for telegram-token of telegram on anchor is still open")
|
|
||||||
d3.vault = v3.ask
|
|
||||||
if _, err := d3.give("anchor", "telegram", "telegram-token", "laptop"); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "nobody was asked to type anything") || len(*asked3) != 0 {
|
|
||||||
t.Errorf("a refused ask: %v, prompts %d", err, len(*asked3))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filled the old way: today's path, the vault never asked.
|
|
||||||
d4, accepted4, _, _ := aDesk(t, sealedTo(t, typed))
|
|
||||||
if _, err := d4.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(*accepted4) != 1 || d4.vault != nil {
|
|
||||||
t.Error("a module filled the old way did not take today's path")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `secret issued` for a provider filled the new way: the provider's tool seals to the vault's key for the call,
|
|
||||||
// the controller passes it on unopened, and nothing is kept, sealed or sent here. A provider filled the old way
|
|
||||||
// keeps today's path and the vault is never asked.
|
|
||||||
func TestAnIssuedValueForAProviderFilledTheNewWayGoesToTheVault(t *testing.T) {
|
|
||||||
var r issuedRecorder
|
|
||||||
vault := newStandInVault(t)
|
|
||||||
taker := r.taker(t, issuedSealed(t, "the-servers-own-token"))
|
|
||||||
taker.keep = func(string, string, string) (inventory.IssuedKept, error) {
|
|
||||||
t.Fatal("today's store was asked to keep it")
|
|
||||||
return inventory.IssuedKept{}, nil
|
|
||||||
}
|
|
||||||
taker.ofTheVault = func(m string) bool { return m == "plex" }
|
|
||||||
taker.vault = vault.ask
|
|
||||||
words, err := taker.take("plex-api")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if vault.taken["issued"] != "the-servers-own-token" {
|
|
||||||
t.Fatalf("the vault opened %q", vault.taken["issued"])
|
|
||||||
}
|
|
||||||
if len(r.sent) != 0 {
|
|
||||||
t.Errorf("machines were sent: %v", r.sent)
|
|
||||||
}
|
|
||||||
if len(r.acts) != 1 || r.acts[0].Verb != handActIssued || !strings.Contains(r.acts[0].Why, "kept in the vault") {
|
|
||||||
t.Errorf("the act: %+v", r.acts)
|
|
||||||
}
|
|
||||||
if !strings.Contains(vault.said(), `"provider":"ace"`) || !strings.Contains(vault.said(), `"module":"plex"`) {
|
|
||||||
t.Errorf("the vault was told %s", vault.said())
|
|
||||||
}
|
|
||||||
for _, said := range []string{words, r.acts[0].Why, vault.said()} {
|
|
||||||
if strings.Contains(said, "the-servers-own-token") {
|
|
||||||
t.Fatalf("the value was said: %q", said)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The same value again: the vault writes nothing, and nothing is recorded.
|
|
||||||
again := newStandInVault(t)
|
|
||||||
again.again = true
|
|
||||||
r2 := issuedRecorder{}
|
|
||||||
t2 := r2.taker(t, issuedSealed(t, "the-servers-own-token"))
|
|
||||||
t2.ofTheVault, t2.vault = func(string) bool { return true }, again.ask
|
|
||||||
if words, err := t2.take("plex-api"); err != nil || len(r2.acts) != 0 || !strings.Contains(words, "nothing was written") {
|
|
||||||
t.Errorf("%q %v %+v", words, err, r2.acts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filled the old way: today's path.
|
|
||||||
var r3 issuedRecorder
|
|
||||||
t3 := r3.taker(t, issuedSealed(t, "the-servers-own-token"))
|
|
||||||
t3.ofTheVault = func(string) bool { return false }
|
|
||||||
t3.vault = func(string, map[string]any) (json.RawMessage, error) {
|
|
||||||
t.Fatal("the vault was asked for a provider filled the old way")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if _, err := t3.take("plex-api"); err != nil || len(r3.kept) != 1 {
|
|
||||||
t.Errorf("today's path: %v, kept %d", err, len(r3.kept))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `secret rotate` for a module filled the new way asks the vault, naming who asked; nothing is sent.
|
|
||||||
func TestARotationForAModuleFilledTheNewWayIsTheVaults(t *testing.T) {
|
|
||||||
vault := newStandInVault(t)
|
|
||||||
words, err := rotateInVault(vault.ask, ownSecretArgs("g14", "dunst", "token"), "g14/claude-code")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(vault.said(), `"verb":"rotate"`) || !strings.Contains(vault.said(), `"by":"g14/claude-code"`) ||
|
|
||||||
!strings.Contains(vault.said(), `"module":"dunst"`) {
|
|
||||||
t.Errorf("the vault was told %s", vault.said())
|
|
||||||
}
|
|
||||||
if !strings.Contains(words, "generation 2") || !strings.Contains(words, "takes it from the vault") {
|
|
||||||
t.Errorf("%q", words)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
@@ -36,4 +36,4 @@ require (
|
|||||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||||
// `go mod vendor` fails loudly, at once, everywhere.
|
// `go mod vendor` fails loudly, at once, everywhere.
|
||||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261013220009-b9a293f8c8bc
|
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261015211328-5a7ec3775b98
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
git.novox.be/novox/mesh-host v0.0.0-20261013220009-b9a293f8c8bc h1:h5v2AbdjM/clMY1YxS9p7VuyYtIjkcw85w9Oz1cOYQc=
|
git.novox.be/novox/mesh-host v0.0.0-20261015211328-5a7ec3775b98 h1:27nPn3XO9yfrHoAOuTuuJPVZTA3Bdnbqlc2wRI7S/LY=
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261013220009-b9a293f8c8bc/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
git.novox.be/novox/mesh-host v0.0.0-20261015211328-5a7ec3775b98/go.mod h1:5QOm3cIfu/k9fN6QqgDbKGCcDst7Ul1UlcfsiMVm9j4=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 h1:mAMt3KFmrHzDiBv4MLL94opWcG9y73ISMrBUyisapQE=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -96,47 +96,3 @@ func TestWhatTheControllerAskedIsBounded(t *testing.T) {
|
|||||||
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The controller may ask the vault's intake verbs in its own name** (design 57's V2, issue 675): the vault
|
|
||||||
// takes a value only from the controller, which it knows from the subject that names its caller — so the
|
|
||||||
// controller is granted that subject for the verbs the vault's holder serves, and no more of the vault's seat.
|
|
||||||
// Composed from the records, as the issued tools are: the installer's first user list carries none.
|
|
||||||
func TestTheControllerMayAskTheVaultInItsOwnName(t *testing.T) {
|
|
||||||
vault := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault",
|
|
||||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
|
|
||||||
users, err := Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {vault}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
controller := users[0]
|
|
||||||
if strings.Join(controller.AsksTheVault, ",") != "accept,issued,rotate,secret-ask" {
|
|
||||||
t.Fatalf("composed %v", controller.AsksTheVault)
|
|
||||||
}
|
|
||||||
p, err := PermissionsFor(controller)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, verb := range []string{"accept", "secret-ask", "issued", "rotate"} {
|
|
||||||
if !MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool."+verb, ControllerName)) {
|
|
||||||
t.Errorf("the controller may not ask the vault's %s in its own name", verb)
|
|
||||||
}
|
|
||||||
if MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool."+verb, "anchor.node-tools")) {
|
|
||||||
t.Errorf("the controller may ask the vault's %s in another's name", verb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool.get", ControllerName)) {
|
|
||||||
t.Error("the controller may ask the vault for a secret: it seals nothing the vault holds")
|
|
||||||
}
|
|
||||||
// A vault that serves none of them yet — the running one — composes no grant, nor does genesis.
|
|
||||||
older := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", Serves: []string{"get"}}}}
|
|
||||||
users, _ = Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {older}}})
|
|
||||||
if len(users[0].AsksTheVault) != 0 {
|
|
||||||
t.Errorf("a vault serving none composed %v", users[0].AsksTheVault)
|
|
||||||
}
|
|
||||||
genesis, _ := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
|
||||||
for _, s := range genesis.Publish {
|
|
||||||
if strings.Contains(s, "mesh-vault") {
|
|
||||||
t.Errorf("the installer's controller may publish %s", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -131,11 +130,6 @@ type Principal struct {
|
|||||||
// grants.
|
// grants.
|
||||||
AsksIssued []string
|
AsksIssued []string
|
||||||
|
|
||||||
// AsksTheVault are the vault's verbs the controller asks in its own name (VerbsTheControllerAsksTheVault,
|
|
||||||
// issue 675), those the vault's holder serves: on the subject that names its caller, so the vault knows the
|
|
||||||
// controller asks. Composed from the records like AsksIssued, so the installer's first user list carries none.
|
|
||||||
AsksTheVault []string
|
|
||||||
|
|
||||||
// State is the local names of the state this principal's module keeps, and Reads the state of
|
// State is the local names of the state this principal's module keeps, and Reads the state of
|
||||||
// others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's
|
// others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's
|
||||||
// instances and read by whoever declares it.
|
// instances and read by whoever declares it.
|
||||||
@@ -275,14 +269,6 @@ var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery",
|
|||||||
{Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"},
|
{Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"},
|
||||||
{Seat: "mesh-delivery", Verb: "times"}, {Seat: "mesh-delivery", Verb: "groups"}}
|
{Seat: "mesh-delivery", Verb: "times"}, {Seat: "mesh-delivery", Verb: "groups"}}
|
||||||
|
|
||||||
// VerbsTheControllerAsksTheVault are the mesh-vault seat's verbs the controller calls in its own name (novox/hq
|
|
||||||
// ADR 0349 clauses 8 to 10, design 57's V2, issue 675): it carries a value given at its terminal, typed at a desk
|
|
||||||
// or issued by a provider's software into the vault, sealed to a key the vault made for the call, and asks a
|
|
||||||
// rotation. The vault takes these from the controller alone, which it knows from the subject that names its
|
|
||||||
// caller; granted as Principal.AsksTheVault says.
|
|
||||||
var VerbsTheControllerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "accept"},
|
|
||||||
{Seat: "mesh-vault", Verb: "secret-ask"}, {Seat: "mesh-vault", Verb: "issued"}, {Seat: "mesh-vault", Verb: "rotate"}}
|
|
||||||
|
|
||||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||||
// controller's own `plans`. Named one by one; a node seat's on any machine, a mesh seat's flat.
|
// controller's own `plans`. Named one by one; a node seat's on any machine, a mesh seat's flat.
|
||||||
@@ -944,13 +930,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
}
|
}
|
||||||
pub = append(pub, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+CallerToken(p.Username()))
|
pub = append(pub, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+CallerToken(p.Username()))
|
||||||
}
|
}
|
||||||
// **And the vault's intake verbs** (issue 675): the vault takes a value from the controller alone, known
|
|
||||||
// from the subject's last token — the verbs its holder serves, flat, and nothing else of its seat.
|
|
||||||
for _, v := range VerbsTheControllerAsksTheVault {
|
|
||||||
if slices.Contains(p.AsksTheVault, v.Verb) {
|
|
||||||
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(pub)
|
sort.Strings(pub)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package broker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
|
||||||
"sort"
|
"sort"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ type Records struct {
|
|||||||
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
||||||
// correct.
|
// correct.
|
||||||
func Users(r Records) ([]Principal, error) {
|
func Users(r Records) ([]Principal, error) {
|
||||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r)}}
|
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r)}}
|
||||||
|
|
||||||
for _, node := range sortedCopy(r.Nodes) {
|
for _, node := range sortedCopy(r.Nodes) {
|
||||||
witness := false
|
witness := false
|
||||||
@@ -243,24 +242,3 @@ func asksSeatOf(r Records) []Seat {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// vaultVerbsOf are the vault's intake verbs its holder serves, as the records say (issue 675): what the controller
|
|
||||||
// is granted to ask it in its own name. None while no holder serves them.
|
|
||||||
func vaultVerbsOf(r Records) []string {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var out []string
|
|
||||||
for _, node := range sortedCopy(r.Nodes) {
|
|
||||||
for _, d := range r.Assigned[node] {
|
|
||||||
for _, s := range d.Holds {
|
|
||||||
for _, v := range VerbsTheControllerAsksTheVault {
|
|
||||||
if s.Name == v.Seat && slices.Contains(s.Serves, v.Verb) && !seen[v.Verb] {
|
|
||||||
seen[v.Verb] = true
|
|
||||||
out = append(out, v.Verb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -129,11 +129,9 @@ var defaultSeats = append([]Seat{
|
|||||||
// test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a
|
// test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a
|
||||||
// second claimant, refused by name, rather than a candidate for a pin.
|
// second claimant, refused by name, rather than a candidate for a pin.
|
||||||
//
|
//
|
||||||
// Its verb `get` hands a secret on request and reply, sealed to the key the asker sends and only to an
|
// Its one verb, `get`, hands a secret on request and reply, sealed to the key the asker sends and only
|
||||||
// asker the vault allows (novox/hq ADR 0349 §11, issue 674). `accept`, `secret-ask`, `issued` and `rotate`
|
// to an asker the vault allows (novox/hq ADR 0349 §11, issue 674). Optional until the vault that runs
|
||||||
// take a value in and make one anew (ADR 0349 clauses 8 to 10, issue 675): the first three in two steps from
|
// serves it (design 33 §7).
|
||||||
// the controller alone, which carries the value sealed to a key the vault made for the call and cannot open
|
|
||||||
// it. All optional until the vault that runs serves them (design 33 §7).
|
|
||||||
{Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161",
|
{Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161",
|
||||||
Serves: []Verb{
|
Serves: []Verb{
|
||||||
{Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " +
|
{Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " +
|
||||||
@@ -142,30 +140,6 @@ var defaultSeats = append([]Seat{
|
|||||||
Input: schema(map[string]string{"name": "the secret's name",
|
Input: schema(map[string]string{"name": "the secret's name",
|
||||||
"public_key": "the asker's X25519 public key as SPKI PEM — never a private key"},
|
"public_key": "the asker's X25519 public key as SPKI PEM — never a private key"},
|
||||||
[]string{"name", "public_key"})},
|
[]string{"name", "public_key"})},
|
||||||
{Name: "accept", Optional: true, Description: "Take a value given at the controller's terminal, from the " +
|
|
||||||
"controller alone: name the secret and get a call and a key made for it, then send the call and the " +
|
|
||||||
"value sealed to that key. The vault alone opens it, and never remakes an accepted credential.",
|
|
||||||
Input: schema(vaultIntake(map[string]string{"node": "the machine, for a module's own secret",
|
|
||||||
"module": "the module, for a module's own secret", "name": "the secret's name in the module",
|
|
||||||
"provider": "the provider's machine, for a provision's credential", "provision": "the provision",
|
|
||||||
"by": "who gave it at the terminal"}), nil)},
|
|
||||||
{Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " +
|
|
||||||
"from the controller alone: the ask is recorded in the vault's store and bounded before the prompt " +
|
|
||||||
"opens, and what is typed is sealed to a key the vault made for the call and opens there alone.",
|
|
||||||
Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for",
|
|
||||||
"module": "the module", "name": "the secret's name in the module",
|
|
||||||
"desk": "the machine whose prompt asks", "asked_by": "who asked, as the bus named its caller",
|
|
||||||
"outcome": "instead of sealed: how the prompt ended"}), nil)},
|
|
||||||
{Name: "issued", Optional: true, Description: "Take the credential a provider's software issued for a " +
|
|
||||||
"provision, from the controller alone, which asks the provider's tool with a key the vault made for " +
|
|
||||||
"the call; kept as the provision's one credential, and the same value again writes nothing.",
|
|
||||||
Input: schema(vaultIntake(map[string]string{"provision": "the provision",
|
|
||||||
"provider": "the provider's machine", "module": "the provider's module"}), nil)},
|
|
||||||
{Name: "rotate", Optional: true, Description: "Make a secret anew. Refused for a value a provider's " +
|
|
||||||
"software issued and for an accepted credential. Never answers the value.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine, for a module's own secret", "module": "the module",
|
|
||||||
"name": "the secret's name in the module", "provider": "the provider's machine",
|
|
||||||
"provision": "the provision", "by": "who asked, when the controller carries the act"}, nil)},
|
|
||||||
}},
|
}},
|
||||||
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
|
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
|
||||||
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
|
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
|
||||||
@@ -848,13 +822,3 @@ func buildAgentVerbs() []Verb {
|
|||||||
// BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may
|
// BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may
|
||||||
// snapshot the bus's streams and do nothing else (novox/hq ADR 0235).
|
// snapshot the bus's streams and do nothing else (novox/hq ADR 0235).
|
||||||
const BrokerSeat = "mesh-broker"
|
const BrokerSeat = "mesh-broker"
|
||||||
|
|
||||||
// vaultIntake is a two-step vault verb's arguments: what its first step names, then the call and what was sealed.
|
|
||||||
func vaultIntake(first map[string]string) map[string]string {
|
|
||||||
out := map[string]string{"call": "the second step: the call the first step answered",
|
|
||||||
"sealed": "the second step: the value sealed to the call's key — never the value"}
|
|
||||||
for k, v := range first {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,46 +54,3 @@ func TestTheVaultsSeatPromisesGet(t *testing.T) {
|
|||||||
t.Fatalf("the vault that does not serve get yet was refused: %v", err)
|
t.Fatalf("the vault that does not serve get yet was refused: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The vault takes values in and rotates them (novox/hq ADR 0349 clauses 8 to 10, issue 675): the seat promises
|
|
||||||
// accept, secret-ask, issued and rotate, each with the arguments of its steps; a holder serving them is accepted,
|
|
||||||
// and, optional while the vault that runs does not, so is one that does not yet.
|
|
||||||
func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
|
||||||
seat, _ := SeatNamed("mesh-vault")
|
|
||||||
want := map[string][]string{
|
|
||||||
"accept": {"node", "module", "name", "provider", "provision", "by", "call", "sealed"},
|
|
||||||
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
|
|
||||||
"issued": {"provision", "provider", "module", "call", "sealed"},
|
|
||||||
"rotate": {"node", "module", "name", "provider", "provision", "by"},
|
|
||||||
}
|
|
||||||
for name, args := range want {
|
|
||||||
var verb *Verb
|
|
||||||
for i := range seat.Serves {
|
|
||||||
if seat.Serves[i].Name == name {
|
|
||||||
verb = &seat.Serves[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if verb == nil {
|
|
||||||
t.Errorf("the vault's seat does not promise %s", name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !verb.Optional {
|
|
||||||
t.Errorf("%s is not optional while the vault that runs does not serve it", name)
|
|
||||||
}
|
|
||||||
props, _ := verb.Input["properties"].(map[string]any)
|
|
||||||
for _, arg := range args {
|
|
||||||
if props[arg] == nil {
|
|
||||||
t.Errorf("%s takes no %s", name, arg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if props["value"] != nil {
|
|
||||||
t.Errorf("%s takes a value in the clear", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
|
||||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
|
||||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
|
|
||||||
if err := CanHold(serving, seat); err != nil {
|
|
||||||
t.Fatalf("the vault serving its verbs was refused: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-17
@@ -609,29 +609,13 @@ func PausedSaid(js *broker.JetStream, seat string, nodes []string) (map[string]H
|
|||||||
// (design 33 §4: a mesh seat's verb carries no machine), and reads its answer (novox/hq ADR 0239: the
|
// (design 33 §4: a mesh seat's verb carries no machine), and reads its answer (novox/hq ADR 0239: the
|
||||||
// delivery's owner's `stalled` and `close`).
|
// delivery's owner's `stalled` and `close`).
|
||||||
func AskMeshSeatTool(ctx context.Context, conn *nats.Conn, seat, verb string, args any, timeout time.Duration) (Answer, error) {
|
func AskMeshSeatTool(ctx context.Context, conn *nats.Conn, seat, verb string, args any, timeout time.Duration) (Answer, error) {
|
||||||
return askMeshSeatToolAt(ctx, conn, SeatToolSubject(seat, verb), seat, verb, args, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AskMeshSeatToolAs is AskMeshSeatTool in the caller's own name: on the subject that names its caller (novox/hq
|
|
||||||
// issue 365), which the bus lets only that user publish, so the holder knows who asks from the subject alone —
|
|
||||||
// how the vault takes a value from the controller and nobody else (design 57's V2, issue 675).
|
|
||||||
func AskMeshSeatToolAs(ctx context.Context, conn *nats.Conn, caller, seat, verb string, args any,
|
|
||||||
timeout time.Duration) (Answer, error) {
|
|
||||||
subject := broker.CalledSubject(SeatToolSubject(seat, verb), caller)
|
|
||||||
if subject == "" {
|
|
||||||
return Answer{}, fmt.Errorf("%q cannot ask %s.%s in its own name: it is not a bus user's name", caller, seat, verb)
|
|
||||||
}
|
|
||||||
return askMeshSeatToolAt(ctx, conn, subject, seat, verb, args, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func askMeshSeatToolAt(ctx context.Context, conn *nats.Conn, subject, seat, verb string, args any,
|
|
||||||
timeout time.Duration) (Answer, error) {
|
|
||||||
body, err := json.Marshal(args)
|
body, err := json.Marshal(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Answer{}, err
|
return Answer{}, err
|
||||||
}
|
}
|
||||||
asking, cancel := context.WithTimeout(ctx, timeout)
|
asking, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
subject := SeatToolSubject(seat, verb)
|
||||||
refused, stop := refusalsOf(conn, subject)
|
refused, stop := refusalsOf(conn, subject)
|
||||||
defer stop()
|
defer stop()
|
||||||
type replied struct {
|
type replied struct {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
package link
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
"github.com/novox/mesh-controller/internal/testbus"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A mesh seat's verb asked in the caller's own name goes on the subject that names its caller (novox/hq issue
|
|
||||||
// 365), so the holder knows who asks from the subject alone: how the vault takes a value from the controller
|
|
||||||
// and nobody else (design 57's V2, issue 675).
|
|
||||||
func TestAMeshSeatsVerbIsAskedInTheCallersOwnName(t *testing.T) {
|
|
||||||
conn, err := nats.Connect(testbus.URL(t))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
heard := make(chan string, 1)
|
|
||||||
sub, err := conn.Subscribe(broker.CalledPattern(SeatToolSubject("mesh-vault", "accept")), func(m *nats.Msg) {
|
|
||||||
heard <- m.Subject
|
|
||||||
raw, _ := json.Marshal(Answer{Result: json.RawMessage(`{"call":"c1"}`)})
|
|
||||||
_ = m.Respond(raw)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer sub.Unsubscribe()
|
|
||||||
answer, err := AskMeshSeatToolAs(context.Background(), conn, broker.ControllerName, "mesh-vault", "accept",
|
|
||||||
map[string]any{"provider": "novox", "provision": "postgres"}, 5*time.Second)
|
|
||||||
if err != nil || string(answer.Result) != `{"call":"c1"}` {
|
|
||||||
t.Fatalf("%+v %v", answer, err)
|
|
||||||
}
|
|
||||||
subject := <-heard
|
|
||||||
if subject != "mesh.seat.mesh-vault.call.accept.controller" || !strings.HasSuffix(subject, "."+broker.ControllerName) {
|
|
||||||
t.Errorf("asked on %s", subject)
|
|
||||||
}
|
|
||||||
if _, err := AskMeshSeatToolAs(context.Background(), conn, "not a user!", "mesh-vault", "accept", nil, time.Second); err == nil {
|
|
||||||
t.Error("a caller that is no bus user was asked in")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
// Package seal is the mesh's sealed box (novox/hq ADR 0183, ADR 0206): a value sealed to one recipient's
|
||||||
|
// public key, which only that recipient's private key opens. X25519 for the agreement, HKDF-SHA256 for the
|
||||||
|
// key, AES-256-GCM for the box — the box the licence manager made and opened before it lived here, byte
|
||||||
|
// for byte, and the one the SDK's TypeScript `seal` makes and opens.
|
||||||
|
//
|
||||||
|
// A box is `{ v: 1, eph, iv, tag, ct }`, every field standard base64; `eph` is the one-time public key as
|
||||||
|
// SPKI DER. The key is derived with the salt eph-DER ‖ the recipient's raw public key and the info
|
||||||
|
// "novox-mesh sealed box v1", so a box cannot be re-addressed to another key.
|
||||||
|
//
|
||||||
|
// Keys travel as PEM: the public key as SPKI ("PUBLIC KEY"), the private key as PKCS #8 ("PRIVATE KEY").
|
||||||
|
// Standard library only.
|
||||||
|
package seal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Box is a value sealed to one recipient, as it travels in JSON.
|
||||||
|
type Box struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Eph string `json:"eph"`
|
||||||
|
IV string `json:"iv"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Ct string `json:"ct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as.
|
||||||
|
type KeyPair struct {
|
||||||
|
PublicKey string `json:"publicKey"`
|
||||||
|
PrivateKey string `json:"privateKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version is the only box version Open opens.
|
||||||
|
const Version = 1
|
||||||
|
|
||||||
|
const info = "novox-mesh sealed box v1"
|
||||||
|
|
||||||
|
// GenerateKeyPair makes a fresh X25519 keypair, PEM-encoded.
|
||||||
|
func GenerateKeyPair() (KeyPair, error) {
|
||||||
|
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey())
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
return KeyPair{
|
||||||
|
PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})),
|
||||||
|
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal seals plaintext to the recipient's PEM public key, with a fresh one-time key and iv.
|
||||||
|
func Seal(plaintext, recipientPEM string) (Box, error) {
|
||||||
|
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
iv := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
return sealWith(plaintext, recipientPEM, eph, iv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sealWith is Seal with its two sources of randomness given, for the byte-for-byte tests.
|
||||||
|
func sealWith(plaintext, recipientPEM string, eph *ecdh.PrivateKey, iv []byte) (Box, error) {
|
||||||
|
recipient, err := publicFromPEM(recipientPEM)
|
||||||
|
if err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
secret, err := eph.ECDH(recipient)
|
||||||
|
if err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey())
|
||||||
|
if err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
gcm, err := newGCM(boxKey(secret, ephDER, recipient.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
out := gcm.Seal(nil, iv, []byte(plaintext), nil)
|
||||||
|
ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():]
|
||||||
|
b64 := base64.StdEncoding.EncodeToString
|
||||||
|
return Box{V: Version, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens a box with the recipient's PEM private key. It fails for a box sealed to another key, one
|
||||||
|
// changed in any field, or one of another version.
|
||||||
|
func Open(box Box, privatePEM string) (string, error) {
|
||||||
|
if box.V != Version {
|
||||||
|
return "", fmt.Errorf("a sealed box of version %d, not %d", box.V, Version)
|
||||||
|
}
|
||||||
|
priv, err := privateFromPEM(privatePEM)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
d := base64.StdEncoding.DecodeString
|
||||||
|
ephDER, err := d(box.Eph)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("the box's eph: %w", err)
|
||||||
|
}
|
||||||
|
ephKey, err := x509.ParsePKIXPublicKey(ephDER)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("the box's eph: %w", err)
|
||||||
|
}
|
||||||
|
eph, ok := ephKey.(*ecdh.PublicKey)
|
||||||
|
if !ok || eph.Curve() != ecdh.X25519() {
|
||||||
|
return "", errors.New("the box's eph is not an X25519 key")
|
||||||
|
}
|
||||||
|
secret, err := priv.ECDH(eph)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
iv, err1 := d(box.IV)
|
||||||
|
tag, err2 := d(box.Tag)
|
||||||
|
ct, err3 := d(box.Ct)
|
||||||
|
if err := errors.Join(err1, err2, err3); err != nil {
|
||||||
|
return "", fmt.Errorf("the box's fields: %w", err)
|
||||||
|
}
|
||||||
|
gcm, err := newGCM(boxKey(secret, ephDER, priv.PublicKey().Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(iv) != gcm.NonceSize() || len(tag) != gcm.Overhead() {
|
||||||
|
return "", errors.New("the box does not open with this key")
|
||||||
|
}
|
||||||
|
plain, err := gcm.Open(nil, iv, append(append([]byte{}, ct...), tag...), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("the box does not open with this key")
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicFromPEM(p string) (*ecdh.PublicKey, error) {
|
||||||
|
block, _ := pem.Decode([]byte(p))
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("not a PEM public key")
|
||||||
|
}
|
||||||
|
k, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pub, ok := k.(*ecdh.PublicKey)
|
||||||
|
if !ok || pub.Curve() != ecdh.X25519() {
|
||||||
|
return nil, errors.New("not an X25519 public key")
|
||||||
|
}
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateFromPEM(p string) (*ecdh.PrivateKey, error) {
|
||||||
|
block, _ := pem.Decode([]byte(p))
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("not a PEM private key")
|
||||||
|
}
|
||||||
|
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
priv, ok := k.(*ecdh.PrivateKey)
|
||||||
|
if !ok || priv.Curve() != ecdh.X25519() {
|
||||||
|
return nil, errors.New("not an X25519 private key")
|
||||||
|
}
|
||||||
|
return priv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// boxKey is HKDF-SHA256 (RFC 5869) for one 32-byte block: written out because crypto/hkdf needs Go 1.24
|
||||||
|
// and this module promises 1.22; the vectors prove it equals the licence manager's hkdf.Key.
|
||||||
|
func boxKey(secret, ephDER, recipientRaw []byte) []byte {
|
||||||
|
salt := append(append([]byte{}, ephDER...), recipientRaw...)
|
||||||
|
extract := hmac.New(sha256.New, salt)
|
||||||
|
extract.Write(secret)
|
||||||
|
prk := extract.Sum(nil)
|
||||||
|
expand := hmac.New(sha256.New, prk)
|
||||||
|
expand.Write([]byte(info))
|
||||||
|
expand.Write([]byte{1})
|
||||||
|
return expand.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cipher.NewGCM(block)
|
||||||
|
}
|
||||||
+169
-3
@@ -19,6 +19,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is the vocabulary this host speaks. A declaration naming any other version is
|
// Version is the vocabulary this host speaks. A declaration naming any other version is
|
||||||
@@ -205,6 +207,12 @@ type File struct {
|
|||||||
// by looking rather than by knowing which field won.
|
// by looking rather than by knowing which field won.
|
||||||
Sealed string `json:"sealed,omitempty"`
|
Sealed string `json:"sealed,omitempty"`
|
||||||
|
|
||||||
|
// VaultSealed is content the vault sealed to this node's sealing key, in the mesh's sealed box (novox/hq ADR
|
||||||
|
// 0206): a core module's own credential, which the controller carries in the declaration and cannot open
|
||||||
|
// (ADR 0349 §16, issue 755). The same promise as Sealed, from the vault rather than the controller; sent only to
|
||||||
|
// an engine that states VaultBoxContract. Exclusive with Content, Sealed and Bytes.
|
||||||
|
VaultSealed *seal.Box `json:"vault-sealed,omitempty"`
|
||||||
|
|
||||||
// Trusted is the catalogue's word on whether the settings this file's content took are trusted (novox/hq
|
// Trusted is the catalogue's word on whether the settings this file's content took are trusted (novox/hq
|
||||||
// issue 339). It is the controller's to act on, and a controller takes it out before it sends a declaration.
|
// issue 339). It is the controller's to act on, and a controller takes it out before it sends a declaration.
|
||||||
// Accepted here, and nothing is done with it, so that a controller that passes it on — an older one, or one
|
// Accepted here, and nothing is done with it, so that a controller that passes it on — an older one, or one
|
||||||
@@ -237,11 +245,48 @@ type File struct {
|
|||||||
|
|
||||||
// Owner is the user this belongs to, by name. Absent means root.
|
// Owner is the user this belongs to, by name. Absent means root.
|
||||||
Owner string `json:"owner,omitempty"`
|
Owner string `json:"owner,omitempty"`
|
||||||
|
|
||||||
|
// OwnerAtRuntime says who owns the file once it is placed (novox/hq ADR 0349 §5): "owned", the mesh's
|
||||||
|
// whole, written back whenever it differs; "seed", written once and then the program's; or "managed",
|
||||||
|
// only its named keys or its marked block the mesh's, the program keeping the rest.
|
||||||
|
//
|
||||||
|
// **Three words for what the forms above already do.** Owned is an ordinary file, seed is create-once
|
||||||
|
// and managed is a file written into, so the word changes no write: a file that says it is applied
|
||||||
|
// exactly as its old form is, and one that does not say is owned by its old form (Ownership). The word
|
||||||
|
// is what the provisioner's files say of themselves, which the old forms only implied. Seed is enough
|
||||||
|
// alone; managed still needs `into`, which says the format. A word that contradicts the form is refused.
|
||||||
|
OwnerAtRuntime string `json:"owner-at-runtime,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The words a file says who owns it at runtime with (novox/hq ADR 0349 §5).
|
||||||
|
const (
|
||||||
|
RuntimeOwned = "owned"
|
||||||
|
RuntimeSeed = "seed"
|
||||||
|
RuntimeManaged = "managed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ownership is who owns the file at runtime: the word it says, or the one its old form implies — a file
|
||||||
|
// written into is managed, one created once is a seed, and any other is owned.
|
||||||
|
func (f *File) Ownership() string {
|
||||||
|
switch {
|
||||||
|
case f.OwnerAtRuntime != "":
|
||||||
|
return f.OwnerAtRuntime
|
||||||
|
case f.Into != "":
|
||||||
|
return RuntimeManaged
|
||||||
|
case f.CreateOnce:
|
||||||
|
return RuntimeSeed
|
||||||
|
default:
|
||||||
|
return RuntimeOwned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seeded reports whether the file is written only when absent: created once, by create-once or by the
|
||||||
|
// word seed.
|
||||||
|
func (f *File) Seeded() bool { return f.CreateOnce || f.OwnerAtRuntime == RuntimeSeed }
|
||||||
|
|
||||||
// Secret reports whether this file arrived sealed, which is what decides both that it must be
|
// Secret reports whether this file arrived sealed, which is what decides both that it must be
|
||||||
// opened before writing and that its contents must never appear in a report.
|
// opened before writing and that its contents must never appear in a report.
|
||||||
func (f *File) Secret() bool { return f.Sealed != "" }
|
func (f *File) Secret() bool { return f.Sealed != "" || f.VaultSealed != nil }
|
||||||
|
|
||||||
func (f *File) Identity() string { return f.ID }
|
func (f *File) Identity() string { return f.ID }
|
||||||
func (f *File) Kind() Type { return TypeFile }
|
func (f *File) Kind() Type { return TypeFile }
|
||||||
@@ -314,12 +359,13 @@ func (f *File) validate(where string, _ bool) []string {
|
|||||||
where, f.At))
|
where, f.At))
|
||||||
}
|
}
|
||||||
if f.Into == IntoJSON || f.Into == IntoBlock {
|
if f.Into == IntoJSON || f.Into == IntoBlock {
|
||||||
if f.Sealed != "" || f.Bytes != "" || len(f.Secrets) > 0 || f.CreateOnce {
|
if f.Secret() || f.Bytes != "" || len(f.Secrets) > 0 || f.CreateOnce {
|
||||||
problems = append(problems, where+
|
problems = append(problems, where+
|
||||||
": a file written into says only its part, in content — not sealed, bytes, "+
|
": a file written into says only its part, in content — not sealed, bytes, "+
|
||||||
"secrets or create-once")
|
"secrets or create-once")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
problems = append(problems, f.checkOwnership(where)...)
|
||||||
var said []string
|
var said []string
|
||||||
for name, value := range map[string]string{
|
for name, value := range map[string]string{
|
||||||
"content": f.Content, "sealed": f.Sealed, "bytes": f.Bytes,
|
"content": f.Content, "sealed": f.Sealed, "bytes": f.Bytes,
|
||||||
@@ -328,6 +374,16 @@ func (f *File) validate(where string, _ bool) []string {
|
|||||||
said = append(said, name)
|
said = append(said, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if f.VaultSealed != nil {
|
||||||
|
said = append(said, "vault-sealed")
|
||||||
|
if f.VaultSealed.V != seal.Version {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s: vault-sealed is a sealed box of version %d, and this "+
|
||||||
|
"node-engine opens version %d", where, f.VaultSealed.V, seal.Version))
|
||||||
|
}
|
||||||
|
if len(f.Secrets) > 0 {
|
||||||
|
problems = append(problems, where+": a file the vault sealed whole has no holes for secrets")
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(said) > 1 {
|
if len(said) > 1 {
|
||||||
sort.Strings(said)
|
sort.Strings(said)
|
||||||
problems = append(problems, where+
|
problems = append(problems, where+
|
||||||
@@ -361,6 +417,42 @@ func (f *File) validate(where string, _ bool) []string {
|
|||||||
return append(problems, checkMode(where, f.Mode)...)
|
return append(problems, checkMode(where, f.Mode)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkOwnership refuses a word that contradicts the file's form (novox/hq ADR 0349 §5): owned is a whole
|
||||||
|
// file written back, so not created once nor written into; a seed is a whole file created once, so not
|
||||||
|
// written into; managed is a file written into, which says its format. Managed and created once together
|
||||||
|
// is already refused by the rule that a file written into is not created once.
|
||||||
|
func (f *File) checkOwnership(where string) []string {
|
||||||
|
switch f.OwnerAtRuntime {
|
||||||
|
case "":
|
||||||
|
case RuntimeOwned:
|
||||||
|
var also []string
|
||||||
|
if f.CreateOnce {
|
||||||
|
also = append(also, "create-once")
|
||||||
|
}
|
||||||
|
if f.Into != "" {
|
||||||
|
also = append(also, "into")
|
||||||
|
}
|
||||||
|
if len(also) > 0 {
|
||||||
|
return []string{fmt.Sprintf("%s: owned at runtime is the mesh's whole file, written back whenever it "+
|
||||||
|
"differs, and this also says %s", where, strings.Join(also, " and "))}
|
||||||
|
}
|
||||||
|
case RuntimeSeed:
|
||||||
|
if f.Into != "" {
|
||||||
|
return []string{where + ": a seed is a whole file written once, and this says into; a file the " +
|
||||||
|
"program shares with the mesh is managed"}
|
||||||
|
}
|
||||||
|
case RuntimeManaged:
|
||||||
|
if f.Into == "" {
|
||||||
|
return []string{where + ": a managed file names the format its keys or block are written into, " +
|
||||||
|
"with into \"json\" or \"block\""}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return []string{fmt.Sprintf("%s: owner-at-runtime %q; a placed file is %q, %q or %q, or omits it to be "+
|
||||||
|
"owned by its form", where, f.OwnerAtRuntime, RuntimeOwned, RuntimeSeed, RuntimeManaged)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// User is a login on the machine.
|
// User is a login on the machine.
|
||||||
//
|
//
|
||||||
// The thing that makes a shell, a chat client or a desktop expressible at all: each is a package
|
// The thing that makes a shell, a chat client or a desktop expressible at all: each is a package
|
||||||
@@ -1565,6 +1657,71 @@ type Declaration struct {
|
|||||||
// this is its home, whether the operator's warrant to take it was carried out, the sub-paths its modules ask for,
|
// this is its home, whether the operator's warrant to take it was carried out, the sub-paths its modules ask for,
|
||||||
// and the tool runner's account to put in its group (§11). Absent is a controller older than ADR 0328.
|
// and the tool runner's account to put in its group (§11). Absent is a controller older than ADR 0328.
|
||||||
Libraries []Library
|
Libraries []Library
|
||||||
|
|
||||||
|
// Filled is the paths of the files the provisioner fills on this machine (novox/hq ADR 0349 §4, §5): each a
|
||||||
|
// file this declaration declares, which says who owns it at runtime. The provisioner's client has the
|
||||||
|
// node-engine write only these. Absent is a controller that hands no file to the provisioner, and every
|
||||||
|
// file is written from the declaration as before.
|
||||||
|
Filled []string
|
||||||
|
|
||||||
|
// Provisioner is the account the provisioner's client runs as on this machine, by name (novox/hq ADR 0349 §4):
|
||||||
|
// the only account whose write of a filled file the node-engine's local write socket accepts. Absent is a
|
||||||
|
// controller that names none, and every such write is refused.
|
||||||
|
Provisioner string
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkProvisioner refuses a provisioner client's account that is no account name, or is root: the account whose
|
||||||
|
// writes the node-engine accepts must be one that could not write the file without it.
|
||||||
|
func checkProvisioner(name string) []string {
|
||||||
|
switch {
|
||||||
|
case name == "":
|
||||||
|
return nil
|
||||||
|
case !accountName.MatchString(name):
|
||||||
|
return []string{fmt.Sprintf("provisioner_account %q is not an account name", name)}
|
||||||
|
case name == "root":
|
||||||
|
return []string{"provisioner_account is root; the provisioner's client runs as an account of its own"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fills reports whether the provisioner fills the file at path on this machine.
|
||||||
|
func (d *Declaration) Fills(path string) bool { return slices.Contains(d.Filled, path) }
|
||||||
|
|
||||||
|
// checkFilled refuses a filled path that is not a declared file saying who owns it at runtime, or that is named
|
||||||
|
// twice or empty. Every file declared at the path must say it: the provisioner writes the path, and a file there
|
||||||
|
// the mesh writes some other way would be a second writer of it (ADR 0349 §5).
|
||||||
|
func checkFilled(filled []string, resources []Resource) []string {
|
||||||
|
var problems []string
|
||||||
|
files := map[string][]*File{}
|
||||||
|
for _, r := range resources {
|
||||||
|
if f, ok := r.(*File); ok {
|
||||||
|
files[f.Path] = append(files[f.Path], f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, path := range filled {
|
||||||
|
switch {
|
||||||
|
case path == "":
|
||||||
|
problems = append(problems, "filled_files names a file with no path")
|
||||||
|
continue
|
||||||
|
case seen[path]:
|
||||||
|
problems = append(problems, fmt.Sprintf("filled_files names %q twice", path))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[path] = true
|
||||||
|
if len(files[path]) == 0 {
|
||||||
|
problems = append(problems, fmt.Sprintf("filled_files names %q, and this declaration declares no "+
|
||||||
|
"file there", path))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range files[path] {
|
||||||
|
if f.OwnerAtRuntime == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf("resource %q: the provisioner fills %q, and a file it "+
|
||||||
|
"fills says who owns it at runtime with owner-at-runtime", f.ID, path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identity is one module's identity, as the controller allocated it (ADR 0328 §4): its user `mesh-<module>` with
|
// Identity is one module's identity, as the controller allocated it (ADR 0328 §4): its user `mesh-<module>` with
|
||||||
@@ -1904,6 +2061,12 @@ type envelope struct {
|
|||||||
// sends them only to a host whose health says link.LibraryContract.
|
// sends them only to a host whose health says link.LibraryContract.
|
||||||
Groups []Group `json:"groups,omitempty"`
|
Groups []Group `json:"groups,omitempty"`
|
||||||
Libraries []Library `json:"libraries,omitempty"`
|
Libraries []Library `json:"libraries,omitempty"`
|
||||||
|
// Filled is optional too (novox/hq ADR 0349 §5), as is a file's `owner-at-runtime`. **An older host refuses
|
||||||
|
// these keys**; a controller sends them only to a host whose health says link.OwnershipContract.
|
||||||
|
Filled []string `json:"filled_files,omitempty"`
|
||||||
|
// Provisioner is optional too (novox/hq ADR 0349 §4). **An older host refuses this key**; a controller sends it
|
||||||
|
// only to a host whose health says link.ProvisionerContract.
|
||||||
|
Provisioner string `json:"provisioner_account,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||||
@@ -1922,8 +2085,10 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
|||||||
|
|
||||||
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence,
|
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence,
|
||||||
Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack,
|
Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack,
|
||||||
Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries}
|
Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries, Filled: env.Filled,
|
||||||
|
Provisioner: env.Provisioner}
|
||||||
problems := checkIdentities(env.Identities, env.Groups)
|
problems := checkIdentities(env.Identities, env.Groups)
|
||||||
|
problems = append(problems, checkProvisioner(env.Provisioner)...)
|
||||||
problems = append(problems, checkLibraries(env.Groups, env.Libraries)...)
|
problems = append(problems, checkLibraries(env.Groups, env.Libraries)...)
|
||||||
if env.Generation < 0 {
|
if env.Generation < 0 {
|
||||||
// Below zero would read as "none claimed" and pass every refusal of an older generation.
|
// Below zero would read as "none claimed" and pass every refusal of an older generation.
|
||||||
@@ -2015,6 +2180,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
|||||||
problems = append(problems, checkWhileStopped(d.Resources)...)
|
problems = append(problems, checkWhileStopped(d.Resources)...)
|
||||||
problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...)
|
problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...)
|
||||||
problems = append(problems, checkReplaces(d.Resources)...)
|
problems = append(problems, checkReplaces(d.Resources)...)
|
||||||
|
problems = append(problems, checkFilled(env.Filled, d.Resources)...)
|
||||||
if env.Adoption == nil {
|
if env.Adoption == nil {
|
||||||
for _, r := range d.Resources {
|
for _, r := range d.Resources {
|
||||||
if r.Kind() == TypeOpening {
|
if r.Kind() == TypeOpening {
|
||||||
|
|||||||
Vendored
+4
-3
@@ -1,6 +1,7 @@
|
|||||||
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19
|
||||||
## explicit; go 1.22
|
## explicit; go 1.22
|
||||||
git.novox.be/novox/mesh-sdk/go/asks
|
git.novox.be/novox/mesh-sdk/go/asks
|
||||||
|
git.novox.be/novox/mesh-sdk/go/seal
|
||||||
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op
|
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op
|
||||||
## explicit; go 1.24.0
|
## explicit; go 1.24.0
|
||||||
github.com/antithesishq/antithesis-sdk-go/assert
|
github.com/antithesishq/antithesis-sdk-go/assert
|
||||||
@@ -78,7 +79,7 @@ github.com/nats-io/nkeys
|
|||||||
# github.com/nats-io/nuid v1.0.1
|
# github.com/nats-io/nuid v1.0.1
|
||||||
## explicit
|
## explicit
|
||||||
github.com/nats-io/nuid
|
github.com/nats-io/nuid
|
||||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261013220009-b9a293f8c8bc
|
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261015211328-5a7ec3775b98
|
||||||
## explicit; go 1.26.0
|
## explicit; go 1.26.0
|
||||||
github.com/novox/mesh-host/internal/declaration
|
github.com/novox/mesh-host/internal/declaration
|
||||||
github.com/novox/mesh-host/rootsearch
|
github.com/novox/mesh-host/rootsearch
|
||||||
@@ -138,4 +139,4 @@ golang.org/x/text/width
|
|||||||
# golang.org/x/time v0.15.0
|
# golang.org/x/time v0.15.0
|
||||||
## explicit; go 1.25.0
|
## explicit; go 1.25.0
|
||||||
golang.org/x/time/rate
|
golang.org/x/time/rate
|
||||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261013220009-b9a293f8c8bc
|
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261015211328-5a7ec3775b98
|
||||||
|
|||||||
Reference in New Issue
Block a user