From f7928a2bf75bd2416bd2720a5840ca7e45b7e993 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 13:47:20 +0200 Subject: [PATCH] Let an own secret's manifest declare a desk prompt (issue 789) A family like mesh-mail's mail-credential-* names only the module and the secret's key at the desk, so the operator cannot tell which account is asked. An own secret (or a family) may now declare a one-line prompt in its manifest, with {member} filled by a family member's suffix; the module check refuses one that is not one line, over 120 characters, or holds another placeholder. desk_secret.go reads it from the controller's own catalogue and sends it to the desk as what, never from the call or the module's runtime; omitted when none is declared, so an old desk or module behaves as today. --- cmd/mesh-controller/desk_secret.go | 26 +++++- cmd/mesh-controller/desk_secret_test.go | 46 +++++++++++ internal/catalogue/manifest.go | 56 ++++++++++++- internal/catalogue/own_secret_prompt_test.go | 86 ++++++++++++++++++++ internal/inventory/secrets.go | 20 +++++ 5 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 internal/catalogue/own_secret_prompt_test.go diff --git a/cmd/mesh-controller/desk_secret.go b/cmd/mesh-controller/desk_secret.go index a0da69d4..67f242cb 100644 --- a/cmd/mesh-controller/desk_secret.go +++ b/cmd/mesh-controller/desk_secret.go @@ -54,6 +54,10 @@ type deskGive struct { trusted func(module string) (bool, error) // ask asks one machine's node-launcher.secret and answers its result, or the holder's refusal. ask func(machine string, args map[string]any) (json.RawMessage, error) + // promptFor is the one line the secret's declaration asks for it as (novox/hq issue 789): read from the + // controller's own catalogue, never from the call or the module's runtime. Nil, or an empty answer, asks by + // name alone, as the desk does today. + promptFor func(module, name string) (string, error) // accept seals the value as `secret accept` does, and says whether it lives until the module's start. accept func(value string) (untilStart bool, err error) // record writes the act in the hand-act log. @@ -153,15 +157,28 @@ func (d deskGive) give(node, module, name, desk string) (string, error) { } // By name, never by words: the holder writes the prompt from these, and says the controller asks, which // the bus alone makes true (broker.ControllerOnly). Who asked is the bus's word on the caller, cut to a - // name's characters — never an argument of the call. - raw, err := d.ask(desk, map[string]any{ + // name's characters — never an argument of the call. `what` is the catalogue's own words for the secret + // (novox/hq issue 789), never a word the caller chose; omitted when the module declares none, so an old + // desk and an old module behave as today. + args := map[string]any{ "module": module, "secret": name, "node": node, "asked_by": askedByName(d.askedBy), "seal_to": public, "timeout_seconds": deskPromptWithin, - }) + } + if d.promptFor != nil { + what, err := d.promptFor(module, name) + if err != nil { + outcome = "refused" + return "", fmt.Errorf("nobody was asked to type anything: %w", err) + } + if what != "" { + args["what"] = what + } + } + raw, err := d.ask(desk, args) if err != nil { outcome = "refused" return "", fmt.Errorf("the desk on %s could not be asked: %w", desk, err) @@ -247,7 +264,8 @@ func askAtDesk(ctx context.Context, node, module, name, desk string) error { _, err := open.inventory.NodeByName(ctx, machine) return err }, - trusted: func(module string) (bool, error) { return open.inventory.RunsAsItsOwnAccount(ctx, module) }, + trusted: func(module string) (bool, error) { return open.inventory.RunsAsItsOwnAccount(ctx, module) }, + promptFor: func(module, name string) (string, error) { return open.inventory.OwnSecretPrompt(ctx, module, name) }, ask: func(machine string, args map[string]any) (json.RawMessage, error) { var result json.RawMessage err := onTheBus(func(conn *nats.Conn) error { diff --git a/cmd/mesh-controller/desk_secret_test.go b/cmd/mesh-controller/desk_secret_test.go index 170df795..d2cc26a9 100644 --- a/cmd/mesh-controller/desk_secret_test.go +++ b/cmd/mesh-controller/desk_secret_test.go @@ -456,6 +456,52 @@ func TestThePromptNamesWhoAskedFromTheBussWordAlone(t *testing.T) { } } +// The desk's prompt names which account of a family is asked for, from the catalogue's own words (novox/hq +// issue 789): a family declaring `prompt` with `{member}` sends `what` with the member's suffix filled in. +func TestThePromptNamesWhichAccountOfAFamilyIsAsked(t *testing.T) { + d, _, _, asked := aDesk(t, sealedTo(t, typed)) + d.declares = func(module, name string) error { + if module != "mesh-mail" || name != "mail-credential-gmail" { + return errors.New(module + " does not declare " + name + " as an own secret") + } + return nil + } + d.promptFor = func(module, name string) (string, error) { + if module == "mesh-mail" && name == "mail-credential-gmail" { + return "the password of the mail account gmail", nil + } + return "", nil + } + if _, err := d.give("anchor", "mesh-mail", "mail-credential-gmail", "laptop"); err != nil { + t.Fatal(err) + } + if got := (*asked)[0]["what"]; got != "the password of the mail account gmail" { + t.Errorf(`"what" = %v`, got) + } +} + +// Without a declared prompt, no `what` is sent: an old module, or one that declares none, is asked by name alone, +// as the desk does today (novox/hq issue 789). +func TestWithNoDeclaredPromptNoWhatIsSent(t *testing.T) { + d, _, _, asked := aDesk(t, sealedTo(t, typed)) + d.promptFor = func(module, name string) (string, error) { return "", nil } + if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil { + t.Fatal(err) + } + if _, there := (*asked)[0]["what"]; there { + t.Errorf(`"what" was sent with no declared prompt: %v`, (*asked)[0]) + } + // And with no promptFor wired at all (an older caller of deskGive), same thing: `give` still works, asking + // nothing more than it does today. + d2, _, _, asked2 := aDesk(t, sealedTo(t, typed)) + if _, err := d2.give("anchor", "telegram", "telegram-token", "laptop"); err != nil { + t.Fatal(err) + } + if _, there := (*asked2)[0]["what"]; there { + t.Errorf(`"what" was sent with promptFor nil: %v`, (*asked2)[0]) + } +} + // An ask for a secret is bounded before anybody is asked to type (ADR 0277): the record refuses it, nothing is // asked; and how every ask ends is recorded. func TestASecretAskIsBoundedAndItsEndRecorded(t *testing.T) { diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 845b9122..b3eded21 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -2190,6 +2190,7 @@ func ParseManifest(raw []byte) (Manifest, error) { "may make it (novox/hq ADR 0228)", m.Module, name, own.IssuedBy, IssuedOutside)) } + problems = append(problems, ownSecretPromptProblems(m, name, own.Prompt)...) } localOf := map[string]string{} for _, to := range m.SecretRequirements() { @@ -2759,6 +2760,11 @@ type OwnSecret struct { Path string Taken string IssuedBy string + // Prompt is the one line the desk asks for this secret as, in place of its bare name (novox/hq + // issue 789): at most PromptMax characters, one line, holding only the {member} placeholder — a + // family's member suffix, filled where the secret is asked for. Optional; reviewed in the + // module's pull request like the rest of the manifest. + Prompt string } // MeshMayMake says the mesh may put a value it makes in place of the one the secret holds: the @@ -2769,6 +2775,43 @@ func (s OwnSecret) MeshMayMake() bool { return s.Taken == TakenAtStart && s.IssuedBy != IssuedOutside } +// PromptMax is the longest an own secret's prompt may be (novox/hq issue 789): one line, short enough that the +// desk's prompt reads at a glance. +const PromptMax = 120 + +// promptPlaceholder is a `{word}` token in a prompt's line; the only one a prompt may hold is `{member}`, a +// family's member suffix (novox/hq issue 789). +var promptPlaceholder = regexp.MustCompile(`\{[^{}]*\}`) + +// ownSecretPromptProblems is what is wrong with an own secret's declared prompt, in the module check's words: +// empty is fine (no prompt is declared, and the desk asks by name alone as it does today); otherwise one line, +// at most PromptMax characters, holding only the `{member}` placeholder. +func ownSecretPromptProblems(m Manifest, name, prompt string) []string { + if prompt == "" { + return nil + } + var problems []string + if strings.ContainsAny(prompt, "\n\r") { + problems = append(problems, fmt.Sprintf( + "%s names its secret %q with a prompt that holds more than one line; a prompt is one line (novox/hq issue 789)", + m.Module, name)) + } + if len(prompt) > PromptMax { + problems = append(problems, fmt.Sprintf( + "%s names its secret %q with a prompt %d characters long; a prompt is at most %d characters", + m.Module, name, len(prompt), PromptMax)) + } + for _, ph := range promptPlaceholder.FindAllString(prompt, -1) { + if ph != "{member}" { + problems = append(problems, fmt.Sprintf( + "%s names its secret %q with a prompt that holds %s; a prompt holds only the {member} placeholder, "+ + "filled with a family's member suffix (novox/hq issue 789)", + m.Module, name, ph)) + } + } + return problems +} + // OwnSecrets is a module's own secrets by name. On the wire each is a path, or an object naming // the path and how it is taken; written back the way it was read, so a manifest the mesh holds // keeps its bytes. @@ -2790,13 +2833,14 @@ func (o *OwnSecrets) UnmarshalJSON(raw []byte) error { Path string `json:"path"` Taken string `json:"taken,omitempty"` IssuedBy string `json:"issued-by,omitempty"` + Prompt string `json:"prompt,omitempty"` } dec := json.NewDecoder(bytes.NewReader(body)) dec.DisallowUnknownFields() if err := dec.Decode(&long); err != nil { - return fmt.Errorf("own-secrets.%s: a path, or {\"path\", \"taken\", \"issued-by\"}: %w", name, typedUnknown(err)) + return fmt.Errorf("own-secrets.%s: a path, or {\"path\", \"taken\", \"issued-by\", \"prompt\"}: %w", name, typedUnknown(err)) } - out[name] = OwnSecret{Path: long.Path, Taken: long.Taken, IssuedBy: long.IssuedBy} + out[name] = OwnSecret{Path: long.Path, Taken: long.Taken, IssuedBy: long.IssuedBy, Prompt: long.Prompt} } *o = out return nil @@ -2805,7 +2849,7 @@ func (o *OwnSecrets) UnmarshalJSON(raw []byte) error { func (o OwnSecrets) MarshalJSON() ([]byte, error) { entries := make(map[string]any, len(o)) for name, s := range o { - if s.Taken == "" && s.IssuedBy == "" { + if s.Taken == "" && s.IssuedBy == "" && s.Prompt == "" { entries[name] = s.Path continue } @@ -2816,6 +2860,9 @@ func (o OwnSecrets) MarshalJSON() ([]byte, error) { if s.IssuedBy != "" { long["issued-by"] = s.IssuedBy } + if s.Prompt != "" { + long["prompt"] = s.Prompt + } entries[name] = long } return json.Marshal(entries) @@ -2854,7 +2901,8 @@ func (o OwnSecrets) Lookup(name string) (s OwnSecret, family string, ok bool) { continue } if family == "" || len(declared) > len(family) { - s, family, ok = OwnSecret{Path: strings.Replace(f.Path, "*", rest, 1), Taken: f.Taken, IssuedBy: f.IssuedBy}, declared, true + s, family, ok = OwnSecret{Path: strings.Replace(f.Path, "*", rest, 1), Taken: f.Taken, IssuedBy: f.IssuedBy, + Prompt: f.Prompt}, declared, true } } return s, family, ok diff --git a/internal/catalogue/own_secret_prompt_test.go b/internal/catalogue/own_secret_prompt_test.go new file mode 100644 index 00000000..fb2419b3 --- /dev/null +++ b/internal/catalogue/own_secret_prompt_test.go @@ -0,0 +1,86 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// An own secret (or a family) may declare `prompt`, one line naming what it is for a desk's prompt in place of +// its bare name (novox/hq issue 789): reviewed in the module's pull request, and the controller validates it in +// the module check. +func TestAnOwnSecretsPromptParsesAndIsWrittenBack(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + "mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside", + "prompt":"the password of the mail account {member}"}, + "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", + "prompt":"the key for the outbound relay"}}}`)) + if err != nil { + t.Fatalf("a manifest with a well-formed prompt was refused: %v", err) + } + if got := m.OwnSecrets["mail-credential-*"].Prompt; got != "the password of the mail account {member}" { + t.Fatalf("the family's prompt: %q", got) + } + if got := m.OwnSecrets["smtp-relay-key"].Prompt; got != "the key for the outbound relay" { + t.Fatalf("the plain secret's prompt: %q", got) + } + // Written back the way it was read, so a registered manifest keeps its bytes. + out, err := json.Marshal(m.OwnSecrets) + if err != nil { + t.Fatal(err) + } + var again OwnSecrets + if err := json.Unmarshal(out, &again); err != nil { + t.Fatal(err) + } + if again["mail-credential-*"].Prompt != "the password of the mail account {member}" || + again["smtp-relay-key"].Prompt != "the key for the outbound relay" { + t.Fatalf("the round trip lost the prompt: %s", out) + } +} + +// No prompt declared is fine: the desk asks by name alone, as it does today. +func TestAnOwnSecretWithNoPromptIsFine(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"telegram","version":"1","own-secrets":{ + "telegram-token":{"path":"/var/lib/telegram/telegram-token","issued-by":"outside"}}}`)) + if err != nil { + t.Fatal(err) + } + if m.OwnSecrets["telegram-token"].Prompt != "" { + t.Fatalf("an undeclared prompt: %q", m.OwnSecrets["telegram-token"].Prompt) + } + out, err := json.Marshal(m.OwnSecrets) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), "prompt") { + t.Fatalf("an empty prompt was written: %s", out) + } +} + +// A prompt that is not one short line naming only {member} is refused by the module check (novox/hq issue 789). +func TestAMalformedPromptIsRefused(t *testing.T) { + for name, c := range map[string]struct{ prompt, says string }{ + "a newline": {"the password of\nthe mail account {member}", "one line"}, + "over 120 characters": {"the password of the mail account {member}, which this mesh needs to log in to " + + "send and receive mail for the operator's account on this machine today", "120 characters"}, + "a placeholder other than {member}": {"the password of the mail account {name}", "{member}"}, + "two placeholders, one unknown": {"the {kind} secret of the mail account {member}", "{member}"}, + } { + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + "mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside", + "prompt":` + jsonString(c.prompt) + `}}}`)) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says) + } + } +} + +// jsonString quotes a Go string the way encoding/json would, for building a literal into a test's manifest JSON. +func jsonString(s string) string { + out, err := json.Marshal(s) + if err != nil { + panic(err) + } + return string(out) +} diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index 0d283381..a4bc7078 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -595,6 +595,26 @@ func (i *Inventory) DeclaresOwnSecret(ctx context.Context, module, name string) return GivableAtDesk(m, name) } +// OwnSecretPrompt is the one line the desk asks for this secret as — the module's declared `prompt`, read from +// the controller's own catalogue, never from the call or the module's runtime (novox/hq issue 789). For a family +// member it is the family's prompt with `{member}` replaced by the member's suffix. Empty when the module or the +// secret is unknown, or declares no prompt — the desk then asks by name alone, as it does today. +func (i *Inventory) OwnSecretPrompt(ctx context.Context, module, name string) (string, error) { + m, err := i.declared(ctx, module) + if err != nil { + return "", err + } + own, family, ok := m.OwnSecrets.Lookup(name) + if !ok || own.Prompt == "" { + return "", nil + } + if family == "" { + return own.Prompt, nil + } + member := strings.TrimPrefix(name, catalogue.FamilyPrefix(family)) + return strings.ReplaceAll(own.Prompt, "{member}", member), nil +} + // RunsAsItsOwnAccount says a module runs as an account of its own (novox/hq ADR 0259 §8): a trusted party — the // router, a channel that proves its sender or shows a link's code — whose own secret is what the operator's // answers are believed by. Its value is given at the controller's terminal alone.