xref: /aosp_15_r20/external/tink/go/integration/awskms/aws_kms_client.go (revision e7b1675dde1b92d52ec075b0a92829627f2c52a5)
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