// Command mciassrv is the MCIAS authentication server. // // It reads a TOML configuration file, derives the master encryption key, // loads or generates the Ed25519 signing key, opens the SQLite database, // runs migrations, and starts an HTTPS listener. // If [server] grpc_addr is set in the config, a gRPC/TLS listener is also // started on that address. Both listeners share the same signing key, DB, // and config. Graceful shutdown drains both within the configured window. // // Usage: // // mciassrv -config /etc/mcias/mcias.toml package main import ( "context" "crypto/ed25519" "crypto/tls" "errors" "flag" "fmt" "log/slog" "net" "net/http" "os" "os/signal" "sync" "syscall" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/grpcserver" "git.wntrmute.dev/kyle/mcias/internal/server" ) func main() { configPath := flag.String("config", "mcias.toml", "path to TOML configuration file") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelInfo, })) if err := run(*configPath, logger); err != nil { logger.Error("fatal", "error", err) os.Exit(1) } } func run(configPath string, logger *slog.Logger) error { // Load and validate configuration. cfg, err := config.Load(configPath) if err != nil { return fmt.Errorf("load config: %w", err) } logger.Info("configuration loaded", "listen_addr", cfg.Server.ListenAddr, "grpc_addr", cfg.Server.GRPCAddr) // Open and migrate the database first — we need it to load the master key salt. database, err := db.Open(cfg.Database.Path) if err != nil { return fmt.Errorf("open database: %w", err) } defer func() { _ = database.Close() }() if err := db.Migrate(database); err != nil { return fmt.Errorf("migrate database: %w", err) } logger.Info("database ready", "path", cfg.Database.Path) // Derive or load the master encryption key. // Security: The master key encrypts TOTP secrets, Postgres passwords, and // the signing key at rest. It is derived from a passphrase via Argon2id // (or loaded directly from a key file). The KDF salt is stored in the DB // for stability across restarts. The passphrase env var is cleared after use. masterKey, err := loadMasterKey(cfg, database) if err != nil { return fmt.Errorf("load master key: %w", err) } defer func() { // Zero the master key when done — reduces the window of exposure. for i := range masterKey { masterKey[i] = 0 } }() // Load or generate the Ed25519 signing key. // Security: The private signing key is stored AES-256-GCM encrypted in the // database. On first run it is generated and stored. The key is decrypted // with the master key each startup. privKey, pubKey, err := loadOrGenerateSigningKey(database, masterKey, logger) if err != nil { return fmt.Errorf("signing key: %w", err) } // Configure TLS. We require TLS 1.2+ and prefer TLS 1.3. // Security: HTTPS/gRPC-TLS is mandatory; no plaintext listener is provided. // The same TLS certificate is used for both REST and gRPC listeners. tlsCfg := &tls.Config{ MinVersion: tls.VersionTLS12, CurvePreferences: []tls.CurveID{ tls.X25519, tls.CurveP256, }, } // Build the REST handler. restSrv := server.New(database, cfg, privKey, pubKey, masterKey, logger) httpServer := &http.Server{ Addr: cfg.Server.ListenAddr, Handler: restSrv.Handler(), TLSConfig: tlsCfg, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, ReadHeaderTimeout: 5 * time.Second, } // Build the gRPC server if grpc_addr is configured. var grpcSrv *grpc.Server var grpcListener net.Listener if cfg.Server.GRPCAddr != "" { // Load TLS credentials for gRPC using the same cert/key as REST. // Security: TLS 1.2 minimum is enforced via tls.Config; no h2c is offered. grpcTLSCreds, err := credentials.NewServerTLSFromFile(cfg.Server.TLSCert, cfg.Server.TLSKey) if err != nil { return fmt.Errorf("load gRPC TLS credentials: %w", err) } grpcSrvImpl := grpcserver.New(database, cfg, privKey, pubKey, masterKey, logger) // Build server directly with TLS credentials. GRPCServerWithCreds builds // the server with transport credentials at construction time per gRPC idiom. grpcSrv = rebuildGRPCServerWithTLS(grpcSrvImpl, grpcTLSCreds) grpcListener, err = net.Listen("tcp", cfg.Server.GRPCAddr) if err != nil { return fmt.Errorf("gRPC listen: %w", err) } logger.Info("gRPC listener ready", "addr", cfg.Server.GRPCAddr) } // Graceful shutdown on SIGINT/SIGTERM. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var wg sync.WaitGroup errCh := make(chan error, 2) // Start REST listener. wg.Add(1) go func() { defer wg.Done() logger.Info("REST server starting", "addr", cfg.Server.ListenAddr) if err := httpServer.ListenAndServeTLS(cfg.Server.TLSCert, cfg.Server.TLSKey); err != nil { if !errors.Is(err, http.ErrServerClosed) { errCh <- fmt.Errorf("REST server: %w", err) } } }() // Start gRPC listener if configured. if grpcSrv != nil && grpcListener != nil { wg.Add(1) go func() { defer wg.Done() logger.Info("gRPC server starting", "addr", cfg.Server.GRPCAddr) if err := grpcSrv.Serve(grpcListener); err != nil { errCh <- fmt.Errorf("gRPC server: %w", err) } }() } // Wait for shutdown signal or a server error. select { case <-ctx.Done(): logger.Info("shutdown signal received") case err := <-errCh: return err } // Graceful drain: give servers up to 15s to finish in-flight requests. shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := httpServer.Shutdown(shutdownCtx); err != nil { logger.Error("REST shutdown error", "error", err) } if grpcSrv != nil { grpcSrv.GracefulStop() } wg.Wait() // Drain any remaining error from startup goroutines. select { case err := <-errCh: return err default: } return nil } // rebuildGRPCServerWithTLS creates a new *grpc.Server with TLS credentials // and re-registers all services from the implementation. // This is needed because grpc.NewServer accepts credentials as an option at // construction time, not after the fact. func rebuildGRPCServerWithTLS(impl *grpcserver.Server, creds credentials.TransportCredentials) *grpc.Server { return impl.GRPCServerWithCreds(creds) } // loadMasterKey derives or loads the AES-256-GCM master key from the config. // // Key file mode: reads exactly 32 bytes from a file. // // Passphrase mode: reads the passphrase from the named environment variable, // then immediately clears it from the environment. The Argon2id KDF salt is // stored in the database on first run and retrieved on subsequent runs so that // the same passphrase always yields the same master key. // // Security: The Argon2id parameters used by crypto.DeriveKey exceed OWASP 2023 // minimums (time=3, memory=128MiB, threads=4). The salt is 32 random bytes. func loadMasterKey(cfg *config.Config, database *db.DB) ([]byte, error) { if cfg.MasterKey.KeyFile != "" { // Key file mode: file must contain exactly 32 bytes (AES-256). data, err := os.ReadFile(cfg.MasterKey.KeyFile) //nolint:gosec // G304: operator-supplied path if err != nil { return nil, fmt.Errorf("read key file: %w", err) } if len(data) != 32 { return nil, fmt.Errorf("key file must be exactly 32 bytes, got %d", len(data)) } key := make([]byte, 32) copy(key, data) // Zero the file buffer before it can be GC'd. for i := range data { data[i] = 0 } return key, nil } // Passphrase mode. passphrase := os.Getenv(cfg.MasterKey.PassphraseEnv) if passphrase == "" { return nil, fmt.Errorf("environment variable %q is not set or empty", cfg.MasterKey.PassphraseEnv) } // Immediately unset the env var so child processes cannot read it. _ = os.Unsetenv(cfg.MasterKey.PassphraseEnv) // Retrieve or create the KDF salt. salt, err := database.ReadMasterKeySalt() if errors.Is(err, db.ErrNotFound) { // First run: generate and persist a new salt. salt, err = crypto.NewSalt() if err != nil { return nil, fmt.Errorf("generate master key salt: %w", err) } if err := database.WriteMasterKeySalt(salt); err != nil { return nil, fmt.Errorf("store master key salt: %w", err) } } else if err != nil { return nil, fmt.Errorf("read master key salt: %w", err) } key, err := crypto.DeriveKey(passphrase, salt) if err != nil { return nil, fmt.Errorf("derive master key: %w", err) } return key, nil } // loadOrGenerateSigningKey loads the Ed25519 signing key from the database // (decrypted with masterKey), or generates and stores a new one on first run. // // Security: The private key is stored AES-256-GCM encrypted. A fresh random // nonce is used for each encryption. The plaintext key only exists in memory // during the process lifetime. func loadOrGenerateSigningKey(database *db.DB, masterKey []byte, logger *slog.Logger) (ed25519.PrivateKey, ed25519.PublicKey, error) { // Try to load existing key. enc, nonce, err := database.ReadServerConfig() if err == nil && enc != nil && nonce != nil { privPEM, err := crypto.OpenAESGCM(masterKey, nonce, enc) if err != nil { return nil, nil, fmt.Errorf("decrypt signing key: %w", err) } priv, err := crypto.ParsePrivateKeyPEM(privPEM) if err != nil { return nil, nil, fmt.Errorf("parse signing key PEM: %w", err) } // Security: ed25519.PrivateKey.Public() always returns ed25519.PublicKey, // but we use the ok form to make the type assertion explicit and safe. pub, ok := priv.Public().(ed25519.PublicKey) if !ok { return nil, nil, fmt.Errorf("signing key has unexpected public key type") } logger.Info("signing key loaded from database") return priv, pub, nil } // First run: generate and store a new signing key. logger.Info("generating new Ed25519 signing key") pub, priv, err := crypto.GenerateEd25519KeyPair() if err != nil { return nil, nil, fmt.Errorf("generate signing key: %w", err) } privPEM, err := crypto.MarshalPrivateKeyPEM(priv) if err != nil { return nil, nil, fmt.Errorf("marshal signing key: %w", err) } encKey, encNonce, err := crypto.SealAESGCM(masterKey, privPEM) if err != nil { return nil, nil, fmt.Errorf("encrypt signing key: %w", err) } if err := database.WriteServerConfig(encKey, encNonce); err != nil { return nil, nil, fmt.Errorf("store signing key: %w", err) } logger.Info("signing key generated and stored") return priv, pub, nil }