/*
Package kms contains an implementation of the github.com/getsops/sops/v3.MasterKey
interface that encrypts and decrypts the data key using AWS KMS with the SDK
for Go V2.
*/
package kms // import "github.com/getsops/sops/v3/kms"

import (
	"context"
	"encoding/base64"
	"fmt"
	"net/http"
	"os"
	"regexp"
	"sort"
	"strings"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials"
	"github.com/aws/aws-sdk-go-v2/service/kms"
	"github.com/aws/aws-sdk-go-v2/service/sts"
	"github.com/sirupsen/logrus"

	"github.com/getsops/sops/v3/logging"
)

const (
	// arnRegex matches an AWS ARN, for example:
	// "arn:aws:kms:us-west-2:107501996527:key/612d5f0p-p1l3-45e6-aca6-a5b005693a48".
	arnRegex = `^arn:aws[\w-]*:kms:(.+):[0-9]+:(key|alias)/.+$`
	// stsSessionRegex matches an AWS STS session name, for example:
	// "john_s", "sops@42WQm042".
	stsSessionRegex = "[^a-zA-Z0-9=,.@-_]+"
	// roleSessionNameLengthLimit is the AWS role session name length limit.
	roleSessionNameLengthLimit = 64
	// kmsTTL is the duration after which a MasterKey requires rotation.
	kmsTTL = time.Hour * 24 * 30 * 6
	// KeyTypeIdentifier is the string used to identify an AWS KMS MasterKey.
	KeyTypeIdentifier = "kms"
)

var (
	// log is the global logger for any AWS KMS MasterKey.
	log *logrus.Logger
	// osHostname returns the hostname as reported by the kernel.
	osHostname = os.Hostname
)

func init() {
	log = logging.NewLogger("AWSKMS")
}

// MasterKey is an AWS KMS key used to encrypt and decrypt SOPS' data key using
// AWS SDK for Go V2.
type MasterKey struct {
	// Arn associated with the AWS KMS key.
	Arn string
	// Role ARN used to assume a role through AWS STS.
	Role string
	// EncryptedKey stores the data key in it's encrypted form.
	EncryptedKey string
	// CreationDate is when this MasterKey was created.
	CreationDate time.Time
	// EncryptionContext provides additional context about the data key.
	// Ref: https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#encrypt_context
	EncryptionContext map[string]*string
	// AwsProfile is the profile to use for loading configuration and credentials.
	// Ref: https://aws.github.io/aws-sdk-go-v2/docs/configuring-sdk/#specifying-profiles
	AwsProfile string

	// credentialsProvider is used to configure the AWS client config with
	// credentials. It can be injected by a (local) keyservice.KeyServiceServer
	// using CredentialsProvider.ApplyToMasterKey. If nil, the default client is used
	// which utilizes runtime environmental values.
	credentialsProvider aws.CredentialsProvider
	// baseEndpoint can be used to override the endpoint the AWS client resolves
	// to by default. This is mostly used for testing purposes as it can not be
	// injected using e.g. an environment variable. The field is not publicly
	// exposed, nor configurable.
	baseEndpoint string
	// httpClient is used to override the default HTTP client used by the AWS client.
	httpClient *http.Client
}

// NewMasterKey creates a new MasterKey from an ARN, role and context, setting
// the creation date to the current date.
func NewMasterKey(arn string, role string, context map[string]*string) *MasterKey {
	return &MasterKey{
		Arn:               arn,
		Role:              role,
		EncryptionContext: context,
		CreationDate:      time.Now().UTC(),
	}
}

// NewMasterKeyWithProfile creates a new MasterKey from an ARN, role, context
// and awsProfile, setting the creation date to the current date.
func NewMasterKeyWithProfile(arn string, role string, context map[string]*string, awsProfile string) *MasterKey {
	k := NewMasterKey(arn, role, context)
	k.AwsProfile = awsProfile
	return k
}

