- proto/mcias/v1/: AdminService, AuthService, TokenService, AccountService, CredentialService; generated Go stubs in gen/ - internal/grpcserver: full handler implementations sharing all business logic (auth, token, db, crypto) with REST server; interceptor chain: logging -> auth (JWT alg-first + revocation) -> rate-limit (token bucket, 10 req/s, burst 10, per-IP) - internal/config: optional grpc_addr field in [server] section - cmd/mciassrv: dual-stack startup; gRPC/TLS listener on grpc_addr when configured; graceful shutdown of both servers in 15s window - cmd/mciasgrpcctl: companion gRPC CLI mirroring mciasctl commands (health, pubkey, account, role, token, pgcreds) using TLS with optional custom CA cert - internal/grpcserver/grpcserver_test.go: 20 tests via bufconn covering public RPCs, auth interceptor (no token, invalid, revoked -> 401), non-admin -> 403, Login/Logout/RenewToken/ValidateToken flows, AccountService CRUD, SetPGCreds/GetPGCreds AES-GCM round-trip, credential fields absent from all responses Security: JWT validation path identical to REST: alg header checked before signature, alg:none rejected, revocation table checked after sig. Authorization metadata value never logged by any interceptor. Credential fields (PasswordHash, TOTPSecret*, PGPassword) absent from all proto response messages — enforced by proto design and confirmed by test TestCredentialFieldsAbsentFromAccountResponse. Login dummy-Argon2 timing guard preserves timing uniformity for unknown users (same as REST handleLogin). TLS required at listener level; cmd/mciassrv uses credentials.NewServerTLSFromFile; no h2c offered. 137 tests pass, zero race conditions (go test -race ./...)
332 lines
11 KiB
Go
332 lines
11 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)
|
|
grpcSrv = grpcSrvImpl.GRPCServer()
|
|
// Apply TLS to the gRPC server by wrapping options.
|
|
// We reconstruct the server with TLS credentials since GRPCServer()
|
|
// returns an already-built server; instead, build with creds directly.
|
|
// Re-create with TLS option.
|
|
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
|
|
}
|