mesh-vault: secret_holders lists every record of the vault's store by name and fingerprint (issue 1409) #670
@@ -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,
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LicencePutVerb is the verb that hands a licence's secret to the vault.
|
||||
@@ -78,3 +81,51 @@ 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, bad, err := v.Store.ListReadable()
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"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 +118,64 @@ 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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"] != 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]
|
||||
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)
|
||||
}
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.<key>.grant, licence.<key>.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 " +
|
||||
|
||||
Reference in New Issue
Block a user