diff --git a/API.md b/API.md index 1df6509b..0ee24c8e 100644 --- a/API.md +++ b/API.md @@ -43,6 +43,7 @@ new MultiStringParameter(scope: Construct, id: string, props: MultiStringParamet | **Name** | **Description** | | --- | --- | | toString | Returns a string representation of this construct. | +| with | Applies one or more mixins to this construct. | --- @@ -54,6 +55,27 @@ public toString(): string Returns a string representation of this construct. +##### `with` + +```typescript +public with(mixins: ...IMixin[]): IConstruct +``` + +Applies one or more mixins to this construct. + +Mixins are applied in order. The list of constructs is captured at the +start of the call, so constructs added by a mixin will not be visited. +Use multiple `with()` calls if subsequent mixins should apply to added +constructs. + +###### `mixins`Required + +- *Type:* ...constructs.IMixin[] + +The mixins to apply. + +--- + #### Static Functions | **Name** | **Description** | @@ -226,6 +248,7 @@ new SopsSecret(scope: Construct, id: string, props: SopsSecretProps) | **Name** | **Description** | | --- | --- | | toString | Returns a string representation of this construct. | +| with | Applies one or more mixins to this construct. | | addRotationSchedule | Adds a rotation schedule to the secret. | | addToResourcePolicy | Adds a statement to the IAM resource policy associated with this secret. | | applyRemovalPolicy | Apply the given removal policy to this resource. | @@ -247,6 +270,27 @@ public toString(): string Returns a string representation of this construct. +##### `with` + +```typescript +public with(mixins: ...IMixin[]): IConstruct +``` + +Applies one or more mixins to this construct. + +Mixins are applied in order. The list of constructs is captured at the +start of the call, so constructs added by a mixin will not be visited. +Use multiple `with()` calls if subsequent mixins should apply to added +constructs. + +###### `mixins`Required + +- *Type:* ...constructs.IMixin[] + +The mixins to apply. + +--- + ##### `addRotationSchedule` ```typescript @@ -637,6 +681,7 @@ new SopsStringParameter(scope: Construct, id: string, props: SopsStringParameter | **Name** | **Description** | | --- | --- | | toString | Returns a string representation of this construct. | +| with | Applies one or more mixins to this construct. | | applyRemovalPolicy | Apply the given removal policy to this resource. | | grantRead | Grants read (DescribeParameter, GetParameters, GetParameter, GetParameterHistory) permissions on the SSM Parameter. | | grantWrite | Grants write (PutParameter) permissions on the SSM Parameter. | @@ -651,6 +696,27 @@ public toString(): string Returns a string representation of this construct. +##### `with` + +```typescript +public with(mixins: ...IMixin[]): IConstruct +``` + +Applies one or more mixins to this construct. + +Mixins are applied in order. The list of constructs is captured at the +start of the call, so constructs added by a mixin will not be visited. +Use multiple `with()` calls if subsequent mixins should apply to added +constructs. + +###### `mixins`Required + +- *Type:* ...constructs.IMixin[] + +The mixins to apply. + +--- + ##### `applyRemovalPolicy` ```typescript @@ -928,6 +994,7 @@ new SopsSync(scope: Construct, id: string, props: SopsSyncProps) | **Name** | **Description** | | --- | --- | | toString | Returns a string representation of this construct. | +| with | Applies one or more mixins to this construct. | --- @@ -939,6 +1006,27 @@ public toString(): string Returns a string representation of this construct. +##### `with` + +```typescript +public with(mixins: ...IMixin[]): IConstruct +``` + +Applies one or more mixins to this construct. + +Mixins are applied in order. The list of constructs is captured at the +start of the call, so constructs added by a mixin will not be visited. +Use multiple `with()` calls if subsequent mixins should apply to added +constructs. + +###### `mixins`Required + +- *Type:* ...constructs.IMixin[] + +The mixins to apply. + +--- + #### Static Functions | **Name** | **Description** | @@ -1056,6 +1144,7 @@ new SopsSyncProvider(scope: Construct, id?: string, props?: SopsSyncProviderProp | **Name** | **Description** | | --- | --- | | toString | Returns a string representation of this construct. | +| with | Applies one or more mixins to this construct. | | applyRemovalPolicy | Apply the given removal policy to this resource. | | addEventSource | Adds an event source to this function. | | addEventSourceMapping | Adds an event source that maps to this AWS Lambda function. | @@ -1080,6 +1169,7 @@ new SopsSyncProvider(scope: Construct, id?: string, props?: SopsSyncProviderProp | addMetadata | Use this method to write to the construct tree. | | dependOn | The SingletonFunction construct cannot be added as a dependency of another construct using node.addDependency(). Use this method instead to declare this as a dependency of another construct. | | addAgeKey | *No description.* | +| addAgeKeyFromSsmParameter | Configure the Lambda to fetch an age private key from an SSM Parameter Store SecureString at runtime, rather than injecting it as a plaintext environment variable at synthesis time. | --- @@ -1091,6 +1181,25 @@ public toString(): string Returns a string representation of this construct. +##### `with` + +```typescript +public with(mixins: ...IMixin[]): IConstruct +``` + +Applies one or more mixins to this construct. + +Mixins are applied in order. The list of constructs is captured at the +start of the call, so constructs added by a mixin will not be visited. +Use multiple `with()` calls if subsequent mixins should apply to added +constructs. + +###### `mixins`Required + +- *Type:* ...constructs.IMixin[] + +--- + ##### `applyRemovalPolicy` ```typescript @@ -1250,6 +1359,8 @@ public grantInvoke(grantee: IGrantable): Grant Grant the given identity permissions to invoke this Lambda. +[disable-awslint:no-grants] + ###### `grantee`Required - *Type:* aws-cdk-lib.aws_iam.IGrantable @@ -1264,6 +1375,8 @@ public grantInvokeCompositePrincipal(compositePrincipal: CompositePrincipal): Gr Grant multiple principals the ability to invoke this Lambda via CompositePrincipal. +[disable-awslint:no-grants] + ###### `compositePrincipal`Required - *Type:* aws-cdk-lib.aws_iam.CompositePrincipal @@ -1278,6 +1391,8 @@ public grantInvokeLatestVersion(grantee: IGrantable): Grant Grant the given identity permissions to invoke the $LATEST version or unqualified version of this Lambda. +[disable-awslint:no-grants] + ###### `grantee`Required - *Type:* aws-cdk-lib.aws_iam.IGrantable @@ -1292,6 +1407,8 @@ public grantInvokeUrl(grantee: IGrantable): Grant Grant the given identity permissions to invoke this Lambda Function URL. +[disable-awslint:no-grants] + ###### `grantee`Required - *Type:* aws-cdk-lib.aws_iam.IGrantable @@ -1306,6 +1423,8 @@ public grantInvokeVersion(grantee: IGrantable, version: IVersion): Grant Grant the given identity permissions to invoke the given version of this Lambda. +[disable-awslint:no-grants] + ###### `grantee`Required - *Type:* aws-cdk-lib.aws_iam.IGrantable @@ -1520,6 +1639,36 @@ public addAgeKey(key: SecretValue): void --- +##### `addAgeKeyFromSsmParameter` + +```typescript +public addAgeKeyFromSsmParameter(param: string | IStringParameter, encryptionKey: IKey): void +``` + +Configure the Lambda to fetch an age private key from an SSM Parameter Store SecureString at runtime, rather than injecting it as a plaintext environment variable at synthesis time. + +The KMS key used to encrypt the SecureString is required: storing an age +private key without envelope encryption is considered insecure. + +The Lambda is automatically granted `ssm:GetParameter` on the parameter +and `kms:Decrypt` on the encryption key. + +###### `param`Required + +- *Type:* string | aws-cdk-lib.aws_ssm.IStringParameter + +Parameter name string (e.g. '/sops/age/key') or an `IStringParameter` reference from `aws-cdk-lib/aws-ssm`. + +--- + +###### `encryptionKey`Required + +- *Type:* aws-cdk-lib.aws_kms.IKey + +KMS key used to encrypt the SecureString parameter. + +--- + #### Static Functions | **Name** | **Description** | diff --git a/README.md b/README.md index a24852b8..4b971406 100644 --- a/README.md +++ b/README.md @@ -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) @@ -299,6 +300,82 @@ 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 { SecretValue } from 'aws-cdk-lib'; +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); +``` + +### 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 diff --git a/lambda/internal/client/__snapshots__/ssm_get_parameter.snap b/lambda/internal/client/__snapshots__/ssm_get_parameter.snap new file mode 100644 index 00000000..87845946 --- /dev/null +++ b/lambda/internal/client/__snapshots__/ssm_get_parameter.snap @@ -0,0 +1,8 @@ + +[TestSsmGetParameter - 1] +&ssm.GetParameterInput{ + Name: &"/test/age/key", + WithDecryption: &bool(true), + noSmithyDocumentSerde: document.NoSerde{}, +} +--- diff --git a/lambda/internal/client/client.go b/lambda/internal/client/client.go index c4b8040f..f210b2dd 100644 --- a/lambda/internal/client/client.go +++ b/lambda/internal/client/client.go @@ -28,6 +28,7 @@ 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 { @@ -35,6 +36,7 @@ type AwsClient interface { 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 { @@ -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: ¶meterName, + 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 +} diff --git a/lambda/internal/client/client_test.go b/lambda/internal/client/client_test.go index 53db1aa8..79bae6bc 100644 --- a/lambda/internal/client/client_test.go +++ b/lambda/internal/client/client_test.go @@ -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" ) @@ -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) + } + }) + } +} diff --git a/lambda/internal/client/mock.go b/lambda/internal/client/mock.go index 89ec3d92..61aa1f74 100644 --- a/lambda/internal/client/mock.go +++ b/lambda/internal/client/mock.go @@ -25,6 +25,7 @@ type MockAwsClient struct { GetObjectRet *s3.GetObjectOutput GetObjectAttributesRet *s3.GetObjectAttributesOutput PutParameterRet *ssm.PutParameterOutput + GetParameterRet *ssm.GetParameterOutput PutSecretValueRet *secretsmanager.PutSecretValueOutput } @@ -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) @@ -79,3 +87,9 @@ 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 a non-nil string pointer to avoid nil dereferences when err == nil. + empty := "" + return &empty, nil +} diff --git a/lambda/internal/sops/sops.go b/lambda/internal/sops/sops.go index 455e2bc2..e0a807f7 100644 --- a/lambda/internal/sops/sops.go +++ b/lambda/internal/sops/sops.go @@ -8,6 +8,7 @@ import ( "github.com/getsops/sops/v3/decrypt" "github.com/markussiebert/cdk-sops-secrets/internal/data" + "gopkg.in/yaml.v3" ) type Format string @@ -42,6 +43,42 @@ func CreateEncryptedSopsSecret(content []byte, format Format, hash string) (*Enc }, nil } +func (e EncryptedSopsSecret) UsesAgeEncryption() bool { + logger := slog.With("Package", "sops", "Function", "UsesAgeEncryption") + + switch e.Format { + case JSON, BINARY: + var meta struct { + SOPS struct { + Age []json.RawMessage `json:"age"` + } `json:"sops"` + } + if err := json.Unmarshal(e.Content, &meta); err != nil { + logger.Warn("could not parse SOPS JSON metadata, assuming age is present", "error", err) + return true + } + return len(meta.SOPS.Age) > 0 + + case YAML: + var meta struct { + SOPS struct { + Age []interface{} `yaml:"age"` + } `yaml:"sops"` + } + if err := yaml.Unmarshal(e.Content, &meta); err != nil { + logger.Warn("could not parse SOPS YAML metadata, assuming age is present", "error", err) + return true + } + return len(meta.SOPS.Age) > 0 + + case DOTENV: + return bytes.Contains(e.Content, []byte("sops_age__list_")) + + default: + return false + } +} + type DecryptedSopsSecret struct { content []byte format Format diff --git a/lambda/internal/sops/sops_test.go b/lambda/internal/sops/sops_test.go index c1d2a4d3..0163fb6a 100644 --- a/lambda/internal/sops/sops_test.go +++ b/lambda/internal/sops/sops_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/assert" ) func read(filename string) ([]byte, error) { @@ -16,6 +17,105 @@ func read(filename string) ([]byte, error) { return existingContent, nil } +func TestUsesAgeEncryption(t *testing.T) { + tests := []struct { + name string + content []byte + format Format + want bool + }{ + { + name: "JSON with age keys", + format: JSON, + content: []byte(`{"sops":{"age":[{"recipient":"age1xxx","enc":"---BEGIN---"}],"kms":null}}`), + want: true, + }, + { + name: "JSON with null age", + format: JSON, + content: []byte(`{"sops":{"age":null,"kms":[{"arn":"arn:aws:kms:..."}]}}`), + want: false, + }, + { + name: "JSON with empty age array", + format: JSON, + content: []byte(`{"sops":{"age":[]}}`), + want: false, + }, + { + name: "YAML with age keys", + format: YAML, + content: []byte("sops:\n age:\n - recipient: age1xxx\n enc: '---'\n"), + want: true, + }, + { + name: "YAML with no age key", + format: YAML, + content: []byte("sops:\n kms:\n - arn: arn:aws:kms:...\n"), + want: false, + }, + { + name: "YAML with empty age list", + format: YAML, + content: []byte("sops:\n age: []\n"), + want: false, + }, + { + name: "dotenv with age metadata", + format: DOTENV, + content: []byte("KEY=ENC[...]\nsops_age__list_0__map_recipient=age1xxx\n"), + want: true, + }, + { + name: "dotenv without age metadata", + format: DOTENV, + content: []byte("KEY=ENC[...]\nsops_kms__list_0__map_arn=arn:aws:kms:...\n"), + want: false, + }, + { + name: "binary (JSON envelope) with age keys", + format: BINARY, + content: []byte(`{"data":"ENC[...]","sops":{"age":[{"recipient":"age1xxx","enc":"---"}]}}`), + want: true, + }, + { + name: "binary (JSON envelope) without age keys", + format: BINARY, + content: []byte(`{"data":"ENC[...]","sops":{"age":null,"kms":[{"arn":"arn:aws:kms:..."}]}}`), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + secret := EncryptedSopsSecret{Content: tt.content, Format: tt.format, Hash: "testhash"} + assert.Equal(t, tt.want, secret.UsesAgeEncryption()) + }) + } +} + +func TestUsesAgeEncryption_RealFiles(t *testing.T) { + cases := []struct { + file string + format Format + }{ + {"testsecret.sops.json", JSON}, + {"testsecret.sops.yaml", YAML}, + {"testsecret.sops.env", DOTENV}, + {"README.sops.binary", BINARY}, + } + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + content, err := read(tc.file) + if err != nil { + t.Fatalf("failed to read %s: %v", tc.file, err) + } + secret := EncryptedSopsSecret{Content: content, Format: tc.format, Hash: "testhash"} + assert.True(t, secret.UsesAgeEncryption(), "expected age encryption to be detected in %s", tc.file) + }) + } +} + func TestDecrypt(t *testing.T) { tests := map[Format]string{ BINARY: "README.sops.binary", diff --git a/lambda/main.go b/lambda/main.go index bcf71226..d1a7829d 100644 --- a/lambda/main.go +++ b/lambda/main.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "os" + "strings" "github.com/aws/aws-lambda-go/cfn" runtime "github.com/aws/aws-lambda-go/lambda" @@ -11,6 +13,48 @@ import ( "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) + } + if value == nil { + return fmt.Errorf("received nil value for SSM parameter %s", paramName) + } + ageKeys = append(ageKeys, *value) + } + + return os.Setenv("SOPS_AGE_KEY", strings.Join(ageKeys, "\n")) +} + 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) @@ -36,6 +80,13 @@ func HandleRequestWithClients(clients client.AwsClient, e cfn.Event) (physicalRe return props.GeneratePhysicalResourceId(), nil, secretEncryptedErr } + // Fetch SSM age keys only when the SOPS file actually uses age encryption. + if secretEncrypted.UsesAgeEncryption() { + if err := loadAgeKeysFromSSM(clients); err != nil { + return props.GeneratePhysicalResourceId(), nil, err + } + } + // Decrypt the secret input with sops secretDecrypted, secretDecryptedErr := secretEncrypted.Decrypt() if secretDecryptedErr != nil { diff --git a/lambda/main_test.go b/lambda/main_test.go index 602f29f0..cf852d6c 100644 --- a/lambda/main_test.go +++ b/lambda/main_test.go @@ -4,7 +4,9 @@ package main import ( "encoding/json" + "fmt" "os" + "strings" "testing" "time" @@ -21,11 +23,12 @@ type putSecretValueCalls map[string]interface{} type getObjectEtagCalls map[string]client.SopsS3File type getObjectCalls map[string]client.SopsS3File type MockAwsClient struct { - t *testing.T - putParameter putParameterCalls - putSecretValue putSecretValueCalls - getObjectEtag getObjectEtagCalls - getObject getObjectCalls + t *testing.T + putParameter putParameterCalls + putSecretValue putSecretValueCalls + getObjectEtag getObjectEtagCalls + getObject getObjectCalls + ssmGetParameterValues map[string]string } func (m *MockAwsClient) S3GetObject(file client.SopsS3File) ([]byte, error) { @@ -60,6 +63,17 @@ func (m *MockAwsClient) SsmPutParameter(parameterName string, parameterContent * return &ssm.PutParameterOutput{Version: 1}, nil } +func (m *MockAwsClient) SsmGetParameter(parameterName string) (*string, error) { + if m.ssmGetParameterValues == nil { + return nil, fmt.Errorf("SsmGetParameter not expected in unit tests") + } + v, ok := m.ssmGetParameterValues[parameterName] + if !ok { + return nil, fmt.Errorf("SsmGetParameter: unexpected parameter %q", parameterName) + } + return &v, nil +} + func TestHandleRequestWithClients(t *testing.T) { t.Logf("Running at: %s", time.Now().String()) // Forces re-run os.Setenv("AWS_REGION", "eu-central-1") @@ -120,3 +134,107 @@ func TestHandleRequestWithClients(t *testing.T) { }) } } + +func TestLoadAgeKeysFromSSM(t *testing.T) { + makeClients := func(params map[string]string) *MockAwsClient { + return &MockAwsClient{ + t: t, + putParameter: putParameterCalls{}, + putSecretValue: putSecretValueCalls{}, + getObjectEtag: getObjectEtagCalls{}, + getObject: getObjectCalls{}, + ssmGetParameterValues: params, + } + } + + t.Run("noop when SOPS_AGE_KEY_PARAMS is unset", func(t *testing.T) { + t.Setenv("SOPS_AGE_KEY_PARAMS", "") + t.Setenv("SOPS_AGE_KEY", "static-key") + + err := loadAgeKeysFromSSM(makeClients(nil)) + assert.NoError(t, err) + assert.Equal(t, "static-key", os.Getenv("SOPS_AGE_KEY")) + }) + + t.Run("single SSM parameter replaces SOPS_AGE_KEY when no static key", func(t *testing.T) { + t.Setenv("SOPS_AGE_KEY_PARAMS", "/my/age/key") + t.Setenv("SOPS_AGE_KEY", "") + staticAgeKey = "" + + err := loadAgeKeysFromSSM(makeClients(map[string]string{ + "/my/age/key": "AGE-SECRET-KEY-FROM-SSM", + })) + assert.NoError(t, err) + assert.Equal(t, "AGE-SECRET-KEY-FROM-SSM", os.Getenv("SOPS_AGE_KEY")) + }) + + t.Run("multiple SSM parameters are joined with newline", func(t *testing.T) { + t.Setenv("SOPS_AGE_KEY_PARAMS", "/key/one\n/key/two") + t.Setenv("SOPS_AGE_KEY", "") + staticAgeKey = "" + + err := loadAgeKeysFromSSM(makeClients(map[string]string{ + "/key/one": "AGE-KEY-ONE", + "/key/two": "AGE-KEY-TWO", + })) + assert.NoError(t, err) + got := os.Getenv("SOPS_AGE_KEY") + parts := strings.Split(got, "\n") + assert.Equal(t, []string{"AGE-KEY-ONE", "AGE-KEY-TWO"}, parts) + }) + + t.Run("static cold-start key is preserved and prepended", func(t *testing.T) { + const coldStartKey = "AGE-SECRET-KEY-COLDSTART" + staticAgeKey = coldStartKey + t.Setenv("SOPS_AGE_KEY_PARAMS", "/ssm/key") + t.Setenv("SOPS_AGE_KEY", coldStartKey) + + err := loadAgeKeysFromSSM(makeClients(map[string]string{ + "/ssm/key": "AGE-SECRET-KEY-FROM-SSM", + })) + assert.NoError(t, err) + got := os.Getenv("SOPS_AGE_KEY") + parts := strings.Split(got, "\n") + assert.Equal(t, []string{coldStartKey, "AGE-SECRET-KEY-FROM-SSM"}, parts) + }) + + t.Run("warm invocation does not accumulate SSM keys", func(t *testing.T) { + const coldStartKey = "AGE-SECRET-KEY-COLDSTART" + staticAgeKey = coldStartKey + t.Setenv("SOPS_AGE_KEY_PARAMS", "/ssm/key") + + clients := makeClients(map[string]string{"/ssm/key": "AGE-SECRET-KEY-FROM-SSM"}) + + assert.NoError(t, loadAgeKeysFromSSM(clients)) + first := os.Getenv("SOPS_AGE_KEY") + + assert.NoError(t, loadAgeKeysFromSSM(clients)) + second := os.Getenv("SOPS_AGE_KEY") + + assert.Equal(t, first, second, "warm invocation must not duplicate keys") + parts := strings.Split(second, "\n") + assert.Equal(t, []string{coldStartKey, "AGE-SECRET-KEY-FROM-SSM"}, parts) + }) + + t.Run("blank lines in SOPS_AGE_KEY_PARAMS are skipped", func(t *testing.T) { + t.Setenv("SOPS_AGE_KEY_PARAMS", "\n/real/key\n\n") + t.Setenv("SOPS_AGE_KEY", "") + staticAgeKey = "" + + err := loadAgeKeysFromSSM(makeClients(map[string]string{ + "/real/key": "AGE-REAL-KEY", + })) + assert.NoError(t, err) + assert.Equal(t, "AGE-REAL-KEY", os.Getenv("SOPS_AGE_KEY")) + }) + + t.Run("SSM fetch error is propagated", func(t *testing.T) { + t.Setenv("SOPS_AGE_KEY_PARAMS", "/bad/param") + t.Setenv("SOPS_AGE_KEY", "") + staticAgeKey = "" + + err := loadAgeKeysFromSSM(makeClients(map[string]string{})) + assert.Error(t, err) + assert.Contains(t, err.Error(), "/bad/param") + }) +} diff --git a/src/SopsSync.ts b/src/SopsSync.ts index 39e22d21..18e9829e 100644 --- a/src/SopsSync.ts +++ b/src/SopsSync.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { Annotations, + ArnFormat, CustomResource, Duration, FileSystem, @@ -21,6 +22,7 @@ import { Code, Runtime, SingletonFunction } from 'aws-cdk-lib/aws-lambda'; import { RetentionDays, ILogGroup } from 'aws-cdk-lib/aws-logs'; import { Asset } from 'aws-cdk-lib/aws-s3-assets'; import { ISecret } from 'aws-cdk-lib/aws-secretsmanager'; +import { IStringParameter } from 'aws-cdk-lib/aws-ssm'; import { Construct } from 'constructs'; import { SopsSyncResourcePropertys } from './LambdaInterface'; @@ -225,6 +227,7 @@ export interface SopsSyncProviderProps { export class SopsSyncProvider extends SingletonFunction implements IGrantable { private sopsAgeKeys: SecretValue[]; + private sopsAgeKeyParams: string[]; constructor(scope: Construct, id?: string, props?: SopsSyncProviderProps) { if (id === undefined) { @@ -250,6 +253,9 @@ export class SopsSyncProvider extends SingletonFunction implements IGrantable { this.sopsAgeKeys.map((secret) => secret.unsafeUnwrap()) ?? [] ).join('\n'), }), + SOPS_AGE_KEY_PARAMS: Lazy.string({ + produce: () => this.sopsAgeKeyParams.join('\n'), + }), }, vpc: props?.vpc, vpcSubnets: props?.vpcSubnets, @@ -260,11 +266,54 @@ export class SopsSyncProvider extends SingletonFunction implements IGrantable { logGroup: props?.logGroup, }); this.sopsAgeKeys = []; + this.sopsAgeKeyParams = []; } public addAgeKey(key: SecretValue) { this.sopsAgeKeys.push(key); } + + /** + * Configure the Lambda to fetch an age private key from an SSM Parameter + * Store SecureString at runtime, rather than injecting it as a plaintext + * environment variable at synthesis time. + * + * The KMS key used to encrypt the SecureString is required: storing an age + * private key without envelope encryption is considered insecure. + * + * The Lambda is automatically granted `ssm:GetParameter` on the parameter + * and `kms:Decrypt` on the encryption key. + * + * @param param Parameter name string (e.g. '/sops/age/key') or an + * `IStringParameter` reference from `aws-cdk-lib/aws-ssm`. + * @param encryptionKey KMS key used to encrypt the SecureString parameter. + */ + public addAgeKeyFromSsmParameter( + param: string | IStringParameter, + encryptionKey: IKey, + ): void { + const parameterName = + typeof param === 'string' ? param : param.parameterName; + this.sopsAgeKeyParams.push(parameterName); + + const paramArn = Stack.of(this).formatArn({ + service: 'ssm', + resource: 'parameter', + resourceName: parameterName.startsWith('/') + ? parameterName.slice(1) + : parameterName, + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }); + + this.addToRolePolicy( + new PolicyStatement({ + actions: ['ssm:GetParameter'], + resources: [paramArn], + }), + ); + + encryptionKey.grantDecrypt(this); + } } /** diff --git a/test/__snapshots__/PARAMETER.integ.snapshot.json b/test/__snapshots__/PARAMETER.integ.snapshot.json index 90346d1d..f11614a5 100644 --- a/test/__snapshots__/PARAMETER.integ.snapshot.json +++ b/test/__snapshots__/PARAMETER.integ.snapshot.json @@ -143,7 +143,8 @@ }, "Environment": { "Variables": { - "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3" + "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3", + "SOPS_AGE_KEY_PARAMS": "" } }, "Handler": "bootstrap", diff --git a/test/__snapshots__/PARAMETER_MULTI.integ.snapshot.json b/test/__snapshots__/PARAMETER_MULTI.integ.snapshot.json index 38d06717..d9e78330 100644 --- a/test/__snapshots__/PARAMETER_MULTI.integ.snapshot.json +++ b/test/__snapshots__/PARAMETER_MULTI.integ.snapshot.json @@ -539,7 +539,8 @@ }, "Environment": { "Variables": { - "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3" + "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3", + "SOPS_AGE_KEY_PARAMS": "" } }, "Handler": "bootstrap", diff --git a/test/__snapshots__/SECRET.integ.snapshot.json b/test/__snapshots__/SECRET.integ.snapshot.json index f8982115..9220db55 100644 --- a/test/__snapshots__/SECRET.integ.snapshot.json +++ b/test/__snapshots__/SECRET.integ.snapshot.json @@ -178,7 +178,8 @@ }, "Environment": { "Variables": { - "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3" + "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3", + "SOPS_AGE_KEY_PARAMS": "" } }, "Handler": "bootstrap", diff --git a/test/secret.test.ts b/test/secret.test.ts index 7fe6eb70..77a6db4c 100644 --- a/test/secret.test.ts +++ b/test/secret.test.ts @@ -10,6 +10,7 @@ import { import { Key } from 'aws-cdk-lib/aws-kms'; import { Function, InlineCode, Runtime } from 'aws-cdk-lib/aws-lambda'; import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; import { SopsSecret, SopsSyncProvider, @@ -743,3 +744,178 @@ test('Custom LogGroup', () => { RetentionInDays: 90, }); }); + +test('Age Key from SSM Parameter string', () => { + const app = new App(); + const stack = new Stack(app, 'SecretIntegration'); + const cmk = Key.fromKeyArn( + stack, + 'SsmCmk', + 'arn:aws:kms:us-east-1:111122223333:key/aaaabbbb-1234-5678-abcd-111122223333', + ); + + const provider = new SopsSyncProvider(stack, 'Provider'); + provider.addAgeKeyFromSsmParameter('/sops/age/private-key', cmk); + new SopsSecret(stack, 'SopsSecret', { + sopsFilePath: 'test-secrets/yaml/sopsfile.enc-kms.yaml', + sopsProvider: provider, + }); + + const template = Template.fromStack(stack); + + // SOPS_AGE_KEY_PARAMS env var must be set on the Lambda + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: Match.objectLike({ + Variables: Match.objectLike({ + SOPS_AGE_KEY_PARAMS: '/sops/age/private-key', + }), + }), + }); + + // Lambda role must have ssm:GetParameter for the parameter + template.hasResource('AWS::IAM::Policy', { + Properties: Match.objectLike({ + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'ssm:GetParameter', + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':ssm:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':parameter/sops/age/private-key', + ], + ], + }, + }), + ]), + }), + }), + }); + + // Lambda role must have kms:Decrypt for the encryption key + template.hasResource('AWS::IAM::Policy', { + Properties: Match.objectLike({ + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'kms:Decrypt', + Effect: 'Allow', + Resource: + 'arn:aws:kms:us-east-1:111122223333:key/aaaabbbb-1234-5678-abcd-111122223333', + }), + ]), + }), + }), + }); +}); + +test('Age Key from SSM IStringParameter reference', () => { + const app = new App(); + const stack = new Stack(app, 'SecretIntegration'); + const cmk = Key.fromKeyArn( + stack, + 'SsmCmk', + 'arn:aws:kms:us-east-1:111122223333:key/aaaabbbb-1234-5678-abcd-111122223333', + ); + + const provider = new SopsSyncProvider(stack, 'Provider'); + const keyParam = StringParameter.fromStringParameterName( + stack, + 'AgeKeyParam', + '/sops/age/private-key', + ); + provider.addAgeKeyFromSsmParameter(keyParam, cmk); + new SopsSecret(stack, 'SopsSecret', { + sopsFilePath: 'test-secrets/yaml/sopsfile.enc-kms.yaml', + sopsProvider: provider, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', { + Environment: Match.objectLike({ + Variables: Match.objectLike({ + SOPS_AGE_KEY_PARAMS: '/sops/age/private-key', + }), + }), + }); +}); + +test('Age Key from SSM combined with static age key', () => { + const app = new App(); + const stack = new Stack(app, 'SecretIntegration'); + const cmk = Key.fromKeyArn( + stack, + 'SsmCmk', + 'arn:aws:kms:us-east-1:111122223333:key/aaaabbbb-1234-5678-abcd-111122223333', + ); + + const provider = new SopsSyncProvider(stack, 'Provider'); + provider.addAgeKey(SecretValue.unsafePlainText('STATIC-KEY')); + provider.addAgeKeyFromSsmParameter('/sops/age/private-key', cmk); + + new SopsSecret(stack, 'SopsSecret', { + sopsFilePath: 'test-secrets/yaml/sopsfile.enc-kms.yaml', + sopsProvider: provider, + }); + + // Both env vars must be set + Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', { + Environment: Match.objectLike({ + Variables: Match.objectLike({ + SOPS_AGE_KEY: 'STATIC-KEY', + SOPS_AGE_KEY_PARAMS: '/sops/age/private-key', + }), + }), + }); +}); + +test('Age Key from SSM Parameter without leading slash gets correct ARN', () => { + const app = new App(); + const stack = new Stack(app, 'SecretIntegration'); + const cmk = Key.fromKeyArn( + stack, + 'SsmCmk', + 'arn:aws:kms:us-east-1:111122223333:key/aaaabbbb-1234-5678-abcd-111122223333', + ); + + const provider = new SopsSyncProvider(stack, 'Provider'); + provider.addAgeKeyFromSsmParameter('sops/age/private-key', cmk); + new SopsSecret(stack, 'SopsSecret', { + sopsFilePath: 'test-secrets/yaml/sopsfile.enc-kms.yaml', + sopsProvider: provider, + }); + + Template.fromStack(stack).hasResource('AWS::IAM::Policy', { + Properties: Match.objectLike({ + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'ssm:GetParameter', + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':ssm:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':parameter/sops/age/private-key', + ], + ], + }, + }), + ]), + }), + }), + }); +});