Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
- [SopsStringParameter — Sops to single SSM ParameterStore Parameter](#sopsstringparameter--sops-to-single-ssm-parameterstore-parameter)
- [MultiStringParameter — Sops to multiple SSM ParameterStore Parameters](#multistringparameter--sops-to-multiple-ssm-parameterstore-parameters)
- [SopsSyncProvider](#sopssyncprovider)
- [Age Key from SSM Parameter Store](#age-key-from-ssm-parameter-store)
- [Common configuration options for SopsSecret, SopsStringParameter and MultiStringParameter](#common-configuration-options-for-sopssecret-sopsstringparameter-and-multistringparameter)
- [Considerations](#considerations)
- [UploadType: INLINE / ASSET](#uploadtype-inline--asset)
Expand Down Expand Up @@ -299,6 +300,81 @@ const secret = new SopsSecret(this, 'MySecret', {
});
```

## Age Key from SSM Parameter Store

By default, age private keys are passed to the sync Lambda via the `SOPS_AGE_KEY` environment variable, which means the key must be present at CDK synthesis time and is stored in plaintext in the Lambda configuration.

`addAgeKeyFromSsmParameter` eliminates this exposure: the key stays in SSM Parameter Store and is fetched at deploy time by the Lambda itself, entirely in-memory during decryption.

The SSM parameter **must** be a `SecureString` encrypted with a KMS customer-managed key (CMK). Storing an age private key without envelope encryption is considered insecure, so the KMS key is a required argument.

### Basic usage

```ts
import { Key } from 'aws-cdk-lib/aws-kms';

const cmk = Key.fromKeyArn(this, 'SsmCmk', 'arn:aws:kms:us-east-1:111122223333:key/…');

const provider = new SopsSyncProvider(this, 'SopsProvider');
provider.addAgeKeyFromSsmParameter('/sops/age/private-key', cmk);

const secret = new SopsSecret(this, 'MySecret', {
sopsFilePath: 'secrets/encrypted.yaml',
sopsProvider: provider,
});
```

The construct automatically grants the Lambda `ssm:GetParameter` on the parameter and `kms:Decrypt` on the CMK.

### Using an `IStringParameter` reference

Pass an `IStringParameter` object instead of a plain string if you already have a CDK parameter reference:

```ts
import { Key } from 'aws-cdk-lib/aws-kms';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';

const cmk = Key.fromKeyArn(this, 'SsmCmk', 'arn:aws:kms:us-east-1:111122223333:key/…');
const keyParam = StringParameter.fromStringParameterName(
this,
'AgeKeyParam',
'/sops/age/private-key',
);

const provider = new SopsSyncProvider(this, 'SopsProvider');
provider.addAgeKeyFromSsmParameter(keyParam, cmk);
```

### Combining with a static age key

Both methods can be used together. All keys — static and SSM-fetched — are merged and presented to sops during decryption:

```ts
import { Key } from 'aws-cdk-lib/aws-kms';

const cmk = Key.fromKeyArn(this, 'SsmCmk', 'arn:aws:kms:us-east-1:111122223333:key/…');
const provider = new SopsSyncProvider(this, 'SopsProvider');

// Statically injected at synthesis time (legacy approach)
provider.addAgeKey(SecretValue.ssmSecure('/sops/age/legacy-key'));

// Fetched from SSM at deploy time (recommended)
provider.addAgeKeyFromSsmParameter('/sops/age/current-key', cmk);
```
Comment thread
klekovkinda marked this conversation as resolved.

### Multiple keys

Call `addAgeKeyFromSsmParameter` multiple times to register additional keys, which is useful for key rotation:

```ts
import { Key } from 'aws-cdk-lib/aws-kms';

const cmk = Key.fromKeyArn(this, 'SsmCmk', 'arn:aws:kms:us-east-1:111122223333:key/…');
const provider = new SopsSyncProvider(this, 'SopsProvider');
provider.addAgeKeyFromSsmParameter('/sops/age/key-v1', cmk);
provider.addAgeKeyFromSsmParameter('/sops/age/key-v2', cmk);
```

## Common configuration options for SopsSecret, SopsStringParameter and MultiStringParameter

```ts
Expand Down
8 changes: 8 additions & 0 deletions lambda/internal/client/__snapshots__/ssm_get_parameter.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions lambda/internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ type SecretsManagerClient interface {

type SsmClient interface {
PutParameter(ctx context.Context, params *ssm.PutParameterInput, optFns ...func(*ssm.Options)) (*ssm.PutParameterOutput, error)
GetParameter(ctx context.Context, params *ssm.GetParameterInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error)
}

type AwsClient interface {
S3GetObject(file SopsS3File) (data []byte, err error)
S3GetObjectETAG(file SopsS3File) (*string, error)
SecretsManagerPutSecretValue(sopsHash string, secretArn string, secretContent *[]byte, binary *bool) (data *secretsmanager.PutSecretValueOutput, err error)
SsmPutParameter(parameterName string, parameterContent *[]byte, keyId string) (response *ssm.PutParameterOutput, err error)
SsmGetParameter(parameterName string) (*string, error)
}

type Client struct {
Expand Down Expand Up @@ -134,3 +136,20 @@ func (c *Client) SsmPutParameter(parameterName string, parameterContent *[]byte,
}
return c.ssm.PutParameter(c.ctx, input)
}

func (c *Client) SsmGetParameter(parameterName string) (*string, error) {
logger := slog.With("Package", "client", "Function", "SsmGetParameter")
logger.Info("Fetching parameter", "Name", parameterName)

resp, err := c.ssm.GetParameter(c.ctx, &ssm.GetParameterInput{
Name: &parameterName,
WithDecryption: aws.Bool(true),
})
if err != nil {
return nil, fmt.Errorf("SSM get parameter error:\n%v", err)
}
if resp.Parameter == nil || resp.Parameter.Value == nil {
return nil, fmt.Errorf("SSM parameter value is nil for parameter: %s", parameterName)
}
return resp.Parameter.Value, nil
}
66 changes: 66 additions & 0 deletions lambda/internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
"github.com/aws/aws-sdk-go-v2/service/ssm"
ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types"
"github.com/aws/smithy-go/ptr"
"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -230,3 +231,68 @@ func TestSsmPutParameter(t *testing.T) {
})
}
}

func TestSsmGetParameter(t *testing.T) {
tests := []struct {
name string
mockClient *MockAwsClient
expectedValue *string
expectedErrMsg string
}{
{
name: "successful get parameter",
mockClient: &MockAwsClient{
t: t,
snapsFileName: "ssm_get_parameter",
GetParameterRet: &ssm.GetParameterOutput{
Parameter: &ssmTypes.Parameter{
Value: aws.String("AGE-SECRET-KEY-1MOCK"),
},
},
ReturnError: nil,
},
expectedValue: aws.String("AGE-SECRET-KEY-1MOCK"),
},
{
name: "get parameter error",
mockClient: &MockAwsClient{
t: t,
snapsFileName: "ssm_get_parameter_error",
GetParameterRet: nil,
ReturnError: fmt.Errorf("mock error"),
},
expectedErrMsg: "SSM get parameter error:\nmock error",
},
{
name: "nil parameter value",
mockClient: &MockAwsClient{
t: t,
snapsFileName: "ssm_get_parameter_nil_value",
GetParameterRet: &ssm.GetParameterOutput{
Parameter: &ssmTypes.Parameter{
Value: nil,
},
},
ReturnError: nil,
},
expectedErrMsg: "SSM parameter value is nil",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &Client{
ctx: context.Background(),
ssm: tt.mockClient,
}
value, err := client.SsmGetParameter("/test/age/key")
if tt.expectedErrMsg != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedErrMsg)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedValue, value)
}
})
}
}
12 changes: 12 additions & 0 deletions lambda/internal/client/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type MockAwsClient struct {
GetObjectRet *s3.GetObjectOutput
GetObjectAttributesRet *s3.GetObjectAttributesOutput
PutParameterRet *ssm.PutParameterOutput
GetParameterRet *ssm.GetParameterOutput
PutSecretValueRet *secretsmanager.PutSecretValueOutput
}

