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
30 changes: 30 additions & 0 deletions conductor/archive/implement_aead_context_20260305/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Implementation Plan: AEAD Context in Transit Engine

## Phase 1: Use Case Layer Update
- [x] Task: Update TransitKeyUseCase interface and implementation signatures
- [x] Update `internal/transit/usecase/interface.go` to add `context []byte` to `Encrypt` and `Decrypt`
- [x] Update `internal/transit/usecase/transit_key_usecase.go` signatures to match interface
- [x] Update existing callers in tests to pass `nil` for context
- [x] Task: Implement AEAD context support in `TransitKeyUseCase` (TDD)
- [x] Write failing unit tests in `internal/transit/usecase/transit_key_usecase_test.go` for encryption/decryption with context
- [x] Implement logic in `internal/transit/usecase/transit_key_usecase.go` to pass context as `aad` to cipher
- [x] Verify tests pass and coverage is >80%
- [x] Task: Conductor - User Manual Verification 'Phase 1: Use Case Layer' (Protocol in workflow.md)

## Phase 2: HTTP Layer Update
- [x] Task: Update `EncryptRequest` and `DecryptRequest` DTOs
- [x] Update `internal/transit/http/dto/request.go` to add optional `context` (base64)
- [x] Task: Implement AEAD context support in `CryptoHandler` (TDD)
- [x] Write failing unit tests in `internal/transit/http/crypto_handler_test.go` for API calls with context
- [x] Update `internal/transit/http/crypto_handler.go` to decode base64 context and pass to use case
- [x] Verify tests pass and coverage is >80%
- [x] Task: Conductor - User Manual Verification 'Phase 2: HTTP Layer' (Protocol in workflow.md)

## Phase 3: Integration and Documentation
- [x] Task: Verify end-to-end flow with integration tests
- [x] Update `test/integration/transit_flow_test.go` to include AEAD context scenarios
- [x] Run all transit integration tests and verify they pass
- [x] Task: Update Documentation
- [x] Update OpenAPI spec in `docs/openapi.yaml`
- [x] Update transit engine guide in `docs/engines/transit.md` with AEAD context examples
- [x] Task: Conductor - User Manual Verification 'Phase 3: Integration and Documentation' (Protocol in workflow.md)
5 changes: 0 additions & 5 deletions conductor/tracks.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
# Project Tracks

This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.

---

- [ ] **Track: Implement Authenticated Encryption with Associated Data (AEAD) Context in Transit Engine**
*Link: [./tracks/implement_aead_context_20260305/](./tracks/implement_aead_context_20260305/)*
30 changes: 0 additions & 30 deletions conductor/tracks/implement_aead_context_20260305/plan.md

This file was deleted.

40 changes: 40 additions & 0 deletions docs/engines/transit.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,43 @@ Example response (`200 OK`):
## Relevant CLI Commands

- `rewrap-deks`: Rewraps transit key DEKs when rotating the KEK.

## AEAD Context (Associated Data)

The Transit API supports Authenticated Encryption with Associated Data (AEAD). This allows you to provide an optional `context` (also known as AAD - Additional Authenticated Data) that is cryptographically bound to the ciphertext but not encrypted itself.

### Why use AEAD Context?

AEAD context prevents ciphertext substitution attacks. By providing a context (e.g., a user ID, a transaction ID, or a resource type), you ensure that the ciphertext can only be decrypted if the *same* context is provided during the decryption operation.

### Using Context in API

Both `encrypt` and `decrypt` endpoints accept an optional `context` field in the JSON body. The context must be **base64-encoded**.

#### Encrypt with context

```bash
curl -X POST http://localhost:8080/v1/transit/keys/payment-data/encrypt \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"plaintext": "c2Vuc2l0aXZlLWRhdGE=",
"context": "dXNlci0xMjM="
}'
```

#### Decrypt with context

To successfully decrypt, you MUST provide the exact same context used during encryption:

```bash
curl -X POST http://localhost:8080/v1/transit/keys/payment-data/decrypt \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"ciphertext": "1:ZW5jcnlwdGVkLWJ5dGVzLi4u",
"context": "dXNlci0xMjM="
}'
```

