Skip to content
Merged
5 changes: 0 additions & 5 deletions .mockery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,19 @@ packages:
interfaces:
KekRepository: {}
KekUseCase: {}
DekRepository: {}
DekUseCase: {}
github.com/allisson/secrets/internal/database:
interfaces:
TxManager: {}
github.com/allisson/secrets/internal/secrets/usecase:
interfaces:
DekRepository: {}
SecretRepository: {}
SecretUseCase: {}
github.com/allisson/secrets/internal/transit/usecase:
interfaces:
DekRepository: {}
TransitKeyRepository: {}
TransitKeyUseCase: {}
github.com/allisson/secrets/internal/tokenization/usecase:
interfaces:
DekRepository: {}
TokenizationKeyRepository: {}
TokenizationKeyUseCase: {}
TokenRepository: {}
Expand Down
109 changes: 109 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# CONTEXT

Domain vocabulary for the `secrets` service. Use these terms exactly in code,
docs, commit messages, and conversations. Drift weakens the seams.

## Cryptography

### Envelope encryption
The pattern in which a user payload is encrypted under a **Data Encryption Key
(DEK)**, and the DEK itself is encrypted under a **Key Encryption Key (KEK)**,
which is in turn protected by a **Master Key** held by an external **KMS**.
See [ADR-0001](docs/adr/0001-envelope-encryption-model.md).

### Master Key
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.

### KEK — Key Encryption Key
A symmetric key that exists only to encrypt DEKs. Persisted in the `keks`
table as ciphertext (wrapped by a Master Key). Rotates on demand via the
KEK rotation worker. The set of all loaded KEKs is the `KekChain`; the
newest is the *active* KEK.

### DEK — Data Encryption Key
A symmetric key that encrypts exactly one piece of user data:
- in `secrets` and `tokenization`: one DEK per stored row (fresh each call);
- in `transit`: one DEK per Transit Key, reused across user requests.

Persisted in the `deks` table as ciphertext (wrapped by a KEK). Identified
by a UUIDv7 (`DekID`).

### AEAD
Authenticated Encryption with Associated Data. The service supports
`aes-256-gcm` and `chacha20-poly1305`. Algorithm is chosen at DEK creation
and recorded on the envelope; ciphertext from one algorithm cannot be
decrypted by another.

### Rewrap
Re-encrypting an existing DEK under a newer KEK without changing the
underlying DEK key material. Used by the KEK rotation worker so old
ciphertexts remain decryptable without bulk re-encryption.

## Modules

### Keyring
**The single module that owns envelope encryption.** Exposes a small interface
to feature modules; hides KEK chain, DEK lifecycle, AEAD selection, and KMS
calls behind it. Call sites do not know KEK from DEK.

- `Encrypt(ctx, plaintext) → Envelope` — fresh-DEK envelope encryption.
Used by `secrets` and `tokenization`.
- `Decrypt(ctx, envelope) → plaintext` — inverse of `Encrypt`.
- `AllocateDek(ctx, alg) → DekHandle` — persists a DEK and returns an
opaque handle. Used by `transit` once per Transit Key.
- `EncryptWith(ctx, handle, plaintext, aad) → (ciphertext, nonce)` — encrypt
under a previously-allocated DEK. Used by `transit` per request.
- `DecryptWith(ctx, handle, ciphertext, nonce, aad) → plaintext` — inverse.
- `Rewrap(ctx, dekID)` — rewrap a DEK under the active KEK. Used by the
rotation worker.

### Envelope
The value returned by `Keyring.Encrypt`. Contains `DekID`, `Ciphertext`,
`Nonce`, and `Algorithm`. Features persist all four fields; nothing else
about the DEK or KEK is leaked to callers.

### DekHandle
An opaque reference to a persistent DEK held by Keyring. Returned by
`AllocateDek`, accepted by `EncryptWith` / `DecryptWith`. Features store
only the handle's `DekID` and reload it on demand. Used to model the
`transit` flow where many user requests share one DEK.

### Transit Key
A named, long-lived encryption key managed via the transit HTTP API.
Backed internally by a single DEK (a DekHandle). Users call `encrypt` and
`decrypt` against the name; the DEK never leaves Keyring.

### Tokenization Key
A named encryption key associated with a token format (UUID, numeric,
alphanumeric, Luhn) and a determinism flag. Each tokenize call still uses
a fresh DEK via `Keyring.Encrypt` — the Tokenization Key itself is
metadata + format rules, not a long-lived crypto key.

