Fix ECDH zeroization, add audit logging, and remediate high findings

- Fix #61: handleRotateKey and handleDeleteUser now zeroize stored
  privBytes instead of calling Bytes() (which returns a copy). New
  state populates privBytes; old references nil'd for GC.
- Add audit logging subsystem (internal/audit) with structured event
  recording for cryptographic operations.
- Add audit log engine spec (engines/auditlog.md).
- Add ValidateName checks across all engines for path traversal (#48).
- Update AUDIT.md: all High findings resolved (0 open).
- Add REMEDIATION.md with detailed remediation tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 14:04:39 -07:00
parent b33d1f99a0
commit 5c5d7e184e
24 changed files with 1699 additions and 72 deletions

79
internal/audit/audit.go Normal file
View File

@@ -0,0 +1,79 @@
// Package audit provides structured audit event logging for Metacrypt.
// Audit events record security-relevant operations (who did what, when,
// and whether it succeeded) as one JSON object per line.
package audit
import (
"context"
"log/slog"
)
// LevelAudit is a custom slog level for audit events. It sits above Warn
// so that audit events are never suppressed by log level filtering.
const LevelAudit = slog.Level(12)
func init() {
// Replace the level name so JSON output shows "AUDIT" instead of "ERROR+4".
slog.SetLogLoggerLevel(LevelAudit)
}
// Logger writes structured audit events. A nil *Logger is safe to use;
// all methods are no-ops.
type Logger struct {
logger *slog.Logger
}
// New creates an audit logger writing to the given handler. Pass nil to
// create a disabled logger (equivalent to using a nil *Logger).
func New(h slog.Handler) *Logger {
if h == nil {
return nil
}
return &Logger{logger: slog.New(h)}
}
// Event represents a single audit event.
type Event struct {
Caller string // MCIAS username, or "operator" for unauthenticated ops
Roles []string // caller's MCIAS roles
Operation string // engine operation name (e.g., "issue", "sign-user")
Engine string // engine type (e.g., "ca", "sshca", "transit", "user")
Mount string // mount name
Resource string // policy resource path evaluated
Outcome string // "success", "denied", or "error"
Error string // error message (on "error" or "denied" outcomes)
Detail map[string]interface{} // operation-specific metadata
}
// Log writes an audit event. Safe to call on a nil receiver.
func (l *Logger) Log(ctx context.Context, e Event) {
if l == nil {
return
}
attrs := []slog.Attr{
slog.String("caller", e.Caller),
slog.String("operation", e.Operation),
slog.String("outcome", e.Outcome),
}
if len(e.Roles) > 0 {
attrs = append(attrs, slog.Any("roles", e.Roles))
}
if e.Engine != "" {
attrs = append(attrs, slog.String("engine", e.Engine))
}
if e.Mount != "" {
attrs = append(attrs, slog.String("mount", e.Mount))
}
if e.Resource != "" {
attrs = append(attrs, slog.String("resource", e.Resource))
}
if e.Error != "" {
attrs = append(attrs, slog.String("error", e.Error))
}
if len(e.Detail) > 0 {
attrs = append(attrs, slog.Any("detail", e.Detail))
}
l.logger.LogAttrs(ctx, LevelAudit, "audit", attrs...)
}

View File

@@ -0,0 +1,124 @@
package audit
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"testing"
)
func TestNilLoggerIsSafe(t *testing.T) {
var l *Logger
// Must not panic.
l.Log(context.Background(), Event{
Caller: "test",
Operation: "issue",
Outcome: "success",
})
}
func TestLogWritesJSON(t *testing.T) {
var buf bytes.Buffer
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{
Level: slog.Level(-10), // accept all levels
})
l := New(h)
l.Log(context.Background(), Event{
Caller: "kyle",
Roles: []string{"admin"},
Operation: "issue",
Engine: "ca",
Mount: "pki",
Outcome: "success",
Detail: map[string]interface{}{"serial": "01:02:03"},
})
var entry map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("invalid JSON: %v\nbody: %s", err, buf.String())
}
checks := map[string]string{
"caller": "kyle",
"operation": "issue",
"engine": "ca",
"mount": "pki",
"outcome": "success",
}
for k, want := range checks {
got, ok := entry[k].(string)
if !ok || got != want {
t.Errorf("field %q = %q, want %q", k, got, want)
}
}
detail, ok := entry["detail"].(map[string]interface{})
if !ok {
t.Fatalf("detail is not a map: %T", entry["detail"])
}
if serial, _ := detail["serial"].(string); serial != "01:02:03" {
t.Errorf("detail.serial = %q, want %q", serial, "01:02:03")
}
}
func TestLogOmitsEmptyFields(t *testing.T) {
var buf bytes.Buffer
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{
Level: slog.Level(-10),
})
l := New(h)
l.Log(context.Background(), Event{
Caller: "kyle",
Operation: "unseal",
Outcome: "success",
})
var entry map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
for _, key := range []string{"roles", "engine", "mount", "resource", "error", "detail"} {
if _, ok := entry[key]; ok {
t.Errorf("field %q should be omitted for empty value", key)
}
}
}
func TestLogIncludesError(t *testing.T) {
var buf bytes.Buffer
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{
Level: slog.Level(-10),
})
l := New(h)
l.Log(context.Background(), Event{
Caller: "operator",
Operation: "unseal",
Outcome: "denied",
Error: "invalid password",
})
var entry map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got, _ := entry["error"].(string); got != "invalid password" {
t.Errorf("error = %q, want %q", got, "invalid password")
}
if got, _ := entry["outcome"].(string); got != "denied" {
t.Errorf("outcome = %q, want %q", got, "denied")
}
}
func TestNewWithNilHandlerReturnsNil(t *testing.T) {
l := New(nil)
if l != nil {
t.Errorf("New(nil) = %v, want nil", l)
}
// Must not panic.
l.Log(context.Background(), Event{Caller: "test", Operation: "test", Outcome: "success"})
}

