1// Copyright 2017 Google Inc. 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14// 15//////////////////////////////////////////////////////////////////////////////// 16 17package awskms 18 19import ( 20 "encoding/csv" 21 "errors" 22 "fmt" 23 "os" 24 "regexp" 25 "strconv" 26 "strings" 27 28 "github.com/aws/aws-sdk-go/aws" 29 "github.com/aws/aws-sdk-go/aws/credentials" 30 "github.com/aws/aws-sdk-go/aws/session" 31 "github.com/aws/aws-sdk-go/service/kms" 32 "github.com/aws/aws-sdk-go/service/kms/kmsiface" 33 "github.com/google/tink/go/core/registry" 34 "github.com/google/tink/go/tink" 35) 36 37const ( 38 awsPrefix = "aws-kms://" 39) 40 41var ( 42 errCred = errors.New("invalid credential path") 43 errBadFile = errors.New("cannot open credential path") 44 errCredCSV = errors.New("malformed credential CSV file") 45) 46 47// awsClient is a wrapper around an AWS SDK provided KMS client that can 48// instantiate Tink primitives. 49type awsClient struct { 50 keyURIPrefix string 51 kms kmsiface.KMSAPI 52 encryptionContextName EncryptionContextName 53} 54 55// ClientOption is an interface for defining options that are passed to 56// [NewClientWithOptions]. 57type ClientOption interface{ set(*awsClient) error } 58 59type option func(*awsClient) error 60 61func (o option) set(a *awsClient) error { return o(a) } 62 63// WithCredentialPath instantiates the underlying AWS KMS client using the 64// credentials located at credentialPath. 65// 66// credentialPath can specify a file in CSV format as provided in the IAM 67// console or an INI-style credentials file. 68// 69// See https://docs.aws.amazon.com/cli/latest/userguide/cli-authentication-user.html#cli-authentication-user-configure-csv 70// and https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html#cli-configure-files-format. 71func WithCredentialPath(credentialPath string) ClientOption { 72 return option(func(a *awsClient) error { 73 if a.kms != nil { 74 return errors.New("WithCredentialPath option cannot be used, KMS client already set") 75 } 76 77 k, err := getKMSFromCredentialPath(a.keyURIPrefix, credentialPath) 78 if err != nil { 79 return err 80 } 81 82 a.kms = k 83 return nil 84 }) 85} 86 87// WithKMS sets the underlying AWS KMS client to kms, a preexisting AWS KMS 88// client instance. 89// 90// It's the callers responsibility to ensure that the configured region of kms 91// aligns with the region in key URIs passed to this client. Otherwise, API 92// requests will fail. 93func WithKMS(kms kmsiface.KMSAPI) ClientOption { 94 return option(func(a *awsClient) error { 95 if a.kms != nil { 96 return errors.New("WithKMS option cannot be used, KMS client already set") 97 } 98 a.kms = kms 99 return nil 100 }) 101} 102 103// EncryptionContextName specifies the name used in the EncryptionContext field 104// of EncryptInput and DecryptInput requests. See [WithEncryptionContextName] 105// for further details. 106type EncryptionContextName uint 107 108const ( 109 // AssociatedData will set the EncryptionContext name to "associatedData". 110 AssociatedData EncryptionContextName = 1 + iota 111 // LegacyAdditionalData will set the EncryptionContext name to "additionalData". 112 LegacyAdditionalData 113) 114 115var encryptionContextNames = map[EncryptionContextName]string{ 116 AssociatedData: "associatedData", 117 LegacyAdditionalData: "additionalData", 118} 119 120func (n EncryptionContextName) valid() bool { 121 _, ok := encryptionContextNames[n] 122 return ok 123} 124 125func (n EncryptionContextName) String() string { 126 if !n.valid() { 127 return "unrecognized value " + strconv.Itoa(int(n)) 128 } 129 return encryptionContextNames[n] 130} 131 132// WithEncryptionContextName sets the name which maps to the base64 encoded 133// associated data within the EncryptionContext field of EncrypInput and 134// DecryptInput requests. 135// 136// The default is [AssociatedData], which is compatible with the Tink AWS KMS 137// extensions in other languages. In older versions of this packge, before this 138// option was present, "additionalData" was hardcoded. 139// 140// This option is provided to facilitate compatibility with older ciphertexts. 141func WithEncryptionContextName(name EncryptionContextName) ClientOption { 142 return option(func(a *awsClient) error { 143 if !name.valid() { 144 return fmt.Errorf("invalid EncryptionContextName: %v", name) 145 } 146 if a.encryptionContextName != 0 { 147 return errors.New("encryptionContextName already set") 148 } 149 a.encryptionContextName = name 150 return nil 151 }) 152} 153 154// NewClientWithOptions returns a [registry.KMSClient] which wraps an AWS KMS 155// client and will handle keys whose URIs start with uriPrefix. 156// 157// By default, the client will use default credentials. 158// 159// AEAD primitives produced by this client will use [AssociatedData] when 160// serializing associated data. 161func NewClientWithOptions(uriPrefix string, opts ...ClientOption) (registry.KMSClient, error) { 162 if !strings.HasPrefix(strings.ToLower(uriPrefix), awsPrefix) { 163 return nil, fmt.Errorf("uriPrefix must start with %q, but got %q", awsPrefix, uriPrefix) 164 } 165 166 a := &awsClient{ 167 keyURIPrefix: uriPrefix, 168 } 169 170 // Process options, if any. 171 for _, opt := range opts { 172 if err := opt.set(a); err != nil { 173 return nil, fmt.Errorf("failed setting option: %v", err) 174 } 175 } 176 177 // Populate values not defined via options. 178 if a.kms == nil { 179 k, err := getKMS(uriPrefix) 180 if err != nil { 181 return nil, err 182 } 183 a.kms = k 184 } 185 if a.encryptionContextName == 0 { 186 a.encryptionContextName = AssociatedData 187 } 188 189 return a, nil 190} 191 192// NewClient returns a KMSClient backed by AWS KMS using default credentials to 193// handle keys whose URIs start with uriPrefix. 194// 195// uriPrefix must have the following format: 196// 197// aws-kms://arn:<partition>:kms:<region>:[<path>] 198// 199// See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html 200// 201// AEAD primitives produced by this client will use [LegacyAdditionalData] when 202// serializing associated data. 203// 204// Deprecated: Instead, use [NewClientWithOptions]. 205// 206// awskms.NewClientWithOptions(uriPrefix) 207func NewClient(uriPrefix string) (registry.KMSClient, error) { 208 return NewClientWithOptions(uriPrefix, WithEncryptionContextName(LegacyAdditionalData)) 209} 210 211// NewClientWithCredentials returns a KMSClient backed by AWS KMS using the given 212// credentials to handle keys whose URIs start with uriPrefix. 213// 214// uriPrefix must have the following format: 215// 216// aws-kms://arn:<partition>:kms:<region>:[<path>] 217// 218// See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html 219// 220// credentialPath can specify a file in CSV format as provided in the IAM 221// console or an INI-style credentials file. 222// 223// See https://docs.aws.amazon.com/cli/latest/userguide/cli-authentication-user.html#cli-authentication-user-configure-csv 224// and https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html#cli-configure-files-format. 225// 226// AEAD primitives produced by this client will use [LegacyAdditionalData] when 227// serializing associated data. 228// 229// Deprecated: Instead use [NewClientWithOptions] and [WithCredentialPath]. 230// 231// awskms.NewClientWithOptions(uriPrefix, awskms.WithCredentialPath(credentialPath)) 232func NewClientWithCredentials(uriPrefix string, credentialPath string) (registry.KMSClient, error) { 233 return NewClientWithOptions(uriPrefix, WithCredentialPath(credentialPath), WithEncryptionContextName(LegacyAdditionalData)) 234} 235 236// NewClientWithKMS returns a KMSClient backed by AWS KMS using the provided 237// instance of the AWS SDK KMS client. 238// 239// The caller is responsible for ensuring that the region specified in the KMS 240// client is consitent with the region specified within uriPrefix. 241// 242// uriPrefix must have the following format: 243// 244// aws-kms://arn:<partition>:kms:<region>:[<path>] 245// 246// See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html 247// 248// AEAD primitives produced by this client will use [LegacyAdditionalData] when 249// serializing associated data. 250// 251// Deprecated: Instead use [NewClientWithOptions] and [WithKMS]. 252// 253// awskms.NewClientWithOptions(uriPrefix, awskms.WithKMS(kms)) 254func NewClientWithKMS(uriPrefix string, kms kmsiface.KMSAPI) (registry.KMSClient, error) { 255 return NewClientWithOptions(uriPrefix, WithKMS(kms), WithEncryptionContextName(LegacyAdditionalData)) 256} 257 258// Supported returns true if keyURI starts with the URI prefix provided when 259// creating the client. 260func (c *awsClient) Supported(keyURI string) bool { 261 return strings.HasPrefix(keyURI, c.keyURIPrefix) 262} 263 264// GetAEAD returns an implementation of the AEAD interface which performs 265// cryptographic operations remotely via AWS KMS using keyURI. 266// 267// keyUri must be supported by this client and must have the following format: 268// 269// aws-kms://arn:<partition>:kms:<region>:<path> 270// 271// See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html 272func (c *awsClient) GetAEAD(keyURI string) (tink.AEAD, error) { 273 if !c.Supported(keyURI) { 274 return nil, fmt.Errorf("keyURI must start with prefix %s, but got %s", c.keyURIPrefix, keyURI) 275 } 276 277 uri := strings.TrimPrefix(keyURI, awsPrefix) 278 return newAWSAEAD(uri, c.kms, c.encryptionContextName), nil 279} 280 281func getKMS(uriPrefix string) (*kms.KMS, error) { 282 r, err := getRegion(uriPrefix) 283 if err != nil { 284 return nil, err 285 } 286 287 session, err := session.NewSession(&aws.Config{ 288 Region: aws.String(r), 289 }) 290 if err != nil { 291 return nil, err 292 } 293 294 return kms.New(session), nil 295} 296 297func getKMSFromCredentialPath(uriPrefix string, credentialPath string) (*kms.KMS, error) { 298 r, err := getRegion(uriPrefix) 299 if err != nil { 300 return nil, err 301 } 302 303 var creds *credentials.Credentials 304 if len(credentialPath) == 0 { 305 return nil, errCred 306 } 307 c, err := extractCredsCSV(credentialPath) 308 switch err { 309 case nil: 310 creds = credentials.NewStaticCredentialsFromCreds(*c) 311 case errBadFile, errCredCSV: 312 return nil, err 313 default: 314 // Fallback to load the credential path as .ini shared credentials. 315 creds = credentials.NewSharedCredentials(credentialPath, "default") 316 } 317 318 session, err := session.NewSession(&aws.Config{ 319 Credentials: creds, 320 Region: aws.String(r), 321 }) 322 if err != nil { 323 return nil, err 324 } 325 326 return kms.New(session), nil 327} 328 329// extractCredsCSV extracts credentials from a CSV file. 330// 331// A CSV formatted credentials file can be obtained when an AWS IAM user is 332// created through the IAM console. 333// 334// Properties of a properly formatted CSV file: 335// 336// 1. The first line consists of the headers: 337// "User name,Password,Access key ID,Secret access key,Console login link" 338// 2. The second line contains 5 comma separated values. 339func extractCredsCSV(file string) (*credentials.Value, error) { 340 f, err := os.Open(file) 341 if err != nil { 342 return nil, errBadFile 343 } 344 defer f.Close() 345 346 lines, err := csv.NewReader(f).ReadAll() 347 if err != nil { 348 return nil, err 349 } 350 351 // It is possible that the file is an AWS .ini credential file, and it can be 352 // parsed as 1-column CSV file as well. A real AWS credentials.csv is never 1 column. 353 if len(lines) > 0 && len(lines[0]) == 1 { 354 return nil, errors.New("not a valid CSV credential file") 355 } 356 357 if len(lines) < 2 { 358 return nil, errCredCSV 359 } 360 361 if len(lines[1]) < 4 { 362 return nil, errCredCSV 363 } 364 365 return &credentials.Value{ 366 AccessKeyID: lines[1][2], 367 SecretAccessKey: lines[1][3], 368 }, nil 369} 370 371// getRegion extracts the region from keyURI. 372func getRegion(keyURI string) (string, error) { 373 re1, err := regexp.Compile(`aws-kms://arn:(aws[a-zA-Z0-9-_]*):kms:([a-z0-9-]+):`) 374 if err != nil { 375 return "", err 376 } 377 r := re1.FindStringSubmatch(keyURI) 378 if len(r) != 3 { 379 return "", errors.New("extracting region from URI failed") 380 } 381 return r[2], nil 382} 383