mesh-mail: move password reading out of the tools' reach into its own package, so only keep and the send actor import it (ADR 0436 clause 12; issue 1193)

This commit is contained in:
jochen
2026-10-19 09:32:54 +02:00
parent 75a9a45c6f
commit 59c0906c79
8 changed files with 209 additions and 48 deletions
+6 -22
View File
@@ -159,33 +159,17 @@ func (s Server) check(which string) error {
return nil
}
// SecretPath is where the account's own secret is placed in the secrets directory, as the manifest declares the
// family: <dir>/mail-credential-<account>.secret.
func SecretPath(dir string, a Account) string { return filepath.Join(dir, a.Secret()+".secret") }
// ReadSecret is the account's password, or given false when nobody gave it yet: the mesh never makes a member of the
// family, so a member not given is no file (ADR 0283 §6).
func ReadSecret(dir string, a Account) (string, bool, error) {
raw, err := os.ReadFile(SecretPath(dir, a))
if errors.Is(err, os.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, err
}
pw := strings.TrimRight(string(raw), "\r\n")
if pw == "" {
return "", false, nil
}
return pw, true, nil
}
// ReadSecret and SecretPath moved into cmd/mesh-mail/internal/secret (novox/hq ADR 0436 §12, issue 1193): only
// keep (imap.go) and the sender's own actor (sendactor.go) read a password, never the tools, which the operator
// account's tool runner serves and which must never open a password file.
// SecretGiven says whether the account's own secret was given.
//
// It looks at the file and never reads it: whoever judges whether a password was given has no need of the password.
// A file that is there and not empty was given; whether it signs in is the keeper's to say.
// A file that is there and not empty was given; whether it signs in is the keeper's to say. It computes the
// secret's path itself (not through the moved package) so the tools, which call this, never import it.
func SecretGiven(dir string, a Account) bool {
info, err := os.Stat(SecretPath(dir, a))
info, err := os.Stat(filepath.Join(dir, a.Secret()+".secret"))
return err == nil && info.Mode().IsRegular() && info.Size() > 0
}
@@ -6,6 +6,8 @@ import (
"reflect"
"strings"
"testing"
"mesh-mail/cmd/mesh-mail/internal/secret"
)
// The first stage's account, as the operator sets the setting `accounts` (ADR 0359 §5): its address and its server,
@@ -22,7 +24,7 @@ func TestTheFirstStagesAccountIsReadAndItsSecretNamed(t *testing.T) {
got[0].Incoming.Address() != "mail.novox.be:993" || got[0].Secret() != "mail-credential-mesh" || got[0].Outgoing != nil {
t.Fatalf("%+v", got)
}
if p := SecretPath("/state", got[0]); p != "/state/mail-credential-mesh.secret" {
if p := secret.Path("/state", got[0].Secret()); p != "/state/mail-credential-mesh.secret" {
t.Fatalf("the secret is placed at %s", p)
}
}
@@ -99,16 +101,16 @@ func TestASecretIsGivenByItsFileNotByReadingIt(t *testing.T) {
if SecretGiven(dir, a) {
t.Fatal("given before anything was written")
}
_ = os.WriteFile(SecretPath(dir, a), nil, 0o600)
_ = os.WriteFile(secret.Path(dir, a.Secret()), nil, 0o600)
if SecretGiven(dir, a) {
t.Fatal("an empty file is a password")
}
_ = os.WriteFile(SecretPath(dir, a), []byte("pw\n"), 0o600)
if pw, given, _ := ReadSecret(dir, a); !given || pw != "pw" || strings.Contains(pw, "\n") {
_ = os.WriteFile(secret.Path(dir, a.Secret()), []byte("pw\n"), 0o600)
if pw, given, _ := secret.Read(dir, a.Secret()); !given || pw != "pw" || strings.Contains(pw, "\n") {
t.Fatalf("%q %v", pw, given)
}
// Unreadable to the judge, as a secret owned by root is to the watch: still given.
if err := os.Chmod(SecretPath(dir, a), 0); err != nil {
if err := os.Chmod(secret.Path(dir, a.Secret()), 0); err != nil {
t.Fatal(err)
}
if !SecretGiven(dir, a) {
+3 -1
View File
@@ -22,6 +22,8 @@ import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"mesh-mail/cmd/mesh-mail/internal/secret"
)
// How the keeper paces itself. IdleCycle is how long one IDLE lasts before it is ended and begun again, which proves
@@ -170,7 +172,7 @@ func (k *Keeper) Watch(ctx context.Context, a Account) {
retry := orDefault(k.RetryFirst, RetryFirst)
var heard, synced time.Time
for ctx.Err() == nil {
pw, given, err := ReadSecret(k.SecretsDir, a)
pw, given, err := secret.Read(k.SecretsDir, a.Secret())
switch {
case err != nil:
k.say(ctx, a, AccountFailing, "its password was given and cannot be read: "+err.Error(), heard, synced)
@@ -0,0 +1,36 @@
// Package secret reads an account's own password from the secrets directory (novox/hq ADR 0283 §6). It is
// imported only by mesh-mail's keeper (which signs in to IMAP as root) and its sender (which signs in to SMTP
// as its own account, mesh-mail-send): never by the tools, which the operator account's tool runner serves and
// which must never open a password file (novox/hq ADR 0436 §12, issue 1193). Moved out of package main's own
// files so a test (secret_boundary_test.go) can prove the tools' bundle (main.go's serve path, tools.go,
// send.go, health.go) never imports it.
package secret
import (
"errors"
"os"
"path/filepath"
"strings"
)
// Path is where an account's own secret is placed in the secrets directory, given the family member name
// (mail-credential-<account>): <dir>/<member>.secret.
func Path(dir, member string) string { return filepath.Join(dir, member+".secret") }
// Read is the member's password, or given false when nobody gave it yet: the mesh never makes a member of the
// family, so a member not given is no file (ADR 0283 §6). A directory that does not exist at all answers the
// same as a file that does not exist: given false, no error (novox/hq issue 1193, AC3).
func Read(dir, member string) (string, bool, error) {
raw, err := os.ReadFile(Path(dir, member))
if errors.Is(err, os.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, err
}
pw := strings.TrimRight(string(raw), "\r\n")
if pw == "" {
return "", false, nil
}
return pw, true, nil
}
+3 -1
View File
@@ -22,6 +22,8 @@ import (
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-imap/v2/imapserver"
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
"mesh-mail/cmd/mesh-mail/internal/secret"
)
func TestMain(m *testing.M) {
@@ -134,7 +136,7 @@ func testAccount() Account {
func giveSecret(t *testing.T, dir string, a Account, pw string) {
t.Helper()
if err := os.WriteFile(SecretPath(dir, a), []byte(pw+"\n"), 0o600); err != nil {
if err := os.WriteFile(secret.Path(dir, a.Secret()), []byte(pw+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,120 @@
package main
// The tools never open a password file (novox/hq ADR 0436 §12, issue 1193): ReadSecret and SecretPath moved
// into cmd/mesh-mail/internal/secret, imported only by keep (imap.go) and the sender's own actor (sendactor.go,
// which — ahead of issue 1195's separate process — is the one piece of the Serve path still reached from
// Decide's "send" case, exactly as 1194's own report still finds it: "Decide/decideSend call SendActor
// directly"). The build seat runs every test under one account, so no permission test can show the split
// (context file, "Clause 12's proof"); this is the file-level proxy the lead chose instead: an AST check over
// the tools' own files.
import (
"encoding/json"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// secretPackage is the import path the tools' bundle must never carry.
const secretPackage = "mesh-mail/cmd/mesh-mail/internal/secret"
// 1193/AC1: none of the tools' bundle files (main.go's serve path, tools.go, send.go, health.go) imports the
// password-reading package; the failure names the offending file.
func TestTheToolsBundleNeverImportsThePasswordPackage(t *testing.T) {
for _, file := range []string{"main.go", "tools.go", "send.go", "health.go"} {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("%s: %v", file, err)
}
for _, imp := range f.Imports {
path := strings.Trim(imp.Path.Value, `"`)
if path == secretPackage {
t.Errorf("%s imports %s, which only keep and the sender's actor may (novox/hq ADR 0436 §12)", file, secretPackage)
}
}
}
}
// 1193/AC2: keep (imap.go) and the sender's actor (sendactor.go) read the password through the moved package;
// SecretGiven (stat only) stays in config.go, available to the tools, and never reads the password itself.
func TestKeepAndTheSenderReadThePasswordThroughTheMovedPackage(t *testing.T) {
for _, file := range []string{"imap.go", "sendactor.go"} {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("%s: %v", file, err)
}
found := false
for _, imp := range f.Imports {
if strings.Trim(imp.Path.Value, `"`) == secretPackage {
found = true
}
}
if !found {
t.Errorf("%s does not import %s", file, secretPackage)
}
}
// SecretGiven itself (config.go) never calls ReadFile, os.Open or the moved package: whether a password was
// given is read from the file's presence and size alone (issue 733, kept by config_test.go's own check too).
src, err := os.ReadFile("config.go")
if err != nil {
t.Fatal(err)
}
body := string(src)
body = body[strings.Index(body, "func SecretGiven("):]
body = body[:strings.Index(body, "\n}\n")]
for _, bad := range []string{"ReadSecret", "ReadFile", "os.Open", "secret.Read"} {
if strings.Contains(body, bad) {
t.Fatalf("SecretGiven reads the password (%s):\n%s", bad, body)
}
}
}
// 1193/AC3: with the secrets directory absent, mail_send raises its ask, the warrant of an Approve is heard,
// and mail_send_status reads it back — each succeeds, and no error mentions a secret file. (The outcome here
// is "failed: ... has no password yet", because the tools still perform the send through sendactor.go until
// issue 1194 replaces that path with queueing for the sender process; what this proves is narrower and holds
// either way: nothing on this path ever names a secret file or path.)
func TestTheToolPathSucceedsWithTheSecretsDirectoryAbsent(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "never-created")
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: missing}
writeFile(t, env.Accounts, `{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be",
"incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"},
"outgoing": {"host": "h", "port": 465, "security": "tls"}}}}`)
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`)
router, sends := newSendRouter(), newMemSends()
b := &Bundle{Env: env, Now: func() time.Time { return judgedAt }, Asks: router, Sends: sends}
rt := startBundle(t, b.Tools())
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
if isErr {
t.Fatalf("mail_send: %s", text)
}
var out map[string]any
_ = json.Unmarshal([]byte(text), &out)
id := out["ask"].(string)
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
router.close(w)
d, err := Decide(sends, b.lookupAccount, b.sendActor(), w, judgedAt.Add(2*time.Minute))
if err != nil {
t.Fatalf("the warrant was not heard cleanly: %v", err)
}
if strings.Contains(d.Outcome, missing) || strings.Contains(strings.ToLower(d.Outcome), "secret") {
t.Fatalf("the decision names a secret file: %q", d.Outcome)
}
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
if isErr {
t.Fatalf("mail_send_status: %s", text)
}
if strings.Contains(text, missing) || strings.Contains(strings.ToLower(text), "secret") {
t.Fatalf("mail_send_status names a secret file: %s", text)
}
}
@@ -0,0 +1,34 @@
package main
// The one piece of the Serve path that still reads a password (novox/hq ADR 0436 §12, issue 1193): moved out
// of tools.go so a test (secret_boundary_test.go) can prove the tools' own files — main.go's serve path,
// tools.go, send.go, health.go — never import the password-reading package. Decide still calls this directly
// (send.go's Heard and Sweep, tools.go's mail_send_status): issue 1194 replaces that with the tools queueing
// the approved send for the sender process instead, at which point this file's job moves to it (issue 1195)
// and this one is removed.
import (
"fmt"
"time"
"mesh-mail/cmd/mesh-mail/internal/secret"
)
// sendActor performs one send on the operator's Approve: it reads the account's own secret itself, so Decide and
// the `sends` state never hold a password.
func (b *Bundle) sendActor() SendActor {
dial := b.Dial
if dial == nil {
dial = DialSMTP
}
return func(a Account, to []string, subject, body string, now time.Time) error {
pw, given, err := secret.Read(b.Env.Secrets, a.Secret())
if err != nil {
return fmt.Errorf("the account %s's password cannot be read: %w", a.Name, err)
}
if !given {
return fmt.Errorf("the account %s has no password yet; the operator gives it at the desk", a.Name)
}
return SendMail(dial, a, pw, to, subject, body, now)
}
}
-19
View File
@@ -416,25 +416,6 @@ func accountByName(accounts []Account, name string) (Account, bool) {
return Account{}, false
}
// sendActor performs one send on the operator's Approve: it reads the account's own secret itself, so Decide and
// the `sends` state never hold a password.
func (b *Bundle) sendActor() SendActor {
dial := b.Dial
if dial == nil {
dial = DialSMTP
}
return func(a Account, to []string, subject, body string, now time.Time) error {
pw, given, err := ReadSecret(b.Env.Secrets, a)
if err != nil {
return fmt.Errorf("the account %s's password cannot be read: %w", a.Name, err)
}
if !given {
return fmt.Errorf("the account %s has no password yet; the operator gives it at the desk", a.Name)
}
return SendMail(dial, a, pw, to, subject, body, now)
}
}
func stringList(v any) []string {
list, _ := v.([]any)
out := make([]string, 0, len(list))