From afed658c69d9e2eb744ec24320a86b32c728f32a Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 27 Oct 2026 22:50:28 +0100 Subject: [PATCH] =?UTF-8?q?mesh-grants:=20hold=20a=20list=20that=20changes?= =?UTF-8?q?=20several=20loaded=20users'=20hashes=20at=20once=20with=20no?= =?UTF-8?q?=20rotation=20by=20the=20vault=20naming=20them,=20and=20raise?= =?UTF-8?q?=20user-list-held=20naming=20them=20(issue=20774,=20ADR=200561?= =?UTF-8?q?=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/mesh-grants/grants_test.go | 62 +++++++++++++++++++ modules/mesh-grants/internal/grants/inputs.go | 16 +++++ modules/mesh-grants/internal/grants/sealed.go | 55 +++++++++++++++- 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 4aa9212..9ded54c 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -1883,6 +1883,68 @@ func TestGrants(t *testing.T) { t.Errorf("once matched: %+v, said %+v", res.Consumers, said) } }) + + // 774/AC13 (novox/hq ADR 0561 §7): after take-over a list in which more than one loaded user's hash changes, with no + // rotation by the vault naming each, is held: the list in force stays, user-list-held is raised naming those users. One + // user's change, or changes the vault's rotation names, are written. + t.Run("a list changing several loaded users' hashes is held unless the vault's rotation names each", func(t *testing.T) { + b := fixture() + b.loaded = shadowOf(t, b) + hash := func(user, h string, generation int, rotating bool) { + v := map[string]any{"user": user, "hash": h, "generation": generation, "fingerprint": fmt.Sprintf("sha256:%d", generation)} + if rotating { + v["rotating"] = map[string]any{"login": user + "--b", "generation": generation, "revision": 7} + } + b.put(grants.ReadAccounts, user, v) + } + for _, u := range shadowOf(t, b) { + hash(u.User, "$2a$10$first", 1, false) + } + busKey, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK, + UserList: &delivery.UserListWritten{Source: "a.nats.src2", Revision: 7, Digest: "sha256:aa", Users: 6, Key: busKey.PublicKey}}) + p := partOn(b) + p.Objects = &fakeObjects{} + var said []asks.Condition + p.Say = func(c asks.Condition) error { said = append(said, c); return nil } + sw, _ := json.Marshal(grants.Switch{V: 1, State: grants.TookOver, Acts: []grants.Act{}}) + if _, err := (ownStore{b}).Put(grants.StateTakeover, grants.KeySwitch, sw); err != nil { + t.Fatal(err) + } + p.Compose() + inForce, _, _ := p.Sealed() + if inForce.Digest == "" { + t.Fatal("no list written after take-over") + } + // One user's hash changes: written. + hash("a.mod-x", "$2a$10$second", 2, false) + p.Compose() + if now, _, _ := p.Sealed(); now.Digest == inForce.Digest { + t.Fatal("a list changing one loaded user's hash was not written") + } + inForce, _, _ = p.Sealed() + // Two change at once, no rotation names them: held, said naming both. + hash("a.mod-x", "$2a$10$third", 3, false) + hash("a.nats", "$2a$10$third", 2, false) + said = nil + res := p.Compose() + if now, _, _ := p.Sealed(); now.Digest != inForce.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a.mod-x, a.nats") { + t.Fatalf("a list changing two loaded users' hashes: %+v", res.Sealed) + } + if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, "a.mod-x, a.nats") { + t.Errorf("the hold was said as %+v", said) + } + // The vault's rotation names each: written, the warning cleared. + hash("a.mod-x", "$2a$10$third", 3, true) + hash("a.nats", "$2a$10$third", 2, true) + p.Compose() + if now, _, _ := p.Sealed(); now.Digest == inForce.Digest || said[len(said)-1].State != asks.StateCleared { + t.Errorf("changes the vault's rotation names were held: %+v", said) + } + }) } type fakeAsker struct { diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index d870f41..61e49f0 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -139,6 +139,22 @@ type HeldHash struct { Generation int `json:"generation,omitempty"` At time.Time `json:"at"` Adopted bool `json:"adopted,omitempty"` + // Fingerprint is the vault's fingerprint of the value the hash is of: with the generation, what says the password + // changed, never the hash itself (a hash of the same password differs by its salt). + Fingerprint string `json:"fingerprint,omitempty"` + // Rotating is the vault's rotation of the account under way: a change of its hash it announces (ADR 0561 §4, §7). + Rotating *struct { + Login string `json:"login"` + } `json:"rotating,omitempty"` +} + +// credentialOf is what says which password a held hash is of, never the hash: adopted, or the vault's generation and the +// fingerprint of its value. +func credentialOf(h HeldHash) string { + if h.Adopted { + return "adopted" + } + return fmt.Sprintf("generation %d %s", h.Generation, h.Fingerprint) } // readHashes reads the vault's bus-accounts; a refusal is a gap, and every user then has no hash held. diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index c8d8eb8..20f1be6 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -133,7 +133,7 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string return } if next.Digest == digest && cur.Digest != digest { - p.keepWrittenUsers(res, list) + p.keepWrittenUsers(res, list, in.Hashes) } // The object the key named before is taken away once the key names the new one: the bucket keeps the list in force. if had && cur.Object != "" && cur.Object != next.Object { @@ -279,11 +279,50 @@ func (p *Part) followReasons(res Result, in Inputs) []string { // composed after take-over must keep. const KeyWrittenUsers = "sealed-users" -func (p *Part) keepWrittenUsers(res *Result, list []byte) { - data, _ := json.Marshal(listUsers(list)) +// KeyWrittenCredentials is the key of the state user-list naming, per user of the list in force, which password its hash +// is of (credentialOf: adopted, or the vault's generation and fingerprint), never the hash: what tells a list that changes +// several loaded users' hashes at once (ADR 0561 §7). +const KeyWrittenCredentials = "sealed-credentials" + +func (p *Part) keepWrittenUsers(res *Result, list []byte, hashes map[string]HeldHash) { + users := listUsers(list) + data, _ := json.Marshal(users) if _, err := p.Store.Put(StateUserList, KeyWrittenUsers, data); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenUsers, err)) } + creds := map[string]string{} + for _, u := range users { + if h, ok := hashes[u]; ok { + creds[u] = credentialOf(h) + } + } + data, _ = json.Marshal(creds) + if _, err := p.Store.Put(StateUserList, KeyWrittenCredentials, data); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenCredentials, err)) + } +} + +// changedHashes are the users of the list in force whose hash the list composed now would change with no rotation by the +// vault naming them, sorted; none when the part kept no credentials of the list in force (a list written before). +func (p *Part) changedHashes(hashes map[string]HeldHash) ([]string, error) { + raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenCredentials) + if err != nil || !found { + return nil, err + } + var had map[string]string + if err := json.Unmarshal(raw, &had); err != nil { + return nil, err + } + var changed []string + for user, was := range had { + h, ok := hashes[user] + if !ok || credentialOf(h) == was || h.Rotating != nil { + continue + } + changed = append(changed, user) + } + slices.Sort(changed) + return changed, nil } var listUser = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`) @@ -333,5 +372,15 @@ func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string { if len(unhashed) > 0 { return "held: the list in force is kept, since " + strings.Join(unhashed, ", ") + " would be left out for want of a hash in the vault" } + // More than one loaded user's hash changing at once, with no rotation by the vault naming each (ADR 0561 §7): a new + // writer of hashes must not lock several holders out in one list, as nine seats were cut on 2026-10-27 at 09:21. + changed, err := p.changedHashes(in.Hashes) + if err != nil { + return "held: the credentials of the list in force cannot be read: " + err.Error() + } + if len(changed) > 1 { + return "held: the list in force is kept, since the hashes of " + fmt.Sprint(len(changed)) + " loaded users would change at " + + "once with no rotation by the vault naming them: " + strings.Join(changed, ", ") + } return "" }