REVIEW-001 (M0) L1c/L2s: secrets.session_key was validated and generated by the wizard but never read by any code path — dead config (KISS). Remove the field, its validation entry and its generation; the example notes that a placeholder is replaced at startup in setup mode (M1s) as well as by the wizard's finish.
307 lines
11 KiB
Go
307 lines
11 KiB
Go
// Package config loads MyDay's configuration from `config.yaml` into a typed
|
|
// struct and validates it fail-closed (SPEC-003 §1, D-010/D-035).
|
|
//
|
|
// Settings live in the config file, not the database (D-035): infra · secrets ·
|
|
// domain · mail · locale. The real `config.yaml` is gitignored (D-010); the
|
|
// committed `config.example.yaml` is used as a development fallback so a fresh
|
|
// checkout can still start (in setup mode, loopback-only).
|
|
//
|
|
// Fail-closed rule (SPEC-003 §1): in PRODUCTIVE mode (`setup: false`) the
|
|
// process refuses to start if a required secret is missing or weak. Setup mode
|
|
// tolerates the placeholder values in the example, because the operator has not
|
|
// provisioned the instance yet — and setup mode is loopback-only (D-025).
|
|
//
|
|
// Spec: docs/specs/spec-003-core-door.md §1, §7.
|
|
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// FileName is the productive configuration file (gitignored — D-010).
|
|
const FileName = "config.yaml"
|
|
|
|
// ExampleFileName is the committed template, used as a development fallback.
|
|
const ExampleFileName = "config.example.yaml"
|
|
|
|
// Secret-length policy. The TOTP key must be exactly totpKeyLen bytes because
|
|
// it feeds AES-256-GCM (mirrors auth.KeySize); the other secrets are signing /
|
|
// derivation keys and must be at least minSecretLen bytes.
|
|
const (
|
|
minSecretLen = 32
|
|
totpKeyLen = 32
|
|
)
|
|
|
|
// Config is the typed shape of `config.yaml` / `config.example.yaml`.
|
|
type Config struct {
|
|
// Setup forces setup mode (D-026). Together with "no user exists" it makes
|
|
// the instance serve only /setup, loopback-only (D-025, SPEC-005 §3).
|
|
Setup bool `yaml:"setup"`
|
|
|
|
// AdminRecovery is the config approval flag for admin recovery (D-033).
|
|
// Without it nothing happens (dual control).
|
|
AdminRecovery bool `yaml:"admin_recovery"`
|
|
|
|
Server Server `yaml:"server"`
|
|
App App `yaml:"app"`
|
|
Database Database `yaml:"database"`
|
|
Secrets Secrets `yaml:"secrets"`
|
|
Mail Mail `yaml:"mail"`
|
|
Modules map[string]bool `yaml:"modules"`
|
|
// Enrollment bounds the invite window (A1). SPEC-006 refuses enrollment
|
|
// outside it.
|
|
Enrollment Enrollment `yaml:"enrollment"`
|
|
|
|
// source is the path actually read (config.yaml or the example fallback).
|
|
source string
|
|
}
|
|
|
|
// Server holds the listener, its public base URL, and the trusted proxies.
|
|
type Server struct {
|
|
Listen string `yaml:"listen"`
|
|
// BaseURL is used in invite / recovery links and decides whether cookies
|
|
// are Secure (https).
|
|
BaseURL string `yaml:"base_url"`
|
|
// TrustedProxies are the peers whose X-Forwarded-For is honoured (A6).
|
|
TrustedProxies []string `yaml:"trusted_proxies"`
|
|
}
|
|
|
|
// App holds locale and timezone.
|
|
type App struct {
|
|
Locale string `yaml:"locale"`
|
|
Timezone string `yaml:"timezone"`
|
|
}
|
|
|
|
// Database points at the SQLite file (D-012).
|
|
type Database struct {
|
|
Path string `yaml:"path"`
|
|
}
|
|
|
|
// Secrets holds the symmetric key material (D-010). Never commit real values.
|
|
// The wizard generates a strong value for any field left at a placeholder
|
|
// (R-6); the startup path does the same in setup mode (M1s).
|
|
type Secrets struct {
|
|
CookieSecret string `yaml:"cookie_secret"`
|
|
CSRFKey string `yaml:"csrf_key"`
|
|
TOTPEncryptionKey string `yaml:"totp_encryption_key"` // A9
|
|
}
|
|
|
|
// Mail holds the SMTP settings used for invites and recovery.
|
|
type Mail struct {
|
|
Host string `yaml:"host"`
|
|
Port int `yaml:"port"`
|
|
Username string `yaml:"username"`
|
|
Password string `yaml:"password"`
|
|
From string `yaml:"from"`
|
|
}
|
|
|
|
// Enrollment holds the invite-window settings (A1).
|
|
type Enrollment struct {
|
|
// Window is a Go duration string, e.g. "72h". Empty defaults to 72h.
|
|
Window string `yaml:"window"`
|
|
}
|
|
|
|
// Sentinel errors.
|
|
var (
|
|
// ErrListenRequired is returned when server.listen is empty.
|
|
ErrListenRequired = errors.New("config: server.listen is required")
|
|
// ErrDatabaseRequired is returned when database.path is empty.
|
|
ErrDatabaseRequired = errors.New("config: database.path is required")
|
|
// ErrBaseURLRequired is returned when server.base_url is empty in
|
|
// productive mode (invite/recovery links need it).
|
|
ErrBaseURLRequired = errors.New("config: server.base_url is required in productive mode")
|
|
// ErrWeakSecret is returned when a required secret is missing or weak.
|
|
ErrWeakSecret = errors.New("config: required secret is missing or weak")
|
|
// ErrInvalidEnrollWindow is returned when enrollment.window is not a valid
|
|
// positive Go duration.
|
|
ErrInvalidEnrollWindow = errors.New("config: enrollment.window is not a valid duration")
|
|
)
|
|
|
|
// DefaultEnrollWindow is the code default for the bounded enrollment window
|
|
// (A1) when enrollment.window is unset.
|
|
const DefaultEnrollWindow = 72 * time.Hour
|
|
|
|
// EnrollWindow returns the configured enrollment window, defaulting to 72h.
|
|
func (c *Config) EnrollWindow() time.Duration {
|
|
d, err := time.ParseDuration(strings.TrimSpace(c.Enrollment.Window))
|
|
if err != nil || d <= 0 {
|
|
return DefaultEnrollWindow
|
|
}
|
|
return d
|
|
}
|
|
|
|
// Load reads and validates the configuration at path.
|
|
//
|
|
// Development fallback (SPEC-003 §1): when path is the canonical `config.yaml`
|
|
// and it does not exist, the committed `config.example.yaml` in the same
|
|
// directory is used instead. Any other missing path is an error.
|
|
func Load(path string) (*Config, error) {
|
|
used, data, err := read(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dec := yaml.NewDecoder(bytes.NewReader(data))
|
|
dec.KnownFields(true) // fail-closed: an unknown key is a configuration error
|
|
|
|
var c Config
|
|
if err := dec.Decode(&c); err != nil {
|
|
return nil, fmt.Errorf("config: parse %s: %w", used, err)
|
|
}
|
|
c.source = used
|
|
|
|
if err := c.Validate(); err != nil {
|
|
return nil, fmt.Errorf("config: %s: %w", used, err)
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
// Source returns the path the configuration was actually loaded from.
|
|
func (c *Config) Source() string { return c.source }
|
|
|
|
// Marshal renders c as the YAML document written to `config.yaml` (D-035). The
|
|
// wizard persists through it (SPEC-005 §4). The unexported source field is not
|
|
// part of the document.
|
|
func Marshal(c *Config) ([]byte, error) {
|
|
if c == nil {
|
|
return nil, errors.New("config: cannot marshal a nil config")
|
|
}
|
|
body, err := yaml.Marshal(c)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("config: marshal: %w", err)
|
|
}
|
|
const header = "# MyDay configuration — written by MyDay (the /setup wizard or the Modules page, D-035).\n" +
|
|
"# Settings live HERE, not in the database. This file holds SECRETS:\n" +
|
|
"# it is gitignored — never commit it (D-010).\n\n"
|
|
return append([]byte(header), body...), nil
|
|
}
|
|
|
|
// IsSecure reports whether the app is served over https (so cookies are Secure).
|
|
func (c *Config) IsSecure() bool {
|
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.Server.BaseURL)), "https://")
|
|
}
|
|
|
|
// GenerateSecret returns n cryptographically-random bytes, base64url-encoded
|
|
// without padding (R-6): the value carries no YAML-hostile characters and its
|
|
// encoded length is 4*ceil(n/3) — 24 bytes yield the 32 characters the TOTP
|
|
// sealer requires (auth.KeySize).
|
|
func GenerateSecret(n int) (string, error) {
|
|
if n <= 0 {
|
|
return "", errors.New("config: secret length must be positive")
|
|
}
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("config: secret entropy: %w", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
// Validate applies the fail-closed rules (SPEC-003 §1).
|
|
func (c *Config) Validate() error {
|
|
if strings.TrimSpace(c.Server.Listen) == "" {
|
|
return ErrListenRequired
|
|
}
|
|
if strings.TrimSpace(c.Database.Path) == "" {
|
|
return ErrDatabaseRequired
|
|
}
|
|
|
|
// The enrollment window (A1) is parsed in both modes: a typo is a config
|
|
// error, never a silently-unbounded window.
|
|
if w := strings.TrimSpace(c.Enrollment.Window); w != "" {
|
|
d, err := time.ParseDuration(w)
|
|
if err != nil || d <= 0 {
|
|
return fmt.Errorf("%w: %q", ErrInvalidEnrollWindow, w)
|
|
}
|
|
}
|
|
|
|
// Setup mode may run with placeholder secrets: the operator has not yet
|
|
// provisioned the instance, and it is loopback-only (D-025). Productive
|
|
// mode is strict.
|
|
if c.Setup {
|
|
return nil
|
|
}
|
|
|
|
if strings.TrimSpace(c.Server.BaseURL) == "" {
|
|
return ErrBaseURLRequired
|
|
}
|
|
|
|
for _, s := range []struct{ name, value string }{
|
|
{"secrets.cookie_secret", c.Secrets.CookieSecret},
|
|
{"secrets.csrf_key", c.Secrets.CSRFKey},
|
|
{"secrets.totp_encryption_key", c.Secrets.TOTPEncryptionKey},
|
|
} {
|
|
if err := checkSecret(s.name, s.value, minSecretLen); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// The TOTP key feeds AES-256-GCM, so it must be exactly 32 bytes.
|
|
if n := len(c.Secrets.TOTPEncryptionKey); n != totpKeyLen {
|
|
return fmt.Errorf("%w: secrets.totp_encryption_key must be exactly %d bytes, got %d",
|
|
ErrWeakSecret, totpKeyLen, n)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// read loads the file, falling back to config.example.yaml for the canonical
|
|
// config.yaml only.
|
|
func read(path string) (used string, data []byte, err error) {
|
|
data, err = os.ReadFile(path)
|
|
if err == nil {
|
|
return path, data, nil
|
|
}
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
return "", nil, fmt.Errorf("config: read %q: %w", path, err)
|
|
}
|
|
if filepath.Base(path) != FileName {
|
|
return "", nil, fmt.Errorf("config: read %q: %w", path, err)
|
|
}
|
|
|
|
alt := filepath.Join(filepath.Dir(path), ExampleFileName)
|
|
data, altErr := os.ReadFile(alt)
|
|
if altErr != nil {
|
|
return "", nil, fmt.Errorf("config: read %q (and fallback %q): %w", path, alt, err)
|
|
}
|
|
return alt, data, nil
|
|
}
|
|
|
|
// checkSecret rejects an empty, short, or placeholder secret value.
|
|
func checkSecret(name, value string, minLen int) error {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fmt.Errorf("%w: %s is empty", ErrWeakSecret, name)
|
|
}
|
|
if len(value) < minLen {
|
|
return fmt.Errorf("%w: %s is %d bytes, need at least %d", ErrWeakSecret, name, len(value), minLen)
|
|
}
|
|
if isPlaceholder(value) {
|
|
return fmt.Errorf("%w: %s still holds a placeholder value", ErrWeakSecret, name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SecretIsPlaceholder reports whether a secret value is empty or still holds
|
|
// one of the shipped placeholder markers (R-6). The /setup wizard replaces such
|
|
// values with strong generated ones, so a first boot from config.example.yaml
|
|
// persists a viable productive configuration.
|
|
func SecretIsPlaceholder(value string) bool {
|
|
return strings.TrimSpace(value) == "" || isPlaceholder(value)
|
|
}
|
|
|
|
// isPlaceholder detects the well-known placeholder markers shipped in the
|
|
// example so a copy that was never edited fails closed in productive mode.
|
|
func isPlaceholder(value string) bool {
|
|
upper := strings.ToUpper(value)
|
|
return strings.Contains(upper, "CHANGE_ME") ||
|
|
strings.Contains(upper, "CHANGEME") ||
|
|
strings.Contains(upper, "PLACEHOLDER")
|
|
}
|