Checkpoint: auth, engine, seal, server, grpc updates

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-03-15 09:54:04 -07:00
parent 33beb33a13
commit 44e5e6e174
21 changed files with 185 additions and 31 deletions

5
.gitignore vendored
View File

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

0
.junie/memory/errors.md Normal file
View File

View File

View File

@@ -0,0 +1 @@
[{"lang":"en","usageCount":2}]

View File

@@ -0,0 +1 @@
1.0

0
.junie/memory/tasks.md Normal file
View File

View File

@@ -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 "<message>"` and verify with `git log -1`

59
AGENTS.md Normal file
View File

@@ -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.

View File

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

View File

@@ -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)

25
go.sum
View File

@@ -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=

View File

@@ -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()

View File

@@ -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 {

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
@@ -80,14 +81,16 @@ type Registry struct {
mounts map[string]*Mount
factories map[EngineType]Factory
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)

View File

@@ -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}
})

View File

@@ -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)

View File

@@ -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(

View File

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

View File

@@ -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())

View File

@@ -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)
})
}

View File

@@ -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{