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:
79
internal/audit/audit.go
Normal file
79
internal/audit/audit.go
Normal 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...)
|
||||
}
|
||||
124
internal/audit/audit_test.go
Normal file
124
internal/audit/audit_test.go
Normal 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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 + "/"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user