// NewMasterKeyFromArn takes an ARN string and returns a new MasterKey for that
// ARN.
func NewMasterKeyFromArn(arn string, context map[string]*string, awsProfile string) *MasterKey {
	key := &MasterKey{}
	arn = strings.Replace(arn, " ", "", -1)
	key.Arn = arn
	// While ARN paths can contain '+', they cannot contain ':'.
	// Thus '+arn:' must be separating two ARNs that have been concatenated with '+'.
	// (While KMS ARN paths currently do not contain '+', I think it's better to be safe than sorry.)
	roleIndex := strings.Index(arn, "+arn:")
	if roleIndex > 0 {
		// Overwrite ARN
		key.Arn = arn[:roleIndex]
		key.Role = arn[roleIndex+1:]
	}
	key.EncryptionContext = context
	key.CreationDate = time.Now().UTC()
	key.AwsProfile = awsProfile
	return key
}

// MasterKeysFromArnString takes a comma separated list of AWS KMS ARNs, and
// returns a slice of new MasterKeys for those ARNs.
func MasterKeysFromArnString(arn string, context map[string]*string, awsProfile string) []*MasterKey {
	var keys []*MasterKey
	if arn == "" {
		return keys
	}
	for _, s := range strings.Split(arn, ",") {
		keys = append(keys, NewMasterKeyFromArn(s, context, awsProfile))
	}
	return keys
}

// ParseKMSContext takes either a KMS context map or a comma-separated list of
// KMS context key:value pairs, and returns a map.
func ParseKMSContext(in interface{}) map[string]*string {
	const nonStringValueWarning = "Encryption context contains a non-string value, context will not be used"
	out := make(map[string]*string)
	switch in := in.(type) {
	case map[string]interface{}:
		if len(in) == 0 {
			return nil
		}
		for k, v := range in {
			value, ok := v.(string)
			if !ok {
				log.Warn(nonStringValueWarning)
				return nil
			}
			out[k] = &value
		}
	case map[interface{}]interface{}:
		if len(in) == 0 {
			return nil
		}
		for k, v := range in {
			key, ok := k.(string)
			if !ok {
				log.Warn(nonStringValueWarning)
				return nil
			}
			value, ok := v.(string)
			if !ok {
				log.Warn(nonStringValueWarning)
				return nil
			}
			out[key] = &value
		}
	case string:
		if in == "" {
			return nil
		}
		for _, kv := range strings.Split(in, ",") {
			kv := strings.Split(kv, ":")
			if len(kv) != 2 {
				log.Warn(nonStringValueWarning)
				return nil
			}
			out[kv[0]] = &kv[1]
		}
	}
	return out
}

// kmsContextToString converts a dictionary into a string that can be parsed
// again with ParseKMSContext().
func kmsContextToString(in map[string]*string) string {
	if len(in) == 0 {
		return ""
	}

	// Collect the keys in a slice and compute the expected length
	keys := make([]string, 0, len(in))
	length := 0
	for key := range in {
		keys = append(keys, key)
		length += len(key) + len(*in[key]) + 2
	}

	// Sort the keys
	sort.Strings(keys)

	// Compose a comma-separated string of key-vale pairs
	var builder strings.Builder
	builder.Grow(length)
	for index, key := range keys {
		if index > 0 {
			builder.WriteString(",")
		}
		builder.WriteString(key)
		builder.WriteByte(':')
		builder.WriteString(*in[key])
	}
	return builder.String()
}

// CredentialsProvider is a wrapper around aws.CredentialsProvider used for
// authentication towards AWS KMS.
type CredentialsProvider struct {
	provider aws.CredentialsProvider
}

// NewCredentialsProvider returns a CredentialsProvider object with the provided
// aws.CredentialsProvider.
func NewCredentialsProvider(cp aws.CredentialsProvider) *CredentialsProvider {
	return &CredentialsProvider{
		provider: cp,
	}
}

// ApplyToMasterKey configures the credentials on the provided key.
func (c CredentialsProvider) ApplyToMasterKey(key *MasterKey) {
	key.credentialsProvider = c.provider
}

// HTTPClient is a wrapper around http.Client used for configuring the
// AWS KMS client.
type HTTPClient struct {
	hc *http.Client
}

// NewHTTPClient creates a new HTTPClient with the provided http.Client.
func NewHTTPClient(hc *http.Client) *HTTPClient {
	return &HTTPClient{hc: hc}
}

