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
42 changes: 19 additions & 23 deletions .mockery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,39 @@ template: testify
template-data:
unroll-variadic: true
packages:
github.com/allisson/secrets/internal/auth/service:
github.com/allisson/secrets/internal/database:
interfaces:
SecretService: {}
TokenService: {}
AuditSigner: {}
github.com/allisson/secrets/internal/auth/usecase:
TxManager: {}
github.com/allisson/secrets/internal/keyring:
interfaces:
KekUseCase: {}
github.com/allisson/secrets/internal/auth/domain:
interfaces:
AuditLogRepository: {}
AuditLogUseCase: {}
ClientRepository: {}
ClientUseCase: {}
TokenRepository: {}
TokenUseCase: {}
github.com/allisson/secrets/internal/crypto/service:
interfaces:
AEAD: {}
AEADManager: {}
KeyManager: {}
github.com/allisson/secrets/internal/crypto/usecase:
github.com/allisson/secrets/internal/auth/usecase:
interfaces:
KekRepository: {}
KekUseCase: {}
github.com/allisson/secrets/internal/database:
AuditLogUseCase: {}
ClientUseCase: {}
TokenUseCase: {}
github.com/allisson/secrets/internal/secrets/domain:
interfaces:
TxManager: {}
SecretRepository: {}
github.com/allisson/secrets/internal/secrets/usecase:
interfaces:
SecretRepository: {}
SecretUseCase: {}
github.com/allisson/secrets/internal/transit/usecase:
github.com/allisson/secrets/internal/transit/domain:
interfaces:
TransitKeyRepository: {}
github.com/allisson/secrets/internal/transit/usecase:
interfaces:
TransitKeyUseCase: {}
github.com/allisson/secrets/internal/tokenization/usecase:
github.com/allisson/secrets/internal/tokenization/domain:
interfaces:
TokenizationKeyRepository: {}
TokenizationKeyUseCase: {}
TokenRepository: {}
github.com/allisson/secrets/internal/tokenization/usecase:
interfaces:
TokenizationKeyUseCase: {}
TokenizationUseCase: {}
HashService: {}
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- Master-key material is no longer reachable outside the `keyring` package. The `MasterKey` type and its key bytes are unexported; only the opaque `MasterKeyChain` handle crosses the package boundary, so no caller can read a root key's bytes.

### Changed

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

### Fixed

Expand Down
13 changes: 12 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ See [ADR-0001](docs/adr/0001-envelope-encryption-model.md).
A symmetric key held outside this service in a KMS (AWS, GCP, Azure, or
`localsecrets://` for development). Never stored in the database. Loaded at
boot via `KMSKeeper.Decrypt` and held in a `MasterKeyChain` for the process
lifetime.
lifetime. The raw key material is unexported — it never leaves the `keyring`
package. `MasterKeyChain` is the opaque handle features and the composition
root pass around; callers cannot read a master key's bytes.

### KEK — Key Encryption Key
A symmetric key that exists only to encrypt DEKs. Persisted in the `keks`
Expand Down Expand Up @@ -60,6 +62,15 @@ calls behind it. Call sites do not know KEK from DEK.
- `Rewrap(ctx, dekID)` — rewrap a DEK under the active KEK. Used by the
rotation worker.

### KMS keeper
The seam `keyring` uses to reach an external KMS, `KMSKeeper`. Three operations:
`Decrypt` (the boot path — unwrap persisted master keys), `Encrypt` (the
operator path — the create/rotate-master-key CLI commands wrap a fresh key),
and `Close`. The running service only ever calls `Decrypt`; `Encrypt` lives on
the interface so the CLI needs no runtime type assertion. Two adapters make it
a real seam: `gocloud.dev/secrets` in production and `FakeKMSService` (a
deterministic in-memory double) in tests.

