// Package config handles loading and validating the MCIAS server configuration. // Sensitive values (master key passphrase) are never stored in this struct // after initial loading — they are read once and discarded. package config import ( "errors" "fmt" "os" "time" "github.com/pelletier/go-toml/v2" ) // Config is the top-level configuration structure parsed from the TOML file. type Config struct { Server ServerConfig `toml:"server"` Database DatabaseConfig `toml:"database"` Tokens TokensConfig `toml:"tokens"` Argon2 Argon2Config `toml:"argon2"` MasterKey MasterKeyConfig `toml:"master_key"` } // ServerConfig holds HTTP listener and TLS settings. type ServerConfig struct { ListenAddr string `toml:"listen_addr"` TLSCert string `toml:"tls_cert"` TLSKey string `toml:"tls_key"` } // DatabaseConfig holds SQLite database settings. type DatabaseConfig struct { Path string `toml:"path"` } // TokensConfig holds JWT issuance settings. type TokensConfig struct { Issuer string `toml:"issuer"` DefaultExpiry duration `toml:"default_expiry"` AdminExpiry duration `toml:"admin_expiry"` ServiceExpiry duration `toml:"service_expiry"` } // Argon2Config holds Argon2id password hashing parameters. // Security: OWASP 2023 minimums are time=2, memory=65536 KiB. // We enforce these minimums to prevent accidental weakening. type Argon2Config struct { Time uint32 `toml:"time"` Memory uint32 `toml:"memory"` // KiB Threads uint8 `toml:"threads"` } // MasterKeyConfig specifies how to obtain the AES-256-GCM master key used to // encrypt stored secrets (TOTP, Postgres passwords, signing key). // Exactly one of PassphraseEnv or KeyFile must be set. type MasterKeyConfig struct { PassphraseEnv string `toml:"passphrase_env"` KeyFile string `toml:"keyfile"` } // duration is a wrapper around time.Duration that supports TOML string parsing // (e.g. "720h", "8h"). type duration struct { time.Duration } func (d *duration) UnmarshalText(text []byte) error { var err error d.Duration, err = time.ParseDuration(string(text)) if err != nil { return fmt.Errorf("invalid duration %q: %w", string(text), err) } return nil } // NewTestConfig returns a minimal valid Config for use in tests. // It does not read a file; callers can override fields as needed. func NewTestConfig(issuer string) *Config { return &Config{ Server: ServerConfig{ ListenAddr: "127.0.0.1:0", TLSCert: "/dev/null", TLSKey: "/dev/null", }, Database: DatabaseConfig{Path: ":memory:"}, Tokens: TokensConfig{ Issuer: issuer, DefaultExpiry: duration{24 * time.Hour}, AdminExpiry: duration{8 * time.Hour}, ServiceExpiry: duration{8760 * time.Hour}, }, Argon2: Argon2Config{ Time: 3, Memory: 65536, Threads: 4, }, MasterKey: MasterKeyConfig{ PassphraseEnv: "MCIAS_MASTER_PASSPHRASE", }, } } // Load reads and validates a TOML config file from path. func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("config: read file: %w", err) } var cfg Config if err := toml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("config: parse TOML: %w", err) } if err := cfg.validate(); err != nil { return nil, fmt.Errorf("config: invalid: %w", err) } return &cfg, nil } // validate checks that all required fields are present and values are safe. func (c *Config) validate() error { var errs []error // Server if c.Server.ListenAddr == "" { errs = append(errs, errors.New("server.listen_addr is required")) } if c.Server.TLSCert == "" { errs = append(errs, errors.New("server.tls_cert is required")) } if c.Server.TLSKey == "" { errs = append(errs, errors.New("server.tls_key is required")) } // Database if c.Database.Path == "" { errs = append(errs, errors.New("database.path is required")) } // Tokens if c.Tokens.Issuer == "" { errs = append(errs, errors.New("tokens.issuer is required")) } if c.Tokens.DefaultExpiry.Duration <= 0 { errs = append(errs, errors.New("tokens.default_expiry must be positive")) } if c.Tokens.AdminExpiry.Duration <= 0 { errs = append(errs, errors.New("tokens.admin_expiry must be positive")) } if c.Tokens.ServiceExpiry.Duration <= 0 { errs = append(errs, errors.New("tokens.service_expiry must be positive")) } // Argon2 — enforce OWASP 2023 minimums (time=2, memory=65536 KiB). // Security: reducing these parameters weakens resistance to brute-force // attacks. Rejection here prevents accidental misconfiguration. const ( minArgon2Time = 2 minArgon2Memory = 65536 // 64 MiB in KiB minArgon2Thread = 1 ) if c.Argon2.Time < minArgon2Time { errs = append(errs, fmt.Errorf("argon2.time must be >= %d (OWASP minimum)", minArgon2Time)) } if c.Argon2.Memory < minArgon2Memory { errs = append(errs, fmt.Errorf("argon2.memory must be >= %d KiB (OWASP minimum)", minArgon2Memory)) } if c.Argon2.Threads < minArgon2Thread { errs = append(errs, errors.New("argon2.threads must be >= 1")) } // Master key — exactly one source must be configured. hasPassEnv := c.MasterKey.PassphraseEnv != "" hasKeyFile := c.MasterKey.KeyFile != "" if !hasPassEnv && !hasKeyFile { errs = append(errs, errors.New("master_key: one of passphrase_env or keyfile must be set")) } if hasPassEnv && hasKeyFile { errs = append(errs, errors.New("master_key: only one of passphrase_env or keyfile may be set")) } return errors.Join(errs...) } // DefaultExpiry returns the configured default token expiry duration. func (c *Config) DefaultExpiry() time.Duration { return c.Tokens.DefaultExpiry.Duration } // AdminExpiry returns the configured admin token expiry duration. func (c *Config) AdminExpiry() time.Duration { return c.Tokens.AdminExpiry.Duration } // ServiceExpiry returns the configured service token expiry duration. func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.Duration }