### Secret
A path-addressed, versioned encrypted payload. Each version has its own
DEK (fresh per write). The path is the lookup key; the latest version is
the default read.

### KEK rotation worker
A background job that calls `Keyring.Rewrap` for every DEK not yet
encrypted under the active KEK. Runs after `Keyring.RotateKek`. Idempotent.

## Storage

### `keks` table
Wrapped KEK material, ordered by `version`. The highest-version, non-revoked
row is the active KEK.

### `deks` table
Wrapped DEK material, joined to the `keks` row that wrapped them. Indexed
by `kek_id` to support the rotation worker's batch query.

### Transactions
All multi-row writes (creating a DEK + the row that references it) happen
inside a `database.TxManager` transaction propagated via `context.Context`
(per [ADR-0005](docs/adr/0005-context-based-transaction-management.md)).
`Keyring.Encrypt` and `Keyring.AllocateDek` join the caller's transaction
when one is present.
51 changes: 19 additions & 32 deletions cmd/app/commands/rewrap_deks.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,21 @@ import (

"github.com/google/uuid"

cryptoDomain "github.com/allisson/secrets/internal/crypto/domain"
cryptoUseCase "github.com/allisson/secrets/internal/crypto/usecase"
"github.com/allisson/secrets/internal/keyring"
)