// ApplyToMasterKey configures the HTTP client on the provided key.
func (h HTTPClient) ApplyToMasterKey(key *MasterKey) {
	key.httpClient = h.hc
}

// Encrypt takes a SOPS data key, encrypts it with KMS and stores the result
// in the EncryptedKey field.
//
// Consider using EncryptContext instead.
func (key *MasterKey) Encrypt(dataKey []byte) error {
	return key.EncryptContext(context.Background(), dataKey)
}

// EncryptContext takes a SOPS data key, encrypts it with KMS and stores the result
// in the EncryptedKey field.
func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error {
	cfg, err := key.createKMSConfig(ctx)
	if err != nil {
		log.WithField("arn", key.Arn).Info("Encryption failed")
		return err
	}
	client := key.createClient(cfg)
	input := &kms.EncryptInput{
		KeyId:             &key.Arn,
		Plaintext:         dataKey,
		EncryptionContext: stringPointerToStringMap(key.EncryptionContext),
	}
	out, err := client.Encrypt(ctx, input)
	if err != nil {
		log.WithField("arn", key.Arn).Info("Encryption failed")
		return fmt.Errorf("failed to encrypt sops data key with AWS KMS: %w", err)
	}
	key.EncryptedKey = base64.StdEncoding.EncodeToString(out.CiphertextBlob)
	log.WithField("arn", key.Arn).Info("Encryption succeeded")
	return nil
}

// EncryptIfNeeded encrypts the provided SOPS data key, if it has not been
// encrypted yet.
func (key *MasterKey) EncryptIfNeeded(dataKey []byte) error {
	if key.EncryptedKey == "" {
		return key.Encrypt(dataKey)
	}
	return nil
}

// EncryptedDataKey returns the encrypted data key this master key holds.
func (key *MasterKey) EncryptedDataKey() []byte {
	return []byte(key.EncryptedKey)
}

// SetEncryptedDataKey sets the encrypted data key for this master key.
func (key *MasterKey) SetEncryptedDataKey(enc []byte) {
	key.EncryptedKey = string(enc)
}

// Decrypt decrypts the EncryptedKey with a newly created AWS KMS config, and
// returns the result.
//
// Consider using DecryptContext instead.
func (key *MasterKey) Decrypt() ([]byte, error) {
	return key.DecryptContext(context.Background())
}

// DecryptContext decrypts the EncryptedKey with a newly created AWS KMS config, and
// returns the result.
func (key *MasterKey) DecryptContext(ctx context.Context) ([]byte, error) {
	k, err := base64.StdEncoding.DecodeString(key.EncryptedKey)
	if err != nil {
		log.WithField("arn", key.Arn).Info("Decryption failed")
		return nil, fmt.Errorf("error base64-decoding encrypted data key: %s", err)
	}
	cfg, err := key.createKMSConfig(ctx)
	if err != nil {
		log.WithField("arn", key.Arn).Info("Decryption failed")
		return nil, err
	}
	client := key.createClient(cfg)
	input := &kms.DecryptInput{
		KeyId:             &key.Arn,
		CiphertextBlob:    k,
		EncryptionContext: stringPointerToStringMap(key.EncryptionContext),
	}
	decrypted, err := client.Decrypt(ctx, input)
	if err != nil {
		log.WithField("arn", key.Arn).Info("Decryption failed")
		return nil, fmt.Errorf("failed to decrypt sops data key with AWS KMS: %w", err)
	}
	log.WithField("arn", key.Arn).Info("Decryption succeeded")
	return decrypted.Plaintext, nil
}

// NeedsRotation returns whether the data key needs to be rotated or not.
func (key *MasterKey) NeedsRotation() bool {
	return time.Since(key.CreationDate) > kmsTTL
}

// ToString converts the key to a string representation.
func (key *MasterKey) ToString() string {
	arnRole := key.Arn
	if key.Role != "" {
		arnRole = fmt.Sprintf("%s+%s", key.Arn, key.Role)
	}
	context := kmsContextToString(key.EncryptionContext)
	if key.AwsProfile != "" {
		return fmt.Sprintf("%s|%s|%s", arnRole, context, key.AwsProfile)
	}
	if context != "" {
		return fmt.Sprintf("%s|%s", arnRole, context)
	}
	return arnRole
}

