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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user