// RunRewrapDeks finds all DEKs that are not encrypted with the specified KEK ID
// and re-encrypts them with the specified KEK in batches.
// RunRewrapDeks finds DEKs not encrypted with the keyring's active KEK and
// rewraps them in batches. The kekIDStr argument is a safety check: it must
// match the keyring's currently-active KEK, so an operator cannot accidentally
// rewrap DEKs against a stale chain.
func RunRewrapDeks(
ctx context.Context,
masterKeyChain *cryptoDomain.MasterKeyChain,
kekUseCase cryptoUseCase.KekUseCase,
dekUseCase cryptoUseCase.DekUseCase,
kr keyring.Keyring,
logger *slog.Logger,
kekIDStr string,
batchSize int,
) error {
// Parse KEK ID
newKekID, err := uuid.Parse(kekIDStr)
wantedKekID, err := uuid.Parse(kekIDStr)
if err != nil {
return fmt.Errorf("invalid kek-id: %w", err)
}
Expand All @@ -32,38 +30,27 @@ func RunRewrapDeks(
return fmt.Errorf("batch-size must be greater than 0")
}

activeKekID := kr.ActiveKekID()
if activeKekID != wantedKekID {
return fmt.Errorf(
"requested kek-id %s does not match keyring active KEK %s; "+
"restart the rewrap process after KEK rotation so the latest chain is loaded",
wantedKekID, activeKekID,
)
}

logger.Info("starting DEK rewrap process",
slog.String("kek_id", kekIDStr),
slog.Int("batch_size", batchSize),
)

kekChain, err := kekUseCase.Unwrap(ctx, masterKeyChain)
total, err := kr.RewrapAll(ctx, batchSize)
if err != nil {
return fmt.Errorf("failed to load and unwrap kek chain: %w", err)
}
defer kekChain.Close()

totalRewrapped := 0

for {
rewrappedCount, err := dekUseCase.Rewrap(ctx, kekChain, newKekID, batchSize)
if err != nil {
return fmt.Errorf("failed to rewrap DEKs in batch: %w", err)
}

if rewrappedCount == 0 {
break
}

totalRewrapped += rewrappedCount
logger.Info("rewrapped batch of DEKs",
slog.Int("rewrapped_in_batch", rewrappedCount),
slog.Int("total_rewrapped", totalRewrapped),
)
return fmt.Errorf("failed to rewrap DEKs: %w", err)
}

logger.Info("DEK rewrap process completed",
slog.Int("total_rewrapped", totalRewrapped),
slog.Int("total_rewrapped", total),
slog.String("target_kek_id", kekIDStr),
)

Expand Down
85 changes: 53 additions & 32 deletions cmd/app/commands/rewrap_deks_test.go
Original file line number Diff line number Diff line change
@@ -1,43 +1,64 @@
package commands
package commands_test

import (
"context"
"io"
"log/slog"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"

cryptoDomain "github.com/allisson/secrets/internal/crypto/domain"
cryptoMocks "github.com/allisson/secrets/internal/crypto/usecase/mocks"
"github.com/allisson/secrets/cmd/app/commands"
"github.com/allisson/secrets/internal/keyring"
)

func TestRunRewrapDeks(t *testing.T) {
ctx := context.Background()
logger := slog.Default()
masterKeyChain := cryptoDomain.NewMasterKeyChain("test-master-key")
kekID := uuid.New()
kekIDStr := kekID.String()

t.Run("success", func(t *testing.T) {
mockKekUseCase := &cryptoMocks.MockKekUseCase{}
mockDekUseCase := &cryptoMocks.MockDekUseCase{}
kekChain := cryptoDomain.NewKekChain(nil)

mockKekUseCase.On("Unwrap", ctx, masterKeyChain).Return(kekChain, nil)
mockDekUseCase.On("Rewrap", ctx, kekChain, kekID, 100).Return(10, nil).Once()
mockDekUseCase.On("Rewrap", ctx, kekChain, kekID, 100).Return(0, nil).Once()

err := RunRewrapDeks(ctx, masterKeyChain, mockKekUseCase, mockDekUseCase, logger, kekIDStr, 100)
require.NoError(t, err)

mockKekUseCase.AssertExpectations(t)
mockDekUseCase.AssertExpectations(t)
})

t.Run("invalid-kek-id", func(t *testing.T) {
err := RunRewrapDeks(ctx, masterKeyChain, nil, nil, logger, "invalid", 100)
require.Error(t, err)
require.Contains(t, err.Error(), "invalid kek-id")
})
func TestRunRewrapDeks_InvalidKekID(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
fake := keyring.NewFake()

err := commands.RunRewrapDeks(context.Background(), fake, logger, "not-a-uuid", 100)
assert.ErrorContains(t, err, "invalid kek-id")
}

func TestRunRewrapDeks_InvalidBatchSize(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
fake := keyring.NewFake()

err := commands.RunRewrapDeks(
context.Background(),
fake,
logger,
uuid.Nil.String(), // Fake's ActiveKekID() returns Nil
0,
)
assert.ErrorContains(t, err, "batch-size must be greater than 0")
}

func TestRunRewrapDeks_MismatchedActiveKek(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
fake := keyring.NewFake()

err := commands.RunRewrapDeks(
context.Background(),
fake,
logger,
uuid.New().String(), // doesn't match Fake's Nil active id
100,
)
assert.ErrorContains(t, err, "does not match keyring active KEK")
}

func TestRunRewrapDeks_SuccessNoDEKs(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
fake := keyring.NewFake()

err := commands.RunRewrapDeks(
context.Background(),
fake,
logger,
uuid.Nil.String(),
100,
)
assert.NoError(t, err)
}
16 changes: 2 additions & 14 deletions cmd/app/key_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,26 +190,14 @@ func getKeyCommands() []*cli.Command {
return commands.ExecuteWithContainer(
ctx,
func(ctx context.Context, container *app.Container) error {
masterKeyChain, err := container.MasterKeyChain(ctx)
if err != nil {
return err
}

kekUseCase, err := container.KekUseCase(ctx)
if err != nil {
return err
}

dekUseCase, err := container.CryptoDekUseCase(ctx)
kr, err := container.Keyring(ctx)
if err != nil {
return err
}

return commands.RunRewrapDeks(
ctx,
masterKeyChain,
kekUseCase,
dekUseCase,
kr,
container.Logger(),
cmd.String("kek-id"),
int(cmd.Int("batch-size")),
Expand Down
10 changes: 10 additions & 0 deletions docs/adr/0001-envelope-encryption-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,19 @@ Use envelope encryption hierarchy:
- historical versions remain decryptable with prior key material
- clear separation between root trust, key-wrapping, and data encryption roles

## Module structure

The envelope-encryption model is implemented by a single module,
`internal/keyring`, introduced in [ADR 0013](0013-keyring-as-envelope-encryption-module.md).
Features (secrets, transit, tokenization) call `Keyring.Encrypt` /
`Keyring.Decrypt` (or the persistent-DEK pair `AllocateDek` /
`EncryptWith` / `DecryptWith`) and do not handle the KEK chain, DEK
lifecycle, or AEAD selection directly.

## See also

- [Architecture](../concepts/architecture.md)
- [Security model](../concepts/security-model.md)
- [Key management operations](../operations/kms/key-management.md)
- [ADR 0012: PostgreSQL-Only Database](0012-postgresql-only-database.md) - Database storage for encrypted key material
- [ADR 0013: Keyring as Envelope Encryption Module](0013-keyring-as-envelope-encryption-module.md) - Single-module implementation of this model
Loading
Loading