Merge pull request 'Vault: name a hash record's secret in a field called of, so the runtime keeps it (issue 1945)' (#487) from fix/1945-vault-hash-field into main

This commit was merged in pull request #487.
This commit is contained in:
2026-10-26 09:00:51 +00:00
5 changed files with 85 additions and 9 deletions
@@ -0,0 +1 @@
/mesh-vault
@@ -42,8 +42,9 @@ type AccountHash struct {
User string `json:"user"`
// Hash is the bcrypt hash of the account's password, at the composer's cost.
Hash string `json:"hash"`
// Secret, Generation and Fingerprint are the vault's own secret the hash is of: absent for an adopted one.
Secret string `json:"secret,omitempty"`
// Of, Generation and Fingerprint name the vault's own secret the hash is of: absent for an adopted one. The field
// is not called "secret": the runtime refuses a state field whose name says it is a credential (ADR 0201).
Of string `json:"of,omitempty"`
Generation int `json:"generation,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
At time.Time `json:"at"`
@@ -149,7 +150,7 @@ func accountOf(secret, value string) (user, password string, err error) {
// Hashed is what one hashing did: the key written or found, never the hash.
type Hashed struct {
Secret string `json:"secret"`
Of string `json:"of"`
User string `json:"user"`
Generation int `json:"generation"`
Fingerprint string `json:"fingerprint"`
@@ -178,13 +179,13 @@ func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) {
return Hashed{}, err
}
fp := Fingerprint(value)
h := Hashed{Secret: secret, User: user, Generation: rec.Generation, Fingerprint: fp}
h := Hashed{Of: secret, User: user, Generation: rec.Generation, Fingerprint: fp}
was, err := v.Accounts.Get(user)
if err != nil {
v.hashes.owe(secret, "its key could not be read: "+err.Error())
return Hashed{}, fmt.Errorf("%s %s could not be read: %w", BusAccountsState, user, err)
}
if !force && was != nil && !was.Adopted && was.Secret == secret && was.Generation == rec.Generation && was.Fingerprint == fp {
if !force && was != nil && !was.Adopted && was.Of == secret && was.Generation == rec.Generation && was.Fingerprint == fp {
v.hashes.paid(secret)
h.At = was.At
return h, nil
@@ -194,7 +195,7 @@ func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) {
return Hashed{}, err
}
h.At = v.Now().UTC()
if err := v.Accounts.Put(AccountHash{User: user, Hash: string(hash), Secret: secret, Generation: rec.Generation,
if err := v.Accounts.Put(AccountHash{User: user, Hash: string(hash), Of: secret, Generation: rec.Generation,
Fingerprint: fp, At: h.At}); err != nil {
v.hashes.owe(secret, "its key could not be written: "+err.Error())
return Hashed{}, fmt.Errorf("the hash of %s (generation %d) could not be written to %s %s, tried again at the "+
@@ -203,7 +204,7 @@ func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) {
v.hashes.paid(secret)
h.Written = true
if v.Emit != nil {
if err := v.Emit(AccountHashedEvent, map[string]any{"user": user, "secret": secret, "generation": rec.Generation,
if err := v.Emit(AccountHashedEvent, map[string]any{"user": user, "of": secret, "generation": rec.Generation,
"fingerprint": fp, "at": h.At.Format(time.RFC3339Nano)}); err != nil {
v.log("the hash of %s is written, but %s could not be said: %v", secret, AccountHashedEvent, err)
}
@@ -0,0 +1,67 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
// credentialEndings and credentialField mirror the runtime's refusal of a state or log value with a field whose name says
// it is a credential (mesh-tools node-tools/internal/bus/state.go, credentialField, ADR 0201): the runtime's check is
// unexported and in another repository, so this is a copy and must follow it. A fake state that skips it let a
// field named "secret" pass every test and be refused on novox (issue 1945).
var credentialEndings = []string{"password", "passwd", "secret", "token", "credential", "credentials",
"authorization", "apikey", "privatekey", "accesskey", "cookie"}
func credentialField(value []byte) string {
var v any
if json.Unmarshal(value, &v) != nil {
return ""
}
var walk func(any) string
walk = func(v any) string {
switch t := v.(type) {
case map[string]any:
for k, x := range t {
norm := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(k))
for _, end := range credentialEndings {
if strings.HasSuffix(norm, end) {
return k
}
}
if f := walk(x); f != "" {
return f
}
}
case []any:
for _, x := range t {
if f := walk(x); f != "" {
return f
}
}
}
return ""
}
return walk(v)
}
// The vault's bus-accounts records pass the runtime's credential-field check; a record with a "secret" field does not.
func TestStateRecordsPassTheRuntimesCredentialFieldCheck(t *testing.T) {
for _, c := range []struct {
name string
value any
field string
}{
{"a hash record of the vault's own secret", AccountHash{User: "ace.mesh-build-go", Hash: "$2a$10$h", Of: "own/ace/mesh-build-go/broker", Generation: 1, Fingerprint: "ab12"}, ""},
{"an adopted hash record", AccountHash{User: "ace.x", Hash: "$2a$10$h", Adopted: true}, ""},
{"a record with a secret field is refused", map[string]any{"user": "u", "secret": "own/x"}, "secret"},
} {
raw, err := json.Marshal(c.value)
if err != nil {
t.Fatal(err)
}
if got := credentialField(raw); got != c.field {
t.Errorf("%s: credential field %q, want %q (%s)", c.name, got, c.field, raw)
}
}
}
@@ -1,7 +1,9 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
@@ -36,6 +38,11 @@ func (f *fakeAccounts) Put(a AccountHash) error {
if f.keys == nil {
f.keys = map[string]AccountHash{}
}
// What the runtime does to every state value (credential_field_test.go): the vault's own record is checked as written.
raw, _ := json.Marshal(a)
if field := credentialField(raw); field != "" {
return fmt.Errorf("%s carries a field %q, which names a credential: no secret is kept in state", a.User, field)
}
f.keys[a.User] = a
f.puts++
return nil
+2 -2
View File
@@ -761,8 +761,8 @@ func hashedRow(v *Vault, r Record, fp string, row map[string]any) {
row["hashed"], row["hashWhy"] = false, "no key"
case h.Adopted:
row["hashed"], row["hashedAt"], row["hashWhy"] = false, h.At, "the key is adopted from the loaded list, not of this value"
case h.Secret != r.Name || h.Generation != r.Generation || h.Fingerprint != fp:
row["hashed"], row["hashedAt"], row["hashWhy"] = false, h.At, fmt.Sprintf("the key is of %s generation %d", h.Secret, h.Generation)
case h.Of != r.Name || h.Generation != r.Generation || h.Fingerprint != fp:
row["hashed"], row["hashedAt"], row["hashWhy"] = false, h.At, fmt.Sprintf("the key is of %s generation %d", h.Of, h.Generation)
default:
row["hashed"], row["hashedAt"] = true, h.At
}