Files
mcias/cmd/mciassrv/main.go
Kyle Isom f34e9a69a0 Fix all golangci-lint warnings
- errorlint: use errors.Is for db.ErrNotFound comparisons
  in accountservice.go, credentialservice.go, tokenservice.go
- gofmt/goimports: move mciasv1 alias into internal import group
  in auth.go, credentialservice.go, grpcserver.go, grpcserver_test.go
- gosec G115: add nolint annotation on int32 port conversions
  in mciasgrpcctl/main.go and credentialservice.go (port validated
  as [1,65535] on input; overflow not reachable)
- govet fieldalignment: reorder Server, grpcRateLimiter,
  grpcRateLimitEntry, testEnv structs to reduce GC bitmap size
  (96 -> 80 pointer bytes each)
- ineffassign: remove intermediate grpcSrv = GRPCServer() call
  in cmd/mciassrv/main.go (immediately overwritten by TLS build)
- staticcheck SA9003: replace empty if-body with _ = Serve(lis)
  in grpcserver_test.go
0 golangci-lint issues; 137 tests pass (go test -race ./...)
2026-03-11 15:24:07 -07:00

329 lines
10 KiB
Go

// 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
}