From 52572698cb9d9df1453e74cd03885483e2c30af3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 17:34:00 +0100 Subject: [PATCH 1/2] mesh-vault: secret_holders lists every record of the vault's store by name and fingerprint (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1 vault-licence-secrets-listed. secret_holders answers `records` and `recordsCount` beside holders, count and enrolments: each store record's name, generation, since, changed, changedBy/changedVia, origin, length and the vault's own fingerprint of the whole value, plus key and part for licence... A record the vault cannot open stays listed with an error. Never a value. Builds: design 39 bus-and-switch §20, §15; design 39 parts §12 (D1); design 24 Every secret lives in the vault, Beyond generate and hold; as-is 06 The vault, as it runs. Lead answers (issue 1409 note 11): V1, V-thorough, V2-V8. V3 drops the map's tokenFingerprint and AC5; V-thorough widens the licence.-prefix list to every record. --- modules/mesh-vault/README.md | 5 +- modules/mesh-vault/cmd/mesh-vault/licences.go | 46 ++++++++++++++++ .../cmd/mesh-vault/licences_test.go | 54 +++++++++++++++++++ modules/mesh-vault/cmd/mesh-vault/tools.go | 23 ++++++-- 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/modules/mesh-vault/README.md b/modules/mesh-vault/README.md index 27af4c7a5..cd22dca7f 100644 --- a/modules/mesh-vault/README.md +++ b/modules/mesh-vault/README.md @@ -257,7 +257,10 @@ runner's `ledgers` are never retired this way. A waiter on `rotated` or `own-rep `secret_export`, `secret_holders`, `secret_verify` answer as the TypeScript vault's did, and its custody ledger (`${dir:ledger}`, the same JSON files) is kept by a pass over the contributions file every minute. -`secret_handed` and `secret_vault_export` read the vault's own store. None returns a value. +`secret_handed` and `secret_vault_export` read the vault's own store. None returns a value. `secret_holders` also answers +`records`: every record the store keeps (a licence's two secrets included), by name, generation, when stored and +changed, who changed it, origin, length and fingerprint, never the value; a record the vault cannot open stays in the +list with an `error`. **The ledger follows ADR 0230.** A contributions file that does not say who is asked for (unreadable, not JSON, no `given` list or a null one, or for another requirement) is not "nobody": the pass records nothing, diff --git a/modules/mesh-vault/cmd/mesh-vault/licences.go b/modules/mesh-vault/cmd/mesh-vault/licences.go index 6eee48ee2..0079f069f 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licences.go +++ b/modules/mesh-vault/cmd/mesh-vault/licences.go @@ -10,6 +10,8 @@ import ( "errors" "fmt" "regexp" + "strings" + "time" ) // LicencePutVerb is the verb that hands a licence's secret to the vault. @@ -78,3 +80,47 @@ func (v *Vault) LicencePutTake(caller, call, sealed string) (Changed, error) { } return v.keepWith(c.secret, string(value), caller, "", OriginAccepted, keepOptions{generation: c.generation}) } + +// RecordHeld is one record of the vault's store as secret_holders lists it (issue 1409, design 39 parts §12): by name +// and fingerprint, never the value. For a licence's secret it also says the licence's key and the part. +type RecordHeld struct { + Name string `json:"name"` + Key string `json:"key,omitempty"` + Part string `json:"part,omitempty"` + Generation int `json:"generation"` + Since time.Time `json:"since"` + Changed time.Time `json:"changed"` + ChangedBy string `json:"changedBy"` + ChangedVia string `json:"changedVia,omitempty"` + Origin string `json:"origin"` + Length int `json:"length"` + Fingerprint string `json:"fingerprint"` + // Error says why the vault could not open its copy; the record stays in the list. + Error string `json:"error,omitempty"` +} + +// RecordsHeld lists every record the store keeps, by name, ordered as the store lists them. The fingerprint is the +// vault's own of the whole value, as the ledger gives it. +func (v *Vault) RecordsHeld() ([]RecordHeld, error) { + records, err := v.Store.List() + if err != nil { + return nil, err + } + out := make([]RecordHeld, 0, len(records)) + for _, r := range records { + row := RecordHeld{Name: r.Name, Generation: r.Generation, Since: r.MadeAt, Changed: r.ChangedAt, + ChangedBy: r.ChangedBy, ChangedVia: r.ChangedVia, Origin: r.Origin} + if rest, ok := strings.CutPrefix(r.Name, "licence."); ok { + if i := strings.LastIndex(rest, "."); i > 0 { + row.Key, row.Part = rest[:i], rest[i+1:] + } + } + if value, err := openAtRest(r, v); err != nil { + row.Error = "the vault could not open its copy: " + err.Error() + } else { + row.Length, row.Fingerprint = len(value), Fingerprint(value) + } + out = append(out, row) + } + return out, nil +} diff --git a/modules/mesh-vault/cmd/mesh-vault/licences_test.go b/modules/mesh-vault/cmd/mesh-vault/licences_test.go index 02f8c3357..c0085b49c 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licences_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licences_test.go @@ -1,8 +1,11 @@ package main import ( + "encoding/json" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/seal" ) // putLicence hands one part of a licence to the vault in licence-put's two steps. @@ -114,3 +117,54 @@ func TestATokenIsGivenOnlyToAMachineABindingNames(t *testing.T) { t.Fatalf("the manager's grant: %q %v", got, err) } } + +// issue 1409, design 39 bus-and-switch §20, parts §12: secret_holders lists every record of the store by name and +// fingerprint, a licence's two secrets with their key and part, a record it cannot open with an error, and no value. +func TestSecretHoldersListsEveryStoreRecordByNameAndFingerprintNeverTheValue(t *testing.T) { + v := newVault(t, nil) + const manager = "claude-licence-manager" + grant, access := `{"refreshToken":"rt-SECRET-grant"}`, `{"accessToken":"at-SECRET-access"}` + for part, value := range map[string]string{LicenceGrant: grant, LicenceAccess: access} { + if _, err := putLicence(t, v, manager, "jane_at_example.org", part, 3, value); err != nil { + t.Fatal(err) + } + } + if _, err := v.Put("pair/postgres/gitea", "other-SECRET-value", "the test"); err != nil { + t.Fatal(err) + } + broken, _, _ := v.Store.Read("pair/postgres/gitea") + broken.Name, broken.AtRest = "pair/broken/one", seal.Box{} + if err := v.Store.Write(broken); err != nil { + t.Fatal(err) + } + + got, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_holders").Run(map[string]any{}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(got) + if strings.Contains(string(raw), "SECRET") { + t.Fatalf("the answer holds a value: %s", raw) + } + m := got.(map[string]any) + rows := map[string]RecordHeld{} + for _, r := range m["records"].([]RecordHeld) { + rows[r.Name] = r + } + if m["recordsCount"] != 4 || len(rows) != 4 { + t.Fatalf("records %v, want 4 (two licence secrets, a pair, a broken one)", rows) + } + for part, value := range map[string]string{LicenceGrant: grant, LicenceAccess: access} { + r := rows["licence.jane_at_example.org."+part] + if r.Key != "jane_at_example.org" || r.Part != part || r.Generation != 3 || r.ChangedBy != manager || + r.Origin != OriginAccepted || r.Length != len(value) || r.Fingerprint != Fingerprint(value) || r.Error != "" { + t.Errorf("the %s secret's row is %+v", part, r) + } + } + if r := rows["pair/postgres/gitea"]; r.Key != "" || r.Fingerprint != Fingerprint("other-SECRET-value") { + t.Errorf("the pair's row is %+v", r) + } + if r := rows["pair/broken/one"]; r.Error == "" || r.Fingerprint != "" { + t.Errorf("a record the vault cannot open must stay listed with an error: %+v", r) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 5e23494bf..ad424f8fa 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -380,15 +380,32 @@ func definitions(env *Env) []toolDef { {Name: "secret_holders", Description: "Who holds a secret from this vault: each consumer's login, node and module, when it was " + "granted, how many times it has been rotated and when, and the fingerprint of the current value. " + + "Also every record the vault's own store keeps (`records`, with `recordsCount`): its name, generation, when " + + "it was stored and last changed, who changed it, its origin, its length and its fingerprint, and for a " + + "licence's secret (licence..grant, licence..access) the key and the part. " + "Fingerprints only — the value is never returned.", run: func(string, map[string]any) (any, error) { enrolments := []EnrolmentHeld{} + records := []RecordHeld{} + recordsError := "" if env.Vault != nil { enrolments, _ = env.Vault.EnrolmentsHeld() + var err error + if records, err = env.Vault.RecordsHeld(); err != nil { + recordsError = "the vault could not list its store: " + err.Error() + records = []RecordHeld{} + } + } + stored := func(m map[string]any) map[string]any { + m["records"], m["recordsCount"] = records, len(records) + if recordsError != "" { + m["recordsError"] = recordsError + } + return m } if env.Ledger == nil { - return map[string]any{"holders": []any{}, "count": 0, "enrolments": enrolments, - "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}, nil + return stored(map[string]any{"holders": []any{}, "count": 0, "enrolments": enrolments, + "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}), nil } var asked []Contribution if env.Receives != "" { @@ -407,7 +424,7 @@ func definitions(env *Env) []toolDef { } holders = append(holders, row) } - return map[string]any{"holders": holders, "count": len(holders), "enrolments": enrolments}, nil + return stored(map[string]any{"holders": holders, "count": len(holders), "enrolments": enrolments}), nil }}, {Name: "secret_verify", Description: "Check one holder's secret without seeing it: the fingerprint the vault recorded against the " + -- 2.54.0 From 45b0779b6801ecbc6997e4ebb50f1e3bb4196988 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 17:41:39 +0100 Subject: [PATCH 2/2] mesh-vault: a store file that is not a record is listed by name with its error (issue 1409, V10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store.ListReadable lists every record and, beside them, each file it cannot read by name with its error; RecordsHeld lists those as rows with an error and goes on. recordsError stays for a store that cannot be listed at all. Store.List and ListPrefix keep their behaviour for their other callers. Builds: design 39 bus-and-switch §20; design 39 parts §12. Lead answers (issue 1409): V9 (keep recordsCount and recordsError), V10. --- modules/mesh-vault/cmd/mesh-vault/licences.go | 7 +++- .../cmd/mesh-vault/licences_test.go | 15 ++++++-- modules/mesh-vault/cmd/mesh-vault/store.go | 36 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/modules/mesh-vault/cmd/mesh-vault/licences.go b/modules/mesh-vault/cmd/mesh-vault/licences.go index 0079f069f..77662e793 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licences.go +++ b/modules/mesh-vault/cmd/mesh-vault/licences.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "regexp" + "sort" "strings" "time" ) @@ -102,7 +103,7 @@ type RecordHeld struct { // RecordsHeld lists every record the store keeps, by name, ordered as the store lists them. The fingerprint is the // vault's own of the whole value, as the ledger gives it. func (v *Vault) RecordsHeld() ([]RecordHeld, error) { - records, err := v.Store.List() + records, bad, err := v.Store.ListReadable() if err != nil { return nil, err } @@ -122,5 +123,9 @@ func (v *Vault) RecordsHeld() ([]RecordHeld, error) { } out = append(out, row) } + for _, b := range bad { + out = append(out, RecordHeld{Name: b.Name, Error: "the vault could not read the record's file: " + b.Err.Error()}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, nil } diff --git a/modules/mesh-vault/cmd/mesh-vault/licences_test.go b/modules/mesh-vault/cmd/mesh-vault/licences_test.go index c0085b49c..6368f4572 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licences_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licences_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "os" "strings" "testing" @@ -138,6 +139,10 @@ func TestSecretHoldersListsEveryStoreRecordByNameAndFingerprintNeverTheValue(t * t.Fatal(err) } + if err := os.WriteFile(v.Store.pathOf("pair/notjson/one"), []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + got, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_holders").Run(map[string]any{}) if err != nil { t.Fatal(err) @@ -151,8 +156,8 @@ func TestSecretHoldersListsEveryStoreRecordByNameAndFingerprintNeverTheValue(t * for _, r := range m["records"].([]RecordHeld) { rows[r.Name] = r } - if m["recordsCount"] != 4 || len(rows) != 4 { - t.Fatalf("records %v, want 4 (two licence secrets, a pair, a broken one)", rows) + if m["recordsCount"] != 5 || len(rows) != 5 { + t.Fatalf("records %v, want 5 (two licence secrets, a pair, a sealed-broken one, a file that is not JSON)", rows) } for part, value := range map[string]string{LicenceGrant: grant, LicenceAccess: access} { r := rows["licence.jane_at_example.org."+part] @@ -167,4 +172,10 @@ func TestSecretHoldersListsEveryStoreRecordByNameAndFingerprintNeverTheValue(t * if r := rows["pair/broken/one"]; r.Error == "" || r.Fingerprint != "" { t.Errorf("a record the vault cannot open must stay listed with an error: %+v", r) } + if r := rows["pair/notjson/one"]; r.Error == "" || r.Fingerprint != "" { + t.Errorf("a file that is not JSON must be listed by name with its error, the listing going on (V10): %+v", r) + } + if _, bad := m["recordsError"]; bad { + t.Errorf("recordsError is for a store that cannot be listed at all (V10): %v", m["recordsError"]) + } } diff --git a/modules/mesh-vault/cmd/mesh-vault/store.go b/modules/mesh-vault/cmd/mesh-vault/store.go index b7ec8b287..fd4b97f96 100644 --- a/modules/mesh-vault/cmd/mesh-vault/store.go +++ b/modules/mesh-vault/cmd/mesh-vault/store.go @@ -327,6 +327,42 @@ func (s *Store) ListPrefix(prefix string) ([]Record, error) { return out, nil } +// Unreadable is a record file the store could not read, by the name its file gives. +type Unreadable struct { + Name string + Err error +} + +// ListReadable is every record by name, and beside them each file that is not a record (not valid JSON, say) by name +// with its error: the listing goes on past it (issue 1409, V10). Only an unreadable directory is an error. +func (s *Store) ListReadable() ([]Record, []Unreadable, error) { + entries, err := os.ReadDir(filepath.Join(s.dir, "secrets")) + if err != nil { + return nil, nil, err + } + var out []Record + var bad []Unreadable + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".json") { + continue + } + name, err := url.PathUnescape(strings.TrimSuffix(e.Name(), ".json")) + if err != nil { + bad = append(bad, Unreadable{Name: strings.TrimSuffix(e.Name(), ".json"), Err: err}) + continue + } + r, ok, err := s.Read(name) + if err != nil { + bad = append(bad, Unreadable{Name: name, Err: err}) + } else if ok { + out = append(out, r) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + sort.Slice(bad, func(i, j int) bool { return bad[i].Name < bad[j].Name }) + return out, bad, nil +} + type operatorFile struct { Key string `json:"key"` Since time.Time `json:"since"` -- 2.54.0