diff --git a/cmd/mesh-controller/asker.go b/cmd/mesh-controller/asker.go index b83e439e..0c8f977d 100644 --- a/cmd/mesh-controller/asker.go +++ b/cmd/mesh-controller/asker.go @@ -373,7 +373,11 @@ func askOf(id string, c conditions.Condition, now time.Time) (asks.Ask, map[stri approves = approves || level != asks.Acknowledge oid := optionID(act.Label) options[oid] = i - q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: doesWords(act), Level: level}) + // Every option binds the exact act it stands for (novox/hq ADR 0259 §6): the verb, the machine and + // every argument. The warrant then authorises that act and no other. + binds, _ := asks.ActDigest(boundAct(act)) + q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: doesWords(act), Level: level, + Binds: binds}) } q.Expires = now.Add(askAcknowledgeFor) if approves { @@ -382,6 +386,12 @@ func askOf(id string, c conditions.Condition, now time.Time) (asks.Ask, map[stri return q, options } +// boundAct is what an option's Binds digests: the act exactly as the controller will perform it, with its +// level, and never its label or words. +func boundAct(act conditions.Action) map[string]any { + return map[string]any{"verb": act.Verb, "machine": act.Machine, "arguments": act.Arguments, "level": act.Level} +} + func newAskID() string { var b [8]byte _, _ = rand.Read(b[:]) @@ -469,6 +479,12 @@ func (a *asker) Decided(ctx context.Context, body []byte) error { return nil } act := r.Actions[index] + // The act about to be performed is the one the option bound when the controller asked: a record changed + // since is refused, never performed. + if err := option.Performs(boundAct(act)); err != nil { + a.logf("REFUSED a warrant for the ask %s: %v", r.ID, err) + return nil + } r.State, r.Warrant = string(asks.OutcomeChosen), &w open, err := a.open(ctx) if err != nil { diff --git a/cmd/mesh-controller/asker_test.go b/cmd/mesh-controller/asker_test.go index 1bc43ae1..0edcb308 100644 --- a/cmd/mesh-controller/asker_test.go +++ b/cmd/mesh-controller/asker_test.go @@ -188,7 +188,8 @@ func (r *askerRig) warrantFor(t *testing.T, condition, label string) asks.Warran if o.Label == label { return asks.Warrant{Ask: a.ID, Asker: "mesh-controller", About: condition, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level, Channel: "telegram", Proofs: []string{"P1"}, At: r.now, - By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} + AskDigest: a.Ask.Digest(), + By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} } } } @@ -242,6 +243,8 @@ func TestAWarrantThatIsNotForItsOwnAskIsRefused(t *testing.T) { "an option not offered": func(w *asks.Warrant) { w.Option = "delete" }, "another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge }, "no person": func(w *asks.Warrant) { w.By = nil }, + "another ask's digest": func(w *asks.Warrant) { w.AskDigest = "sha256:0000" }, + "no ask's digest": func(w *asks.Warrant) { w.AskDigest = "" }, } { t.Run(name, func(t *testing.T) { r := newAskerRig(t) @@ -474,3 +477,40 @@ func TestAWarrantIsActedOnlyForAnOpenAskItClaimsBeforeItExpired(t *testing.T) { t.Errorf("acted on a cancelled ask: %v", r.called) } } + +// novox/hq ADR 0259 §6: a warrant authorises the act its option bound when the controller asked, and no +// other. A record of the act changed after the ask — another delivery, another machine, another argument — +// is refused and nothing is performed. +func TestAWarrantPerformsOnlyTheActItsOptionBound(t *testing.T) { + for name, change := range map[string]func(*conditions.Action){ + "another argument": func(a *conditions.Action) { + a.Arguments = map[string]string{"id": "novox/mesh-controller@000000000000"} + }, + "another verb": func(a *conditions.Action) { a.Verb = "mesh-delivery.stop" }, + "another machine": func(a *conditions.Action) { a.Machine = "anchor" }, + } { + t.Run(name, func(t *testing.T) { + r := newAskerRig(t) + r.open = []conditions.Condition{heldCondition()} + _ = r.a.reconcile(context.Background()) + w := r.warrantFor(t, heldCondition().Key, "Release") + kept := r.store[w.Ask] + acts := append([]conditions.Action(nil), kept.Actions...) + i := kept.Options[w.Option] + change(&acts[i]) + kept.Actions = acts + r.store[w.Ask] = kept + answerWith(t, r, w) + if len(r.called)+len(r.acts) != 0 { + t.Errorf("performed an act the option did not bind: %v %v", r.called, r.acts) + } + }) + } + // Every option of an ask binds its act. + q, _ := askOf("x", heldCondition(), time.Now()) + for _, o := range q.Options { + if o.Binds == "" { + t.Errorf("the option %s binds nothing", o.ID) + } + } +} diff --git a/go.mod b/go.mod index 5eaafc6b..af4c63ee 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-controller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f + git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/go.sum b/go.sum index 4c87bdf9..46fc965e 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6 h1:JT7xM1bnL git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f h1:BNvyWq899GwP7F3sY4ACieB5a5fnFAq+sJ9lP6HQ5qI= git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 h1:soqhLNpEXThdq6PdiPy6ExxjJ+yjhh1N1n9E3j1CtrM= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go b/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go index 491d2ba2..ca2e8006 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go @@ -6,6 +6,9 @@ package asks import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" "fmt" "regexp" @@ -96,6 +99,34 @@ type Option struct { Label string `json:"label"` Does string `json:"does"` Level Level `json:"level"` + // Binds is the digest of exactly what the asker performs when this option is chosen — the verb, the + // machine and every argument — as ActDigest gives it. It travels in the ask, so the router's warrant, + // which names the ask's digest, names it too: a warrant then authorises that act and no other, and an + // asker whose record of the act changed after it asked finds the digests differ and does nothing. + // Required on an option above acknowledge. + Binds string `json:"binds,omitempty"` +} + +// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's JSON (Go's encoding sorts a +// map's keys, so the same act gives the same digest), written "sha256:". +func ActDigest(act any) (string, error) { + raw, err := json.Marshal(act) + if err != nil { + return "", fmt.Errorf("the act cannot be digested: %w", err) + } + sum := sha256.Sum256(raw) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// Digest is the digest of the ask exactly as its asker published it: its id, words, options with what each +// binds, who answers, and its expiry. The router puts it in the warrant (Warrant.AskDigest), and an asker +// acts only on a warrant whose digest is that of the ask it keeps — so a warrant answers one ask, as the +// person was shown it, and nothing published under the same id before or after. +func (a Ask) Digest() string { + a.Expires = a.Expires.UTC() + raw, _ := json.Marshal(a) + sum := sha256.Sum256(raw) + return "sha256:" + hex.EncodeToString(sum[:]) } // Ask is a request for a person's word (novox/hq ADR 0259 §4). @@ -191,6 +222,9 @@ func (a Ask) Check(now time.Time) error { if o.Level.Rank() > Destroy.Rank() { say("the option %s has the level %q, which is none of acknowledge, approve, destroy", o.ID, o.Level) } + if o.Level != Acknowledge && !strings.HasPrefix(o.Binds, "sha256:") { + say("the option %s authorises and does not bind what it performs (its binds is not an ActDigest)", o.ID) + } } if !a.Expires.After(now) { say("it expires before it is asked") @@ -256,6 +290,9 @@ type Warrant struct { Channel string `json:"channel,omitempty"` Proofs []string `json:"proofs,omitempty"` At time.Time `json:"at"` + // AskDigest is the digest of the ask as the router took it (Ask.Digest): the warrant answers that ask + // alone, with the options it bound. + AskDigest string `json:"ask-digest,omitempty"` // Words are why an ask ended without a choice, or what refused it. Words string `json:"words,omitempty"` } @@ -273,8 +310,9 @@ func (w Warrant) Says() string { return fmt.Sprintf("the %s, via %s, chose %s", w.By.Who, via, w.Label) } -// For checks a warrant against the ask its asker made: the same asker and ask, a choice, an option the ask -// offered, at that option's level. An asker acts on nothing else. +// For checks a warrant against the ask its asker made: the same asker and ask, the same ask's digest (so the +// same words, options and binds), a choice, an option the ask offered, at that option's level, before the +// ask expired. An asker acts on nothing else, and then performs only what the option's Binds names. func (w Warrant) For(asker string, a Ask) (Option, error) { if w.Asker != asker || w.Ask != a.ID { return Option{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's %s", w.Asker, w.Ask, asker, a.ID) @@ -282,6 +320,10 @@ func (w Warrant) For(asker string, a Ask) (Option, error) { if w.Outcome != OutcomeChosen || w.By == nil || w.By.Who != a.Who { return Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome) } + if d := a.Digest(); w.AskDigest != d { + return Option{}, fmt.Errorf("the warrant answers an ask whose digest is %q, and the ask %s kept here is %s: "+ + "it was not the ask the person was shown", w.AskDigest, a.ID, d) + } o, offered := a.Option(w.Option) if !offered { return Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option) @@ -297,6 +339,22 @@ func (w Warrant) For(asker string, a Ask) (Option, error) { return o, nil } +// Performs checks that the act an asker is about to perform is the one the chosen option bound when it +// asked: the act's digest equals the option's Binds. An acknowledge option that bound nothing passes. +func (o Option) Performs(act any) error { + if o.Binds == "" && o.Level == Acknowledge { + return nil + } + d, err := ActDigest(act) + if err != nil { + return err + } + if d != o.Binds { + return fmt.Errorf("the option %s bound %s, and the act about to be performed is %s: nothing is done", o.ID, o.Binds, d) + } + return nil +} + // Button is one answer a channel offers: its label and the router's one-time ticket for it. type Button struct { Label string `json:"label"` @@ -318,6 +376,9 @@ type Message struct { // To is the account linked as the operator on this kind, for a channel that verifies its sender: where // the channel sends what is not a reply. The router's word, from its list; empty when none is linked. To string `json:"to,omitempty"` + // Secret says the words carry something shown once (a link's code): the channel shows it and keeps no + // copy of it — no state, no history of its own. + Secret bool `json:"secret,omitempty"` } // Sender is who a channel's service says sent something: the account, the name it shows, and whether the diff --git a/vendor/modules.txt b/vendor/modules.txt index 365367ae..f7f6eb66 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,4 +1,4 @@ -# git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f +# git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 ## explicit; go 1.22 git.novox.be/novox/mesh-sdk/go/asks # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op