// ToMap converts the MasterKey to a map for serialization purposes.
func (key MasterKey) ToMap() map[string]interface{} {
	out := make(map[string]interface{})
	out["arn"] = key.Arn
	if key.Role != "" {
		out["role"] = key.Role
	}
	out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339)
	out["enc"] = key.EncryptedKey
	if key.EncryptionContext != nil {
		outcontext := make(map[string]string)
		for k, v := range key.EncryptionContext {
			outcontext[k] = *v
		}
		out["context"] = outcontext
	}
	return out
}

// TypeToIdentifier returns the string identifier for the MasterKey type.
func (key *MasterKey) TypeToIdentifier() string {
	return KeyTypeIdentifier
}

// createKMSConfig returns an AWS config with the credentialsProvider of the
// MasterKey, or the default configuration sources.
func (key MasterKey) createKMSConfig(ctx context.Context) (*aws.Config, error) {
	re := regexp.MustCompile(arnRegex)
	matches := re.FindStringSubmatch(key.Arn)
	if matches == nil {
		return nil, fmt.Errorf("no valid ARN found in '%s'", key.Arn)
	}
	region := matches[1]

	cfg, err := config.LoadDefaultConfig(ctx, func(lo *config.LoadOptions) error {
		// Use the credentialsProvider if present, otherwise default to reading credentials
		// from the environment.
		if key.credentialsProvider != nil {
			lo.Credentials = key.credentialsProvider
		}
		if key.AwsProfile != "" {
			lo.SharedConfigProfile = key.AwsProfile
		}
		lo.Region = region
		if key.httpClient != nil {
			lo.HTTPClient = key.httpClient
		}
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("could not load AWS config: %w", err)
	}

	if key.Role != "" {
		return key.createSTSConfig(ctx, &cfg)
	}
	return &cfg, nil
}

// createClient creates a new AWS KMS client with the provided config.
func (key MasterKey) createClient(config *aws.Config) *kms.Client {
	return kms.NewFromConfig(*config, func(o *kms.Options) {
		if key.baseEndpoint != "" {
			o.BaseEndpoint = aws.String(key.baseEndpoint)
		}
	})
}

// createSTSConfig uses AWS STS to assume a role and returns a config
// configured with that role's credentials. It returns an error if
// it fails to construct a session name, or assume the role.
func (key MasterKey) createSTSConfig(ctx context.Context, config *aws.Config) (*aws.Config, error) {
	name, err := stsSessionName()
	if err != nil {
		return nil, err
	}
	input := &sts.AssumeRoleInput{
		RoleArn:         &key.Role,
		RoleSessionName: &name,
	}

	client := sts.NewFromConfig(*config)
	out, err := client.AssumeRole(ctx, input)
	if err != nil {
		return nil, fmt.Errorf("failed to assume role '%s': %w", key.Role, err)
	}

	config.Credentials = credentials.NewStaticCredentialsProvider(*out.Credentials.AccessKeyId,
		*out.Credentials.SecretAccessKey, *out.Credentials.SessionToken,
	)
	return config, nil
}

// stsSessionName returns the name for the STS session in the format of
// `sops@<hostname>`. It sanitizes the hostname with stsSessionRegex, and
// truncates to roleSessionNameLengthLimit when it exceeds the limit.
func stsSessionName() (string, error) {
	hostname, err := osHostname()
	if err != nil {
		return "", fmt.Errorf("failed to construct STS session name: %w", err)
	}

	re := regexp.MustCompile(stsSessionRegex)
	sanitizedHostname := re.ReplaceAllString(hostname, "")

	name := "sops@" + sanitizedHostname
	if len(name) >= roleSessionNameLengthLimit {
		name = name[:roleSessionNameLengthLimit]
	}
	return name, nil
}

func stringPointerToStringMap(in map[string]*string) map[string]string {
	var out = make(map[string]string)
	for k, v := range in {
		if v == nil {
			continue
		}
		out[k] = *v
	}
	return out
}
