diff --git a/modules/mesh-mail/module.json b/modules/mesh-mail/module.json index 0d6e32b1..5969c5df 100644 --- a/modules/mesh-mail/module.json +++ b/modules/mesh-mail/module.json @@ -22,7 +22,8 @@ "own-secrets": { "mail-credential-*": { "path": "${dir:state}/mail-credential-*.secret", - "issued-by": "outside" + "issued-by": "outside", + "prompt": "the password of the mail account {member}" } }, "tools": [ diff --git a/modules/rofi/cmd/rofi-tools/secret.go b/modules/rofi/cmd/rofi-tools/secret.go index d6fe7164..ce95e678 100644 --- a/modules/rofi/cmd/rofi-tools/secret.go +++ b/modules/rofi/cmd/rofi-tools/secret.go @@ -46,6 +46,13 @@ var aName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) // break, no markup, no punctuation a sentence is written with — so the prompt's words stay this holder's. var aCaller = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/@ -]{0,79}$`) +// escapeMarkup is `what` shown as text, never markup (novox/hq issue 789): the window's message is read with +// pango markup on, so a catalogue's words reach it with the characters pango gives meaning to turned to their +// entities first — never interpreted, whatever a module's manifest holds. +var escapeMarkupReplacer = strings.NewReplacer("&", "&", "<", "<", ">", ">") + +func escapeMarkup(s string) string { return escapeMarkupReplacer.Replace(s) } + // secretQuestion reads the call. The prompt is this holder's words, written from the module, the secret and the // machine (the review of 2026-10-09, M4): it says the mesh's controller asks, which the bus makes true — it // denies these subjects to every principal but the controller — and a caller's own words are refused, so @@ -81,14 +88,37 @@ func secretQuestion(args map[string]any) (SecretQuestion, error) { } askedBy = v } + // What the secret is for, in the catalogue's own words (novox/hq issue 789): the controller's own line, read + // from the secret's declared `prompt` and never a word the caller chose. One line, so it is shown under the + // module and secret names as its own; its characters are never markup, whatever the catalogue holds. + what := "" + if raw, there := args["what"]; there { + v, err := text(args, "what", true) + if err != nil { + return q, err + } + if strings.ContainsAny(v, "\n\r") { + return q, fmt.Errorf("what %q holds more than one line", raw) + } + what = v + } q.Prompt = names["secret"] + " for " + names["module"] q.Message = fmt.Sprintf("The mesh's controller asks for %s, the own secret of %s on %s.", names["secret"], names["module"], names["node"]) - if askedBy != "" { - q.Message += fmt.Sprintf(" It was asked for by %s.", askedBy) + if what != "" { + q.Message += "\nFor: " + escapeMarkup(what) } - q.Message += " What you type is not shown, and is sealed before it leaves this machine. Type it only if you " + + tail := "" + if askedBy != "" { + tail += fmt.Sprintf(" It was asked for by %s.", askedBy) + } + tail += " What you type is not shown, and is sealed before it leaves this machine. Type it only if you " + "expect this ask." + if what != "" { + q.Message += "\n" + strings.TrimSpace(tail) + } else { + q.Message += tail + } key, err := text(args, "seal_to", true) if err != nil { return q, err diff --git a/modules/rofi/cmd/rofi-tools/secret_test.go b/modules/rofi/cmd/rofi-tools/secret_test.go index 3aeaf2de..d9db64c6 100644 --- a/modules/rofi/cmd/rofi-tools/secret_test.go +++ b/modules/rofi/cmd/rofi-tools/secret_test.go @@ -150,3 +150,61 @@ func TestThePromptIsWrittenHereFromNamesAndSaysTheControllerAsks(t *testing.T) { } } } + +// What the secret is for shows as its own line under the module and secret names, in the catalogue's own +// words (novox/hq issue 789): read from the secret's declared `prompt`, never a word the caller chose, and +// never shown without a declared one — an old module, or one that declares none, prompts as it does today. +func TestWhatShowsAsItsOwnLineUnderTheNamesWhenDeclaredAndNothingWhenNot(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + base := map[string]any{"module": "mesh-mail", "secret": "mail-credential-gmail", "node": "anchor", "seal_to": key} + q, err := secretQuestion(base) + if err != nil { + t.Fatal(err) + } + if strings.Contains(q.Message, "For:") { + t.Errorf("a \"For:\" line with no what declared: %q", q.Message) + } + + withWhat := map[string]any{} + for k, v := range base { + withWhat[k] = v + } + withWhat["what"] = "the password of the mail account gmail" + q, err = secretQuestion(withWhat) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(q.Message, "\nFor: the password of the mail account gmail") { + t.Errorf("no \"For:\" line of its own: %q", q.Message) + } + if !strings.Contains(q.Message, "What you type is not shown") { + t.Errorf("the rest of the message was lost: %q", q.Message) + } +} + +// What the secret is for is shown as text, never markup (novox/hq issue 789): the prompt's window reads its +// message with markup on, so what the catalogue holds reaches it with the characters markup gives meaning to +// turned to their entities first, and a line holding more than one is refused before anybody sees it. +func TestWhatIsShownAsTextNeverMarkupAndIsOneLine(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + base := map[string]any{"module": "mesh-mail", "secret": "mail-credential-gmail", "node": "anchor", "seal_to": key, + "what": "the password of the mail & account"} + q, err := secretQuestion(base) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(q.Message, "For: the password of <b>the</b> mail & account") { + t.Errorf("markup was not escaped: %q", q.Message) + } + + for _, bad := range []string{"a\nb", "a\rb"} { + multi := map[string]any{} + for k, v := range base { + multi[k] = v + } + multi["what"] = bad + if _, err := secretQuestion(multi); err == nil { + t.Errorf("what=%q holding more than one line was taken", bad) + } + } +}