If the context does not match, the API will return a `422 Unprocessable Entity` error indicating that decryption failed.
11 changes: 11 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,9 @@ paths:
plaintext:
type: string
description: Base64-encoded plaintext
context:
type: string
description: Optional base64-encoded context (AAD)
required: [plaintext]
responses:
"200":
Expand Down Expand Up @@ -549,12 +552,20 @@ paths:
ciphertext:
type: string
description: Versioned ciphertext in format "<version>:<base64-ciphertext>"
context:
type: string
description: Optional base64-encoded context (AAD)
required: [ciphertext]
examples:
validCiphertext:
summary: Versioned ciphertext from encrypt response
value:
ciphertext: "1:ZW5jcnlwdGVkLWJ5dGVzLi4u"
validCiphertextWithContext:
summary: Decryption with context
value:
ciphertext: "1:ZW5jcnlwdGVkLWJ5dGVzLi4u"
context: "YmVhcmVy"
invalidCiphertext:
summary: Invalid ciphertext format
value:
Expand Down
28 changes: 25 additions & 3 deletions internal/transit/http/crypto_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,26 @@ func (h *CryptoHandler) EncryptHandler(c *gin.Context) {
return
}

// Decode base64 plaintext
// Decode plaintext
plaintext, err := base64.StdEncoding.DecodeString(req.Plaintext)
if err != nil {
httputil.HandleBadRequestGin(c, fmt.Errorf("invalid base64 plaintext: %w", err), h.logger)
return
}

// Decode optional context
var contextAAD []byte
if req.Context != "" {
var err error
contextAAD, err = base64.StdEncoding.DecodeString(req.Context)
if err != nil {
httputil.HandleBadRequestGin(c, fmt.Errorf("invalid base64 context: %w", err), h.logger)
return
}
}

// Call use case
encryptedBlob, err := h.transitKeyUseCase.Encrypt(c.Request.Context(), name, plaintext)
encryptedBlob, err := h.transitKeyUseCase.Encrypt(c.Request.Context(), name, plaintext, contextAAD)
if err != nil {
httputil.HandleErrorGin(c, err, h.logger)
return
Expand Down Expand Up @@ -113,8 +124,19 @@ func (h *CryptoHandler) DecryptHandler(c *gin.Context) {
return
}

// Decode optional context
var contextAAD []byte
if req.Context != "" {
var err error
contextAAD, err = base64.StdEncoding.DecodeString(req.Context)
if err != nil {
httputil.HandleBadRequestGin(c, fmt.Errorf("invalid base64 context: %w", err), h.logger)
return
}
}

// Call use case with ciphertext string (format: "version:base64-ciphertext")
decryptedBlob, err := h.transitKeyUseCase.Decrypt(c.Request.Context(), name, req.Ciphertext)
decryptedBlob, err := h.transitKeyUseCase.Decrypt(c.Request.Context(), name, req.Ciphertext, contextAAD)
if err != nil {
httputil.HandleErrorGin(c, err, h.logger)
return
Expand Down
87 changes: 80 additions & 7 deletions internal/transit/http/crypto_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func TestCryptoHandler_EncryptHandler(t *testing.T) {
}

mockUseCase.EXPECT().
Encrypt(mock.Anything, "test-key", plaintext).
Encrypt(mock.Anything, "test-key", plaintext, mock.Anything).
Return(encryptedBlob, nil).
Once()

Expand All @@ -67,6 +67,40 @@ func TestCryptoHandler_EncryptHandler(t *testing.T) {
assert.Equal(t, uint(1), response.Version)
})

t.Run("Success_EncryptWithContext", func(t *testing.T) {
handler, mockUseCase := setupTestCryptoHandler(t)

plaintext := []byte("my secret data")
contextAAD := []byte("aead context")

request := dto.EncryptRequest{
Plaintext: base64.StdEncoding.EncodeToString(plaintext),
Context: base64.StdEncoding.EncodeToString(contextAAD),
}

encryptedBlob := &transitDomain.EncryptedBlob{
Version: 1,
Ciphertext: []byte("encrypted-data"),
}

mockUseCase.EXPECT().
Encrypt(mock.Anything, "test-key", plaintext, contextAAD).
Return(encryptedBlob, nil).
Once()

c, w := createTestContext(http.MethodPost, "/v1/transit/keys/test-key/encrypt", request)
c.Params = gin.Params{gin.Param{Key: "name", Value: "test-key"}}

handler.EncryptHandler(c)

assert.Equal(t, http.StatusOK, w.Code)

var response dto.EncryptResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, encryptedBlob.String(), response.Ciphertext)
})