Expand All @@ -49,6 +50,13 @@ func (s *MockAwsClient) PutParameter(ctx context.Context, params *ssm.PutParamet
return s.PutParameterRet, s.ReturnError
}

func (s *MockAwsClient) GetParameter(ctx context.Context, params *ssm.GetParameterInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) {
if s.ReturnError == nil {
snaps.WithConfig(snaps.Filename(s.snapsFileName)).MatchSnapshot(s.t, params)
}
return s.GetParameterRet, s.ReturnError
}

func (s *MockAwsClient) PutSecretValue(ctx context.Context, params *secretsmanager.PutSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.PutSecretValueOutput, error) {
if s.ReturnError == nil {
snaps.WithConfig(snaps.Filename(s.snapsFileName)).MatchSnapshot(s.t, params)
Expand Down Expand Up @@ -79,3 +87,7 @@ func (m *MockClient) SecretsManagerPutSecretValue(sopsHash string, secretArn str
func (m *MockClient) SsmPutParameter(parameterName string, parameterContent *[]byte, keyId string) (*ssm.PutParameterOutput, error) {
return nil, nil
}

func (m *MockClient) SsmGetParameter(parameterName string) (*string, error) {
return nil, nil
Comment thread
klekovkinda marked this conversation as resolved.
Outdated
}
47 changes: 47 additions & 0 deletions lambda/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,54 @@ import (
"context"
"fmt"
"log/slog"
"os"
"strings"

"github.com/aws/aws-lambda-go/cfn"
runtime "github.com/aws/aws-lambda-go/lambda"
"github.com/markussiebert/cdk-sops-secrets/internal/client"
"github.com/markussiebert/cdk-sops-secrets/internal/event"
)

// staticAgeKey captures the value of SOPS_AGE_KEY at Lambda cold-start so that
// loadAgeKeysFromSSM can always rebuild the combined key list correctly across
// warm invocations without duplicating the static portion.
var staticAgeKey = os.Getenv("SOPS_AGE_KEY")

// loadAgeKeysFromSSM reads parameter names from SOPS_AGE_KEY_PARAMS (newline-
// separated), fetches each SecureString from SSM Parameter Store with decryption,
// and sets SOPS_AGE_KEY to the combined value of any static key already present
// at cold-start plus all SSM-fetched keys. The function is a no-op when
// SOPS_AGE_KEY_PARAMS is unset or empty.
func loadAgeKeysFromSSM(clients client.AwsClient) error {
logger := slog.With("Package", "main", "Function", "loadAgeKeysFromSSM")

paramList := os.Getenv("SOPS_AGE_KEY_PARAMS")
if paramList == "" {
return nil
}

var ageKeys []string
if staticAgeKey != "" {
ageKeys = append(ageKeys, staticAgeKey)
}

for _, paramName := range strings.Split(paramList, "\n") {
paramName = strings.TrimSpace(paramName)
if paramName == "" {
continue
}
logger.Info("Fetching age key from SSM Parameter Store", "Parameter", paramName)
value, err := clients.SsmGetParameter(paramName)
if err != nil {
return fmt.Errorf("failed to fetch age key from SSM parameter %s: %v", paramName, err)
}
Comment thread
klekovkinda marked this conversation as resolved.
ageKeys = append(ageKeys, *value)
}

return os.Setenv("SOPS_AGE_KEY", strings.Join(ageKeys, "\n"))
}
Comment thread
klekovkinda marked this conversation as resolved.

func HandleRequestWithClients(clients client.AwsClient, e cfn.Event) (physicalResourceID string, data map[string]interface{}, err error) {
logger := slog.With("Package", "main", "Function", "HandleRequestWithClients")
logger.Debug("Incoming Event", "Event", e)
Expand All @@ -24,6 +65,12 @@ func HandleRequestWithClients(clients client.AwsClient, e cfn.Event) (physicalRe
return event.GenerateTempPhysicalResourceId(), nil, fmt.Errorf("requestType '%s' not supported", e.RequestType)
}

// Fetch any age private keys stored in SSM Parameter Store and make them
// available to sops via SOPS_AGE_KEY before attempting decryption.
if err := loadAgeKeysFromSSM(clients); err != nil {
return event.GenerateTempPhysicalResourceId(), nil, err
}

// Get the event input from the cloudformation event
props, err := event.FromCfnEvent(e)
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions lambda/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main

import (
"encoding/json"
"fmt"
"os"
"testing"
"time"
Expand Down Expand Up @@ -60,6 +61,11 @@ func (m *MockAwsClient) SsmPutParameter(parameterName string, parameterContent *
return &ssm.PutParameterOutput{Version: 1}, nil
}

func (m *MockAwsClient) SsmGetParameter(parameterName string) (*string, error) {
// SOPS_AGE_KEY_PARAMS is not set in unit tests; this method should not be called
return nil, fmt.Errorf("SsmGetParameter not expected in unit tests")
}

func TestHandleRequestWithClients(t *testing.T) {
t.Logf("Running at: %s", time.Now().String()) // Forces re-run
os.Setenv("AWS_REGION", "eu-central-1")
Expand Down
Loading
Loading