From d4077b473ea854d84ea2b197f580f21a0bf9bf3e Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 10:15:03 +0200 Subject: [PATCH] =?UTF-8?q?Bind=20a=20warrant=20to=20the=20exact=20ask=20a?= =?UTF-8?q?nd=20each=20option=20to=20the=20act=20it=20performs=20(hq=20ADR?= =?UTF-8?q?=200259=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A warrant named the ask by id and the option by id, and the asker acted on whatever its record held for them. Now the ask carries, for every option above acknowledge, the digest of exactly what it performs (Option.Binds, ActDigest), the router puts the digest of the ask it took in the warrant (Warrant.AskDigest), For refuses a warrant whose digest is not that of the ask the asker keeps, and Option.Performs refuses an act the option did not bind. A warrant then authorises one act, as the person was shown it, once. --- go/asker/asker_test.go | 17 ++++++++-- go/asks/asks.go | 62 ++++++++++++++++++++++++++++++++++-- go/asks/asks_test.go | 72 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 143 insertions(+), 8 deletions(-) diff --git a/go/asker/asker_test.go b/go/asker/asker_test.go index 4550784..6285876 100644 --- a/go/asker/asker_test.go +++ b/go/asker/asker_test.go @@ -55,15 +55,19 @@ func client() (*Client, map[string][]string) { return c, sent } +// releaseHQ is the act the ask's one option binds. +var releaseHQ = map[string]string{"verb": "release", "delivery": "hq"} + func held() asks.Ask { + binds, _ := asks.ActDigest(releaseHQ) return asks.Ask{ID: "d-1", Headline: "Delivery of hq held", Explanation: "Needs you: release it, or stop it.", Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held", - Options: []asks.Option{{ID: "release", Label: "Release", Does: "it goes on", Level: asks.Approve}}} + Options: []asks.Option{{ID: "release", Label: "Release", Does: "it goes on", Level: asks.Approve, Binds: binds}}} } func warrant() asks.Warrant { return asks.Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: asks.OutcomeChosen, Option: "release", - Label: "Release", Level: asks.Approve, At: now.Add(time.Minute), + Label: "Release", Level: asks.Approve, At: now.Add(time.Minute), AskDigest: held().Digest(), By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} } @@ -89,7 +93,13 @@ func TestAWarrantIsActedOnOnceAndOnlyForItsOwnAsk(t *testing.T) { c, _ := client() _ = c.Ask(held()) acted := 0 - act := func(asks.Option, asks.Warrant) error { acted++; return nil } + act := func(o asks.Option, _ asks.Warrant) error { + if err := o.Performs(releaseHQ); err != nil { + t.Errorf("the bound act: %v", err) + } + acted++ + return nil + } if h, err := c.Take(warrant(), act); h != Acted || err != nil || acted != 1 { t.Fatalf("%s %v %d", h, err, acted) } @@ -102,6 +112,7 @@ func TestAWarrantIsActedOnOnceAndOnlyForItsOwnAsk(t *testing.T) { "another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge }, "an option not offered": func(w *asks.Warrant) { w.Option = "delete" }, "after its expiry": func(w *asks.Warrant) { w.At = now.Add(2 * time.Hour) }, + "another ask's digest": func(w *asks.Warrant) { w.AskDigest = "sha256:00" }, } { c2, _ := client() _ = c2.Ask(held()) diff --git a/go/asks/asks.go b/go/asks/asks.go index 6d7fdd5..ca2e800 100644 --- a/go/asks/asks.go +++ b/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"` diff --git a/go/asks/asks_test.go b/go/asks/asks_test.go index 9c6ff89..029b9af 100644 --- a/go/asks/asks_test.go +++ b/go/asks/asks_test.go @@ -1,6 +1,7 @@ package asks import ( + "encoding/json" "strings" "testing" "time" @@ -12,12 +13,20 @@ func held() Ask { return Ask{ID: "d-1", Headline: "openrazer delivery held", Explanation: "Needs you: release it, or stop it.", Who: Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held", About: "delivery.openrazer", Options: []Option{ - {ID: "release", Label: "Release", Does: "the delivery goes on", Level: Approve}, - {ID: "stop", Label: "Stop", Does: "the delivery ends", Level: Approve}, + {ID: "release", Label: "Release", Does: "the delivery goes on", Level: Approve, Binds: bind("release", "openrazer")}, + {ID: "stop", Label: "Stop", Does: "the delivery ends", Level: Approve, Binds: bind("stop", "openrazer")}, {ID: "silence", Label: "Silence for a week", Does: "nothing more is said for a week", Level: Acknowledge}, }} } +func bind(verb, what string) string { + d, err := ActDigest(map[string]string{"verb": verb, "delivery": what}) + if err != nil { + panic(err) + } + return d +} + func TestASoundAskPasses(t *testing.T) { if err := held().Check(now); err != nil { t.Fatal(err) @@ -40,6 +49,7 @@ func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) { "destroys lasts at most": func(a *Ask) { a.Options[0].Level = Destroy }, "never defaults": func(a *Ask) { a.OnExpiry = "" }, "is a key, without spaces": func(a *Ask) { a.About = "a b" }, + "does not bind what it": func(a *Ask) { a.Options[0].Binds = "" }, } { a := held() change(&a) @@ -58,7 +68,8 @@ func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) { func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) { w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release", - Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} + Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}, + AskDigest: held().Digest()} if o, err := w.For("mesh-delivery", held()); err != nil || o.ID != "release" { t.Fatalf("a sound warrant was refused: %v", err) } @@ -73,6 +84,12 @@ func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) { "not offered": func(w *Warrant) { w.Option = "delete" }, "a lower level": func(w *Warrant) { w.Level = Acknowledge }, "another role": func(w *Warrant) { w.By = &Person{Who: "agent"} }, + "no digest": func(w *Warrant) { w.AskDigest = "" }, + "another ask's digest": func(w *Warrant) { + other := held() + other.Options[0].Binds = bind("release", "another") + w.AskDigest = other.Digest() + }, } { c := w by := *w.By @@ -97,3 +114,52 @@ func TestTheSubjectsNameTheirPublisher(t *testing.T) { } } } + +// A warrant answers one ask as it was shown, and authorises the act its option bound and no other +// (novox/hq ADR 0259 §6: the warrant is bound to the exact ask and its arguments). +func TestAWarrantAuthorisesOnlyTheActItsOptionBound(t *testing.T) { + a := held() + // The digest survives the trip through the bus: what the router decodes and digests is what was sent. + raw, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + var decoded Ask + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Digest() != a.Digest() { + t.Fatalf("the ask's digest changed on the way: %s, %s", decoded.Digest(), a.Digest()) + } + local := a + local.Expires = a.Expires.In(time.FixedZone("CEST", 2*3600)) + if local.Digest() != a.Digest() { + t.Error("the same instant in another zone digests differently") + } + // Changing anything the person was shown, or what an option binds, changes the digest. + for what, change := range map[string]func(*Ask){ + "the headline": func(a *Ask) { a.Headline += "!" }, + "the explanation": func(a *Ask) { a.Explanation += " " }, + "a label": func(a *Ask) { a.Options[0].Label = "Go" }, + "what it binds": func(a *Ask) { a.Options[0].Binds = bind("release", "another") }, + "the expiry": func(a *Ask) { a.Expires = a.Expires.Add(time.Second) }, + "what it is about": func(a *Ask) { a.About = "delivery.other" }, + } { + c := held() + change(&c) + if c.Digest() == a.Digest() { + t.Errorf("changing %s leaves the digest", what) + } + } + o, _ := a.Option("release") + if err := o.Performs(map[string]string{"verb": "release", "delivery": "openrazer"}); err != nil { + t.Errorf("the bound act was refused: %v", err) + } + if err := o.Performs(map[string]string{"verb": "release", "delivery": "another"}); err == nil { + t.Error("an act the option did not bind was let through") + } + ack, _ := a.Option("silence") + if err := ack.Performs(map[string]string{"anything": "x"}); err != nil { + t.Errorf("an acknowledgement that bound nothing: %v", err) + } +}