### Envelope
The value returned by `Keyring.Encrypt`. Contains `DekID`, `Ciphertext`,
`Nonce`, and `Algorithm`. Features persist all four fields; nothing else
Expand Down
12 changes: 2 additions & 10 deletions cmd/app/commands/master_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,24 +62,16 @@ func RunCreateMasterKey(
_, _ = fmt.Fprintln(writer)

// Open keeper
keeperInterface, err := kmsService.OpenKeeper(ctx, kmsKeyURI)
keeper, err := kmsService.OpenKeeper(ctx, kmsKeyURI)
if err != nil {
return fmt.Errorf("failed to open KMS keeper: %w", err)
}
defer func() {
if closeErr := keeperInterface.Close(); closeErr != nil {
if closeErr := keeper.Close(); closeErr != nil {
_, _ = fmt.Fprintf(writer, "Warning: failed to close KMS keeper: %v\n", closeErr)
}
}()

// Type assert to get Encrypt method (needed for encryption)
keeper, ok := keeperInterface.(interface {
Encrypt(ctx context.Context, plaintext []byte) ([]byte, error)
})
if !ok {
return fmt.Errorf("KMS keeper does not support encryption")
}

// Encrypt master key with KMS
ciphertext, err := keeper.Encrypt(ctx, masterKey)
if err != nil {
Expand Down
50 changes: 3 additions & 47 deletions cmd/app/commands/master_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,59 +8,20 @@ import (
"log/slog"
"testing"

"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/allisson/secrets/internal/keyring"
)

// Manual mocks for KMS since they might not be generated in all environments
type MockKMSService struct {
mock.Mock
}

func (m *MockKMSService) OpenKeeper(ctx context.Context, uri string) (keyring.KMSKeeper, error) {
args := m.Called(ctx, uri)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(keyring.KMSKeeper), args.Error(1)
}

type MockKMSKeeper struct {
mock.Mock
}

func (m *MockKMSKeeper) Encrypt(ctx context.Context, plaintext []byte) ([]byte, error) {
args := m.Called(ctx, plaintext)
return args.Get(0).([]byte), args.Error(1)
}

func (m *MockKMSKeeper) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) {
args := m.Called(ctx, ciphertext)
return args.Get(0).([]byte), args.Error(1)
}

func (m *MockKMSKeeper) Close() error {
return m.Called().Error(0)
}

func TestRunCreateMasterKey(t *testing.T) {
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))

t.Run("success", func(t *testing.T) {
mockService := &MockKMSService{}
mockKeeper := &MockKMSKeeper{}

mockService.On("OpenKeeper", ctx, "base64key://...").Return(mockKeeper, nil)
mockKeeper.On("Encrypt", ctx, mock.AnythingOfType("[]uint8")).Return([]byte("encrypted"), nil)
mockKeeper.On("Close").Return(nil)

var out bytes.Buffer
err := RunCreateMasterKey(
ctx,
mockService,
keyring.NewFakeKMSService(),
logger,
&out,
"test-key",
Expand All @@ -69,9 +30,6 @@ func TestRunCreateMasterKey(t *testing.T) {
)
require.NoError(t, err)
require.Contains(t, out.String(), "MASTER_KEYS=\"test-key:")

mockService.AssertExpectations(t)
mockKeeper.AssertExpectations(t)
})

t.Run("missing-parameters", func(t *testing.T) {
Expand All @@ -81,12 +39,10 @@ func TestRunCreateMasterKey(t *testing.T) {
})

t.Run("kms-error", func(t *testing.T) {
mockService := &MockKMSService{}
mockService.On("OpenKeeper", ctx, "invalid").Return(nil, errors.New("kms error"))

svc := &keyring.FakeKMSService{FailOpen: errors.New("kms error")}
err := RunCreateMasterKey(
ctx,
mockService,
svc,
logger,
&bytes.Buffer{},
"test-key",
Expand Down
12 changes: 2 additions & 10 deletions cmd/app/commands/rotate_master_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,24 +66,16 @@ func RunRotateMasterKey(
_, _ = fmt.Fprintln(writer)

// Create KMS service and open keeper
keeperInterface, err := kmsService.OpenKeeper(ctx, kmsKeyURI)
keeper, err := kmsService.OpenKeeper(ctx, kmsKeyURI)
if err != nil {
return fmt.Errorf("failed to open KMS keeper: %w", err)
}
defer func() {
if closeErr := keeperInterface.Close(); closeErr != nil {
if closeErr := keeper.Close(); closeErr != nil {
_, _ = fmt.Fprintf(writer, "Warning: failed to close KMS keeper: %v\n", closeErr)
}
}()

// Type assert to get Encrypt method
keeper, ok := keeperInterface.(interface {
Encrypt(ctx context.Context, plaintext []byte) ([]byte, error)
})
if !ok {
return fmt.Errorf("KMS keeper does not support encryption")
}

// Encrypt master key with KMS
ciphertext, err := keeper.Encrypt(ctx, masterKey)
if err != nil {
Expand Down
38 changes: 20 additions & 18 deletions cmd/app/commands/rotate_master_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import (
"log/slog"
"testing"

"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/allisson/secrets/internal/keyring"
)

func TestRunRotateMasterKey(t *testing.T) {
Expand All @@ -19,17 +20,10 @@ func TestRunRotateMasterKey(t *testing.T) {
existingActiveKeyID := "old-key"

t.Run("success", func(t *testing.T) {
mockKMSService := &MockKMSService{}
mockKeeper := &MockKMSKeeper{}

mockKMSService.On("OpenKeeper", ctx, kmsKeyURI).Return(mockKeeper, nil)
mockKeeper.On("Encrypt", ctx, mock.Anything).Return([]byte("new-ciphertext"), nil)
mockKeeper.On("Close").Return(nil)

var out bytes.Buffer
err := RunRotateMasterKey(
ctx,
mockKMSService,
keyring.NewFakeKMSService(),
logger,
&out,
"new-key",
Expand All @@ -40,25 +34,34 @@ func TestRunRotateMasterKey(t *testing.T) {
)

require.NoError(t, err)
require.Contains(t, out.String(), "MASTER_KEYS=\"old-key:ciphertext,new-key:bmV3LWNpcGhlcnRleHQ=\"")
// The freshly generated master key is random, so the appended ciphertext
// is non-deterministic; assert the structure preserves the existing keys
// and appends the new one as active.
require.Contains(t, out.String(), "MASTER_KEYS=\"old-key:ciphertext,new-key:")
require.Contains(t, out.String(), "ACTIVE_MASTER_KEY_ID=\"new-key\"")
mockKMSService.AssertExpectations(t)
mockKeeper.AssertExpectations(t)
})

t.Run("missing-kms-params", func(t *testing.T) {
mockKMSService := &MockKMSService{}
err := RunRotateMasterKey(ctx, mockKMSService, logger, &bytes.Buffer{}, "new-key", "", "", "", "")
err := RunRotateMasterKey(
ctx,
keyring.NewFakeKMSService(),
logger,
&bytes.Buffer{},
"new-key",
"",
"",
"",
"",
)

require.Error(t, err)
require.Contains(t, err.Error(), "KMS_PROVIDER and KMS_KEY_URI are required")
})

t.Run("missing-existing-keys", func(t *testing.T) {
mockKMSService := &MockKMSService{}
err := RunRotateMasterKey(
ctx,
mockKMSService,
keyring.NewFakeKMSService(),
logger,
&bytes.Buffer{},
"new-key",
Expand All @@ -73,10 +76,9 @@ func TestRunRotateMasterKey(t *testing.T) {
})

t.Run("invalid-active-key-id", func(t *testing.T) {
mockKMSService := &MockKMSService{}
err := RunRotateMasterKey(
ctx,
mockKMSService,
keyring.NewFakeKMSService(),
logger,
&bytes.Buffer{},
"new-key",
Expand Down
20 changes: 2 additions & 18 deletions internal/app/di_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,27 +483,11 @@ func TestContainerMasterKeyChainMultipleKeys(t *testing.T) {
t.Fatal("expected non-nil master key chain")
}

// Verify active key ID
// Verify active key ID. Per-key presence of a multi-key chain is covered
// by the keyring package's loadMasterKeyChainFromKMS tests (no DB needed).
if masterKeyChain.ActiveMasterKeyID() != "key2" {
t.Errorf("expected active key ID 'key2', got '%s'", masterKeyChain.ActiveMasterKeyID())
}

// Verify both keys are accessible
key1Obj, ok := masterKeyChain.Get("key1")
if !ok {
t.Error("expected to find key1 in master key chain")
}
if key1Obj == nil {
t.Error("expected non-nil key1")
}

key2Obj, ok := masterKeyChain.Get("key2")
if !ok {
t.Error("expected to find key2 in master key chain")
}
if key2Obj == nil {
t.Error("expected non-nil key2")
}
}

// TestContainerShutdownWithMasterKeyChain verifies that shutdown properly closes the master key chain.
Expand Down
Loading
Loading