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 " +