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
2 changes: 1 addition & 1 deletion conductor/product.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ To provide a secure, developer-friendly, and lightweight secrets management plat
- **Security Compliance Teams:** Who require tamper-resistant audit trails for compliance audits (PCI-DSS, SOC2).

## Core Features
- **Secret Management (Storage):** Versioned, envelope-encrypted storage with support for arbitrary key-value pairs.
- **Secret Management (Storage):** Versioned, envelope-encrypted storage with support for arbitrary key-value pairs and strict path validation.
- **Transit Engine (EaaS):** On-the-fly encryption/decryption of application data without database storage.
- **Tokenization Engine:** Format-preserving tokens for sensitive data types like credit card numbers.
- **Auth Token Revocation:** Immediate invalidation of authentication tokens (single or client-wide) with full state management.
Expand Down
1 change: 1 addition & 0 deletions conductor/tech-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- **Password Hashing:** [go-pwdhash](https://github.com/allisson/go-pwdhash) - Argon2id hashing for secure storage of client secrets and passwords.
- **Request Body Size Limiting:** Middleware to prevent DoS attacks from large payloads.
- **Secret Value Size Limiting:** Global limit on individual secret values to ensure predictable storage and memory usage.
- **Secret Path Validation:** Strict naming rules for secret paths (alphanumeric, -, _, /) to ensure consistency and security.
- **Audit Signing:** HMAC-SHA256 for tamper-evident cryptographic audit logs.

## KMS Providers (Native Support)
Expand Down
13 changes: 13 additions & 0 deletions docs/engines/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ graph TD
C -->|Invalid| H[401/403 Error]
```

## Path Validation

All secret paths are subject to strict validation rules:

- **Character Set**: Only alphanumeric characters (`a-z`, `A-Z`, `0-9`), hyphens (`-`), underscores (`_`), and forward slashes (`/`) are allowed.
- **Length**: Paths must be between 1 and 255 characters.
- **Formatting Constraints**:
- **No leading or trailing slashes**: Paths cannot start or end with `/`.
- **No consecutive slashes**: Paths cannot contain `//`.
- **No consecutive symbols**: Paths cannot contain consecutive hyphens (`--`) or underscores (`__`).

Failure to comply with these rules results in a `400 Bad Request` with the error message `invalid secret path format`.

## Endpoints

All endpoints require `Authorization: Bearer <token>`.
Expand Down
24 changes: 24 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ paths:
description: Cursor for pagination (path of last item from previous page)
schema:
type: string
minLength: 1
maxLength: 255
pattern: '^[a-zA-Z0-9\-_/]+$'
- name: limit
in: query
description: Maximum number of items to return (clamped to 1000)
Expand Down Expand Up @@ -294,6 +297,9 @@ paths:
required: true
schema:
type: string
minLength: 1
maxLength: 255
pattern: '^[a-zA-Z0-9\-_/]+$'
post:
tags: [secrets]
summary: Create or update secret
Expand Down Expand Up @@ -1048,6 +1054,9 @@ components:
properties:
path:
type: string
minLength: 1
maxLength: 255
pattern: '^[a-zA-Z0-9\-_/]+$'
capabilities:
type: array
items:
Expand All @@ -1067,6 +1076,9 @@ components:
type: string
path:
type: string
minLength: 1
maxLength: 255
pattern: '^[a-zA-Z0-9\-_/]+$'
version:
type: integer
created_at:
Expand All @@ -1079,6 +1091,9 @@ components:
type: string
path:
type: string
minLength: 1
maxLength: 255
pattern: '^[a-zA-Z0-9\-_/]+$'
version:
type: integer
value:
Expand Down Expand Up @@ -1136,6 +1151,9 @@ components:
type: string
path:
type: string
minLength: 1
maxLength: 255
pattern: '^[a-zA-Z0-9\-_/]+$'
metadata:
type: object
additionalProperties: true
Expand Down Expand Up @@ -1192,6 +1210,9 @@ components:
properties:
path:
type: string
minLength: 1
maxLength: 255
pattern: '^[a-zA-Z0-9\-_/]+$'
capabilities:
type: array
items:
Expand Down Expand Up @@ -1235,6 +1256,9 @@ components:
properties:
path:
type: string
minLength: 1
maxLength: 255
pattern: '^[a-zA-Z0-9\-_/]+$'
capabilities:
type: array
items:
Expand Down
3 changes: 3 additions & 0 deletions internal/secrets/domain/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ var (

// ErrSecretValueTooLarge indicates the secret value exceeds the maximum allowed size.
ErrSecretValueTooLarge = errors.Wrap(errors.ErrTooLarge, "secret value too large")

// ErrInvalidSecretPath indicates the secret path fails validation.
ErrInvalidSecretPath = errors.Wrap(errors.ErrInvalidInput, "invalid secret path format")
)
5 changes: 5 additions & 0 deletions internal/secrets/usecase/secret_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ func (s *secretUseCase) CreateOrUpdate(
path string,
value []byte,
) (*secretsDomain.Secret, error) {
// Validate secret path
if err := validateSecretPath(path); err != nil {
return nil, err
}

// Check if the secret value size exceeds the limit
if len(value) > s.secretValueSizeLimit {
return nil, secretsDomain.ErrSecretValueTooLarge
Expand Down
72 changes: 53 additions & 19 deletions internal/secrets/usecase/secret_usecase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func TestSecretUseCase_CreateOrUpdate(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
value := []byte("secret-value")

dekID := uuid.Must(uuid.NewV7())
Expand Down Expand Up @@ -159,7 +159,7 @@ func TestSecretUseCase_CreateOrUpdate(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
value := []byte("new-secret-value")

existingSecret := &secretsDomain.Secret{
Expand Down Expand Up @@ -268,7 +268,7 @@ func TestSecretUseCase_CreateOrUpdate(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
value := []byte("secret-value")

// Execute
Expand Down Expand Up @@ -313,7 +313,7 @@ func TestSecretUseCase_CreateOrUpdate(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
value := []byte("secret-value")
expectedError := errors.New("database error")

Expand Down Expand Up @@ -372,7 +372,7 @@ func TestSecretUseCase_CreateOrUpdate(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
value := []byte("secret-value")
expectedError := errors.New("failed to create dek")

Expand Down Expand Up @@ -426,7 +426,7 @@ func TestSecretUseCase_CreateOrUpdate(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
value := make([]byte, 10) // 10 bytes

// Use a limit of 5 bytes
Expand All @@ -447,6 +447,40 @@ func TestSecretUseCase_CreateOrUpdate(t *testing.T) {
assert.Nil(t, secret)
assert.True(t, errors.Is(err, secretsDomain.ErrSecretValueTooLarge))
})

t.Run("Error_InvalidPath", func(t *testing.T) {
t.Parallel()
// Setup mocks
mockTxManager := databaseMocks.NewMockTxManager(t)
mockDekRepo := secretsUsecaseMocks.NewMockDekRepository(t)
mockSecretRepo := secretsUsecaseMocks.NewMockSecretRepository(t)
mockAEADManager := cryptoServiceMocks.NewMockAEADManager(t)
mockKeyManager := cryptoServiceMocks.NewMockKeyManager(t)

kekChain := createKekChain([]*cryptoDomain.Kek{})
defer kekChain.Close()

path := "/invalid/path"
value := []byte("secret-value")

// Execute
uc := NewSecretUseCase(
mockTxManager,
mockDekRepo,
mockSecretRepo,
kekChain,
mockAEADManager,
mockKeyManager,
cryptoDomain.AESGCM,
524288,
)
secret, err := uc.CreateOrUpdate(ctx, path, value)

// Assert
assert.Error(t, err)
assert.Nil(t, secret)
assert.Equal(t, "invalid secret path format: invalid input", err.Error())
})
}

// TestSecretUseCase_Get tests the Get method of secretUseCase.
Expand Down Expand Up @@ -479,7 +513,7 @@ func TestSecretUseCase_Get(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
dekID := uuid.Must(uuid.NewV7())
ciphertext := []byte("encrypted-secret")
nonce := []byte("secret-nonce")
Expand Down Expand Up @@ -575,7 +609,7 @@ func TestSecretUseCase_Get(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/nonexistent"
path := "app/nonexistent"

// Setup expectations
mockSecretRepo.EXPECT().
Expand Down Expand Up @@ -625,7 +659,7 @@ func TestSecretUseCase_Get(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
dekID := uuid.Must(uuid.NewV7())

secret := &secretsDomain.Secret{
Expand Down Expand Up @@ -691,7 +725,7 @@ func TestSecretUseCase_Get(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
dekID := uuid.Must(uuid.NewV7())
differentKekID := uuid.Must(uuid.NewV7())

Expand Down Expand Up @@ -769,7 +803,7 @@ func TestSecretUseCase_Get(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
dekID := uuid.Must(uuid.NewV7())
ciphertext := []byte("encrypted-secret")
nonce := []byte("secret-nonce")
Expand Down Expand Up @@ -869,7 +903,7 @@ func TestSecretUseCase_Delete(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"

// Setup expectations
mockSecretRepo.EXPECT().
Expand Down Expand Up @@ -917,7 +951,7 @@ func TestSecretUseCase_Delete(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/nonexistent"
path := "app/nonexistent"

// Setup expectations
mockSecretRepo.EXPECT().
Expand Down Expand Up @@ -966,7 +1000,7 @@ func TestSecretUseCase_Delete(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
expectedError := errors.New("database error")

// Setup expectations
Expand Down Expand Up @@ -1024,7 +1058,7 @@ func TestSecretUseCase_GetByVersion(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
version := uint(2)
dekID := uuid.Must(uuid.NewV7())
ciphertext := []byte("encrypted-secret")
Expand Down Expand Up @@ -1122,7 +1156,7 @@ func TestSecretUseCase_GetByVersion(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/nonexistent"
path := "app/nonexistent"
version := uint(1)

// Setup expectations
Expand Down Expand Up @@ -1174,7 +1208,7 @@ func TestSecretUseCase_GetByVersion(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
version := uint(1)
dekID := uuid.Must(uuid.NewV7())
ciphertext := []byte("encrypted-secret")
Expand Down Expand Up @@ -1269,7 +1303,7 @@ func TestSecretUseCase_GetByVersion(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
version := uint(1)
dekID := uuid.Must(uuid.NewV7())

Expand Down Expand Up @@ -1336,7 +1370,7 @@ func TestSecretUseCase_GetByVersion(t *testing.T) {
kekChain := createKekChain([]*cryptoDomain.Kek{kek})
defer kekChain.Close()

path := "/app/api-key"
path := "app/api-key"
version := uint(1)
dekID := uuid.Must(uuid.NewV7())
differentKekID := uuid.Must(uuid.NewV7()) // Different KEK ID
Expand Down
41 changes: 41 additions & 0 deletions internal/secrets/usecase/validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package usecase

import (
"regexp"
"strings"

secretsDomain "github.com/allisson/secrets/internal/secrets/domain"
)

var (
pathRegex = regexp.MustCompile(`^[a-zA-Z0-9\-_/]+$`)
)

func validateSecretPath(path string) error {
// Check length
if len(path) < 1 || len(path) > 255 {
return secretsDomain.ErrInvalidSecretPath
}

// Check characters
if !pathRegex.MatchString(path) {
return secretsDomain.ErrInvalidSecretPath
}

// Check leading/trailing slashes
if path[0] == '/' || path[len(path)-1] == '/' {
return secretsDomain.ErrInvalidSecretPath
}

// Check consecutive slashes
if strings.Contains(path, "//") {
return secretsDomain.ErrInvalidSecretPath
}

// Check consecutive symbols
if strings.Contains(path, "--") || strings.Contains(path, "__") {
return secretsDomain.ErrInvalidSecretPath
}

return nil
}
Loading
Loading