Files
mcias/internal/config/config.go
Kyle Isom 25417b24f4 Add FIDO2/WebAuthn passkey authentication
Phase 14: Full WebAuthn support for passwordless passkey login and
hardware security key 2FA.

- go-webauthn/webauthn v0.16.1 dependency
- WebAuthnConfig with RPID/RPOrigin/DisplayName validation
- Migration 000009: webauthn_credentials table
- DB CRUD with ownership checks and admin operations
- internal/webauthn adapter: encrypt/decrypt at rest with AES-256-GCM
- REST: register begin/finish, login begin/finish, list, delete
- Web UI: profile enrollment, login passkey button, admin management
- gRPC: ListWebAuthnCredentials, RemoveWebAuthnCredential RPCs
- mciasdb: webauthn list/delete/reset subcommands
- OpenAPI: 6 new endpoints, WebAuthnCredentialInfo schema
- Policy: self-service enrollment rule, admin remove via wildcard
- Tests: DB CRUD, adapter round-trip, interface compliance
- Docs: ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14

Security: Credential IDs and public keys encrypted at rest with
AES-256-GCM via vault master key. Challenge ceremonies use 128-bit
nonces with 120s TTL in sync.Map. Sign counter validated on each
assertion to detect cloned authenticators. Password re-auth required
for registration (SEC-01 pattern). No credential material in API
responses or logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:12:59 -07:00

265 lines
9.7 KiB
Go

// 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"
"net"
"os"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
)
// Config is the top-level configuration structure parsed from the TOML file.
type Config struct { //nolint:govet // fieldalignment: TOML section order is more readable
Server ServerConfig `toml:"server"`
MasterKey MasterKeyConfig `toml:"master_key"`
Database DatabaseConfig `toml:"database"`
Tokens TokensConfig `toml:"tokens"`
Argon2 Argon2Config `toml:"argon2"`
WebAuthn WebAuthnConfig `toml:"webauthn"`
}
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
// section disables WebAuthn support. If any field is set, RPID and RPOrigin are
// required and RPOrigin must use the HTTPS scheme.
type WebAuthnConfig struct {
RPID string `toml:"rp_id"`
RPOrigin string `toml:"rp_origin"`
DisplayName string `toml:"display_name"`
}
// ServerConfig holds HTTP listener and TLS settings.
type ServerConfig struct {
// ListenAddr is the HTTPS listen address (required).
ListenAddr string `toml:"listen_addr"`
// GRPCAddr is the gRPC listen address (optional; omit to disable gRPC).
// The gRPC listener uses the same TLS certificate and key as the REST listener.
GRPCAddr string `toml:"grpc_addr"`
TLSCert string `toml:"tls_cert"`
TLSKey string `toml:"tls_key"`
// TrustedProxy is the IP address (not a range) of a reverse proxy that
// sits in front of the server and sets X-Forwarded-For or X-Real-IP
// headers. When set, the rate limiter and audit log extract the real
// client IP from these headers instead of r.RemoteAddr.
//
// Security: only requests whose r.RemoteAddr matches TrustedProxy are
// trusted to carry a valid forwarded-IP header. All other requests use
// r.RemoteAddr directly, so this field cannot be exploited for IP
// spoofing by external clients. Omit or leave empty when running
// without a reverse proxy.
TrustedProxy string `toml:"trusted_proxy"`
}
// 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. "168h", "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", //nolint:gosec // G101: env var name, not a credential value
},
}
}
// Load reads and validates a TOML config file from path.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path comes from the operator-supplied --config flag, not user input
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"))
}
// Security (DEF-03): if trusted_proxy is set it must be a valid IP address
// (not a hostname or CIDR) so the middleware can compare it to the parsed
// host part of r.RemoteAddr using a reliable byte-level equality check.
if c.Server.TrustedProxy != "" {
if net.ParseIP(c.Server.TrustedProxy) == nil {
errs = append(errs, fmt.Errorf("server.trusted_proxy %q is not a valid IP address", c.Server.TrustedProxy))
}
}
// 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"))
}
// Security (DEF-05): enforce both lower and upper bounds on token expiry
// durations. An operator misconfiguration could otherwise produce tokens
// valid for centuries, which would be irrevocable (bar explicit JTI
// revocation) if a token were stolen. Upper bounds are intentionally
// generous to accommodate a range of legitimate deployments while
// catching obvious typos (e.g. "876000h" instead of "8760h").
const (
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
maxAdminExpiry = 24 * time.Hour // 24 hours
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
)
if c.Tokens.DefaultExpiry.Duration <= 0 {
errs = append(errs, errors.New("tokens.default_expiry must be positive"))
} else if c.Tokens.DefaultExpiry.Duration > maxDefaultExpiry {
errs = append(errs, fmt.Errorf("tokens.default_expiry must be <= %s (got %s)", maxDefaultExpiry, c.Tokens.DefaultExpiry.Duration))
}
if c.Tokens.AdminExpiry.Duration <= 0 {
errs = append(errs, errors.New("tokens.admin_expiry must be positive"))
} else if c.Tokens.AdminExpiry.Duration > maxAdminExpiry {
errs = append(errs, fmt.Errorf("tokens.admin_expiry must be <= %s (got %s)", maxAdminExpiry, c.Tokens.AdminExpiry.Duration))
}
if c.Tokens.ServiceExpiry.Duration <= 0 {
errs = append(errs, errors.New("tokens.service_expiry must be positive"))
} else if c.Tokens.ServiceExpiry.Duration > maxServiceExpiry {
errs = append(errs, fmt.Errorf("tokens.service_expiry must be <= %s (got %s)", maxServiceExpiry, c.Tokens.ServiceExpiry.Duration))
}
// 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"))
}
// WebAuthn — if any field is set, RPID and RPOrigin are required.
hasWebAuthn := c.WebAuthn.RPID != "" || c.WebAuthn.RPOrigin != "" || c.WebAuthn.DisplayName != ""
if hasWebAuthn {
if c.WebAuthn.RPID == "" {
errs = append(errs, errors.New("webauthn.rp_id is required when webauthn is configured"))
}
if c.WebAuthn.RPOrigin == "" {
errs = append(errs, errors.New("webauthn.rp_origin is required when webauthn is configured"))
} else if !strings.HasPrefix(c.WebAuthn.RPOrigin, "https://") {
errs = append(errs, fmt.Errorf("webauthn.rp_origin must use the https:// scheme (got %q)", c.WebAuthn.RPOrigin))
}
}
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 }
// WebAuthnEnabled reports whether WebAuthn/passkey support is configured.
func (c *Config) WebAuthnEnabled() bool {
return c.WebAuthn.RPID != "" && c.WebAuthn.RPOrigin != ""
}