Checkpoint: auth, engine, seal, server, grpc updates
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
0
.junie/memory/errors.md
Normal file
0
.junie/memory/feedback.md
Normal file
0
.junie/memory/feedback.md
Normal file
1
.junie/memory/language.json
Normal file
1
.junie/memory/language.json
Normal file
@@ -0,0 +1 @@
|
||||
[{"lang":"en","usageCount":2}]
|
||||
1
.junie/memory/memory.version
Normal file
1
.junie/memory/memory.version
Normal file
@@ -0,0 +1 @@
|
||||
1.0
|
||||
0
.junie/memory/tasks.md
Normal file
0
.junie/memory/tasks.md
Normal file
8
.junie/skills/checkpoint/SKILL.md
Normal file
8
.junie/skills/checkpoint/SKILL.md
Normal 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
59
AGENTS.md
Normal 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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
25
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=
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user