Merge branch 'feat/mail-hardening' into mh-711-728-r1
mesh/merge-gate head 2347a4c73bd0 merged into trunk 20e58907b0f8 — pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus…
mesh/repo-check head 2347a4c73bd0 merged into trunk 20e58907b0f8 — pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/mail-hardening delivered: every member is delivered

This commit is contained in:
jochen
2026-10-15 15:43:34 +02:00
2 changed files with 38 additions and 5 deletions
+13 -5
View File
@@ -14,6 +14,7 @@ import (
"sort"
"strconv"
"strings"
"unicode/utf8"
)
// Scopes a claim can have.
@@ -2190,7 +2191,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)...)
problems = append(problems, ownSecretPromptProblems(m, name, own.Prompt, IsFamily(name))...)
}
localOf := map[string]string{}
for _, to := range m.SecretRequirements() {
@@ -2785,8 +2786,10 @@ 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 {
// at most PromptMax characters — counting what a person reads, not its bytes — holding only the `{member}`
// placeholder, and only where the secret is a family: a single secret's prompt has no member to fill, so
// `{member}` there would stay in the prompt literally (novox/hq issue 789).
func ownSecretPromptProblems(m Manifest, name, prompt string, family bool) []string {
if prompt == "" {
return nil
}
@@ -2796,10 +2799,10 @@ func ownSecretPromptProblems(m Manifest, name, prompt string) []string {
"%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 {
if n := utf8.RuneCountInString(prompt); n > 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))
m.Module, name, n, PromptMax))
}
for _, ph := range promptPlaceholder.FindAllString(prompt, -1) {
if ph != "{member}" {
@@ -2807,6 +2810,11 @@ func ownSecretPromptProblems(m Manifest, name, prompt string) []string {
"%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))
} else if !family {
problems = append(problems, fmt.Sprintf(
"%s names its secret %q with a prompt that holds {member}; {member} is replaced only for a "+
"family's member (novox/hq issue 789)",
m.Module, name))
}
}
return problems
@@ -58,6 +58,31 @@ func TestAnOwnSecretWithNoPromptIsFine(t *testing.T) {
}
}
// PromptMax counts characters, not bytes: a prompt of 100 multi-byte characters is well within it even
// though it holds well over 120 bytes (novox/hq issue 789).
func TestAPromptOfMultiByteCharactersIsCountedByCharacter(t *testing.T) {
prompt := strings.Repeat("é", 100)
if n := len(prompt); n <= 120 {
t.Fatalf("the test prompt is %d bytes, not over 120 as intended", n)
}
_, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{
"smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start",
"prompt":` + jsonString(prompt) + `}}}`))
if err != nil {
t.Fatalf("a 100-character prompt was refused: %v", err)
}
}
// {member} is replaced only for a family's member: a single secret's prompt may not hold it (novox/hq issue 789).
func TestAPromptHoldingMemberOnASingleSecretIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{
"smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start",
"prompt":"For {member}"}}}`))
if err == nil || !strings.Contains(err.Error(), "{member} is replaced only for a family's member") {
t.Fatalf("a single secret's prompt holding {member}: %v; want a refusal saying so", err)
}
}
// 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 }{