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) + } +}