From 76902998cd39969dc86cafff3162f4e466299ba5 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:59:28 +0200 Subject: [PATCH] Digest an ask and an act over named fields in a canonical encoding, not a language's encoding of a struct The review of 2026-10-09: a digest over Go's JSON of a struct changes when a tag is renamed or a field added, and another language cannot reproduce it. An act is now a flat map of named strings, and both digests are over length-prefixed values in a fixed order, versioned; a test fails when a field is added to Ask or Option without being added to the digest. --- go/asks/asks.go | 67 ++++++++++++++++++++++++++++------ go/asks/asks_test.go | 87 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 16 deletions(-) diff --git a/go/asks/asks.go b/go/asks/asks.go index ca2e800..efe2a66 100644 --- a/go/asks/asks.go +++ b/go/asks/asks.go @@ -8,10 +8,11 @@ package asks import ( "crypto/sha256" "encoding/hex" - "encoding/json" "errors" "fmt" "regexp" + "sort" + "strconv" "strings" "time" ) @@ -107,25 +108,67 @@ type Option struct { 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) +// An Act is what an option binds: named fields, each a string — the verb, the machine, the level, and each +// argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over these names and +// values alone, never over how a language happens to encode a struct. +type Act map[string]string + +// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's canonical encoding (canonical), +// written "sha256:". The same names and values give the same digest in any language, whatever order +// they were set in; a field renamed, added or emptied gives another. +func ActDigest(act Act) (string, error) { + if len(act) == 0 { + return "", fmt.Errorf("the act cannot be digested: it names nothing") } - sum := sha256.Sum256(raw) + keys := make([]string, 0, len(act)) + for k := range act { + if k == "" { + return "", fmt.Errorf("the act cannot be digested: a field has no name") + } + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + b.WriteString("novox.act.v1\n") + for _, k := range keys { + canonical(&b, k) + canonical(&b, act[k]) + } + sum := sha256.Sum256([]byte(b.String())) return "sha256:" + hex.EncodeToString(sum[:]), nil } +// canonical writes one value as its length in bytes, a colon, the bytes and a newline: no value can be read +// as another's end or start, so two different sequences of values never encode the same. +func canonical(b *strings.Builder, v string) { + b.WriteString(strconv.Itoa(len(v))) + b.WriteByte(':') + b.WriteString(v) + b.WriteByte('\n') +} + // 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. +// +// It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC +// RFC 3339 to the nanosecond — never over a language's encoding of the struct, so a field added to Ask +// later changes no digest until it is added here, on purpose. func (a Ask) Digest() string { - a.Expires = a.Expires.UTC() - raw, _ := json.Marshal(a) - sum := sha256.Sum256(raw) + var b strings.Builder + b.WriteString("novox.ask.v1\n") + for _, v := range []string{a.ID, a.Headline, a.Explanation, a.Who, + a.Expires.UTC().Format(time.RFC3339Nano), a.OnExpiry, a.About, strconv.FormatBool(a.Urgent), + strconv.Itoa(len(a.Options))} { + canonical(&b, v) + } + for _, o := range a.Options { + for _, v := range []string{o.ID, o.Label, o.Does, string(o.Level), o.Binds} { + canonical(&b, v) + } + } + sum := sha256.Sum256([]byte(b.String())) return "sha256:" + hex.EncodeToString(sum[:]) } @@ -341,7 +384,7 @@ func (w Warrant) For(asker string, a Ask) (Option, error) { // 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 { +func (o Option) Performs(act Act) error { if o.Binds == "" && o.Level == Acknowledge { return nil } diff --git a/go/asks/asks_test.go b/go/asks/asks_test.go index 029b9af..cfa05a7 100644 --- a/go/asks/asks_test.go +++ b/go/asks/asks_test.go @@ -1,7 +1,10 @@ package asks import ( + "crypto/sha256" + "encoding/hex" "encoding/json" + "reflect" "strings" "testing" "time" @@ -20,7 +23,7 @@ func held() Ask { } func bind(verb, what string) string { - d, err := ActDigest(map[string]string{"verb": verb, "delivery": what}) + d, err := ActDigest(Act{"verb": verb, "delivery": what}) if err != nil { panic(err) } @@ -152,14 +155,90 @@ func TestAWarrantAuthorisesOnlyTheActItsOptionBound(t *testing.T) { } } o, _ := a.Option("release") - if err := o.Performs(map[string]string{"verb": "release", "delivery": "openrazer"}); err != nil { + if err := o.Performs(Act{"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 { + if err := o.Performs(Act{"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 { + if err := ack.Performs(Act{"anything": "x"}); err != nil { t.Errorf("an acknowledgement that bound nothing: %v", err) } } + +// The digests are over named fields in a canonical encoding (the review of 2026-10-09): not over Go's +// encoding of a struct, so a field added to Ask or Option, a JSON tag renamed, or a map built in another +// order changes nothing it should not — and every field that is shown or bound is in it. +func TestDigestsAreCanonicalOverNamedFields(t *testing.T) { + a1, _ := ActDigest(Act{"verb": "release", "arg.delivery": "hq"}) + b := Act{} + b["arg.delivery"] = "hq" + b["verb"] = "release" + a2, _ := ActDigest(b) + if a1 != a2 { + t.Error("the order an act was built in changed its digest") + } + // A value cannot be read as two: "a"+"b=c" is not "a=b"+"c". + x, _ := ActDigest(Act{"a": "b\n1:c"}) + y, _ := ActDigest(Act{"a": "b", "c": ""}) + if x == y { + t.Error("two different acts encode the same") + } + if _, err := ActDigest(Act{}); err == nil { + t.Error("an empty act was digested") + } + // The encoding, by hand: any language reproduces it. + sum := sha256.Sum256([]byte("novox.act.v1\n4:verb\n7:release\n")) + if got, _ := ActDigest(Act{"verb": "release"}); got != "sha256:"+hex.EncodeToString(sum[:]) { + t.Errorf("the act's encoding is not the one written down: %s", got) + } + + // Every exported field of Ask and Option is in the digest: changing any one changes it. + base := held() + mutations := map[string]func(*Ask){ + "ID": func(a *Ask) { a.ID = "other" }, + "Headline": func(a *Ask) { a.Headline += "!" }, + "Explanation": func(a *Ask) { a.Explanation += "!" }, + "Who": func(a *Ask) { a.Who = "someone" }, + "Expires": func(a *Ask) { a.Expires = a.Expires.Add(time.Nanosecond) }, + "OnExpiry": func(a *Ask) { a.OnExpiry += "!" }, + "About": func(a *Ask) { a.About += "!" }, + "Urgent": func(a *Ask) { a.Urgent = !a.Urgent }, + "Options": func(a *Ask) { a.Options = a.Options[:1] }, + "Option.ID": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].ID = "x" }, + "Option.Label": func(a *Ask) { + a.Options = append([]Option(nil), a.Options...) + a.Options[0].Label += "!" + }, + "Option.Does": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].Does += "!" }, + "Option.Level": func(a *Ask) { + a.Options = append([]Option(nil), a.Options...) + a.Options[0].Level = Destroy + }, + "Option.Binds": func(a *Ask) { + a.Options = append([]Option(nil), a.Options...) + a.Options[0].Binds = "sha256:00" + }, + } + for name, mutate := range mutations { + c := base + mutate(&c) + if c.Digest() == base.Digest() { + t.Errorf("changing %s left the digest the same", name) + } + } + // A field added to Ask or Option without being added to Digest fails here. + if n := reflect.TypeOf(Ask{}).NumField(); n != 9 { + t.Errorf("Ask has %d fields, and Digest covers 9: add the new one to Digest and to this test", n) + } + if n := reflect.TypeOf(Option{}).NumField(); n != 5 { + t.Errorf("Option has %d fields, and Digest covers 5: add the new one to Digest and to this test", n) + } + // The same instant in another zone is the same ask. + z := base + z.Expires = z.Expires.In(time.FixedZone("x", 3600)) + if z.Digest() != base.Digest() { + t.Error("the expiry's zone changed the digest") + } +}