View File

@@ -16,6 +16,17 @@ type Config struct {
Database DatabaseConfig `toml:"database"`
Log LogConfig `toml:"log"`
Seal SealConfig `toml:"seal"`
Audit AuditConfig `toml:"audit"`
}
// AuditConfig holds audit logging settings.
type AuditConfig struct {
// Mode controls audit log output: "file", "stdout", or "" (disabled).
Mode string `toml:"mode"`
// Path is the audit log file path (required when mode is "file").
Path string `toml:"path"`
// IncludeReads enables audit logging for read-only operations.
IncludeReads bool `toml:"include_reads"`
}
// ServerConfig holds HTTP/gRPC server settings.
@@ -119,5 +130,17 @@ func (c *Config) Validate() error {
c.Web.ListenAddr = "127.0.0.1:8080"
}
// Validate audit config.
switch c.Audit.Mode {
case "", "stdout":
// ok
case "file":
if c.Audit.Path == "" {
return fmt.Errorf("config: audit.path is required when audit.mode is \"file\"")
}
default:
return fmt.Errorf("config: audit.mode must be \"file\", \"stdout\", or empty")
}
return nil
}

View File

@@ -596,6 +596,9 @@ func (e *CAEngine) handleGetChain(_ context.Context, req *engine.Request) (*engi
if issuerName == "" {
issuerName = req.Path
}
if err := engine.ValidateName(issuerName); err != nil {
return nil, err
}
chain, err := e.GetChainPEM(issuerName)
if err != nil {
@@ -610,6 +613,9 @@ func (e *CAEngine) handleGetChain(_ context.Context, req *engine.Request) (*engi
func (e *CAEngine) handleGetIssuer(_ context.Context, req *engine.Request) (*engine.Response, error) {
name := req.Path
if err := engine.ValidateName(name); err != nil {
return nil, err
}
certPEM, err := e.GetIssuerCertPEM(name)
if err != nil {
@@ -698,6 +704,7 @@ func (e *CAEngine) handleCreateIssuer(ctx context.Context, req *engine.Request)
Expiry: expiry,
}
e.setProfileAIA(&profile)
issuerCert, err := profile.SignRequest(e.rootCert, csr, e.rootKey)
if err != nil {
return nil, fmt.Errorf("ca: sign issuer cert: %w", err)
@@ -757,6 +764,9 @@ func (e *CAEngine) handleDeleteIssuer(ctx context.Context, req *engine.Request)
if name == "" {
name = req.Path
}
if err := engine.ValidateName(name); err != nil {
return nil, err
}
e.mu.Lock()
defer e.mu.Unlock()
@@ -830,6 +840,9 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
if issuerName == "" {
return nil, fmt.Errorf("ca: issuer name is required")
}
if err := engine.ValidateName(issuerName); err != nil {
return nil, err
}
profileName, _ := req.Data["profile"].(string)
if profileName == "" {
@@ -922,6 +935,7 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
return nil, fmt.Errorf("ca: create leaf CSR: %w", err)
}
e.setProfileAIA(&profile)
leafCert, err := profile.SignRequest(is.cert, csr, is.key)
if err != nil {
return nil, fmt.Errorf("ca: sign leaf cert: %w", err)
@@ -1171,6 +1185,7 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
return nil, fmt.Errorf("ca: create renewal CSR: %w", err)
}
e.setProfileAIA(&profile)
newCert, err := profile.SignRequest(is.cert, csr, is.key)
if err != nil {
return nil, fmt.Errorf("ca: sign renewal cert: %w", err)
@@ -1238,6 +1253,9 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
if issuerName == "" {
return nil, fmt.Errorf("ca: issuer name is required")
}
if err := engine.ValidateName(issuerName); err != nil {
return nil, err
}
csrPEM, _ := req.Data["csr_pem"].(string)
if csrPEM == "" {
@@ -1293,6 +1311,7 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
}
}
e.setProfileAIA(&profile)
leafCert, err := profile.SignRequest(is.cert, csr, is.key)
if err != nil {
return nil, fmt.Errorf("ca: sign CSR: %w", err)
@@ -1436,6 +1455,20 @@ func (e *CAEngine) handleDeleteCert(ctx context.Context, req *engine.Request) (*
// --- Helpers ---
// setProfileAIA populates the AIA (Authority Information Access) extension
// URLs on the profile if external_url is configured. This allows clients
// to discover the issuing CA certificate for chain building.
func (e *CAEngine) setProfileAIA(profile *certgen.Profile) {
if e.config.ExternalURL == "" {
return
}
base := strings.TrimSuffix(e.config.ExternalURL, "/")
mount := e.mountName()
profile.IssuingCertificateURL = []string{
base + "/v1/pki/" + mount + "/ca/chain",
}
}
func defaultCAConfig() *CAConfig {
return &CAConfig{
Organization: "Metacircular",
@@ -1461,6 +1494,9 @@ func mapToCAConfig(m map[string]interface{}, cfg *CAConfig) error {
if v, ok := m["root_expiry"].(string); ok {
cfg.RootExpiry = v
}
if v, ok := m["external_url"].(string); ok {
cfg.ExternalURL = v
}
return nil
}

View File

@@ -29,11 +29,13 @@ func GetProfile(name string) (certgen.Profile, bool) {
}
// Return a copy so callers can modify.
cp := certgen.Profile{
IsCA: p.IsCA,
PathLen: p.PathLen,
Expiry: p.Expiry,
KeyUse: make([]string, len(p.KeyUse)),
ExtKeyUsages: make([]string, len(p.ExtKeyUsages)),
IsCA: p.IsCA,
PathLen: p.PathLen,
Expiry: p.Expiry,
KeyUse: make([]string, len(p.KeyUse)),
ExtKeyUsages: make([]string, len(p.ExtKeyUsages)),
OCSPServer: append([]string(nil), p.OCSPServer...),
IssuingCertificateURL: append([]string(nil), p.IssuingCertificateURL...),
}
copy(cp.KeyUse, p.KeyUse)
copy(cp.ExtKeyUsages, p.ExtKeyUsages)

View File

@@ -10,6 +10,7 @@ type CAConfig struct {
Country string `json:"country,omitempty"`
KeyAlgorithm string `json:"key_algorithm"`
RootExpiry string `json:"root_expiry"`
ExternalURL string `json:"external_url,omitempty"`
KeySize int `json:"key_size"`
}

View File

@@ -588,6 +588,9 @@ func (e *SSHCAEngine) handleUpdateProfile(ctx context.Context, req *engine.Reque
if name == "" {
return nil, fmt.Errorf("sshca: name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, err
}
// Load existing profile.
profile, err := e.loadProfile(ctx, name)
@@ -631,6 +634,9 @@ func (e *SSHCAEngine) handleGetProfile(ctx context.Context, req *engine.Request)
if name == "" {
return nil, fmt.Errorf("sshca: name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, err
}
profile, err := e.loadProfile(ctx, name)
if err != nil {
@@ -697,6 +703,9 @@ func (e *SSHCAEngine) handleDeleteProfile(ctx context.Context, req *engine.Reque
if name == "" {
return nil, fmt.Errorf("sshca: name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, err
}
// Check existence.
if _, err := e.barrier.Get(ctx, e.mountPath+"profiles/"+name+".json"); err != nil {

View File

@@ -450,6 +450,9 @@ func (e *TransitEngine) handleDeleteKey(ctx context.Context, req *engine.Request
if name == "" {
return nil, fmt.Errorf("transit: name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, err
}
ks, ok := e.keys[name]
if !ok {
@@ -498,6 +501,9 @@ func (e *TransitEngine) handleGetKey(_ context.Context, req *engine.Request) (*e
if name == "" {
return nil, fmt.Errorf("transit: name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, err
}
ks, ok := e.keys[name]
if !ok {
@@ -561,6 +567,9 @@ func (e *TransitEngine) handleRotateKey(ctx context.Context, req *engine.Request
if name == "" {
return nil, fmt.Errorf("transit: name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, err
}
ks, ok := e.keys[name]
if !ok {
@@ -638,6 +647,9 @@ func (e *TransitEngine) handleUpdateKeyConfig(ctx context.Context, req *engine.R
if name == "" {
return nil, fmt.Errorf("transit: name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, err
}
ks, ok := e.keys[name]
if !ok {
@@ -684,6 +696,9 @@ func (e *TransitEngine) handleTrimKey(ctx context.Context, req *engine.Request)
if name == "" {
return nil, fmt.Errorf("transit: name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, err
}
ks, ok := e.keys[name]
if !ok {
@@ -1290,6 +1305,9 @@ func (e *TransitEngine) handleGetPublicKey(_ context.Context, req *engine.Reques
if keyName == "" {
return nil, fmt.Errorf("transit: name is required")
}
if err := engine.ValidateName(keyName); err != nil {
return nil, err
}
ks, ok := e.keys[keyName]
if !ok {

View File

@@ -212,6 +212,9 @@ func (e *UserEngine) handleRegister(ctx context.Context, req *engine.Request) (*
}
username := req.CallerInfo.Username
if err := engine.ValidateName(username); err != nil {
return nil, fmt.Errorf("user: invalid username: %w", err)
}
e.mu.RLock()
if u, ok := e.users[username]; ok {
pubB64 := base64.StdEncoding.EncodeToString(u.pubKey.Bytes())
@@ -302,6 +305,9 @@ func (e *UserEngine) handleGetPublicKey(_ context.Context, req *engine.Request)
if username == "" {
return nil, fmt.Errorf("user: username is required")
}
if err := engine.ValidateName(username); err != nil {
return nil, err
}
e.mu.RLock()
defer e.mu.RUnlock()
@@ -657,14 +663,16 @@ func (e *UserEngine) handleRotateKey(ctx context.Context, req *engine.Request) (
return nil, fmt.Errorf("user: rotate key: %w", err)
}
// Zeroize old key.
oldRaw := oldState.privKey.Bytes()
crypto.Zeroize(oldRaw)
// Zeroize old key material and drop reference for GC.
crypto.Zeroize(oldState.privBytes)
oldState.privKey = nil
oldState.privBytes = nil
// Update in-memory state.
e.users[caller] = &userState{
privKey: priv,
pubKey: priv.PublicKey(),
privKey: priv,
privBytes: priv.Bytes(),
pubKey: priv.PublicKey(),
config: &UserKeyConfig{
Algorithm: e.config.KeyAlgorithm,
CreatedAt: time.Now().UTC(),
@@ -692,6 +700,9 @@ func (e *UserEngine) handleDeleteUser(ctx context.Context, req *engine.Request)
if username == "" {
return nil, fmt.Errorf("user: username is required")
}
if err := engine.ValidateName(username); err != nil {
return nil, err
}
e.mu.Lock()
defer e.mu.Unlock()
@@ -701,9 +712,10 @@ func (e *UserEngine) handleDeleteUser(ctx context.Context, req *engine.Request)
return nil, ErrUserNotFound
}
// Zeroize private key.
oldRaw := oldState.privKey.Bytes()
crypto.Zeroize(oldRaw)
// Zeroize private key material and drop reference for GC.
crypto.Zeroize(oldState.privBytes)
oldState.privKey = nil
oldState.privBytes = nil
// Delete from barrier.
prefix := e.mountPath + "users/" + username + "/"

View File

@@ -29,6 +29,14 @@ func (es *engineServer) Mount(ctx context.Context, req *pb.MountRequest) (*pb.Mo
}
}
// Inject external_url into engine config if available and not already set.
if config == nil {
config = make(map[string]interface{})
}
if _, ok := config["external_url"]; !ok && es.s.cfg.Server.ExternalURL != "" {
config["external_url"] = es.s.cfg.Server.ExternalURL
}
if err := es.s.engines.Mount(ctx, req.Name, engine.EngineType(req.Type), config); err != nil {
es.s.logger.Error("grpc: mount engine", "name", req.Name, "type", req.Type, "error", err)
switch {

View File

@@ -71,7 +71,7 @@ func newTestGRPCServer(t *testing.T) (*GRPCServer, func()) {
t.Fatalf("migrate: %v", err)
}
b := barrier.NewAESGCMBarrier(database)
sealMgr := seal.NewManager(database, b, slog.Default())
sealMgr := seal.NewManager(database, b, nil, slog.Default())
policyEngine := policy.NewEngine(b)
reg := newTestRegistry()
authenticator := auth.NewAuthenticator(nil, slog.Default())
@@ -82,7 +82,7 @@ func newTestGRPCServer(t *testing.T) (*GRPCServer, func()) {
Argon2Threads: 1,
},
}
srv := New(cfg, sealMgr, authenticator, policyEngine, reg, slog.Default())
srv := New(cfg, sealMgr, authenticator, policyEngine, reg, nil, slog.Default())
return srv, func() { _ = database.Close() }
}

View File

@@ -3,6 +3,7 @@ package grpcserver
import (
"context"
"log/slog"
"path"
"strings"
"google.golang.org/grpc"
@@ -10,6 +11,7 @@ import (
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"git.wntrmute.dev/kyle/metacrypt/internal/audit"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
)
@@ -97,6 +99,46 @@ func chainInterceptors(interceptors ...grpc.UnaryServerInterceptor) grpc.UnarySe
}
}
// auditInterceptor logs an audit event after each RPC completes. Must run
// after authInterceptor so that caller info is available in the context.
func auditInterceptor(auditLog *audit.Logger) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
resp, err := handler(ctx, req)
caller := "anonymous"
var roles []string
if ti := tokenInfoFromContext(ctx); ti != nil {
caller = ti.Username
roles = ti.Roles
}
outcome := "success"
var errMsg string
if err != nil {
outcome = "error"
if st, ok := status.FromError(err); ok {
if st.Code() == codes.PermissionDenied || st.Code() == codes.Unauthenticated {
outcome = "denied"
}
errMsg = st.Message()
} else {
errMsg = err.Error()
}
}
auditLog.Log(ctx, audit.Event{
Caller: caller,
Roles: roles,
Operation: path.Base(info.FullMethod),
Resource: info.FullMethod,
Outcome: outcome,
Error: errMsg,
})
return resp, err
}
}
func extractToken(ctx context.Context) string {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {

View File

@@ -13,6 +13,7 @@ import (
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2"
internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme"
"git.wntrmute.dev/kyle/metacrypt/internal/audit"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/config"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
@@ -27,6 +28,7 @@ type GRPCServer struct {
auth *auth.Authenticator
policy *policy.Engine
engines *engine.Registry
audit *audit.Logger
logger *slog.Logger
srv *grpc.Server
acmeHandlers map[string]*internacme.Handler
@@ -35,13 +37,14 @@ type GRPCServer struct {
// New creates a new GRPCServer.
func New(cfg *config.Config, sealMgr *seal.Manager, authenticator *auth.Authenticator,
policyEngine *policy.Engine, engineRegistry *engine.Registry, logger *slog.Logger) *GRPCServer {
policyEngine *policy.Engine, engineRegistry *engine.Registry, auditLog *audit.Logger, logger *slog.Logger) *GRPCServer {
return &GRPCServer{
cfg: cfg,
sealMgr: sealMgr,
auth: authenticator,
policy: policyEngine,
engines: engineRegistry,
audit: auditLog,
logger: logger,
acmeHandlers: make(map[string]*internacme.Handler),
}
@@ -68,6 +71,7 @@ func (s *GRPCServer) Start() error {
sealInterceptor(s.sealMgr, s.logger, sealRequiredMethods()),
authInterceptor(s.auth, s.logger, authRequiredMethods()),
adminInterceptor(s.logger, adminRequiredMethods()),
auditInterceptor(s.audit),
)
s.srv = grpc.NewServer(

View File

@@ -10,6 +10,7 @@ import (
"sync"
"time"
"git.wntrmute.dev/kyle/metacrypt/internal/audit"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
)
@@ -54,6 +55,7 @@ type Manager struct {
lockoutUntil time.Time
db *sql.DB
barrier *barrier.AESGCMBarrier
audit *audit.Logger
logger *slog.Logger
mek []byte
state ServiceState
@@ -62,10 +64,11 @@ type Manager struct {
}
// NewManager creates a new seal manager.
func NewManager(db *sql.DB, b *barrier.AESGCMBarrier, logger *slog.Logger) *Manager {
func NewManager(db *sql.DB, b *barrier.AESGCMBarrier, auditLog *audit.Logger, logger *slog.Logger) *Manager {
return &Manager{
db: db,
barrier: b,
audit: auditLog,
logger: logger,
state: StateUninitialized,
}
@@ -223,6 +226,10 @@ func (m *Manager) Unseal(password []byte) error {
mek, err := crypto.Decrypt(kwk, encryptedMEK, nil)
if err != nil {
m.logger.Debug("unseal failed: invalid password")
m.audit.Log(context.Background(), audit.Event{
Caller: "operator", Operation: "unseal", Outcome: "denied",
Error: "invalid password",
})
return ErrInvalidPassword
}
@@ -235,6 +242,9 @@ func (m *Manager) Unseal(password []byte) error {
m.mek = mek
m.state = StateUnsealed
m.unsealAttempts = 0
m.audit.Log(context.Background(), audit.Event{
Caller: "operator", Operation: "unseal", Outcome: "success",
})
m.logger.Debug("unseal succeeded, barrier unsealed")
return nil
}
@@ -340,6 +350,9 @@ func (m *Manager) Seal() error {
}
_ = m.barrier.Seal()
m.state = StateSealed
m.audit.Log(context.Background(), audit.Event{
Caller: "operator", Operation: "seal", Outcome: "success",
})
m.logger.Debug("service sealed")
return nil
}

View File

@@ -23,7 +23,7 @@ func setupSeal(t *testing.T) (*Manager, func()) {
t.Fatalf("migrate: %v", err)
}
b := barrier.NewAESGCMBarrier(database)
mgr := NewManager(database, b, slog.Default())
mgr := NewManager(database, b, nil, slog.Default())
return mgr, func() { _ = database.Close() }
}
@@ -103,7 +103,7 @@ func TestSealCheckInitializedPersists(t *testing.T) {
database, _ := db.Open(dbPath)
_ = db.Migrate(database)
b := barrier.NewAESGCMBarrier(database)
mgr := NewManager(database, b, slog.Default())
mgr := NewManager(database, b, nil, slog.Default())
_ = mgr.CheckInitialized()
params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1}
_ = mgr.Initialize(context.Background(), []byte("password"), params)
@@ -113,7 +113,7 @@ func TestSealCheckInitializedPersists(t *testing.T) {
database2, _ := db.Open(dbPath)
defer func() { _ = database2.Close() }()
b2 := barrier.NewAESGCMBarrier(database2)
mgr2 := NewManager(database2, b2, slog.Default())
mgr2 := NewManager(database2, b2, nil, slog.Default())
_ = mgr2.CheckInitialized()
if mgr2.State() != StateSealed {
t.Fatalf("state after reopen: got %v, want Sealed", mgr2.State())

View File

@@ -11,6 +11,7 @@ import (
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
"git.wntrmute.dev/kyle/metacrypt/internal/audit"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
@@ -286,6 +287,14 @@ func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
return
}
// Inject external_url into CA engine config if available and not already set.
if req.Config == nil {
req.Config = make(map[string]interface{})
}
if _, ok := req.Config["external_url"]; !ok && s.cfg.Server.ExternalURL != "" {
req.Config["external_url"] = s.cfg.Server.ExternalURL
}
if err := s.engines.Mount(r.Context(), req.Name, engine.EngineType(req.Type), req.Config); err != nil {
s.logger.Error("mount engine", "name", req.Name, "type", req.Type, "error", err)
writeJSONError(w, err.Error(), http.StatusBadRequest)
@@ -435,10 +444,16 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
case strings.Contains(err.Error(), "not found"):
status = http.StatusNotFound
}
outcome := "error"
if status == http.StatusForbidden || status == http.StatusUnauthorized {
outcome = "denied"
}
s.auditOp(r, info, req.Operation, "", req.Mount, outcome, nil, err)
writeJSONError(w, err.Error(), status)
return
}
s.auditOp(r, info, req.Operation, "", req.Mount, "success", nil, nil)
writeJSON(w, http.StatusOK, resp.Data)
}
@@ -1317,6 +1332,24 @@ func writeJSONError(w http.ResponseWriter, msg string, status int) {
writeJSON(w, status, map[string]string{"error": msg})
}
// auditOp logs an audit event for a completed engine operation.
func (s *Server) auditOp(r *http.Request, info *auth.TokenInfo,
op, engineType, mount, outcome string, detail map[string]interface{}, err error) {
e := audit.Event{
Caller: info.Username,
Roles: info.Roles,
Operation: op,
Engine: engineType,
Mount: mount,
Outcome: outcome,
Detail: detail,
}
if err != nil {
e.Error = err.Error()
}
s.audit.Log(r.Context(), e)
}
// newPolicyChecker builds a PolicyChecker closure for a caller, used by typed
// REST handlers to pass service-level policy evaluation into the engine.
func (s *Server) newPolicyChecker(r *http.Request, info *auth.TokenInfo) engine.PolicyChecker {

View File

@@ -14,6 +14,7 @@ import (
"google.golang.org/grpc"
internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme"
"git.wntrmute.dev/kyle/metacrypt/internal/audit"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/config"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
@@ -28,6 +29,7 @@ type Server struct {
auth *auth.Authenticator
policy *policy.Engine
engines *engine.Registry
audit *audit.Logger
httpSrv *http.Server
grpcSrv *grpc.Server
logger *slog.Logger
@@ -38,13 +40,14 @@ type Server struct {
// New creates a new server.
func New(cfg *config.Config, sealMgr *seal.Manager, authenticator *auth.Authenticator,
policyEngine *policy.Engine, engineRegistry *engine.Registry, logger *slog.Logger, version string) *Server {
policyEngine *policy.Engine, engineRegistry *engine.Registry, auditLog *audit.Logger, logger *slog.Logger, version string) *Server {
s := &Server{
cfg: cfg,
seal: sealMgr,
auth: authenticator,
policy: policyEngine,
engines: engineRegistry,
audit: auditLog,
logger: logger,
version: version,
}

View File

@@ -36,7 +36,7 @@ func setupTestServer(t *testing.T) (*Server, *seal.Manager, chi.Router) {
_ = db.Migrate(database)
b := barrier.NewAESGCMBarrier(database)
sealMgr := seal.NewManager(database, b, slog.Default())
sealMgr := seal.NewManager(database, b, nil, slog.Default())
_ = sealMgr.CheckInitialized()
// Auth requires MCIAS client which we can't create in tests easily,
@@ -61,7 +61,7 @@ func setupTestServer(t *testing.T) (*Server, *seal.Manager, chi.Router) {
}
logger := slog.Default()
srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger, "test")
srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, nil, logger, "test")
r := chi.NewRouter()
srv.registerRoutes(r)