Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Deepened audit-log signature verification behind a single shared core used by both single-log and batch verification. Batch `verify-audit-logs` now reports KEK-missing logs in their own `kek_missing_count`/`kek_missing_logs` bucket instead of lumping them into `invalid_count`, so batch agrees with single-log verification (`ErrKekNotFoundForLog`).
- Internal refactors with no API change: consolidated the six retention-sweep CLI commands (`purge-secrets`, `purge-transit-keys`, `purge-tokenization-keys`, `clean-expired-tokens`, `clean-audit-logs`, `purge-auth-tokens`) behind a single `RunRetentionSweep` module, and relocated the interactive policy-prompt helpers from `internal/ui` into the CLI commands package (#141).
- Folded the master-key/KMS lifecycle into the `keyring` deep module. KEK loading (`bootstrapWith`) and the KMS decrypt path are now unit-testable without a database or live KMS; `KMSKeeper` gained an explicit `Encrypt` operation so the create/rotate-master-key CLI commands no longer type-assert the concrete keeper; added an in-memory `keyring.FakeKMSService` test double and removed the unused `internal/tokenization/testing` helper.

Expand Down
79 changes: 53 additions & 26 deletions cmd/app/commands/verify_audit_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ import (

// VerifyAuditLogsResult holds the result of the audit log verification operation.
type VerifyAuditLogsResult struct {
TotalChecked int64 `json:"total_checked"`
SignedCount int64 `json:"signed_count"`
UnsignedCount int64 `json:"unsigned_count"`
ValidCount int64 `json:"valid_count"`
InvalidCount int64 `json:"invalid_count"`
InvalidLogs []string `json:"invalid_logs"`
Passed bool `json:"passed"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
TotalChecked int64 `json:"total_checked"`
SignedCount int64 `json:"signed_count"`
UnsignedCount int64 `json:"unsigned_count"`
ValidCount int64 `json:"valid_count"`
InvalidCount int64 `json:"invalid_count"`
InvalidLogs []string `json:"invalid_logs"`
KekMissingCount int64 `json:"kek_missing_count"`
KekMissingLogs []string `json:"kek_missing_logs"`
Passed bool `json:"passed"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
}

// ToText returns a human-readable representation of the verification result.
Expand All @@ -38,14 +40,28 @@ func (r *VerifyAuditLogsResult) ToText() string {
output += fmt.Sprintf("Signed: %d\n", r.SignedCount)
output += fmt.Sprintf("Unsigned: %d (legacy)\n", r.UnsignedCount)
output += fmt.Sprintf("Valid: %d\n", r.ValidCount)
output += fmt.Sprintf("Invalid: %d\n\n", r.InvalidCount)
output += fmt.Sprintf("Invalid: %d\n", r.InvalidCount)
output += fmt.Sprintf("Kek Missing: %d\n", r.KekMissingCount)

switch {
case r.InvalidCount > 0:
output += fmt.Sprintf("WARNING: %d log(s) failed integrity check!\n\n", r.InvalidCount)
output += "Invalid Log IDs:\n"
for _, id := range r.InvalidLogs {
output += fmt.Sprintf(" - %s\n", id)
case r.InvalidCount > 0 || r.KekMissingCount > 0:
output += "\n"
if r.InvalidCount > 0 {
output += fmt.Sprintf("WARNING: %d log(s) failed integrity check!\n\n", r.InvalidCount)
output += "Invalid Log IDs:\n"
for _, id := range r.InvalidLogs {
output += fmt.Sprintf(" - %s\n", id)
}
}
if r.KekMissingCount > 0 {
output += fmt.Sprintf(
"WARNING: %d log(s) have an unavailable KEK and could not be verified.\n\n",
r.KekMissingCount,
)
output += "Kek Missing Log IDs:\n"
for _, id := range r.KekMissingLogs {
output += fmt.Sprintf(" - %s\n", id)
}
}
output += "\nStatus: FAILED ❌"
case r.TotalChecked == 0:
Expand Down Expand Up @@ -103,18 +119,24 @@ func RunVerifyAuditLogs(
for i, id := range report.InvalidLogs {
invalidLogs[i] = id.String()
}
kekMissingLogs := make([]string, len(report.KekMissingLogs))
for i, id := range report.KekMissingLogs {
kekMissingLogs[i] = id.String()
}

// Output result
result := &VerifyAuditLogsResult{
TotalChecked: report.TotalChecked,
SignedCount: report.SignedCount,
UnsignedCount: report.UnsignedCount,
ValidCount: report.ValidCount,
InvalidCount: report.InvalidCount,
InvalidLogs: invalidLogs,
Passed: report.InvalidCount == 0,
StartDate: start,
EndDate: end,
TotalChecked: report.TotalChecked,
SignedCount: report.SignedCount,
UnsignedCount: report.UnsignedCount,
ValidCount: report.ValidCount,
InvalidCount: report.InvalidCount,
InvalidLogs: invalidLogs,
KekMissingCount: report.KekMissingCount,
KekMissingLogs: kekMissingLogs,
Passed: report.InvalidCount == 0 && report.KekMissingCount == 0,
StartDate: start,
EndDate: end,
}
WriteOutput(writer, format, result)

Expand All @@ -123,12 +145,17 @@ func RunVerifyAuditLogs(
slog.Int64("total_checked", report.TotalChecked),
slog.Int64("valid", report.ValidCount),
slog.Int64("invalid", report.InvalidCount),
slog.Int64("kek_missing", report.KekMissingCount),
slog.Int64("unsigned", report.UnsignedCount),
)

// Exit with error code if integrity check failed
if report.InvalidCount > 0 {
return fmt.Errorf("integrity check failed: %d invalid signature(s)", report.InvalidCount)
if report.InvalidCount > 0 || report.KekMissingCount > 0 {
return fmt.Errorf(
"integrity check failed: %d invalid signature(s), %d KEK missing",
report.InvalidCount,
report.KekMissingCount,
)
}

return nil
Expand Down
39 changes: 39 additions & 0 deletions cmd/app/commands/verify_audit_logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,45 @@ func TestRunVerifyAuditLogs(t *testing.T) {
mockUseCase.AssertExpectations(t)
})

t.Run("text-output-kek-missing-fails", func(t *testing.T) {
mockUseCase := &authMocks.MockAuditLogUseCase{}
kekMissingLogs := []uuid.UUID{uuid.New()}
mockUseCase.On("VerifyBatch", ctx, mock.Anything, mock.Anything).
Return(&authUseCase.VerificationReport{
TotalChecked: 5,
KekMissingCount: 1,
KekMissingLogs: kekMissingLogs,
}, nil)

var out bytes.Buffer
err := RunVerifyAuditLogs(ctx, mockUseCase, logger, &out, startDate, endDate, "text")

require.Error(t, err)
require.Contains(t, out.String(), "Status: FAILED")
require.Contains(t, out.String(), "Kek Missing")
require.Contains(t, out.String(), kekMissingLogs[0].String())
mockUseCase.AssertExpectations(t)
})

t.Run("json-output-kek-missing-fails", func(t *testing.T) {
mockUseCase := &authMocks.MockAuditLogUseCase{}
mockUseCase.On("VerifyBatch", ctx, mock.Anything, mock.Anything).
Return(&authUseCase.VerificationReport{
TotalChecked: 10,
ValidCount: 9,
KekMissingCount: 1,
KekMissingLogs: []uuid.UUID{uuid.New()},
}, nil)

var out bytes.Buffer
err := RunVerifyAuditLogs(ctx, mockUseCase, logger, &out, startDate, endDate, "json")

require.Error(t, err)
require.Contains(t, out.String(), `"kek_missing_count": 1`)
require.Contains(t, out.String(), `"passed": false`)
mockUseCase.AssertExpectations(t)
})

t.Run("json-output", func(t *testing.T) {
mockUseCase := &authMocks.MockAuditLogUseCase{}
mockUseCase.On("VerifyBatch", ctx, mock.Anything, mock.Anything).
Expand Down
2 changes: 2 additions & 0 deletions docs/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,8 @@ Output (JSON format):
"valid_count": 120,
"invalid_count": 0,
"invalid_logs": [],
"kek_missing_count": 0,
"kek_missing_logs": [],
"passed": true
}
```
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,9 @@ require (
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.272.0 // indirect
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -344,18 +344,18 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE=
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw=
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js=
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
82 changes: 58 additions & 24 deletions internal/auth/usecase/audit_log_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,42 @@ func (a *auditLogUseCase) DeleteOlderThan(
return count, nil
}

// signatureStatus classifies the outcome of verifying one audit log's HMAC
// signature. Shared by VerifyIntegrity (single) and VerifyBatch (batch) so the
// two paths never disagree about what a signature failure means.
type signatureStatus int

const (
sigValid signatureStatus = iota
sigMissing // legacy/unsigned: no signature data
sigKekMissing // KEK referenced by the log is not in the chain
sigInvalid // signature present but does not verify
)

// verifyAuditSignature verifies one audit log's signature against the KEK it
// references. The status classifies the crypto outcome; err is non-nil only for
// canonicalization failures, which single-log verification surfaces distinctly
// from a tampered signature.
func (a *auditLogUseCase) verifyAuditSignature(log *authDomain.AuditLog) (signatureStatus, error) {
if !log.IsSigned || log.KekID == nil {
return sigMissing, nil
}

canonical, err := log.Canonical()
if err != nil {
return sigInvalid, apperrors.Wrap(err, "failed to canonicalize audit log")
}

if err := a.keySigner.VerifyWithKey(*log.KekID, canonical, log.Signature); err != nil {
if errors.Is(err, keyring.ErrKekNotFound) {
return sigKekMissing, nil
}
return sigInvalid, nil
}

return sigValid, nil
}

// VerifyIntegrity verifies the cryptographic signature of a specific audit log.
// Retrieves the log from the repository and validates its HMAC-SHA256 signature
// using the KEK referenced by log.KekID. Returns nil if valid, error otherwise.
Expand All @@ -115,23 +151,19 @@ func (a *auditLogUseCase) VerifyIntegrity(ctx context.Context, id uuid.UUID) (er
return apperrors.Wrap(err, "failed to retrieve audit log")
}

// Check if legacy unsigned log
if !auditLog.IsSigned || auditLog.KekID == nil {
return authDomain.ErrSignatureMissing
}

canonical, err := auditLog.Canonical()
status, err := a.verifyAuditSignature(auditLog)
if err != nil {
return apperrors.Wrap(err, "failed to canonicalize audit log")
return err
}

if err = a.keySigner.VerifyWithKey(*auditLog.KekID, canonical, auditLog.Signature); err != nil {
if errors.Is(err, keyring.ErrKekNotFound) {
return authDomain.ErrKekNotFoundForLog
}
switch status {
case sigMissing:
return authDomain.ErrSignatureMissing
case sigKekMissing:
return authDomain.ErrKekNotFoundForLog
case sigInvalid:
return apperrors.Wrap(authDomain.ErrSignatureInvalid, "audit log signature verification failed")
}

return nil
}

Expand All @@ -143,7 +175,8 @@ func (a *auditLogUseCase) VerifyBatch(
startTime, endTime time.Time,
) (result *VerificationReport, err error) {
report := &VerificationReport{
InvalidLogs: []uuid.UUID{},
InvalidLogs: []uuid.UUID{},
KekMissingLogs: []uuid.UUID{},
}

// Paginate through logs in batches using cursor-based pagination
Expand All @@ -165,28 +198,29 @@ func (a *auditLogUseCase) VerifyBatch(
for _, log := range logs {
report.TotalChecked++

// Check if signed
if !log.IsSigned || log.KekID == nil {
status, err := a.verifyAuditSignature(log)
if status == sigMissing {
report.UnsignedCount++
continue
}

report.SignedCount++

canonical, err := log.Canonical()
if err != nil {
report.InvalidCount++
report.InvalidLogs = append(report.InvalidLogs, log.ID)
if err == nil && status == sigValid {
report.ValidCount++
continue
}

if err := a.keySigner.VerifyWithKey(*log.KekID, canonical, log.Signature); err != nil {
// Tampered, KEK-missing, or unverifiable signature: distinct buckets so
// batch agrees with single-log verification (ErrKekNotFoundForLog is
// not reported as a tampered signature).
switch status {
case sigKekMissing:
report.KekMissingCount++
report.KekMissingLogs = append(report.KekMissingLogs, log.ID)
default: // sigInvalid, or unverifiable (canonicalization) signature
report.InvalidCount++
report.InvalidLogs = append(report.InvalidLogs, log.ID)
continue
}

report.ValidCount++
}

// Check if we have more pages
Expand Down
Loading
Loading