checkpoint mciassrv
This commit is contained in:
194
internal/config/config.go
Normal file
194
internal/config/config.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// 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 }
|
||||
225
internal/config/config_test.go
Normal file
225
internal/config/config_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// validConfig returns a minimal valid TOML config string.
|
||||
func validConfig() string {
|
||||
return `
|
||||
[server]
|
||||
listen_addr = "0.0.0.0:8443"
|
||||
tls_cert = "/etc/mcias/server.crt"
|
||||
tls_key = "/etc/mcias/server.key"
|
||||
|
||||
[database]
|
||||
path = "/var/lib/mcias/mcias.db"
|
||||
|
||||
[tokens]
|
||||
issuer = "https://auth.example.com"
|
||||
default_expiry = "720h"
|
||||
admin_expiry = "8h"
|
||||
service_expiry = "8760h"
|
||||
|
||||
[argon2]
|
||||
time = 3
|
||||
memory = 65536
|
||||
threads = 4
|
||||
|
||||
[master_key]
|
||||
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
||||
`
|
||||
}
|
||||
|
||||
func writeTempConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mcias.toml")
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("write temp config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadValidConfig(t *testing.T) {
|
||||
path := writeTempConfig(t, validConfig())
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.ListenAddr != "0.0.0.0:8443" {
|
||||
t.Errorf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, "0.0.0.0:8443")
|
||||
}
|
||||
if cfg.Tokens.Issuer != "https://auth.example.com" {
|
||||
t.Errorf("Issuer = %q, want %q", cfg.Tokens.Issuer, "https://auth.example.com")
|
||||
}
|
||||
if cfg.DefaultExpiry() != 720*time.Hour {
|
||||
t.Errorf("DefaultExpiry = %v, want %v", cfg.DefaultExpiry(), 720*time.Hour)
|
||||
}
|
||||
if cfg.AdminExpiry() != 8*time.Hour {
|
||||
t.Errorf("AdminExpiry = %v, want %v", cfg.AdminExpiry(), 8*time.Hour)
|
||||
}
|
||||
if cfg.ServiceExpiry() != 8760*time.Hour {
|
||||
t.Errorf("ServiceExpiry = %v, want %v", cfg.ServiceExpiry(), 8760*time.Hour)
|
||||
}
|
||||
if cfg.Argon2.Time != 3 {
|
||||
t.Errorf("Argon2.Time = %d, want 3", cfg.Argon2.Time)
|
||||
}
|
||||
if cfg.Argon2.Memory != 65536 {
|
||||
t.Errorf("Argon2.Memory = %d, want 65536", cfg.Argon2.Memory)
|
||||
}
|
||||
if cfg.MasterKey.PassphraseEnv != "MCIAS_MASTER_PASSPHRASE" {
|
||||
t.Errorf("MasterKey.PassphraseEnv = %q", cfg.MasterKey.PassphraseEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFile(t *testing.T) {
|
||||
_, err := Load("/nonexistent/path/mcias.toml")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidTOML(t *testing.T) {
|
||||
path := writeTempConfig(t, "this is not valid TOML {{{{")
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid TOML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingListenAddr(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
[server]
|
||||
tls_cert = "/etc/mcias/server.crt"
|
||||
tls_key = "/etc/mcias/server.key"
|
||||
|
||||
[database]
|
||||
path = "/var/lib/mcias/mcias.db"
|
||||
|
||||
[tokens]
|
||||
issuer = "https://auth.example.com"
|
||||
default_expiry = "720h"
|
||||
admin_expiry = "8h"
|
||||
service_expiry = "8760h"
|
||||
|
||||
[argon2]
|
||||
time = 3
|
||||
memory = 65536
|
||||
threads = 4
|
||||
|
||||
[master_key]
|
||||
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing listen_addr, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateArgon2TooWeak(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
time uint32
|
||||
memory uint32
|
||||
}{
|
||||
{"time too low", 1, 65536},
|
||||
{"memory too low", 3, 32768},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
content := validConfig()
|
||||
// Override argon2 section
|
||||
path := writeTempConfig(t, content)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("baseline load failed: %v", err)
|
||||
}
|
||||
// Manually set unsafe params and re-validate
|
||||
cfg.Argon2.Time = tc.time
|
||||
cfg.Argon2.Memory = tc.memory
|
||||
if err := cfg.validate(); err == nil {
|
||||
t.Errorf("expected validation error for time=%d memory=%d, got nil", tc.time, tc.memory)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMasterKeyBothSet(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
[server]
|
||||
listen_addr = "0.0.0.0:8443"
|
||||
tls_cert = "/etc/mcias/server.crt"
|
||||
tls_key = "/etc/mcias/server.key"
|
||||
|
||||
[database]
|
||||
path = "/var/lib/mcias/mcias.db"
|
||||
|
||||
[tokens]
|
||||
issuer = "https://auth.example.com"
|
||||
default_expiry = "720h"
|
||||
admin_expiry = "8h"
|
||||
service_expiry = "8760h"
|
||||
|
||||
[argon2]
|
||||
time = 3
|
||||
memory = 65536
|
||||
threads = 4
|
||||
|
||||
[master_key]
|
||||
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
||||
keyfile = "/etc/mcias/master.key"
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Error("expected error when both passphrase_env and keyfile are set, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMasterKeyNoneSet(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
[server]
|
||||
listen_addr = "0.0.0.0:8443"
|
||||
tls_cert = "/etc/mcias/server.crt"
|
||||
tls_key = "/etc/mcias/server.key"
|
||||
|
||||
[database]
|
||||
path = "/var/lib/mcias/mcias.db"
|
||||
|
||||
[tokens]
|
||||
issuer = "https://auth.example.com"
|
||||
default_expiry = "720h"
|
||||
admin_expiry = "8h"
|
||||
service_expiry = "8760h"
|
||||
|
||||
[argon2]
|
||||
time = 3
|
||||
memory = 65536
|
||||
threads = 4
|
||||
|
||||
[master_key]
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Error("expected error when neither passphrase_env nor keyfile is set, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationParsing(t *testing.T) {
|
||||
var d duration
|
||||
if err := d.UnmarshalText([]byte("1h30m")); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Duration != 90*time.Minute {
|
||||
t.Errorf("Duration = %v, want %v", d.Duration, 90*time.Minute)
|
||||
}
|
||||
|
||||
if err := d.UnmarshalText([]byte("not-a-duration")); err == nil {
|
||||
t.Error("expected error for invalid duration, got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user