ipn/store/awsstore: allow providing a KMS key

Implements a KMS input for AWS parameter to support encrypting Tailscale
state

Fixes #14765

Change-Id: I39c0fae4bfd60a9aec17c5ea6a61d0b57143d4ba
Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Signed-off-by: Lee Briggs <lee@leebriggs.co.uk>
This commit is contained in:
Lee Briggs 2025-01-24 11:15:28 -08:00 committed by Brad Fitzpatrick
parent ef906763ee
commit 74d7d8a77b
4 changed files with 157 additions and 43 deletions

View File

@ -10,7 +10,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/url"
"regexp" "regexp"
"strings"
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/arn" "github.com/aws/aws-sdk-go-v2/aws/arn"
@ -28,6 +30,14 @@ const (
var parameterNameRx = regexp.MustCompile(parameterNameRxStr) var parameterNameRx = regexp.MustCompile(parameterNameRxStr)
// Option defines a functional option type for configuring awsStore.
type Option func(*storeOptions)
// storeOptions holds optional settings for creating a new awsStore.
type storeOptions struct {
kmsKey string
}
// awsSSMClient is an interface allowing us to mock the couple of // awsSSMClient is an interface allowing us to mock the couple of
// API calls we are leveraging with the AWSStore provider // API calls we are leveraging with the AWSStore provider
type awsSSMClient interface { type awsSSMClient interface {
@ -46,6 +56,10 @@ type awsStore struct {
ssmClient awsSSMClient ssmClient awsSSMClient
ssmARN arn.ARN ssmARN arn.ARN
// kmsKey is optional. If empty, the parameter is stored in plaintext.
// If non-empty, the parameter is encrypted with this KMS key.
kmsKey string
memory mem.Store memory mem.Store
} }
@ -57,30 +71,80 @@ type awsStore struct {
// Tailscaled to only only store new state in-memory and // Tailscaled to only only store new state in-memory and
// restarting Tailscaled can fail until you delete your state // restarting Tailscaled can fail until you delete your state
// from the AWS Parameter Store. // from the AWS Parameter Store.
func New(_ logger.Logf, ssmARN string) (ipn.StateStore, error) { //
return newStore(ssmARN, nil) // If you want to specify an optional KMS key,
// pass one or more Option objects, e.g. awsstore.WithKeyID("alias/my-key").
func New(_ logger.Logf, ssmARN string, opts ...Option) (ipn.StateStore, error) {
// Apply all options to an empty storeOptions
var so storeOptions
for _, opt := range opts {
opt(&so)
}
return newStore(ssmARN, so, nil)
}
// WithKeyID sets the KMS key to be used for encryption. It can be
// a KeyID, an alias ("alias/my-key"), or a full ARN.
//
// If kmsKey is empty, the Option is a no-op.
func WithKeyID(kmsKey string) Option {
return func(o *storeOptions) {
o.kmsKey = kmsKey
}
}
// ParseARNAndOpts parses an ARN and optional URL-encoded parameters
// from arg.
func ParseARNAndOpts(arg string) (ssmARN string, opts []Option, err error) {
ssmARN = arg
// Support optional ?url-encoded-parameters.
if s, q, ok := strings.Cut(arg, "?"); ok {
ssmARN = s
q, err := url.ParseQuery(q)
if err != nil {
return "", nil, err
}
for k := range q {
switch k {
default:
return "", nil, fmt.Errorf("unknown arn option parameter %q", k)
case "kmsKey":
// We allow an ARN, a key ID, or an alias name for kmsKeyID.
// If it doesn't look like an ARN and doesn't have a '/',
// prepend "alias/" for KMS alias references.
kmsKey := q.Get(k)
if kmsKey != "" &&
!strings.Contains(kmsKey, "/") &&
!strings.HasPrefix(kmsKey, "arn:") {
kmsKey = "alias/" + kmsKey
}
if kmsKey != "" {
opts = append(opts, WithKeyID(kmsKey))
}
}
}
}
return ssmARN, opts, nil
} }
// newStore is NewStore, but for tests. If client is non-nil, it's // newStore is NewStore, but for tests. If client is non-nil, it's
// used instead of making one. // used instead of making one.
func newStore(ssmARN string, client awsSSMClient) (ipn.StateStore, error) { func newStore(ssmARN string, so storeOptions, client awsSSMClient) (ipn.StateStore, error) {
s := &awsStore{ s := &awsStore{
ssmClient: client, ssmClient: client,
kmsKey: so.kmsKey,
} }
var err error var err error
// Parse the ARN
if s.ssmARN, err = arn.Parse(ssmARN); err != nil { if s.ssmARN, err = arn.Parse(ssmARN); err != nil {
return nil, fmt.Errorf("unable to parse the ARN correctly: %v", err) return nil, fmt.Errorf("unable to parse the ARN correctly: %v", err)
} }
// Validate the ARN corresponds to the SSM service
if s.ssmARN.Service != "ssm" { if s.ssmARN.Service != "ssm" {
return nil, fmt.Errorf("invalid service %q, expected 'ssm'", s.ssmARN.Service) return nil, fmt.Errorf("invalid service %q, expected 'ssm'", s.ssmARN.Service)
} }
// Validate the ARN corresponds to a parameter store resource
if !parameterNameRx.MatchString(s.ssmARN.Resource) { if !parameterNameRx.MatchString(s.ssmARN.Resource) {
return nil, fmt.Errorf("invalid resource %q, expected to match %v", s.ssmARN.Resource, parameterNameRxStr) return nil, fmt.Errorf("invalid resource %q, expected to match %v", s.ssmARN.Resource, parameterNameRxStr)
} }
@ -96,12 +160,11 @@ func newStore(ssmARN string, client awsSSMClient) (ipn.StateStore, error) {
s.ssmClient = ssm.NewFromConfig(cfg) s.ssmClient = ssm.NewFromConfig(cfg)
} }
// Hydrate cache with the potentially current state // Preload existing state, if any
if err := s.LoadState(); err != nil { if err := s.LoadState(); err != nil {
return nil, err return nil, err
} }
return s, nil return s, nil
} }
// LoadState attempts to read the state from AWS SSM parameter store key. // LoadState attempts to read the state from AWS SSM parameter store key.
@ -172,15 +235,21 @@ func (s *awsStore) persistState() error {
// which is free. However, if it exceeds 4kb it switches the parameter to advanced tiering // which is free. However, if it exceeds 4kb it switches the parameter to advanced tiering
// doubling the capacity to 8kb per the following docs: // doubling the capacity to 8kb per the following docs:
// https://aws.amazon.com/about-aws/whats-new/2019/08/aws-systems-manager-parameter-store-announces-intelligent-tiering-to-enable-automatic-parameter-tier-selection/ // https://aws.amazon.com/about-aws/whats-new/2019/08/aws-systems-manager-parameter-store-announces-intelligent-tiering-to-enable-automatic-parameter-tier-selection/
_, err = s.ssmClient.PutParameter( in := &ssm.PutParameterInput{
context.TODO(), Name: aws.String(s.ParameterName()),
&ssm.PutParameterInput{ Value: aws.String(string(bs)),
Name: aws.String(s.ParameterName()), Overwrite: aws.Bool(true),
Value: aws.String(string(bs)), Tier: ssmTypes.ParameterTierIntelligentTiering,
Overwrite: aws.Bool(true), Type: ssmTypes.ParameterTypeSecureString,
Tier: ssmTypes.ParameterTierIntelligentTiering, }
Type: ssmTypes.ParameterTypeSecureString,
}, // If kmsKey is specified, encrypt with that key
) // NOTE: this input allows any alias, keyID or ARN
// If this isn't specified, AWS will use the default KMS key
if s.kmsKey != "" {
in.KeyId = aws.String(s.kmsKey)
}
_, err = s.ssmClient.PutParameter(context.TODO(), in)
return err return err
} }

View File

@ -1,18 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux || ts_omit_aws
package awsstore
import (
"fmt"
"runtime"
"tailscale.com/ipn"
"tailscale.com/types/logger"
)
func New(logger.Logf, string) (ipn.StateStore, error) {
return nil, fmt.Errorf("AWS store is not supported on %v", runtime.GOOS)
}

View File

@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
//go:build linux //go:build linux && !ts_omit_aws
package awsstore package awsstore
@ -65,7 +65,11 @@ func TestNewAWSStore(t *testing.T) {
Resource: "parameter/foo", Resource: "parameter/foo",
} }
s, err := newStore(storeParameterARN.String(), mc) opts := storeOptions{
kmsKey: "arn:aws:kms:eu-west-1:123456789:key/MyCustomKey",
}
s, err := newStore(storeParameterARN.String(), opts, mc)
if err != nil { if err != nil {
t.Fatalf("creating aws store failed: %v", err) t.Fatalf("creating aws store failed: %v", err)
} }
@ -73,7 +77,7 @@ func TestNewAWSStore(t *testing.T) {
// Build a brand new file store and check that both IDs written // Build a brand new file store and check that both IDs written
// above are still there. // above are still there.
s2, err := newStore(storeParameterARN.String(), mc) s2, err := newStore(storeParameterARN.String(), opts, mc)
if err != nil { if err != nil {
t.Fatalf("creating second aws store failed: %v", err) t.Fatalf("creating second aws store failed: %v", err)
} }
@ -162,3 +166,54 @@ func testStoreSemantics(t *testing.T, store ipn.StateStore) {
} }
} }
} }
func TestParseARNAndOpts(t *testing.T) {
tests := []struct {
name string
arg string
wantARN string
wantKey string
}{
{
name: "no-key",
arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
},
{
name: "custom-key",
arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam?kmsKey=alias/MyCustomKey",
wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
wantKey: "alias/MyCustomKey",
},
{
name: "bare-name",
arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam?kmsKey=Bare",
wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
wantKey: "alias/Bare",
},
{
name: "arn-arg",
arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam?kmsKey=arn:foo",
wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
wantKey: "arn:foo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
arn, opts, err := ParseARNAndOpts(tt.arg)
if err != nil {
t.Fatalf("New: %v", err)
}
if arn != tt.wantARN {
t.Errorf("ARN = %q; want %q", arn, tt.wantARN)
}
var got storeOptions
for _, opt := range opts {
opt(&got)
}
if got.kmsKey != tt.wantKey {
t.Errorf("kmsKey = %q; want %q", got.kmsKey, tt.wantKey)
}
})
}
}

View File

@ -6,7 +6,9 @@
package store package store
import ( import (
"tailscale.com/ipn"
"tailscale.com/ipn/store/awsstore" "tailscale.com/ipn/store/awsstore"
"tailscale.com/types/logger"
) )
func init() { func init() {
@ -14,5 +16,11 @@ func init() {
} }
func registerAWSStore() { func registerAWSStore() {
Register("arn:", awsstore.New) Register("arn:", func(logf logger.Logf, arg string) (ipn.StateStore, error) {
ssmARN, opts, err := awsstore.ParseARNAndOpts(arg)
if err != nil {
return nil, err
}
return awsstore.New(logf, ssmARN, opts...)
})
} }