t.Run("Error_EmptyName", func(t *testing.T) {
handler, _ := setupTestCryptoHandler(t)

Expand Down Expand Up @@ -154,7 +188,7 @@ func TestCryptoHandler_EncryptHandler(t *testing.T) {
}

mockUseCase.EXPECT().
Encrypt(mock.Anything, "nonexistent-key", plaintext).
Encrypt(mock.Anything, "nonexistent-key", plaintext, mock.Anything).
Return(nil, transitDomain.ErrTransitKeyNotFound).
Once()

Expand Down Expand Up @@ -188,7 +222,7 @@ func TestCryptoHandler_DecryptHandler(t *testing.T) {
}

mockUseCase.EXPECT().
Decrypt(mock.Anything, "test-key", ciphertextString).
Decrypt(mock.Anything, "test-key", ciphertextString, mock.Anything).
Return(decryptedBlob, nil).
Once()

Expand All @@ -206,6 +240,45 @@ func TestCryptoHandler_DecryptHandler(t *testing.T) {
assert.Equal(t, uint(1), response.Version)
})

t.Run("Success_DecryptWithContext", func(t *testing.T) {
handler, mockUseCase := setupTestCryptoHandler(t)

plaintext := []byte("my secret data")
expectedPlaintext := make([]byte, len(plaintext))
copy(expectedPlaintext, plaintext)
ciphertext := []byte("encrypted-data")
ciphertextString := "1:ZW5jcnlwdGVkLWRhdGE="
contextAAD := []byte("aead context")

request := dto.DecryptRequest{
Ciphertext: ciphertextString,
Context: base64.StdEncoding.EncodeToString(contextAAD),
}

decryptedBlob := &transitDomain.EncryptedBlob{
Version: 1,
Ciphertext: ciphertext,
Plaintext: plaintext,
}

mockUseCase.EXPECT().
Decrypt(mock.Anything, "test-key", ciphertextString, contextAAD).
Return(decryptedBlob, nil).
Once()

c, w := createTestContext(http.MethodPost, "/v1/transit/keys/test-key/decrypt", request)
c.Params = gin.Params{gin.Param{Key: "name", Value: "test-key"}}

handler.DecryptHandler(c)

assert.Equal(t, http.StatusOK, w.Code)

var response dto.DecryptResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, base64.StdEncoding.EncodeToString(expectedPlaintext), response.Plaintext)
})

t.Run("Error_EmptyName", func(t *testing.T) {
handler, _ := setupTestCryptoHandler(t)

Expand Down Expand Up @@ -271,7 +344,7 @@ func TestCryptoHandler_DecryptHandler(t *testing.T) {
}

mockUseCase.EXPECT().
Decrypt(mock.Anything, "test-key", "invalid-format").
Decrypt(mock.Anything, "test-key", "invalid-format", mock.Anything).
Return(nil, transitDomain.ErrInvalidBlobFormat).
Once()

Expand All @@ -291,7 +364,7 @@ func TestCryptoHandler_DecryptHandler(t *testing.T) {
}

mockUseCase.EXPECT().
Decrypt(mock.Anything, "test-key", "1:invalid-base64!!!").
Decrypt(mock.Anything, "test-key", "1:invalid-base64!!!", mock.Anything).
Return(nil, transitDomain.ErrInvalidBlobBase64).
Once()

Expand All @@ -313,7 +386,7 @@ func TestCryptoHandler_DecryptHandler(t *testing.T) {
}

mockUseCase.EXPECT().
Decrypt(mock.Anything, "nonexistent-key", ciphertextString).
Decrypt(mock.Anything, "nonexistent-key", ciphertextString, mock.Anything).
Return(nil, transitDomain.ErrTransitKeyNotFound).
Once()

Expand All @@ -335,7 +408,7 @@ func TestCryptoHandler_DecryptHandler(t *testing.T) {
}

mockUseCase.EXPECT().
Decrypt(mock.Anything, "test-key", ciphertextString).
Decrypt(mock.Anything, "test-key", ciphertextString, mock.Anything).
Return(nil, apperrors.ErrInvalidInput).
Once()

Expand Down
8 changes: 8 additions & 0 deletions internal/transit/http/dto/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func (r *RotateTransitKeyRequest) Validate() error {
// EncryptRequest contains the parameters for encrypting data.
type EncryptRequest struct {
Plaintext string `json:"plaintext"` // Base64-encoded plaintext
Context string `json:"context"` // Optional base64-encoded context (AAD)
}

// Validate checks if the encrypt request is valid.
Expand All @@ -62,12 +63,16 @@ func (r *EncryptRequest) Validate() error {
customValidation.NotBlank,
customValidation.Base64,
),
validation.Field(&r.Context,
customValidation.Base64,
),
)
}

// DecryptRequest contains the parameters for decrypting data.
type DecryptRequest struct {
Ciphertext string `json:"ciphertext"` // Format: "version:base64-ciphertext"
Context string `json:"context"` // Optional base64-encoded context (AAD)
}

// Validate checks if the decrypt request is valid.
Expand All @@ -77,6 +82,9 @@ func (r *DecryptRequest) Validate() error {
validation.Required,
customValidation.NotBlank,
),
validation.Field(&r.Context,
customValidation.Base64,
),
)
}

Expand Down
Loading
Loading