From 44e5e6e17400eac3ce51bd7f90465bc10cda7d2b Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sun, 15 Mar 2026 09:54:04 -0700 Subject: [PATCH] Checkpoint: auth, engine, seal, server, grpc updates Co-authored-by: Junie --- .gitignore | 5 +++ .junie/memory/errors.md | 0 .junie/memory/feedback.md | 0 .junie/memory/language.json | 1 + .junie/memory/memory.version | 1 + .junie/memory/tasks.md | 0 .junie/skills/checkpoint/SKILL.md | 8 ++++ AGENTS.md | 59 +++++++++++++++++++++++++++++ cmd/metacrypt/init.go | 5 ++- cmd/metacrypt/server.go | 6 +-- go.sum | 25 +++++++++++- internal/auth/auth.go | 15 +++++++- internal/auth/auth_test.go | 5 ++- internal/engine/engine.go | 20 ++++++++-- internal/engine/engine_test.go | 11 +++--- internal/grpcserver/interceptors.go | 13 +++++-- internal/grpcserver/server.go | 6 +-- internal/seal/seal.go | 16 +++++++- internal/seal/seal_test.go | 7 ++-- internal/server/middleware.go | 7 ++++ internal/server/server_test.go | 6 +-- 21 files changed, 185 insertions(+), 31 deletions(-) create mode 100644 .junie/memory/errors.md create mode 100644 .junie/memory/feedback.md create mode 100644 .junie/memory/language.json create mode 100644 .junie/memory/memory.version create mode 100644 .junie/memory/tasks.md create mode 100644 .junie/skills/checkpoint/SKILL.md create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 2b9ae48..e80bea3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Binary (root only, not cmd/metacrypt/) /metacrypt /metacrypt.exe +/metacrypt-web # Database *.db @@ -19,6 +20,10 @@ certs/ # Claude Code worktrees .claude/worktrees/ +# Junie outputs +.output.txt* +.env + # IDE .idea/ .vscode/ diff --git a/.junie/memory/errors.md b/.junie/memory/errors.md new file mode 100644 index 0000000..e69de29 diff --git a/.junie/memory/feedback.md b/.junie/memory/feedback.md new file mode 100644 index 0000000..e69de29 diff --git a/.junie/memory/language.json b/.junie/memory/language.json new file mode 100644 index 0000000..e320ead --- /dev/null +++ b/.junie/memory/language.json @@ -0,0 +1 @@ +[{"lang":"en","usageCount":2}] \ No newline at end of file diff --git a/.junie/memory/memory.version b/.junie/memory/memory.version new file mode 100644 index 0000000..9f8e9b6 --- /dev/null +++ b/.junie/memory/memory.version @@ -0,0 +1 @@ +1.0 \ No newline at end of file diff --git a/.junie/memory/tasks.md b/.junie/memory/tasks.md new file mode 100644 index 0000000..e69de29 diff --git a/.junie/skills/checkpoint/SKILL.md b/.junie/skills/checkpoint/SKILL.md new file mode 100644 index 0000000..437dfeb --- /dev/null +++ b/.junie/skills/checkpoint/SKILL.md @@ -0,0 +1,8 @@ +# Checkpoint Skill + +1. Run `go build ./...` abort if errors +2. Run `go test ./...` abort if failures +3. Run `go vet ./...` +4. Run `git add -A && git status` show user what will be committed +5. Generate an appropriate commit message based on your instructions. +6. Run `git commit -m ""` and verify with `git log -1` \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3c32b0e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Metacrypt is a cryptographic service for the Metacircular platform, written in Go. It provides cryptographic resources via an "engines" architecture (CA, SSH CA, transit encryption, user-to-user encryption). Authentication is handled by MCIAS (Metacircular Identity and Access Service) using the client library at `git.wntrmute.dev/kyle/mcias/clients/go`. MCIAS API docs: https://mcias.metacircular.net:8443/docs + +## Build & Test Commands + +```bash +go build ./... # Build all packages +go test ./... # Run all tests +go vet ./... # Static analysis +``` + +## Architecture + +- **Engines**: Modular cryptographic service providers (CA, SSH CA, transit, user-to-user encryption) +- **Storage**: SQLite database with an encrypted storage barrier (similar to HashiCorp Vault) +- **Seal/Unseal**: Single password unseals the service; a master encryption key serves as a key-encryption key (KEK) to decrypt per-engine data encryption keys +- **Auth**: MCIAS integration; MCIAS admin users get admin privileges on this service + +## Project Structure + +``` +. +├── cmd/metacrypt/ # CLI entry point (server, init, status, snapshot) +├── deploy/ +│ ├── docker/ # Docker Compose configuration +│ ├── examples/ # Example config files +│ ├── scripts/ # Deployment scripts +│ └── systemd/ # systemd unit files +├── internal/ +│ ├── auth/ # MCIAS token authentication & caching +│ ├── barrier/ # Encrypted key-value storage abstraction +│ ├── config/ # TOML configuration loading & validation +│ ├── crypto/ # Low-level cryptographic primitives +│ ├── db/ # SQLite setup & schema migrations +│ ├── engine/ # Pluggable engine registry & interface +│ ├── policy/ # Priority-based ACL engine +│ ├── seal/ # Seal/unseal state machine +│ └── server/ # HTTP server, routes, middleware +├── proto/metacrypt/ # Protobuf/gRPC definitions +├── web/ +│ ├── static/ # CSS, HTMX +│ └── templates/ # Go HTML templates +├── Dockerfile +├── Makefile +└── metacrypt.toml.example +``` + +## Ignored Directories + +- `srv/` — Local runtime data (database, certs, config). Do not read, modify, or reference these files. + +## API Sync Rule + +The gRPC proto definitions (`proto/metacrypt/v1/`) and the REST API (`internal/server/routes.go`) must always be kept in sync. When adding, removing, or changing an endpoint in either surface, the other must be updated in the same change. Every REST endpoint must have a corresponding gRPC RPC (and vice versa), with matching request/response fields. diff --git a/cmd/metacrypt/init.go b/cmd/metacrypt/init.go index 1988eee..7624a3c 100644 --- a/cmd/metacrypt/init.go +++ b/cmd/metacrypt/init.go @@ -3,6 +3,8 @@ package main import ( "context" "fmt" + "log/slog" + "os" "syscall" "github.com/spf13/cobra" @@ -47,8 +49,9 @@ func runInit(cmd *cobra.Command, args []string) error { return err } + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) b := barrier.NewAESGCMBarrier(database) - sealMgr := seal.NewManager(database, b) + sealMgr := seal.NewManager(database, b, logger) if err := sealMgr.CheckInitialized(); err != nil { return err } diff --git a/cmd/metacrypt/server.go b/cmd/metacrypt/server.go index c8f98ef..3484ef7 100644 --- a/cmd/metacrypt/server.go +++ b/cmd/metacrypt/server.go @@ -57,7 +57,7 @@ func runServer(cmd *cobra.Command, args []string) error { } b := barrier.NewAESGCMBarrier(database) - sealMgr := seal.NewManager(database, b) + sealMgr := seal.NewManager(database, b, logger) if err := sealMgr.CheckInitialized(); err != nil { return err @@ -70,9 +70,9 @@ func runServer(cmd *cobra.Command, args []string) error { return err } - authenticator := auth.NewAuthenticator(mcClient) + authenticator := auth.NewAuthenticator(mcClient, logger) policyEngine := policy.NewEngine(b) - engineRegistry := engine.NewRegistry(b) + engineRegistry := engine.NewRegistry(b, logger) engineRegistry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine) srv := server.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger, version) diff --git a/go.sum b/go.sum index 98b69d8..930f394 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -9,11 +11,16 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -58,6 +65,18 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= @@ -79,6 +98,8 @@ golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f71e25a..57ec1c5 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "log/slog" "sync" "time" @@ -34,29 +35,35 @@ type cachedClaims struct { // Authenticator provides MCIAS-backed authentication. type Authenticator struct { client *mcias.Client + logger *slog.Logger mu sync.RWMutex cache map[string]*cachedClaims // keyed by SHA-256(token) } // NewAuthenticator creates a new authenticator with the given MCIAS client. -func NewAuthenticator(client *mcias.Client) *Authenticator { +func NewAuthenticator(client *mcias.Client, logger *slog.Logger) *Authenticator { return &Authenticator{ client: client, + logger: logger, cache: make(map[string]*cachedClaims), } } // Login authenticates a user via MCIAS and returns the token. func (a *Authenticator) Login(username, password, totpCode string) (token string, expiresAt string, err error) { + a.logger.Debug("login attempt", "username", username) tok, exp, err := a.client.Login(username, password, totpCode) if err != nil { var authErr *mcias.MciasAuthError if errors.As(err, &authErr) { + a.logger.Debug("login failed: invalid credentials", "username", username) return "", "", ErrInvalidCredentials } + a.logger.Debug("login failed", "username", username, "error", err) return "", "", err } + a.logger.Debug("login succeeded", "username", username) return tok, exp, nil } @@ -69,15 +76,19 @@ func (a *Authenticator) ValidateToken(token string) (*TokenInfo, error) { cached, ok := a.cache[key] a.mu.RUnlock() if ok && time.Now().Before(cached.expiresAt) { + a.logger.Debug("token validated from cache") return cached.info, nil } + a.logger.Debug("validating token with MCIAS") // Validate with MCIAS. claims, err := a.client.ValidateToken(token) if err != nil { + a.logger.Debug("token validation failed", "error", err) return nil, err } if !claims.Valid { + a.logger.Debug("token invalid per MCIAS") return nil, ErrInvalidToken } @@ -94,6 +105,7 @@ func (a *Authenticator) ValidateToken(token string) (*TokenInfo, error) { expiresAt: time.Now().Add(tokenCacheTTL), } a.mu.Unlock() + a.logger.Debug("token validated and cached", "username", info.Username, "is_admin", info.IsAdmin) return info, nil } @@ -105,6 +117,7 @@ func (a *Authenticator) Logout(client *mcias.Client) error { // ClearCache removes all cached token validations. func (a *Authenticator) ClearCache() { + a.logger.Debug("clearing token cache") a.mu.Lock() a.cache = make(map[string]*cachedClaims) a.mu.Unlock() diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 4de6230..6fd1efe 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1,6 +1,7 @@ package auth import ( + "log/slog" "testing" ) @@ -33,7 +34,7 @@ func TestHasAdminRole(t *testing.T) { } func TestNewAuthenticator(t *testing.T) { - a := NewAuthenticator(nil) + a := NewAuthenticator(nil, slog.Default()) if a == nil { t.Fatal("NewAuthenticator returned nil") } @@ -43,7 +44,7 @@ func TestNewAuthenticator(t *testing.T) { } func TestClearCache(t *testing.T) { - a := NewAuthenticator(nil) + a := NewAuthenticator(nil, slog.Default()) a.cache["test"] = &cachedClaims{info: &TokenInfo{Username: "test"}} a.ClearCache() if len(a.cache) != 0 { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 3da5d62..2b2821f 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "strings" "sync" @@ -76,18 +77,20 @@ type Mount struct { // Registry manages mounted engine instances. type Registry struct { - mu sync.RWMutex - mounts map[string]*Mount + mu sync.RWMutex + mounts map[string]*Mount factories map[EngineType]Factory - barrier barrier.Barrier + barrier barrier.Barrier + logger *slog.Logger } // NewRegistry creates a new engine registry. -func NewRegistry(b barrier.Barrier) *Registry { +func NewRegistry(b barrier.Barrier, logger *slog.Logger) *Registry { return &Registry{ mounts: make(map[string]*Mount), factories: make(map[EngineType]Factory), barrier: b, + logger: logger, } } @@ -95,6 +98,7 @@ func NewRegistry(b barrier.Barrier) *Registry { func (r *Registry) RegisterFactory(t EngineType, f Factory) { r.mu.Lock() defer r.mu.Unlock() + r.logger.Debug("registering engine factory", "type", t) r.factories[t] = f } @@ -120,6 +124,7 @@ func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType return fmt.Errorf("%w: %s", ErrUnknownType, engineType) } + r.logger.Debug("mounting engine", "name", name, "type", engineType) eng := factory() mountPath := fmt.Sprintf("engine/%s/%s/", engineType, name) @@ -142,6 +147,7 @@ func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType MountPath: mountPath, Engine: eng, } + r.logger.Debug("engine mounted", "name", name, "type", engineType, "mount_path", mountPath) return nil } @@ -179,6 +185,7 @@ func (r *Registry) Unmount(ctx context.Context, name string) error { return ErrMountNotFound } + r.logger.Debug("unmounting engine", "name", name, "type", mount.Type) if err := mount.Engine.Seal(); err != nil { return fmt.Errorf("engine: seal %q: %w", name, err) } @@ -189,6 +196,7 @@ func (r *Registry) Unmount(ctx context.Context, name string) error { } delete(r.mounts, name) + r.logger.Debug("engine unmounted", "name", name) return nil } @@ -231,6 +239,7 @@ func (r *Registry) UnsealAll(ctx context.Context) error { continue // already loaded } + r.logger.Debug("discovered pre-migration engine mount", "name", name, "type", engineType) eng := factory() mountPath := fmt.Sprintf("engine/%s/%s/", engineType, name) if err := eng.Unseal(ctx, r.barrier, mountPath); err != nil { @@ -280,6 +289,7 @@ func (r *Registry) loadFromMetadata(ctx context.Context) error { return fmt.Errorf("%w: %s (mount %q)", ErrUnknownType, meta.Type, meta.Name) } + r.logger.Debug("unsealing engine from metadata", "name", meta.Name, "type", meta.Type) eng := factory() mountPath := fmt.Sprintf("engine/%s/%s/", meta.Type, meta.Name) if err := eng.Unseal(ctx, r.barrier, mountPath); err != nil { @@ -323,6 +333,7 @@ func (r *Registry) HandleRequest(ctx context.Context, mountName string, req *Req return nil, ErrMountNotFound } + r.logger.Debug("routing engine request", "mount", mountName, "operation", req.Operation, "path", req.Path) return mount.Engine.HandleRequest(ctx, req) } @@ -331,6 +342,7 @@ func (r *Registry) SealAll() error { r.mu.Lock() defer r.mu.Unlock() + r.logger.Debug("sealing all engines", "count", len(r.mounts)) for name, mount := range r.mounts { if err := mount.Engine.Seal(); err != nil { return fmt.Errorf("engine: seal %q: %w", name, err) diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 55fb2a1..30e5cde 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -2,6 +2,7 @@ package engine import ( "context" + "log/slog" "testing" "git.wntrmute.dev/kyle/metacrypt/internal/barrier" @@ -39,7 +40,7 @@ func (m *mockBarrier) Delete(_ context.Context, _ string) error { retu func (m *mockBarrier) List(_ context.Context, _ string) ([]string, error) { return nil, nil } func TestRegistryMountUnmount(t *testing.T) { - reg := NewRegistry(&mockBarrier{}) + reg := NewRegistry(&mockBarrier{}, slog.Default()) reg.RegisterFactory(EngineTypeTransit, func() Engine { return &mockEngine{engineType: EngineTypeTransit} }) @@ -73,14 +74,14 @@ func TestRegistryMountUnmount(t *testing.T) { } func TestRegistryUnmountNotFound(t *testing.T) { - reg := NewRegistry(&mockBarrier{}) + reg := NewRegistry(&mockBarrier{}, slog.Default()) if err := reg.Unmount(context.Background(), "nonexistent"); err != ErrMountNotFound { t.Fatalf("expected ErrMountNotFound, got: %v", err) } } func TestRegistryUnknownType(t *testing.T) { - reg := NewRegistry(&mockBarrier{}) + reg := NewRegistry(&mockBarrier{}, slog.Default()) err := reg.Mount(context.Background(), "test", EngineTypeTransit, nil) if err == nil { t.Fatal("expected error for unknown engine type") @@ -88,7 +89,7 @@ func TestRegistryUnknownType(t *testing.T) { } func TestRegistryHandleRequest(t *testing.T) { - reg := NewRegistry(&mockBarrier{}) + reg := NewRegistry(&mockBarrier{}, slog.Default()) reg.RegisterFactory(EngineTypeTransit, func() Engine { return &mockEngine{engineType: EngineTypeTransit} }) @@ -111,7 +112,7 @@ func TestRegistryHandleRequest(t *testing.T) { } func TestRegistrySealAll(t *testing.T) { - reg := NewRegistry(&mockBarrier{}) + reg := NewRegistry(&mockBarrier{}, slog.Default()) reg.RegisterFactory(EngineTypeTransit, func() Engine { return &mockEngine{engineType: EngineTypeTransit} }) diff --git a/internal/grpcserver/interceptors.go b/internal/grpcserver/interceptors.go index 503ee67..81e768a 100644 --- a/internal/grpcserver/interceptors.go +++ b/internal/grpcserver/interceptors.go @@ -2,6 +2,7 @@ package grpcserver import ( "context" + "log/slog" "strings" "google.golang.org/grpc" @@ -25,7 +26,7 @@ func tokenInfoFromContext(ctx context.Context) *auth.TokenInfo { // authInterceptor validates the Bearer token from gRPC metadata and injects // *auth.TokenInfo into the context. The set of method full names that require // auth is passed in; all others pass through without validation. -func authInterceptor(authenticator *auth.Authenticator, methods map[string]bool) grpc.UnaryServerInterceptor { +func authInterceptor(authenticator *auth.Authenticator, logger *slog.Logger, methods map[string]bool) grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { if !methods[info.FullMethod] { return handler(ctx, req) @@ -33,14 +34,17 @@ func authInterceptor(authenticator *auth.Authenticator, methods map[string]bool) token := extractToken(ctx) if token == "" { + logger.Debug("grpc request rejected: missing token", "method", info.FullMethod) return nil, status.Error(codes.Unauthenticated, "missing authorization token") } tokenInfo, err := authenticator.ValidateToken(token) if err != nil { + logger.Debug("grpc request rejected: invalid token", "method", info.FullMethod, "error", err) return nil, status.Error(codes.Unauthenticated, "invalid token") } + logger.Debug("grpc request authenticated", "method", info.FullMethod, "username", tokenInfo.Username) ctx = context.WithValue(ctx, tokenInfoKey, tokenInfo) return handler(ctx, req) } @@ -48,27 +52,30 @@ func authInterceptor(authenticator *auth.Authenticator, methods map[string]bool) // adminInterceptor requires IsAdmin on the token info for the listed methods. // Must run after authInterceptor. -func adminInterceptor(methods map[string]bool) grpc.UnaryServerInterceptor { +func adminInterceptor(logger *slog.Logger, methods map[string]bool) grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { if !methods[info.FullMethod] { return handler(ctx, req) } ti := tokenInfoFromContext(ctx) if ti == nil || !ti.IsAdmin { + logger.Debug("grpc request rejected: admin required", "method", info.FullMethod) return nil, status.Error(codes.PermissionDenied, "admin required") } + logger.Debug("grpc admin request authorized", "method", info.FullMethod, "username", ti.Username) return handler(ctx, req) } } // sealInterceptor rejects calls with FailedPrecondition when the vault is // sealed, for the listed methods. -func sealInterceptor(sealMgr *seal.Manager, methods map[string]bool) grpc.UnaryServerInterceptor { +func sealInterceptor(sealMgr *seal.Manager, logger *slog.Logger, methods map[string]bool) grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { if !methods[info.FullMethod] { return handler(ctx, req) } if sealMgr.State() != seal.StateUnsealed { + logger.Debug("grpc request rejected: vault sealed", "method", info.FullMethod) return nil, status.Error(codes.FailedPrecondition, "vault is sealed") } return handler(ctx, req) diff --git a/internal/grpcserver/server.go b/internal/grpcserver/server.go index e3206c8..518b018 100644 --- a/internal/grpcserver/server.go +++ b/internal/grpcserver/server.go @@ -66,9 +66,9 @@ func (s *GRPCServer) Start() error { creds := credentials.NewTLS(tlsCfg) interceptor := chainInterceptors( - sealInterceptor(s.sealMgr, sealRequiredMethods()), - authInterceptor(s.auth, authRequiredMethods()), - adminInterceptor(adminRequiredMethods()), + sealInterceptor(s.sealMgr, s.logger, sealRequiredMethods()), + authInterceptor(s.auth, s.logger, authRequiredMethods()), + adminInterceptor(s.logger, adminRequiredMethods()), ) s.srv = grpc.NewServer( diff --git a/internal/seal/seal.go b/internal/seal/seal.go index 9c873ab..276e278 100644 --- a/internal/seal/seal.go +++ b/internal/seal/seal.go @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + "log/slog" "sync" "time" @@ -51,6 +52,7 @@ var ( type Manager struct { db *sql.DB barrier *barrier.AESGCMBarrier + logger *slog.Logger mu sync.RWMutex state ServiceState @@ -63,10 +65,11 @@ type Manager struct { } // NewManager creates a new seal manager. -func NewManager(db *sql.DB, b *barrier.AESGCMBarrier) *Manager { +func NewManager(db *sql.DB, b *barrier.AESGCMBarrier, logger *slog.Logger) *Manager { return &Manager{ db: db, barrier: b, + logger: logger, state: StateUninitialized, } } @@ -98,8 +101,10 @@ func (m *Manager) CheckInitialized() error { } if count > 0 { m.state = StateSealed + m.logger.Debug("seal config found, state set to sealed") } else { m.state = StateUninitialized + m.logger.Debug("no seal config found, state set to uninitialized") } return nil } @@ -114,6 +119,7 @@ func (m *Manager) Initialize(ctx context.Context, password []byte, params crypto return ErrAlreadyInitialized } + m.logger.Debug("initializing seal manager") m.state = StateInitializing defer func() { if m.mek == nil { @@ -162,6 +168,7 @@ func (m *Manager) Initialize(ctx context.Context, password []byte, params crypto m.mek = mek m.state = StateUnsealed + m.logger.Debug("seal initialization complete, barrier unsealed") return nil } @@ -177,9 +184,11 @@ func (m *Manager) Unseal(password []byte) error { return ErrNotSealed } + m.logger.Debug("unseal attempt") // Rate limiting. now := time.Now() if now.Before(m.lockoutUntil) { + m.logger.Debug("unseal attempt rate limited") return ErrRateLimited } if now.Sub(m.lastAttempt) > time.Minute { @@ -190,6 +199,7 @@ func (m *Manager) Unseal(password []byte) error { if m.unsealAttempts > 5 { m.lockoutUntil = now.Add(60 * time.Second) m.unsealAttempts = 0 + m.logger.Debug("unseal attempts exceeded, locking out") return ErrRateLimited } @@ -215,6 +225,7 @@ func (m *Manager) Unseal(password []byte) error { mek, err := crypto.Decrypt(kwk, encryptedMEK) if err != nil { + m.logger.Debug("unseal failed: invalid password") return ErrInvalidPassword } @@ -227,6 +238,7 @@ func (m *Manager) Unseal(password []byte) error { m.mek = mek m.state = StateUnsealed m.unsealAttempts = 0 + m.logger.Debug("unseal succeeded, barrier unsealed") return nil } @@ -239,11 +251,13 @@ func (m *Manager) Seal() error { return ErrNotSealed } + m.logger.Debug("sealing service") if m.mek != nil { crypto.Zeroize(m.mek) m.mek = nil } m.barrier.Seal() m.state = StateSealed + m.logger.Debug("service sealed") return nil } diff --git a/internal/seal/seal_test.go b/internal/seal/seal_test.go index 9c893a3..5d635b5 100644 --- a/internal/seal/seal_test.go +++ b/internal/seal/seal_test.go @@ -2,6 +2,7 @@ package seal import ( "context" + "log/slog" "path/filepath" "testing" @@ -21,7 +22,7 @@ func setupSeal(t *testing.T) (*Manager, func()) { t.Fatalf("migrate: %v", err) } b := barrier.NewAESGCMBarrier(database) - mgr := NewManager(database, b) + mgr := NewManager(database, b, slog.Default()) return mgr, func() { database.Close() } } @@ -101,7 +102,7 @@ func TestSealCheckInitializedPersists(t *testing.T) { database, _ := db.Open(dbPath) db.Migrate(database) b := barrier.NewAESGCMBarrier(database) - mgr := NewManager(database, b) + mgr := NewManager(database, b, slog.Default()) mgr.CheckInitialized() params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1} mgr.Initialize(context.Background(), []byte("password"), params) @@ -111,7 +112,7 @@ func TestSealCheckInitializedPersists(t *testing.T) { database2, _ := db.Open(dbPath) defer database2.Close() b2 := barrier.NewAESGCMBarrier(database2) - mgr2 := NewManager(database2, b2) + mgr2 := NewManager(database2, b2, slog.Default()) mgr2.CheckInitialized() if mgr2.State() != StateSealed { t.Fatalf("state after reopen: got %v, want Sealed", mgr2.State()) diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 632d5ba..893d5d8 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -42,9 +42,11 @@ func (s *Server) requireUnseal(next http.HandlerFunc) http.HandlerFunc { state := s.seal.State() switch state { case seal.StateUninitialized: + s.logger.Debug("request rejected: service uninitialized", "path", r.URL.Path) http.Error(w, `{"error":"not initialized"}`, http.StatusPreconditionFailed) return case seal.StateSealed, seal.StateInitializing: + s.logger.Debug("request rejected: service sealed", "path", r.URL.Path) http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) return } @@ -57,16 +59,19 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { return s.requireUnseal(func(w http.ResponseWriter, r *http.Request) { token := extractToken(r) if token == "" { + s.logger.Debug("request rejected: missing token", "path", r.URL.Path) http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } info, err := s.auth.ValidateToken(token) if err != nil { + s.logger.Debug("request rejected: invalid token", "path", r.URL.Path, "error", err) http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } + s.logger.Debug("request authenticated", "path", r.URL.Path, "username", info.Username) ctx := context.WithValue(r.Context(), tokenInfoKey, info) next(w, r.WithContext(ctx)) }) @@ -77,9 +82,11 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { info := TokenInfoFromContext(r.Context()) if info == nil || !info.IsAdmin { + s.logger.Debug("request rejected: admin required", "path", r.URL.Path) http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } + s.logger.Debug("admin request authorized", "path", r.URL.Path, "username", info.Username) next(w, r) }) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 91e3bac..5b4f135 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -36,14 +36,14 @@ func setupTestServer(t *testing.T) (*Server, *seal.Manager, chi.Router) { db.Migrate(database) b := barrier.NewAESGCMBarrier(database) - sealMgr := seal.NewManager(database, b) + sealMgr := seal.NewManager(database, b, slog.Default()) sealMgr.CheckInitialized() // Auth requires MCIAS client which we can't create in tests easily, // so we pass nil and avoid auth-dependent routes in these tests. - authenticator := auth.NewAuthenticator(nil) + authenticator := auth.NewAuthenticator(nil, slog.Default()) policyEngine := policy.NewEngine(b) - engineRegistry := engine.NewRegistry(b) + engineRegistry := engine.NewRegistry(b, slog.Default()) cfg := &config.Config{ Server: config.ServerConfig{