diff --git a/modules/mesh-grants/grantsstate/go.mod b/modules/mesh-grants/grantsstate/go.mod new file mode 100644 index 00000000..e86b9b17 --- /dev/null +++ b/modules/mesh-grants/grantsstate/go.mod @@ -0,0 +1,13 @@ +module git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0 + git.novox.be/novox/mesh-delivery/sdk v0.0.0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f +) + +replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds => ../../mesh-delivery-builder/builds + +replace git.novox.be/novox/mesh-delivery/sdk => ../../../sdk diff --git a/modules/mesh-grants/grantsstate/grants.go b/modules/mesh-grants/grantsstate/grants.go new file mode 100644 index 00000000..4fff716e --- /dev/null +++ b/modules/mesh-grants/grantsstate/grants.go @@ -0,0 +1,102 @@ +// Package grantsstate holds what the grants part owns and no seat carries (novox/hq ADR 0634 §1): the layout of its states +// `grants` (what a build's module may use) and `user-list` (the sealed list of users a bus node follows), and the rules over +// them. Other modules import it from the grants part by its published version (ADR 0573 §1). +package grantsstate + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/kept" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// **The grants issued** (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, ADR 0547 §4, §5, ADR 0590): when the grants +// part has issued a module's bus credentials for a fingerprint, it writes `.` in its state +// `grants` (PartGrantsBucket). The assigner reads it: a node's want moves to a fingerprint only once its grant exists. +// Only the grants part writes the bucket. +const ( + // PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it. + PartGrantsState = "grants" + // PartGrantsBucket is that state's bucket, `_`. + PartGrantsBucket = "mesh-grants_" + PartGrantsState + // PartGrantsRead is that state as a reader names it in `reads`. + PartGrantsRead = "mesh-grants." + PartGrantsState + // PartGrantsHistory is the revisions the bucket keeps per key. + PartGrantsHistory = 5 +) + +// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: `.`, +// the hex being the fingerprint's key part (FingerprintKeyPart). +func PartGrantKey(module, fingerprint string) (string, error) { + return builds.BuilderBuildKey(module, fingerprint) +} + +// ParsePartGrantKey is the inverse of PartGrantKey; it refuses a want or held key, naming it. +func ParsePartGrantKey(key string) (module, fingerprint string, err error) { + if module, fingerprint, err = builds.ParseBuilderBuildKey(key); err != nil { + return "", "", fmt.Errorf("grantsstate: %q is not a grant key, .: %w", key, err) + } + return module, fingerprint, nil +} + +// Grant is the value of a grant key: the bus accounts (their public keys) the module's processes were issued +// for that fingerprint, none for a module whose grants the tool runner's account carries, so a reissue for another +// account is a new revision. The entry's own time says when it was issued. +type Grant struct { + V int `json:"v"` + Accounts []string `json:"accounts,omitempty"` +} + +func (g Grant) check() error { + for _, a := range g.Accounts { + if strings.TrimSpace(a) == "" || strings.ContainsAny(a, " \t\n") { + return fmt.Errorf("grantsstate: a grant names an empty or spaced account %q", a) + } + } + return nil +} + +// EncodeGrant is g as JSON at Version; it refuses an empty or spaced account. +func EncodeGrant(g Grant) ([]byte, error) { + if err := g.check(); err != nil { + return nil, err + } + g.V = keys.Version + return json.Marshal(g) +} + +// DecodeGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account. +func DecodeGrant(data []byte) (Grant, error) { + var g Grant + if err := kept.Decode(data, &g); err != nil { + return Grant{}, err + } + if err := g.check(); err != nil { + return Grant{}, err + } + return g, nil +} + +// SameGrant says whether two grants are the same value. +func SameGrant(a, b Grant) bool { return reflect.DeepEqual(a, b) } + +// NamesNode says whether the grant names module's accounts on node: its own `.` or a process's +// `..`. A grant that names no account (a module that does not speak on the bus) names every +// node. The assigner wants a node only once the grant names its account (issue 1945), and a put waits for it (ADR +// 0546 clause 4). +func (g Grant) NamesNode(node, module string) bool { + if len(g.Accounts) == 0 { + return true + } + own := node + "." + module + for _, a := range g.Accounts { + if a == own || strings.HasPrefix(a, own+".") { + return true + } + } + return false +} diff --git a/modules/mesh-grants/grantsstate/grantsstate_test.go b/modules/mesh-grants/grantsstate/grantsstate_test.go new file mode 100644 index 00000000..48f43546 --- /dev/null +++ b/modules/mesh-grants/grantsstate/grantsstate_test.go @@ -0,0 +1,118 @@ +package grantsstate + +import ( + "bytes" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// 1394/B1b: a grant key round-trips; a want or held key is refused, naming it; a grant round-trips and one naming an +// empty account is refused. +func TestTheGrantsIssued(t *testing.T) { + fp := "src2:" + strings.Repeat("c", 64) + key, err := PartGrantKey("mesh-delivery-assigner", fp) + if err != nil || key != "mesh-delivery-assigner."+strings.Repeat("c", 64) { + t.Fatalf("PartGrantKey = %q, %v", key, err) + } + module, back, err := ParsePartGrantKey(key) + if err != nil || module != "mesh-delivery-assigner" || back != fp { + t.Errorf("ParsePartGrantKey = %q, %q, %v", module, back, err) + } + held := "mesh-mail." + strings.Repeat("c", 64) + ".held" + for _, bad := range []string{"want.ace.mesh-mail", held, "ace.mesh-mail", ""} { + if _, _, err := ParsePartGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParsePartGrantKey(%q) = %v, want a refusal naming it", bad, err) + } + } + for _, g := range []Grant{{}, {Accounts: []string{"UABC", "UDEF"}}} { + data, err := EncodeGrant(g) + if err != nil { + t.Fatal(err) + } + got, err := DecodeGrant(data) + g.V = keys.Version + if err != nil || !SameGrant(got, g) { + t.Errorf("grant %+v came back %+v, %v", g, got, err) + } + } + if _, err := EncodeGrant(Grant{Accounts: []string{""}}); err == nil { + t.Errorf("a grant naming an empty account was accepted") + } + g := Grant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}} + for _, c := range []struct { + g Grant + node, module string + names bool + }{ + {g, "ace", "mesh-build-go", true}, + {g, "shanks", "mesh-mail", true}, + {g, "g14", "mesh-build-go", false}, + {g, "ace", "mesh-build", false}, + {Grant{}, "novox", "nats", true}, + } { + if got := c.g.NamesNode(c.node, c.module); got != c.names { + t.Errorf("%v names %s.%s: %v, want %v", c.g.Accounts, c.node, c.module, got, c.names) + } + } +} + +// The grants part's list sealed to the bus node (novox/hq ADR 0547 clause 7): the key's value round-trips and refuses what +// the bus node could not follow, and the object opens with the node's key alone, to the digest the key names. +func TestTheGrantsPartsSealedUserList(t *testing.T) { + list := []byte("accounts { MESH { users = [ { user: \"controller\" } ] } }\n") + digest := UserListDigest(list) + node, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + other, _ := seal.GenerateKeyPair() + g := GrantsUserList{Follow: FollowGrants, Digest: digest, Object: UserListObjectName(digest), + SealedTo: SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: 1} + raw, err := EncodeGrantsUserList(g) + if err != nil { + t.Fatal(err) + } + back, err := DecodeGrantsUserList(raw) + if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants { + t.Fatalf("decoded %+v, %v", back, err) + } + // Issue 1126 B4a (ADR 0590): a key written with the lease's flag still reads; the controller's list is no source. + if old, err := DecodeGrantsUserList([]byte(`{"v":1,"follow":"grants","digest":"","object":"","sealed_to":"",` + + `"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z","lease-handed":true}`)); err != nil || old.Revision != 3 { + t.Errorf("a key with the lease's flag decodes %+v, %v", old, err) + } + for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object}, {Follow: "controller"}, + {Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} { + if _, err := EncodeGrantsUserList(bad); err == nil { + t.Errorf("%+v was written", bad) + } + } + if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowGrants}); err != nil { + t.Errorf("a switch with no list yet refused: %v", err) + } + object, err := SealUserList(list, node.PublicKey) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(object, []byte("controller")) { + t.Fatal("the object carries the list in the clear") + } + opener := func(priv string) func(seal.Box) ([]byte, error) { + return func(b seal.Box) ([]byte, error) { s, err := seal.Open(b, priv); return []byte(s), err } + } + if got, err := OpenUserList(object, digest, opener(node.PrivateKey)); err != nil || !bytes.Equal(got, list) { + t.Fatalf("opened %q, %v", got, err) + } + if _, err := OpenUserList(object, digest, opener(other.PrivateKey)); err == nil { + t.Error("another key opened the list") + } + if _, err := OpenUserList(object, "sha256:00", opener(node.PrivateKey)); err == nil { + t.Error("a list of another digest was taken") + } + if _, err := SealUserList(list, "not a key"); err == nil { + t.Error("a list was sealed to no key") + } +} diff --git a/modules/mesh-grants/grantsstate/userlist.go b/modules/mesh-grants/grantsstate/userlist.go new file mode 100644 index 00000000..e968c7a2 --- /dev/null +++ b/modules/mesh-grants/grantsstate/userlist.go @@ -0,0 +1,148 @@ +package grantsstate + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-delivery/sdk/kept" + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clauses 7 and 8, issue 1945): the contract +// between the grants part (mesh-delivery modules/mesh-grants), which writes it, and the node-engine of the node holding +// mesh-broker (mesh-host), which follows it once the operator's take-over says so. +// +// - The grants part composes the bus's user list (the accounts block, buscompose.ComposeAccounts, with the vault's +// hashes), seals it to the bus node's sealing key (seal.Seal, the key the node says in its have of the bus module, +// UserListWritten.Key), and puts the box as one object of its object bucket GrantsUserListObjects, named by the +// list's digest (UserListObjectName). +// - It writes the key GrantsUserListKey of its state `user-list` (bucket GrantsUserListBucket): the GrantsUserList +// below, which names the object, the list's digest, its revision, its user count and the users left out, and which +// source the bus node follows (Follow: FollowGrants, the one source since the controller ended, ADR 0590). +// - The node-engine of the bus node watches that key alone. It opens the object, checks the digest, writes the bus's +// user-list file (mode 0600) only when the file's digest differs, and says what it loaded in its have of the bus +// module (UserListWritten, Source GrantsUserListSource). + +// The names of the grants part's user list on the bus. +const ( + // GrantsUserListBucket is the grants part's state `user-list`. + GrantsUserListBucket = "mesh-grants_user-list" + // GrantsUserListKey is the one key of it the bus node reads. + GrantsUserListKey = "sealed" + // GrantsUserListObjects is the grants part's object bucket, where each sealed list is one object. + GrantsUserListObjects = "mesh-grants-user-list" + // GrantsUserListObjectStream is the bucket's stream. + GrantsUserListObjectStream = "OBJ_" + GrantsUserListObjects + // GrantsUserListSource is the source a have names for a list taken from the grants part. + GrantsUserListSource = GrantsUserListBucket + "." + GrantsUserListKey +) + +// FollowGrants is the list the bus node follows: the grants part's, the one source (ADR 0590). +const FollowGrants = "grants" + +// GrantsUserList is the value of the grants part's key `user-list.sealed`. +type GrantsUserList struct { + V int `json:"v"` + // Follow is which list the bus node follows: FollowGrants, the one value it takes. + Follow string `json:"follow"` + // Digest is the list's digest, `sha256:` of the file's bytes as the bus loads them. + Digest string `json:"digest"` + // Object is the object's name in GrantsUserListObjects (UserListObjectName of Digest). + Object string `json:"object"` + // SealedTo is the fingerprint of the key the object is sealed to (SealingKeyID), and Node the node it holds. + SealedTo string `json:"sealed_to"` + Node string `json:"node"` + // Revision counts the lists the grants part wrote: one more for each new digest. + Revision uint64 `json:"revision"` + Users int `json:"users"` + // LeftOut are the users the list leaves out for want of a hash in the vault. + LeftOut []string `json:"left_out"` + At time.Time `json:"at"` +} + +// EncodeGrantsUserList is g as JSON at Version; it refuses a value the bus node could not follow. +func EncodeGrantsUserList(g GrantsUserList) ([]byte, error) { + g.V = keys.Version + if g.LeftOut == nil { + g.LeftOut = []string{} + } + if err := g.check(); err != nil { + return nil, err + } + return json.Marshal(g) +} + +// DecodeGrantsUserList reads the key's value; it refuses another version and what Encode refuses. +func DecodeGrantsUserList(data []byte) (GrantsUserList, error) { + var g GrantsUserList + if err := kept.Decode(data, &g); err != nil { + return GrantsUserList{}, err + } + if err := g.check(); err != nil { + return GrantsUserList{}, err + } + return g, nil +} + +func (g GrantsUserList) check() error { + if g.Follow != FollowGrants { + return fmt.Errorf("grantsstate: the user list's follow %q is not %s, the one list since the controller ended (ADR 0590)", + g.Follow, FollowGrants) + } + if g.Digest == "" { + return nil // a switch with no list written yet: the bus node keeps what it follows + } + if !strings.HasPrefix(g.Digest, "sha256:") || g.Object != UserListObjectName(g.Digest) { + return fmt.Errorf("grantsstate: the user list names object %q for digest %q; the object is the digest's hex", g.Object, g.Digest) + } + return nil +} + +// UserListDigest is a list's digest as the have and the key say it, `sha256:` of its bytes. +func UserListDigest(content []byte) string { + sum := sha256.Sum256(content) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// UserListObjectName is the object a list of that digest is put under: the digest's hex. +func UserListObjectName(digest string) string { return strings.TrimPrefix(digest, "sha256:") } + +// SealingKeyID is a short fingerprint of a public key in PEM, `sha256:<16 hex>`, to say which key a box is sealed to +// without carrying the key. +func SealingKeyID(publicPEM string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM))) + return "sha256:" + hex.EncodeToString(sum[:8]) +} + +// SealUserList is the object a list is put as: the sealed box (seal.Seal) of its bytes to the bus node's key, as JSON. +func SealUserList(list []byte, nodePEM string) ([]byte, error) { + if !strings.Contains(nodePEM, "PUBLIC KEY") { + return nil, fmt.Errorf("grantsstate: the bus node's key is not a public key in PEM") + } + box, err := seal.Seal(string(list), nodePEM) + if err != nil { + return nil, fmt.Errorf("grantsstate: the user list could not be sealed: %w", err) + } + return json.Marshal(box) +} + +// OpenUserList opens an object with open (the node's own key) and checks it is the list of digest. +func OpenUserList(object []byte, digest string, open func(seal.Box) ([]byte, error)) ([]byte, error) { + var box seal.Box + if err := json.Unmarshal(object, &box); err != nil { + return nil, fmt.Errorf("grantsstate: the object is no sealed box: %w", err) + } + list, err := open(box) + if err != nil { + return nil, err + } + if got := UserListDigest(list); got != digest { + return nil, fmt.Errorf("grantsstate: the opened list's digest is %s, and the key names %s", got, digest) + } + return list, nil +} diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index a982d3c6..11d77c8e 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -133,6 +133,15 @@ "loads": [ "mesh-grants" ] + }, + { + "name": "grantsstate", + "kind": "package", + "language": "go", + "paths": [ + "grantsstate" + ], + "series": "0.1" } ] }