From d3adc85886d4cc719fa3867cbffb619b233c27d8 Mon Sep 17 00:00:00 2001 From: vishalnayak Date: Tue, 5 Apr 2016 20:42:26 -0400 Subject: [PATCH] AWS EC2 instances authentication backend --- builtin/credential/aws/backend.go | 70 +++ builtin/credential/aws/backend_test.go | 68 +++ builtin/credential/aws/client.go | 48 ++ .../credential/aws/path_blacklist_roletag.go | 245 +++++++++ .../aws/path_blacklist_roletag_tidy.go | 88 ++++ .../credential/aws/path_config_certificate.go | 245 +++++++++ builtin/credential/aws/path_config_client.go | 144 ++++++ builtin/credential/aws/path_image.go | 246 +++++++++ builtin/credential/aws/path_image_tag.go | 299 +++++++++++ builtin/credential/aws/path_login.go | 483 ++++++++++++++++++ .../credential/aws/path_whitelist_identity.go | 176 +++++++ .../aws/path_whitelist_identity_tidy.go | 87 ++++ builtin/credential/aws/role_tag_hmac_key.go | 44 ++ cli/commands.go | 2 + 14 files changed, 2245 insertions(+) create mode 100644 builtin/credential/aws/backend.go create mode 100644 builtin/credential/aws/backend_test.go create mode 100644 builtin/credential/aws/client.go create mode 100644 builtin/credential/aws/path_blacklist_roletag.go create mode 100644 builtin/credential/aws/path_blacklist_roletag_tidy.go create mode 100644 builtin/credential/aws/path_config_certificate.go create mode 100644 builtin/credential/aws/path_config_client.go create mode 100644 builtin/credential/aws/path_image.go create mode 100644 builtin/credential/aws/path_image_tag.go create mode 100644 builtin/credential/aws/path_login.go create mode 100644 builtin/credential/aws/path_whitelist_identity.go create mode 100644 builtin/credential/aws/path_whitelist_identity_tidy.go create mode 100644 builtin/credential/aws/role_tag_hmac_key.go diff --git a/builtin/credential/aws/backend.go b/builtin/credential/aws/backend.go new file mode 100644 index 000000000..b9221f293 --- /dev/null +++ b/builtin/credential/aws/backend.go @@ -0,0 +1,70 @@ +package aws + +import ( + "github.com/hashicorp/vault/helper/salt" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func Factory(conf *logical.BackendConfig) (logical.Backend, error) { + b, err := Backend(conf) + if err != nil { + return nil, err + } + return b.Setup(conf) +} + +func Backend(conf *logical.BackendConfig) (*framework.Backend, error) { + salt, err := salt.NewSalt(conf.StorageView, &salt.Config{ + HashFunc: salt.SHA256Hash, + }) + if err != nil { + return nil, err + } + + var b backend + b.Salt = salt + b.Backend = &framework.Backend{ + Help: backendHelp, + + PathsSpecial: &logical.Paths{ + Unauthenticated: []string{ + "login", + }, + }, + + Paths: append([]*framework.Path{ + pathLogin(&b), + pathImage(&b), + pathListImages(&b), + pathImageTag(&b), + pathConfigClient(&b), + pathConfigCertificate(&b), + pathBlacklistRoleTag(&b), + pathListBlacklistRoleTags(&b), + pathBlacklistRoleTagTidy(&b), + pathWhitelistIdentity(&b), + pathWhitelistIdentityTidy(&b), + pathListWhitelistIdentities(&b), + }), + + AuthRenew: b.pathLoginRenew, + } + + return b.Backend, nil +} + +type backend struct { + *framework.Backend + Salt *salt.Salt +} + +const backendHelp = ` +AWS auth backend takes in a AWS EC2 instance identity document, its PKCS#7 signature +and a client created nonce to authenticates the instance with Vault. + +Authentication is backed by a preconfigured association of AMIs to Vault's policies +through 'image/' endpoint. For instances that share an AMI, an instance tag can +be created through 'image//tag'. This tag should be attached to the EC2 instance +before the instance attempts to login to Vault. +` diff --git a/builtin/credential/aws/backend_test.go b/builtin/credential/aws/backend_test.go new file mode 100644 index 000000000..861ce8dc2 --- /dev/null +++ b/builtin/credential/aws/backend_test.go @@ -0,0 +1,68 @@ +package aws + +import ( + "testing" + + "github.com/hashicorp/vault/logical" + logicaltest "github.com/hashicorp/vault/logical/testing" +) + +func TestBackend_ConfigClient(t *testing.T) { + config := logical.TestBackendConfig() + storageView := &logical.InmemStorage{} + config.StorageView = storageView + + b, err := Factory(config) + if err != nil { + t.Fatal(err) + } + + logicaltest.Test(t, logicaltest.TestCase{ + AcceptanceTest: false, + Backend: b, + Steps: []logicaltest.TestStep{}, + }) +} + +func TestBackend_parseRoleTagValue(t *testing.T) { + tag := "v1:XwuKhyyBNJc=:a=ami-fce3c696:p=root:t=3h0m0s:lhvKJAZn8kxNwmPFnyXzmphQTtbXqQe6WG6sLiIf3dQ=" + expected := roleTag{ + Version: "v1", + Nonce: "XwuKhyyBNJc=", + Policies: []string{"root"}, + MaxTTL: 10800000000000, + ImageID: "ami-fce3c696", + HMAC: "lhvKJAZn8kxNwmPFnyXzmphQTtbXqQe6WG6sLiIf3dQ=", + } + actual, err := parseRoleTagValue(tag) + if err != nil { + t.Fatalf("err: %s", err) + } + if !actual.Equal(&expected) { + t.Fatalf("err: expected:%#v \ngot: %#v\n", expected, actual) + } + + tag = "v2:XwuKhyyBNJc=:a=ami-fce3c696:p=root:t=3h0m0s:lhvKJAZn8kxNwmPFnyXzmphQTtbXqQe6WG6sLiIf3dQ=" + actual, err = parseRoleTagValue(tag) + if err == nil { + t.Fatalf("err: expected error due to invalid role tag version", err) + } + + tag = "v1:XwuKhyyBNJc=:a=ami-fce3c696:lhvKJAZn8kxNwmPFnyXzmphQTtbXqQe6WG6sLiIf3dQ=" + expected = roleTag{ + Version: "v1", + Nonce: "XwuKhyyBNJc=", + ImageID: "ami-fce3c696", + HMAC: "lhvKJAZn8kxNwmPFnyXzmphQTtbXqQe6WG6sLiIf3dQ=", + } + actual, err = parseRoleTagValue(tag) + if err != nil { + t.Fatalf("err: %s", err) + } + + tag = "v1:XwuKhyyBNJc=:p=ami-fce3c696:lhvKJAZn8kxNwmPFnyXzmphQTtbXqQe6WG6sLiIf3dQ=" + actual, err = parseRoleTagValue(tag) + if err == nil { + t.Fatalf("err: expected error due to missing image ID", err) + } +} diff --git a/builtin/credential/aws/client.go b/builtin/credential/aws/client.go new file mode 100644 index 000000000..483fe6f6a --- /dev/null +++ b/builtin/credential/aws/client.go @@ -0,0 +1,48 @@ +package aws + +import ( + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/hashicorp/go-cleanhttp" + "github.com/hashicorp/vault/logical" +) + +// getClientConfig creates a aws-sdk-go config, which is used to create +// client that can interact with AWS API. This reads out the secret key +// and access key that was configured via 'config/client' endpoint and +// uses them to create credentials required to make the AWS API calls. +func getClientConfig(s logical.Storage) (*aws.Config, error) { + // Read the configured secret key and access key + config, err := clientConfigEntry(s) + if err != nil { + return nil, err + } + if config == nil { + return nil, fmt.Errorf( + "client credentials haven't been configured. Please configure\n" + + "them at the 'config/client' endpoint") + } + + // Create the credentials required to access the API. + creds := credentials.NewStaticCredentials(config.AccessKey, config.SecretKey, "") + + // Create a config that can be used to make the API calls. + return &aws.Config{ + Credentials: creds, + Region: aws.String(config.Region), + HTTPClient: cleanhttp.DefaultClient(), + }, nil +} + +// clientEC2 creates a client to interact with AWS EC2 API. +func clientEC2(s logical.Storage) (*ec2.EC2, error) { + awsConfig, err := getClientConfig(s) + if err != nil { + return nil, err + } + return ec2.New(session.New(awsConfig)), nil +} diff --git a/builtin/credential/aws/path_blacklist_roletag.go b/builtin/credential/aws/path_blacklist_roletag.go new file mode 100644 index 000000000..dd5f42f78 --- /dev/null +++ b/builtin/credential/aws/path_blacklist_roletag.go @@ -0,0 +1,245 @@ +package aws + +import ( + "time" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathBlacklistRoleTag(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "blacklist/roletag$", + Fields: map[string]*framework.FieldSchema{ + "role_tag": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Role tag that needs be blacklisted", + }, + }, + + ExistenceCheck: b.pathBlacklistRoleTagExistenceCheck, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathBlacklistRoleTagUpdate, + logical.ReadOperation: b.pathBlacklistRoleTagRead, + logical.DeleteOperation: b.pathBlacklistRoleTagDelete, + }, + + HelpSynopsis: pathBlacklistRoleTagSyn, + HelpDescription: pathBlacklistRoleTagDesc, + } +} + +// Path to list all the blacklisted tags. +func pathListBlacklistRoleTags(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "blacklist/roletags/?", + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ListOperation: b.pathBlacklistRoleTagsList, + }, + + HelpSynopsis: pathListBlacklistRoleTagsHelpSyn, + HelpDescription: pathListBlacklistRoleTagsHelpDesc, + } +} + +// Lists all the blacklisted role tags. +func (b *backend) pathBlacklistRoleTagsList( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + tags, err := req.Storage.List("blacklist/roletag/") + if err != nil { + return nil, err + } + return logical.ListResponse(tags), nil +} + +// Establishes dichotomy of request operation between CreateOperation and UpdateOperation. +// Returning 'true' forces an UpdateOperation, CreateOperation otherwise. +// +// A role should be allowed to be blacklisted even if it was prevously blacklisted. +func (b *backend) pathBlacklistRoleTagExistenceCheck(req *logical.Request, data *framework.FieldData) (bool, error) { + return true, nil +} + +// Fetch an un-expired entry from the role tag blacklist for a given tag. +func blacklistRoleTagValidEntry(s logical.Storage, tag string) (*roleTagBlacklistEntry, error) { + entry, err := blacklistRoleTagEntry(s, tag) + if err != nil { + return nil, err + } + + // Exclude the item if it is expired. + if entry == nil || time.Now().After(entry.ExpirationTime) { + return nil, nil + } + + return entry, nil +} + +// Fetch an entry from the role tag blacklist for a given tag. +func blacklistRoleTagEntry(s logical.Storage, tag string) (*roleTagBlacklistEntry, error) { + entry, err := s.Get("blacklist/roletag/" + tag) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + + var result roleTagBlacklistEntry + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + return &result, nil +} + +// Deletes an entry from the role tag blacklist for a given tag. +func (b *backend) pathBlacklistRoleTagDelete( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + tag := data.Get("role_tag").(string) + if tag == "" { + return logical.ErrorResponse("missing role_tag"), nil + } + + err := req.Storage.Delete("blacklist/roletag/" + tag) + if err != nil { + return nil, err + } + return nil, nil +} + +// If the given role tag is blacklisted, returns the details of the blacklist entry. +// Returns 'nil' otherwise. +func (b *backend) pathBlacklistRoleTagRead( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + tag := data.Get("role_tag").(string) + if tag == "" { + return logical.ErrorResponse("missing role_tag"), nil + } + + entry, err := blacklistRoleTagEntry(req.Storage, tag) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + + return &logical.Response{ + Data: map[string]interface{}{ + "creation_time": entry.CreationTime, + "expiration_time": entry.ExpirationTime, + }, + }, nil +} + +// pathBlacklistRoleTagUpdate is used to blacklist a given role tag. +// Before a role tag is blacklisted, the correctness of the plaintext part +// in the role tag is verified using the associated HMAC. +func (b *backend) pathBlacklistRoleTagUpdate( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + tag := data.Get("role_tag").(string) + if tag == "" { + return logical.ErrorResponse("missing role_tag"), nil + } + + // Parse the role tag from string form to a struct form. + rTag, err := parseRoleTagValue(tag) + if err != nil { + return nil, err + } + + // Build the plaintext form of the role tag and verify the prepared + // value using the HMAC. + verified, err := verifyRoleTagValue(req.Storage, rTag) + if err != nil { + return nil, err + } + if !verified { + return logical.ErrorResponse("role tag invalid"), nil + } + + // Get the entry for the AMI used by the instance. + imageEntry, err := awsImage(req.Storage, rTag.ImageID) + if err != nil { + return nil, err + } + if imageEntry == nil { + return logical.ErrorResponse("image entry not found"), nil + } + + blEntry, err := blacklistRoleTagEntry(req.Storage, tag) + if err != nil { + return nil, err + } + if blEntry == nil { + blEntry = &roleTagBlacklistEntry{} + } + + currentTime := time.Now() + + var epoch time.Time + if blEntry.CreationTime.Equal(epoch) { + // Set the creation time for the blacklist entry. + // This should not be updated after setting it once. + // If blacklist operation is invoked more than once, only update the expiration time. + blEntry.CreationTime = currentTime + } + + // If max_ttl is not set for the role tag, fall back on the mount's max_ttl. + if rTag.MaxTTL == time.Duration(0) { + rTag.MaxTTL = b.System().MaxLeaseTTL() + } + + if imageEntry.MaxTTL > time.Duration(0) && rTag.MaxTTL > imageEntry.MaxTTL { + rTag.MaxTTL = imageEntry.MaxTTL + } + + // Expiration time is decided by the max_ttl value. + blEntry.ExpirationTime = currentTime.Add(rTag.MaxTTL) + + entry, err := logical.StorageEntryJSON("blacklist/roletag/"+tag, blEntry) + if err != nil { + return nil, err + } + + // Store it. + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + + return nil, nil +} + +type roleTagBlacklistEntry struct { + CreationTime time.Time `json:"creation_time" structs:"creation_time" mapstructure:"creation_time"` + ExpirationTime time.Time `json:"expiration_time" structs:"expiration_time" mapstructure:"expiration_time"` +} + +const pathBlacklistRoleTagSyn = ` +Blacklist a previously created role tag. +` + +const pathBlacklistRoleTagDesc = ` +Blacklist a role tag so that it cannot be used by an EC2 instance to perform logins +in the future. This can be used if the role tag is suspected or believed to be possessed +by an unauthorized entity. + +The entries in the blacklist are not automatically deleted. Although, they will have an +expiration time set on the entry. There is a separate endpoint 'blacklist/roletag/tidy', +that needs to be invoked to clean-up all the expired entries in the blacklist. +` + +const pathListBlacklistRoleTagsHelpSyn = ` +List the blacklisted role tags. +` + +const pathListBlacklistRoleTagsHelpDesc = ` +List all the entries present in the blacklist. This will show both the valid entries and +the expired entries in the blacklist. Use 'blacklist/roletag/tidy' endpoint to clean-up +the blacklist of role tags. +` diff --git a/builtin/credential/aws/path_blacklist_roletag_tidy.go b/builtin/credential/aws/path_blacklist_roletag_tidy.go new file mode 100644 index 000000000..ccbc20f62 --- /dev/null +++ b/builtin/credential/aws/path_blacklist_roletag_tidy.go @@ -0,0 +1,88 @@ +package aws + +import ( + "fmt" + "time" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathBlacklistRoleTagTidy(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "blacklist/roletag/tidy$", + Fields: map[string]*framework.FieldSchema{ + "safety_buffer": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Default: 259200, // 72h + Description: `The amount of extra time that must have passed beyond the roletag's +expiration, before it is removed from the backend storage.`, + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathBlacklistRoleTagTidyUpdate, + }, + + HelpSynopsis: pathBlacklistRoleTagTidySyn, + HelpDescription: pathBlacklistRoleTagTidyDesc, + } +} + +// pathBlacklistRoleTagTidyUpdate is used to clean-up the entries in the role tag blacklist. +func (b *backend) pathBlacklistRoleTagTidyUpdate( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + // safety_buffer is an optional parameter. + safety_buffer := data.Get("safety_buffer").(int) + bufferDuration := time.Duration(safety_buffer) * time.Second + + tags, err := req.Storage.List("blacklist/roletag/") + if err != nil { + return nil, err + } + + for _, tag := range tags { + tagEntry, err := req.Storage.Get("blacklist/roletag/" + tag) + if err != nil { + return nil, fmt.Errorf("error fetching tag %s: %s", tag, err) + } + + if tagEntry == nil { + return nil, fmt.Errorf("tag entry for tag %s is nil", tag) + } + + if tagEntry.Value == nil || len(tagEntry.Value) == 0 { + return nil, fmt.Errorf("found entry for tag %s but actual tag is empty", tag) + } + + var result roleTagBlacklistEntry + if err := tagEntry.DecodeJSON(&result); err != nil { + return nil, err + } + + if time.Now().After(result.ExpirationTime.Add(bufferDuration)) { + if err := req.Storage.Delete("blacklist/roletag" + tag); err != nil { + return nil, fmt.Errorf("error deleting tag %s from storage: %s", tag, err) + } + } + } + + return nil, nil +} + +const pathBlacklistRoleTagTidySyn = ` +Clean-up the blacklisted role tag entries. +` + +const pathBlacklistRoleTagTidyDesc = ` +When a role tag is blacklisted, the expiration time of the blacklist entry is +determined by the 'max_ttl' present in the role tag. If 'max_ttl' is not provided +in the role tag, the backend mount's 'max_ttl' value will be used to determine +the expiration time of the blacklist entry. + +When this endpoint is invoked all the entries that are expired will be deleted. + +A 'safety_buffer' (duration in seconds) can be provided, to ensure deletion of +only those entries that are expired before 'safety_buffer' seconds. +` diff --git a/builtin/credential/aws/path_config_certificate.go b/builtin/credential/aws/path_config_certificate.go new file mode 100644 index 000000000..624fd106b --- /dev/null +++ b/builtin/credential/aws/path_config_certificate.go @@ -0,0 +1,245 @@ +package aws + +import ( + "crypto" + "crypto/dsa" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +// dsaSignature represents the contents of the signature of a signed +// content using digital signature algorithm. +type dsaSignature struct { + R, S *big.Int +} + +// As per AWS documentation, this public key is valid for US East (N. Virginia), +// US West (Oregon), US West (N. California), EU (Ireland), EU (Frankfurt), +// Asia Pacific (Tokyo), Asia Pacific (Seoul), Asia Pacific (Singapore), +// Asia Pacific (Sydney), and South America (Sao Paulo) +const defaultAWSPublicCert = ` +-----BEGIN CERTIFICATE----- +MIIC7TCCAq0CCQCWukjZ5V4aZzAJBgcqhkjOOAQDMFwxCzAJBgNVBAYTAlVTMRkw +FwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYD +VQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzAeFw0xMjAxMDUxMjU2MTJaFw0z +ODAxMDUxMjU2MTJaMFwxCzAJBgNVBAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9u +IFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNl +cnZpY2VzIExMQzCCAbcwggEsBgcqhkjOOAQBMIIBHwKBgQCjkvcS2bb1VQ4yt/5e +ih5OO6kK/n1Lzllr7D8ZwtQP8fOEpp5E2ng+D6Ud1Z1gYipr58Kj3nssSNpI6bX3 +VyIQzK7wLclnd/YozqNNmgIyZecN7EglK9ITHJLP+x8FtUpt3QbyYXJdmVMegN6P +hviYt5JH/nYl4hh3Pa1HJdskgQIVALVJ3ER11+Ko4tP6nwvHwh6+ERYRAoGBAI1j +k+tkqMVHuAFcvAGKocTgsjJem6/5qomzJuKDmbJNu9Qxw3rAotXau8Qe+MBcJl/U +hhy1KHVpCGl9fueQ2s6IL0CaO/buycU1CiYQk40KNHCcHfNiZbdlx1E9rpUp7bnF +lRa2v1ntMX3caRVDdbtPEWmdxSCYsYFDk4mZrOLBA4GEAAKBgEbmeve5f8LIE/Gf +MNmP9CM5eovQOGx5ho8WqD+aTebs+k2tn92BBPqeZqpWRa5P/+jrdKml1qx4llHW +MXrs3IgIb6+hUIB+S8dz8/mmO0bpr76RoZVCXYab2CZedFut7qc3WUH9+EUAH5mw +vSeDCOUMYQR7R9LINYwouHIziqQYMAkGByqGSM44BAMDLwAwLAIUWXBlk40xTwSw +7HX32MxXYruse9ACFBNGmdX2ZBrVNGrN9N2f6ROk0k9K +-----END CERTIFICATE----- +` + +func pathConfigCertificate(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "config/certificate$", + Fields: map[string]*framework.FieldSchema{ + "aws_public_cert": &framework.FieldSchema{ + Type: framework.TypeString, + Default: defaultAWSPublicCert, + Description: "AWS Public key required to verify PKCS7 signature.", + }, + }, + + ExistenceCheck: b.pathConfigCertificateExistenceCheck, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.CreateOperation: b.pathConfigCertificateCreateUpdate, + logical.UpdateOperation: b.pathConfigCertificateCreateUpdate, + logical.ReadOperation: b.pathConfigCertificateRead, + }, + + HelpSynopsis: pathConfigCertificateSyn, + HelpDescription: pathConfigCertificateDesc, + } +} + +// Establishes dichotomy of request operation between CreateOperation and UpdateOperation. +// Returning 'true' forces an UpdateOperation, CreateOperation otherwise. +func (b *backend) pathConfigCertificateExistenceCheck(req *logical.Request, data *framework.FieldData) (bool, error) { + entry, err := awsPublicCertificateEntry(req.Storage) + if err != nil { + return false, err + } + return entry != nil, nil +} + +// Decodes the PEM encoded certiticate and parses it into a x509 cert. +func decodePEMAndParseCertificate(certificate string) (*x509.Certificate, error) { + // Decode the PEM block and error out if a block is not detected in the first attempt. + decodedPublicCert, rest := pem.Decode([]byte(certificate)) + if len(rest) != 0 { + return nil, fmt.Errorf("invalid certificate; failed to decode certificate") + } + + // Check if the certificate can be parsed. + publicCert, err := x509.ParseCertificate(decodedPublicCert.Bytes) + if err != nil { + return nil, err + } + if publicCert == nil { + return nil, fmt.Errorf("invalid certificate; failed to parse certificate") + } + return publicCert, nil +} + +// awsPublicCertificateParsed will fetch the storage entry for the certificate, +// decodes it and returns the parsed certificate. +func awsPublicCertificateParsed(s logical.Storage) (*x509.Certificate, error) { + certEntry, err := awsPublicCertificateEntry(s) + if err != nil { + return nil, err + } + if certEntry == nil { + return decodePEMAndParseCertificate(defaultAWSPublicCert) + } + return decodePEMAndParseCertificate(certEntry.AWSPublicCert) +} + +// awsPublicCertificate is used to get the configured AWS Public Key that is used +// to verify the PKCS#7 signature of the instance identity document. +func awsPublicCertificateEntry(s logical.Storage) (*awsPublicCert, error) { + entry, err := s.Get("config/certificate") + if err != nil { + return nil, err + } + if entry == nil { + // Existence check depends on this being nil when the storage entry is not present. + return nil, nil + } + + var result awsPublicCert + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + return &result, nil +} + +// pathConfigCertificateRead is used to view the configured AWS Public Key that is +// used to verify the PKCS#7 signature of the instance identity document. +func (b *backend) pathConfigCertificateRead( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + certificateEntry, err := awsPublicCertificateEntry(req.Storage) + if err != nil { + return nil, err + } + if certificateEntry == nil { + return nil, nil + } + + return &logical.Response{ + Data: map[string]interface{}{ + "aws_public_cert": certificateEntry.AWSPublicCert, + }, + }, nil +} + +// pathConfigCertificateCreateUpdate is used to register an AWS Public Key that is +// used to verify the PKCS#7 signature of the instance identity document. +func (b *backend) pathConfigCertificateCreateUpdate( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + // Check if there is already a certificate entry registered. + certEntry, err := awsPublicCertificateEntry(req.Storage) + if err != nil { + return nil, err + } + if certEntry == nil { + certEntry = &awsPublicCert{} + } + + // Check if the value is provided by the client. + certStrB64, ok := data.GetOk("aws_public_cert") + if ok { + certBytes, err := base64.StdEncoding.DecodeString(certStrB64.(string)) + if err != nil { + return nil, err + } + + certEntry.AWSPublicCert = string(certBytes) + } else if req.Operation == logical.CreateOperation { + certEntry.AWSPublicCert = data.Get("aws_public_cert").(string) + } + + // If explicitly set to empty string, error out. + if certEntry.AWSPublicCert == "" { + return logical.ErrorResponse("missing aws_public_cert"), nil + } + + // Verify the certificate by decoding it and parsing it. + publicCert, err := decodePEMAndParseCertificate(certEntry.AWSPublicCert) + if err != nil { + return nil, err + } + if publicCert == nil { + return logical.ErrorResponse("invalid certificate; failed to decode and parse certificate"), nil + } + + // Before trusting the signature provided, validate its signature. + + // Extract the signature of the certificate. + dsaSig := &dsaSignature{} + dsaSigRest, err := asn1.Unmarshal(publicCert.Signature, dsaSig) + if err != nil { + return nil, err + } + if len(dsaSigRest) != 0 { + return nil, fmt.Errorf("failed to unmarshal certificate's signature") + } + + certHashFunc := crypto.SHA1.New() + + // RawTBSCertificate will contain the information in the certificate that is signed. + certHashFunc.Write(publicCert.RawTBSCertificate) + + // Verify the signature using the public key present in the certificate. + if !dsa.Verify(publicCert.PublicKey.(*dsa.PublicKey), certHashFunc.Sum(nil), dsaSig.R, dsaSig.S) { + return logical.ErrorResponse("invalid certificate; failed to verify certificate's signature"), nil + } + + // If none of the checks fail, save the provided certificate. + entry, err := logical.StorageEntryJSON("config/certificate", certEntry) + if err != nil { + return nil, err + } + + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + + return nil, nil +} + +// Struct awsPublicCert holds the AWS Public Key that is used to verify the PKCS#7 signature +// of the instnace identity document. +type awsPublicCert struct { + AWSPublicCert string `json:"aws_public_cert" structs:"aws_public_cert" mapstructure:"aws_public_cert"` +} + +const pathConfigCertificateSyn = ` +Configure the AWS Public Key that is used to verify the PKCS#7 signature of the identidy document. +` + +const pathConfigCertificateDesc = ` +AWS Public Key used to verify the PKCS#7 signature of the identity document +varies by region. It can be found in AWS's documentation. The default key that +is used to verify the signature is the one that is applicable for following regions: +US East (N. Virginia), US West (Oregon), US West (N. California), EU (Ireland), +EU (Frankfurt), Asia Pacific (Tokyo), Asia Pacific (Seoul), Asia Pacific (Singapore), +Asia Pacific (Sydney), and South America (Sao Paulo). +` diff --git a/builtin/credential/aws/path_config_client.go b/builtin/credential/aws/path_config_client.go new file mode 100644 index 000000000..9d6227104 --- /dev/null +++ b/builtin/credential/aws/path_config_client.go @@ -0,0 +1,144 @@ +package aws + +import ( + "fmt" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathConfigClient(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "config/client", + Fields: map[string]*framework.FieldSchema{ + "access_key": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Access key with permission to query instance metadata.", + }, + + "secret_key": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Secret key with permission to query instance metadata.", + }, + + "region": &framework.FieldSchema{ + Type: framework.TypeString, + Default: "us-east-1", + Description: "Region for API calls.", + }, + }, + + ExistenceCheck: b.pathConfigClientExistenceCheck, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.CreateOperation: b.pathConfigClientCreateUpdate, + logical.UpdateOperation: b.pathConfigClientCreateUpdate, + }, + + HelpSynopsis: pathConfigClientHelpSyn, + HelpDescription: pathConfigClientHelpDesc, + } +} + +// Establishes dichotomy of request operation between CreateOperation and UpdateOperation. +// Returning 'true' forces an UpdateOperation, CreateOperation otherwise. +func (b *backend) pathConfigClientExistenceCheck( + req *logical.Request, data *framework.FieldData) (bool, error) { + entry, err := clientConfigEntry(req.Storage) + if err != nil { + return false, err + } + return entry != nil, nil +} + +// Fetch the client configuration required to access the AWS API. +func clientConfigEntry(s logical.Storage) (*clientConfig, error) { + entry, err := s.Get("config/client") + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + + var result clientConfig + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + return &result, nil +} + +// pathConfigClientCreateUpdate is used to register the 'aws_secret_key' and 'aws_access_key' +// that can be used to interact with AWS EC2 API. +func (b *backend) pathConfigClientCreateUpdate( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + configEntry, err := clientConfigEntry(req.Storage) + if err != nil { + return nil, err + } + if configEntry == nil { + configEntry = &clientConfig{} + } + + regionStr, ok := data.GetOk("region") + if ok { + configEntry.Region = regionStr.(string) + } else if req.Operation == logical.CreateOperation { + configEntry.Region = data.Get("region").(string) + } + + // Either a valid region needs to be provided or it should be left empty + // so a default value could take over. + if configEntry.Region == "" { + return nil, fmt.Errorf("invalid region") + + } + + accessKeyStr, ok := data.GetOk("access_key") + if ok { + configEntry.AccessKey = accessKeyStr.(string) + } else if req.Operation == logical.CreateOperation { + if configEntry.AccessKey = data.Get("access_key").(string); configEntry.AccessKey == "" { + return nil, fmt.Errorf("missing access_key") + } + } + + secretKeyStr, ok := data.GetOk("secret_key") + if ok { + configEntry.SecretKey = secretKeyStr.(string) + } else if req.Operation == logical.CreateOperation { + if configEntry.SecretKey = data.Get("secret_key").(string); configEntry.SecretKey == "" { + return nil, fmt.Errorf("missing secret_key") + } + } + + entry, err := logical.StorageEntryJSON("config/client", configEntry) + if err != nil { + return nil, err + } + + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + + return nil, nil +} + +// Struct to hold 'aws_access_key' and 'aws_secret_key' that are required to +// interact with the AWS EC2 API. +type clientConfig struct { + AccessKey string `json:"access_key" structs:"access_key" mapstructure:"access_key"` + SecretKey string `json:"secret_key" structs:"secret_key" mapstructure:"secret_key"` + Region string `json:"region" structs:"region" mapstructure:"region"` +} + +const pathConfigClientHelpSyn = ` +Configure the client credentials that are used to query instance details from AWS EC2 API. +` + +const pathConfigClientHelpDesc = ` +AWS auth backend makes API calls to retrieve EC2 instance metadata. +The aws_secret_key and aws_access_key registered with Vault should have the +permissions to make these API calls. +` diff --git a/builtin/credential/aws/path_image.go b/builtin/credential/aws/path_image.go new file mode 100644 index 000000000..0a047d393 --- /dev/null +++ b/builtin/credential/aws/path_image.go @@ -0,0 +1,246 @@ +package aws + +import ( + "fmt" + "strings" + "time" + + "github.com/hashicorp/vault/helper/policyutil" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathImage(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "image/" + framework.GenericNameRegex("name"), + Fields: map[string]*framework.FieldSchema{ + "name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "AMI name to be mapped.", + }, + + "role_tag": &framework.FieldSchema{ + Type: framework.TypeString, + Default: "", + Description: "If set, enables the RoleTag for this AMI. The value set for this field should be the 'key' of the tag on the EC2 instance using the RoleTag. Defaults to empty string.", + }, + + "max_ttl": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Default: 0, + Description: "The maximum allowed lease duration", + }, + + "policies": &framework.FieldSchema{ + Type: framework.TypeString, + Default: "default", + Description: "Policies to be associated with the AMI.", + }, + + "allow_instance_reboot": &framework.FieldSchema{ + Type: framework.TypeBool, + Default: false, + Description: "If set, allows rebooting of the OS where the client resides. Essentially, this disables the client nonce check. Use with caution.", + }, + }, + + ExistenceCheck: b.pathImageExistenceCheck, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.CreateOperation: b.pathImageCreateUpdate, + logical.UpdateOperation: b.pathImageCreateUpdate, + logical.ReadOperation: b.pathImageRead, + logical.DeleteOperation: b.pathImageDelete, + }, + + HelpSynopsis: pathImageSyn, + HelpDescription: pathImageDesc, + } +} + +// pathListImages createa a path that enables listing of all the AMIs that are +// registered with Vault. +func pathListImages(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "images/?", + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ListOperation: b.pathImageList, + }, + + HelpSynopsis: pathListImagesHelpSyn, + HelpDescription: pathListImagesHelpDesc, + } +} + +// Establishes dichotomy of request operation between CreateOperation and UpdateOperation. +// Returning 'true' forces an UpdateOperation, CreateOperation otherwise. +func (b *backend) pathImageExistenceCheck(req *logical.Request, data *framework.FieldData) (bool, error) { + entry, err := awsImage(req.Storage, strings.ToLower(data.Get("name").(string))) + if err != nil { + return false, err + } + return entry != nil, nil +} + +// awsImage is used to get the information registered for the given AMI ID. +func awsImage(s logical.Storage, name string) (*awsImageEntry, error) { + entry, err := s.Get("image/" + strings.ToLower(name)) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + + var result awsImageEntry + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + return &result, nil +} + +// pathImageDelete is used to delete the information registered for a given AMI ID. +func (b *backend) pathImageDelete( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + err := req.Storage.Delete("image/" + strings.ToLower(data.Get("name").(string))) + if err != nil { + return nil, err + } + return nil, nil +} + +// pathImageList is used to list all the AMI IDs registered with Vault. +func (b *backend) pathImageList( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + images, err := req.Storage.List("image/") + if err != nil { + return nil, err + } + return logical.ListResponse(images), nil +} + +// pathImageRead is used to view the information registered for a given AMI ID. +func (b *backend) pathImageRead( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + imageEntry, err := awsImage(req.Storage, strings.ToLower(data.Get("name").(string))) + if err != nil { + return nil, err + } + if imageEntry == nil { + return nil, nil + } + + return &logical.Response{ + Data: map[string]interface{}{ + "role_tag": imageEntry.RoleTag, + "policies": strings.Join(imageEntry.Policies, ","), + "max_ttl": imageEntry.MaxTTL / time.Second, + }, + }, nil +} + +// pathImageCreateUpdate is used to associate Vault policies to a given AMI ID. +func (b *backend) pathImageCreateUpdate( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + imageID := strings.ToLower(data.Get("name").(string)) + if imageID == "" { + return logical.ErrorResponse("missing AMI name"), nil + } + + imageEntry, err := awsImage(req.Storage, imageID) + if err != nil { + return nil, err + } + if imageEntry == nil { + imageEntry = &awsImageEntry{} + } + + policiesStr, ok := data.GetOk("policies") + if ok { + imageEntry.Policies = policyutil.ParsePolicies(policiesStr.(string)) + } else if req.Operation == logical.CreateOperation { + imageEntry.Policies = []string{"default"} + } + + allowInstanceRebootBool, ok := data.GetOk("allow_instance_reboot") + if ok { + imageEntry.AllowInstanceReboot = allowInstanceRebootBool.(bool) + } else if req.Operation == logical.CreateOperation { + imageEntry.AllowInstanceReboot = data.Get("allow_instance_reboot").(bool) + } + + maxTTLInt, ok := data.GetOk("max_ttl") + if ok { + maxTTL := time.Duration(maxTTLInt.(int)) * time.Second + systemMaxTTL := b.System().MaxLeaseTTL() + if maxTTL > systemMaxTTL { + return logical.ErrorResponse(fmt.Sprintf("Given TTL of %d seconds greater than current mount/system default of %d seconds", maxTTL/time.Second, systemMaxTTL/time.Second)), nil + } + + if maxTTL < time.Duration(0) { + return logical.ErrorResponse("max_ttl cannot be negative"), nil + } + + imageEntry.MaxTTL = maxTTL + } else if req.Operation == logical.CreateOperation { + imageEntry.MaxTTL = time.Duration(data.Get("max_ttl").(int)) * time.Second + } + + roleTagStr, ok := data.GetOk("role_tag") + if ok { + imageEntry.RoleTag = roleTagStr.(string) + if len(imageEntry.RoleTag) > 127 { + return logical.ErrorResponse("role tag 'key' is exceeding the limit of 127 characters"), nil + } + } else if req.Operation == logical.CreateOperation { + imageEntry.RoleTag = data.Get("role_tag").(string) + } + + entry, err := logical.StorageEntryJSON("image/"+imageID, imageEntry) + if err != nil { + return nil, err + } + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + return nil, nil +} + +// Struct to hold the information associated with an AMI ID in Vault. +type awsImageEntry struct { + RoleTag string `json:"role_tag" structs:"role_tag" mapstructure:"role_tag"` + AllowInstanceReboot bool `json:"allow_instance_reboot" structs:"allow_instance_reboot" mapstructure:"allow_instance_reboot"` + MaxTTL time.Duration `json:"max_ttl" structs:"max_ttl" mapstructure:"max_ttl"` + Policies []string `json:"policies" structs:"policies" mapstructure:"policies"` +} + +const pathImageSyn = ` +Associate an AMI to Vault's policies. +` + +const pathImageDesc = ` +A precondition for login is that the AMI used by the EC2 instance, needs to +be registered with Vault. After the authentication of the instance, the +authorization for the instance to access Vault's resources is determined +by the policies that are associated to the AMI through this endpoint. + +In case the AMI is shared by many instances, then a role tag can be created +through the endpoint 'image//tag'. This tag needs to be applied on the +instance before it attempts to login to Vault. The policies on the tag should +be a subset of policies that are associated to the AMI in this endpoint. In +order to enable login using tags, RoleTag needs to be enabled in this endpoint. + +Also, a 'max_ttl' can be configured in this endpoint that determines the maximum +duration for which a login can be renewed. Note that the 'max_ttl' has a upper +limit of the 'max_ttl' value that is applicable to the backend. +` + +const pathListImagesHelpSyn = ` +Lists all the AMIs that are registered with Vault. +` + +const pathListImagesHelpDesc = ` +AMIs will be listed by their respective AMI ID. +` diff --git a/builtin/credential/aws/path_image_tag.go b/builtin/credential/aws/path_image_tag.go new file mode 100644 index 000000000..f1bb95a7a --- /dev/null +++ b/builtin/credential/aws/path_image_tag.go @@ -0,0 +1,299 @@ +package aws + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + "time" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/helper/policyutil" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +const roleTagVersion = "v1" + +func pathImageTag(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "image/" + framework.GenericNameRegex("name") + "/tag$", + Fields: map[string]*framework.FieldSchema{ + "name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "AMI name to create a tag for.", + }, + + "policies": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Policies to be associated with the tag.", + }, + + "max_ttl": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Default: 0, + Description: "The maximum allowed lease duration", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathImageTagUpdate, + }, + + HelpSynopsis: pathImageTagSyn, + HelpDescription: pathImageTagDesc, + } +} + +// pathImageTagUpdate is used to create an EC2 instance tag which will +// identify the Vault resources that the instance will be authorized for. +func (b *backend) pathImageTagUpdate( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + imageID := strings.ToLower(data.Get("name").(string)) + if imageID == "" { + return logical.ErrorResponse("missing image name"), nil + } + + // Parse the given policies into a slice and add 'default' if not provided. + // Remove all other policies if 'root' is present. + policies := policyutil.ParsePolicies(data.Get("policies").(string)) + + // Fetch the image entry corresponding to the AMI name + imageEntry, err := awsImage(req.Storage, imageID) + if err != nil { + return nil, err + } + if imageEntry == nil { + return logical.ErrorResponse("image entry not found"), nil + } + + // If RoleTag is empty, disallow creation of tag. + if imageEntry.RoleTag == "" { + return logical.ErrorResponse("tag creation is not enabled for this image"), nil + } + + // Create a random nonce + nonce, err := createRoleTagNonce() + if err != nil { + return nil, err + } + + // max_ttl for the role tag should be less than the max_ttl set on the image. + maxTTL := time.Duration(data.Get("max_ttl").(int)) * time.Second + + // max_ttl on the tag should not be greater than the system view's max_ttl value. + if maxTTL > b.System().MaxLeaseTTL() { + return logical.ErrorResponse(fmt.Sprintf("Registered AMI does not have a max_ttl set. So, the given TTL of %d seconds should be less than the max_ttl set for the corresponding backend mount of %d seconds.", maxTTL/time.Second, b.System().MaxLeaseTTL()/time.Second)), nil + } + + // If max_ttl is set for the image, check the bounds for tag's max_ttl value using that. + if imageEntry.MaxTTL != time.Duration(0) && maxTTL > imageEntry.MaxTTL { + return logical.ErrorResponse(fmt.Sprintf("Given TTL of %d seconds greater than the max_ttl set for the corresponding image of %d seconds", maxTTL/time.Second, imageEntry.MaxTTL/time.Second)), nil + } + + if maxTTL < time.Duration(0) { + return logical.ErrorResponse("max_ttl cannot be negative"), nil + } + + // Attach version, nonce, policies and maxTTL to the role tag value. + rTagValue, err := prepareRoleTagPlainValue(&roleTag{Version: roleTagVersion, + ImageID: imageID, + Nonce: nonce, + Policies: policies, + MaxTTL: maxTTL, + }) + if err != nil { + return nil, err + } + + // Get the key used for creating the HMAC + key, err := hmacKey(req.Storage) + if err != nil { + return nil, err + } + + // Create the HMAC of the value + hmacB64, err := createRoleTagHMACBase64(key, rTagValue) + if err != nil { + return nil, err + } + + // attach the HMAC to the value + rTagValue = fmt.Sprintf("%s:%s", rTagValue, hmacB64) + if len(rTagValue) > 255 { + return nil, fmt.Errorf("role tag 'value' exceeding the limit of 255 characters") + } + + return &logical.Response{ + Data: map[string]interface{}{ + "tag_key": imageEntry.RoleTag, + "tag_value": rTagValue, + }, + }, nil +} + +// verifyRoleTagValue rebuilds the role tag value without the HMAC, +// computes the HMAC from it using the backend specific key and +// compares it with the received HMAC. +func verifyRoleTagValue(s logical.Storage, rTag *roleTag) (bool, error) { + // Fetch the plaintext part of role tag + rTagPlainText, err := prepareRoleTagPlainValue(rTag) + if err != nil { + return false, err + } + + // Get the key used for creating the HMAC + key, err := hmacKey(s) + if err != nil { + return false, err + } + + // TODO: for testing purposes. Remove this. + key = "ab1728ba-5fd5-7298-d344-e9df1b09f5ea" + + // Compute the HMAC of the plaintext + hmacB64, err := createRoleTagHMACBase64(key, rTagPlainText) + if err != nil { + return false, err + } + return rTag.HMAC == hmacB64, nil +} + +// prepareRoleTagPlainValue builds the role tag value without the HMAC in it. +func prepareRoleTagPlainValue(rTag *roleTag) (string, error) { + if rTag.Version == "" { + return "", fmt.Errorf("missing version") + } + // attach version to the value + value := rTag.Version + + if rTag.Nonce == "" { + return "", fmt.Errorf("missing nonce") + } + // attach nonce to the value + value = fmt.Sprintf("%s:%s", value, rTag.Nonce) + + if rTag.ImageID == "" { + return "", fmt.Errorf("missing ami_name") + } + // attach ami_name to the value + value = fmt.Sprintf("%s:a=%s", value, rTag.ImageID) + + // attach policies to value + value = fmt.Sprintf("%s:p=%s", value, strings.Join(rTag.Policies, ",")) + + // attach max_ttl if it is provided + if rTag.MaxTTL > time.Duration(0) { + value = fmt.Sprintf("%s:t=%s", value, rTag.MaxTTL) + } + + return value, nil +} + +// Parses the tag from string form into a struct form. +func parseRoleTagValue(tag string) (*roleTag, error) { + tagItems := strings.Split(tag, ":") + // Tag must contain version, nonce, policies and HMAC + if len(tagItems) < 4 { + return nil, fmt.Errorf("invalid tag") + } + + rTag := &roleTag{} + + // Cache the HMAC value. The last item in the collection. + rTag.HMAC = tagItems[len(tagItems)-1] + + // Delete the HMAC from the list. + tagItems = tagItems[:len(tagItems)-1] + + // Version is the first element. + rTag.Version = tagItems[0] + if rTag.Version != roleTagVersion { + return nil, fmt.Errorf("invalid role tag version") + } + + // Nonce is the second element. + rTag.Nonce = tagItems[1] + + if len(tagItems) > 2 { + // Delete the version and nonce from the list. + tagItems = tagItems[2:] + for _, tagItem := range tagItems { + switch { + case strings.Contains(tagItem, "a="): + rTag.ImageID = strings.TrimPrefix(tagItem, "a=") + case strings.Contains(tagItem, "p="): + rTag.Policies = strings.Split(strings.TrimPrefix(tagItem, "p="), ",") + case strings.Contains(tagItem, "t="): + var err error + rTag.MaxTTL, err = time.ParseDuration(strings.TrimPrefix(tagItem, "t=")) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unrecognized item in tag") + } + } + } + if rTag.ImageID == "" { + return nil, fmt.Errorf("missing image ID") + } + + return rTag, nil +} + +// Creates base64 encoded HMAC using a backend specific key. +func createRoleTagHMACBase64(key, value string) (string, error) { + hm := hmac.New(sha256.New, []byte(key)) + hm.Write([]byte(value)) + + // base64 encode the hmac bytes. + return base64.StdEncoding.EncodeToString(hm.Sum(nil)), nil +} + +// Creates a base64 encoded random nonce. +func createRoleTagNonce() (string, error) { + uuidBytes, err := uuid.GenerateRandomBytes(8) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(uuidBytes), nil +} + +// Struct roleTag represents a role tag in a struc form. +type roleTag struct { + Version string `json:"version" structs:"version" mapstructure:"version"` + Nonce string `json:"nonce" structs:"nonce" mapstructure:"nonce"` + Policies []string `json:"policies" structs:"policies" mapstructure:"policies"` + MaxTTL time.Duration `json:"max_ttl" structs:"max_ttl" mapstructure:"max_ttl"` + ImageID string `json:"image_id" structs:"image_id" mapstructure:"image_id"` + HMAC string `json:"hmac" structs:"hmac" mapstructure:"hmac"` +} + +func (rTag1 *roleTag) Equal(rTag2 *roleTag) bool { + return rTag1.Version == rTag2.Version && + rTag1.Nonce == rTag2.Nonce && + policyutil.EquivalentPolicies(rTag1.Policies, rTag2.Policies) && + rTag1.MaxTTL == rTag2.MaxTTL && + rTag1.ImageID == rTag2.ImageID && + rTag1.HMAC == rTag2.HMAC +} + +const pathImageTagSyn = ` +Create a tag for an EC2 instance. +` + +const pathImageTagDesc = ` +When an AMI is used by more than one EC2 instance, policies to be associated +during login are determined by a particular tag on the instance. This tag +can be created using this endpoint. + +A RoleTag setting needs to be enabled in 'image/' endpoint, to be able +to create a tag. Also, the policies to be associated with the tag should be +a subset of the policies associated with the regisred AMI. + +This endpoint will return both the 'key' and the 'value' to be set for the +instance tag. +` diff --git a/builtin/credential/aws/path_login.go b/builtin/credential/aws/path_login.go new file mode 100644 index 000000000..2e4b00846 --- /dev/null +++ b/builtin/credential/aws/path_login.go @@ -0,0 +1,483 @@ +package aws + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/hashicorp/vault/helper/strutil" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" + "github.com/vishalnayak/pkcs7" +) + +func pathLogin(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "login$", + Fields: map[string]*framework.FieldSchema{ + "pkcs7": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "PKCS7 signature of the identity document.", + }, + + "nonce": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "The nonce created by a client of this backend.", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathLoginUpdate, + }, + + HelpSynopsis: pathLoginSyn, + HelpDescription: pathLoginDesc, + } +} + +// validateInstanceID queries the status of the EC2 instance using AWS EC2 API and +// checks if the instance is running and is healthy. +func validateInstanceID(s logical.Storage, instanceID string) error { + // Create an EC2 client to pull the instance information + ec2Client, err := clientEC2(s) + if err != nil { + return err + } + + // Get the status of the instance + instanceStatus, err := ec2Client.DescribeInstanceStatus(&ec2.DescribeInstanceStatusInput{ + InstanceIds: []*string{aws.String(instanceID)}, + }) + if err != nil { + return err + } + + // Validate the instance through InstanceState, InstanceStatus and SystemStatus + return validateInstanceStatus(instanceStatus) +} + +// validateMetadata matches the given client nonce and pending time with the one cached +// in the identity whitelist during the previous login. +func validateMetadata(clientNonce, pendingTime string, storedIdentity *whitelistIdentity, imageEntry *awsImageEntry) error { + + givenPendingTime, err := time.Parse(time.RFC3339, pendingTime) + if err != nil { + return err + } + + storedPendingTime, err := time.Parse(time.RFC3339, storedIdentity.PendingTime) + if err != nil { + return err + } + + // When the presented client nonce does not match the cached entry, it is either that a + // rogue client is trying to login or that a valid client suffered an OS reboot and + // lost its client nonce. + // + // If `allow_instance_reboot` property of the registered AMI, is enabled, then the + // client nonce mismatch is ignored, as long as the pending time in the presented + // instance identity document is newer than the cached pending time. + // + // This is a weak creterion and hence the `allow_instance_reboot` option should be used with caution. + if clientNonce != storedIdentity.ClientNonce { + if !imageEntry.AllowInstanceReboot { + return fmt.Errorf("client nonce mismatch") + } + if imageEntry.AllowInstanceReboot && !givenPendingTime.After(storedPendingTime) { + return fmt.Errorf("client nonce mismatch and instance meta-data incorrect") + } + } + + // ensure that the 'pendingTime' on the given identity document is not before than the + // 'pendingTime' that was used for previous login. + if givenPendingTime.Before(storedPendingTime) { + return fmt.Errorf("instance meta-data is older than the one used for previous login") + } + return nil +} + +// Verifies the correctness of the authenticated attributes present in the PKCS#7 +// signature. After verification, extracts the instance identity document from the +// signature, parses it and returns it. +func parseIdentityDocument(s logical.Storage, pkcs7B64 string) (*identityDocument, error) { + pkcs7B64 = fmt.Sprintf("-----BEGIN PKCS7-----\n%s\n-----END PKCS7-----", pkcs7B64) + + // Decode the PEM encoded signature. + pkcs7BER, pkcs7Rest := pem.Decode([]byte(pkcs7B64)) + if len(pkcs7Rest) != 0 { + return nil, fmt.Errorf("failed to decode the PEM encoded PKCS#7 signature") + } + + // Parse the signature from asn1 format into a struct. + pkcs7Data, err := pkcs7.Parse(pkcs7BER.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse the BER encoded PKCS#7 signature: %s\n", err) + } + + // Get the public certificate that is used to verify the signature. + publicCert, err := awsPublicCertificateParsed(s) + if err != nil { + return nil, err + } + if publicCert == nil { + return nil, fmt.Errorf("certificate to verify the signature is not found") + } + + // Before calling Verify() on the PKCS#7 struct, set the certificate to be used + // to verify the contents in the signer information. + pkcs7Data.Certificates = []*x509.Certificate{publicCert} + + // Verify extracts the authenticated attributes in the PKCS#7 signature, and verifies + // the authenticity of the content using 'dsa.PublicKey' embedded in the public certificate. + if pkcs7Data.Verify() != nil { + return nil, fmt.Errorf("failed to verify the signature") + } + + // Check if the signature has content inside of it. + if len(pkcs7Data.Content) == 0 { + return nil, fmt.Errorf("instance identity document could not be found in the signature") + } + + var identityDoc identityDocument + err = json.Unmarshal(pkcs7Data.Content, &identityDoc) + if err != nil { + return nil, err + } + + return &identityDoc, nil +} + +// pathLoginUpdate is used to create a Vault token by the EC2 instances +// by providing its instance identity document, pkcs7 signature of the document, +// and a client created nonce. +func (b *backend) pathLoginUpdate( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + pkcs7B64 := data.Get("pkcs7").(string) + + if pkcs7B64 == "" { + return logical.ErrorResponse("missing pkcs7"), nil + } + + // Verify the signature of the identity document. + identityDoc, err := parseIdentityDocument(req.Storage, pkcs7B64) + if err != nil { + return nil, err + } + if identityDoc == nil { + return logical.ErrorResponse("failed to extract instance identity document from PKCS#7 signature"), nil + } + + clientNonce := data.Get("nonce").(string) + if clientNonce == "" { + return logical.ErrorResponse("missing nonce"), nil + } + + // Allowing the lengh of UUID for a client nonce. + if len(clientNonce) > 36 { + return logical.ErrorResponse("client nonce exceeding the limit of 36 characters"), nil + } + + // Validate the instance ID. + if err := validateInstanceID(req.Storage, identityDoc.InstanceID); err != nil { + return logical.ErrorResponse(fmt.Sprintf("failed to verify instance ID: %s", err)), nil + } + + // Get the entry for the AMI used by the instance. + imageEntry, err := awsImage(req.Storage, identityDoc.ImageID) + if err != nil { + return nil, err + } + if imageEntry == nil { + return logical.ErrorResponse("image entry not found"), nil + } + + // Ensure that the TTL is less than the backend mount's max_ttl. + // If RoleTag is enabled, max_ttl on the RoleTag will be checked to be smaller than this, before being set. + maxTTL := imageEntry.MaxTTL + if maxTTL > b.System().MaxLeaseTTL() { + maxTTL = b.System().MaxLeaseTTL() + } + + // Get the entry from the identity whitelist, if there is one. + storedIdentity, err := whitelistIdentityValidEntry(req.Storage, identityDoc.InstanceID) + if err != nil { + return nil, err + } + + // This is NOT a first login attempt from the client. + if storedIdentity != nil { + // Check if the client nonce match the cached nonce and if the pending time + // of the identity document is not before the pending time of the document + // with which previous login was made. + err = validateMetadata(clientNonce, identityDoc.PendingTime, storedIdentity, imageEntry) + if err != nil { + return nil, err + } + } + + // Initially, set the policies that are applicable to the image. + // This may get updated if the image has RoleTag enabled. + policies := imageEntry.Policies + + rTagMaxTTL := time.Duration(0) + + // Role tag is enabled for the AMI. + if imageEntry.RoleTag != "" { + // Overwrite the policies with the ones returned from processing the role tag. + resp, err := handleRoleTagLogin(req.Storage, identityDoc, imageEntry) + if err != nil { + return nil, err + } + policies = resp.Policies + rTagMaxTTL = resp.MaxTTL + + // maxTTL should be set to least among these: image max_ttl, role-tag max_ttl, backend mount's max_ttl. + if maxTTL > rTagMaxTTL { + maxTTL = rTagMaxTTL + } + } + + // Save the login attempt in the identity whitelist. + currentTime := time.Now() + if storedIdentity == nil { + // ImageID, ClientNonce and CreationTime of the identity entry, + // once set, should never change. + storedIdentity = &whitelistIdentity{ + ImageID: identityDoc.ImageID, + ClientNonce: clientNonce, + CreationTime: currentTime, + } + } + + // PendingTime, LastUpdatedTime and ExpirationTime may change. + storedIdentity.LastUpdatedTime = currentTime + storedIdentity.ExpirationTime = currentTime.Add(maxTTL) + storedIdentity.PendingTime = identityDoc.PendingTime + + if err = setWhitelistIdentityEntry(req.Storage, identityDoc.InstanceID, storedIdentity); err != nil { + return nil, err + } + + return &logical.Response{ + Auth: &logical.Auth{ + Policies: policies, + Metadata: map[string]string{ + "instance_id": identityDoc.InstanceID, + "role_tag_max_ttl": rTagMaxTTL.String(), + }, + LeaseOptions: logical.LeaseOptions{ + Renewable: true, + // There is no TTL on the image/role-tag. Set it to mount's default TTL. + TTL: b.System().DefaultLeaseTTL(), + }, + }, + }, nil + +} + +// fetchRoleTagValue creates an AWS EC2 client and queries the tags +// attached to the instance identified by the given instanceID. +func fetchRoleTagValue(s logical.Storage, tagKey string) (string, error) { + ec2Client, err := clientEC2(s) + if err != nil { + return "", err + } + + // Retrieve the instance tag with a "key" filter matching tagKey. + tagsOutput, err := ec2Client.DescribeTags(&ec2.DescribeTagsInput{ + Filters: []*ec2.Filter{ + &ec2.Filter{ + Name: aws.String("key"), + Values: []*string{ + aws.String(tagKey), + }, + }, + }, + }) + if err != nil { + return "", err + } + + if tagsOutput.Tags == nil || + len(tagsOutput.Tags) != 1 || + *tagsOutput.Tags[0].Key != tagKey || + *tagsOutput.Tags[0].ResourceType != "instance" { + return "", nil + } + + return *tagsOutput.Tags[0].Value, nil +} + +// handleRoleTagLogin is used to fetch the role tag if the instance and verifies it to be correct. +// Then the policies for the login request will be set off of the role tag, if certain creteria satisfies. +func handleRoleTagLogin(s logical.Storage, identityDoc *identityDocument, imageEntry *awsImageEntry) (*roleTagLoginResponse, error) { + + // Make a secondary call to the AWS instance to see if the desired tag is set. + // NOTE: If AWS adds the instance tags as meta-data in the instance identity + // document, then it is better to look this information there instead of making + // another API call. Currently, we don't have an option but make this call. + rTagValue, err := fetchRoleTagValue(s, imageEntry.RoleTag) + if err != nil { + return nil, err + } + + if rTagValue == "" { + return nil, fmt.Errorf("missing tag with key %s on the instance", imageEntry.RoleTag) + } + + // Check if the role tag is blacklisted. + blacklistEntry, err := blacklistRoleTagValidEntry(s, rTagValue) + if err != nil { + return nil, err + } + if blacklistEntry != nil { + return nil, fmt.Errorf("role tag is blacklisted") + } + + rTag, err := parseRoleTagValue(rTagValue) + if err != nil { + return nil, err + } + + // Ensure that the policies on the RoleTag is a subset of policies on the image + if !strutil.StrListSubset(imageEntry.Policies, rTag.Policies) { + return nil, fmt.Errorf("policies on the role tag must be subset of policies on the image") + } + + // Create a HMAC of the plaintext value of role tag and compare it with the given value. + verified, err := verifyRoleTagValue(s, rTag) + if err != nil { + return nil, err + } + if !verified { + return nil, fmt.Errorf("role tag signature mismatch") + } + return &roleTagLoginResponse{ + Policies: rTag.Policies, + MaxTTL: rTag.MaxTTL, + }, nil +} + +// pathLoginRenew is used to renew an authenticated token. +func (b *backend) pathLoginRenew( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + storedIdentity, err := whitelistIdentityValidEntry(req.Storage, req.Auth.Metadata["instance_id"]) + if err != nil { + return nil, err + } + + // For now, rTagMaxTTL is cached in internal data during login and used in renewal for + // setting the MaxTTL for the stored login identity entry. + // If `instance_id` can be used to fetch the role tag again (through an API), it would be good. + // For accessing the max_ttl, storing the entire identity document is too heavy. + rTagMaxTTL, err := time.ParseDuration(req.Auth.Metadata["role_tag_max_ttl"]) + if err != nil { + return nil, err + } + + imageEntry, err := awsImage(req.Storage, storedIdentity.ImageID) + if err != nil { + return nil, err + } + if imageEntry == nil { + return logical.ErrorResponse("image entry not found"), nil + } + + maxTTL := imageEntry.MaxTTL + if maxTTL > b.System().MaxLeaseTTL() { + maxTTL = b.System().MaxLeaseTTL() + } + if rTagMaxTTL > time.Duration(0) && maxTTL > rTagMaxTTL { + maxTTL = rTagMaxTTL + } + + // Only LastUpdatedTime and ExpirationTime change, none else. + currentTime := time.Now() + storedIdentity.LastUpdatedTime = currentTime + storedIdentity.ExpirationTime = currentTime.Add(maxTTL) + + if err = setWhitelistIdentityEntry(req.Storage, req.Auth.Metadata["instance_id"], storedIdentity); err != nil { + return nil, err + } + + return framework.LeaseExtend(req.Auth.TTL, maxTTL, b.System())(req, data) +} + +// Validates the instance by checking the InstanceState, InstanceStatus and SystemStatus +func validateInstanceStatus(instanceStatus *ec2.DescribeInstanceStatusOutput) error { + + if instanceStatus.InstanceStatuses == nil { + return fmt.Errorf("instance statuses not found") + } + + if len(instanceStatus.InstanceStatuses) != 1 { + return fmt.Errorf("length of instance statuses is more than 1") + } + + if instanceStatus.InstanceStatuses[0].InstanceState == nil { + return fmt.Errorf("instance state not found") + } + + // Instance should be in 'running'(code 16) state. + if *instanceStatus.InstanceStatuses[0].InstanceState.Code != 16 { + return fmt.Errorf("instance state is not 'running'") + } + + if instanceStatus.InstanceStatuses[0].InstanceStatus == nil { + return fmt.Errorf("instance status not found") + } + + // InstanceStatus should be 'ok' + if *instanceStatus.InstanceStatuses[0].InstanceStatus.Status != "ok" { + return fmt.Errorf("instance status is not 'ok'") + } + + if instanceStatus.InstanceStatuses[0].SystemStatus == nil { + return fmt.Errorf("system status not found") + } + + // SystemStatus should be 'ok' + if *instanceStatus.InstanceStatuses[0].SystemStatus.Status != "ok" { + return fmt.Errorf("system status is not 'ok'") + } + + return nil +} + +// Struct to represent items of interest from the EC2 instance identity document. +type identityDocument struct { + Tags map[string]interface{} `json:"tags,omitempty" structs:"tags" mapstructure:"tags"` + InstanceID string `json:"instanceId,omitempty" structs:"instanceId" mapstructure:"instanceId"` + ImageID string `json:"imageId,omitempty" structs:"imageId" mapstructure:"imageId"` + Region string `json:"region,omitempty" structs:"region" mapstructure:"region"` + PendingTime string `json:"pendingTime,omitempty" structs:"pendingTime" mapstructure:"pendingTime"` +} + +type roleTagLoginResponse struct { + Policies []string `json:"policies" structs:"policies" mapstructure:"policies"` + MaxTTL time.Duration `json:"max_ttl", structs:"max_ttl" mapstructure:"max_ttl"` +} + +const pathLoginSyn = ` +Authenticates an EC2 instance with Vault. +` + +const pathLoginDesc = ` +An EC2 instance is authenticated using the instance identity document, the identity document's +PKCS#7 signature and a client created nonce. This nonce should be unique and should be used by +the instance for all future logins. + +First login attempt, creates a whitelist entry in Vault associating the instance to the nonce +provided. All future logins will succeed only if the client nonce matches the nonce in the +whitelisted entry. + +The entries in the whitelist are not automatically deleted. Although, they will have an +expiration time set on the entry. There is a separate endpoint 'whitelist/identity/tidy', +that needs to be invoked to clean-up all the expired entries in the whitelist. +` diff --git a/builtin/credential/aws/path_whitelist_identity.go b/builtin/credential/aws/path_whitelist_identity.go new file mode 100644 index 000000000..2227202c5 --- /dev/null +++ b/builtin/credential/aws/path_whitelist_identity.go @@ -0,0 +1,176 @@ +package aws + +import ( + "time" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathWhitelistIdentity(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "whitelist/identity$", + Fields: map[string]*framework.FieldSchema{ + "instance_id": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "EC2 instance ID.", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ReadOperation: b.pathWhitelistIdentityRead, + logical.DeleteOperation: b.pathWhitelistIdentityDelete, + }, + + HelpSynopsis: pathWhitelistIdentitySyn, + HelpDescription: pathWhitelistIdentityDesc, + } +} + +func pathListWhitelistIdentities(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "whitelist/identity/?", + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ListOperation: b.pathWhitelistIdentitiesList, + }, + + HelpSynopsis: pathListWhitelistIdentitiesHelpSyn, + HelpDescription: pathListWhitelistIdentitiesHelpDesc, + } +} + +// pathWhitelistIdentitiesList is used to list all the instance IDs that are present +// in the identity whitelist. This will list both valid and expired entries. +func (b *backend) pathWhitelistIdentitiesList( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + identities, err := req.Storage.List("whitelist/identity/") + if err != nil { + return nil, err + } + return logical.ListResponse(identities), nil +} + +// Fetch an un-expired item from the whitelist given an instance ID. +func whitelistIdentityValidEntry(s logical.Storage, instanceID string) (*whitelistIdentity, error) { + identity, err := whitelistIdentityEntry(s, instanceID) + if err != nil { + return nil, err + } + + // Don't return an expired item. + if identity == nil || time.Now().After(identity.ExpirationTime) { + return nil, nil + } + + return identity, nil +} + +// Fetch an item from the whitelist given an instance ID. +func whitelistIdentityEntry(s logical.Storage, instanceID string) (*whitelistIdentity, error) { + entry, err := s.Get("whitelist/identity/" + instanceID) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + + var result whitelistIdentity + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + return &result, nil +} + +// Stores an instance ID and the information required to validate further login/renewal attempts from +// the same instance ID. +func setWhitelistIdentityEntry(s logical.Storage, instanceID string, identity *whitelistIdentity) error { + entry, err := logical.StorageEntryJSON("whitelist/identity/"+instanceID, identity) + if err != nil { + return err + } + + if err := s.Put(entry); err != nil { + return err + } + return nil +} + +// pathWhitelistIdentityDelete is used to delete an entry from the identity whitelist given an instance ID. +func (b *backend) pathWhitelistIdentityDelete( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + instanceID := data.Get("instance_id").(string) + if instanceID == "" { + return logical.ErrorResponse("missing instance_id"), nil + } + + err := req.Storage.Delete("whitelist/identity/" + instanceID) + if err != nil { + return nil, err + } + + return nil, nil +} + +// pathWhitelistIdentityRead is used to view an entry in the identity whitelist given an instance ID. +func (b *backend) pathWhitelistIdentityRead( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + instanceID := data.Get("instance_id").(string) + if instanceID == "" { + return logical.ErrorResponse("missing instance_id"), nil + } + + entry, err := whitelistIdentityEntry(req.Storage, instanceID) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + + return &logical.Response{ + Data: map[string]interface{}{ + "imate_id": entry.ImageID, + "creation_time": entry.CreationTime.String(), + "expiration_time": entry.ExpirationTime.String(), + "client_nonce": entry.ClientNonce, + "pending_time": entry.PendingTime, + }, + }, nil +} + +// Struct to represent each item in the identity whitelist. +type whitelistIdentity struct { + ImageID string `json:"image_id" structs:"image_id" mapstructure:"image_id"` + PendingTime string `json:"pending_time" structs:"pending_time" mapstructure:"pending_time"` + ClientNonce string `json:"client_nonce" structs:"client_nonce" mapstructure:"client_nonce"` + CreationTime time.Time `json:"creation_time" structs:"creation_time" mapstructure:"creation_time"` + LastUpdatedTime time.Time `json:"last_updated_time" structs:"last_updated_time" mapstructure:"last_updated_time"` + ExpirationTime time.Time `json:"expiration_time" structs:"expiration_time" mapstructure:"expiration_time"` +} + +const pathWhitelistIdentitySyn = ` +Read or delete entries in the identity whitelist. +` + +const pathWhitelistIdentityDesc = ` +Each login from an EC2 instance creates/updates an entry in the identity whitelist. + +Entries in this list can be viewed or deleted using this endpoint. + +The entries in the whitelist are not automatically deleted. Although, they will have an +expiration time set on the entry. There is a separate endpoint 'whitelist/identity/tidy', +that needs to be invoked to clean-up all the expired entries in the whitelist. +` + +const pathListWhitelistIdentitiesHelpSyn = ` +List the items present in the identity whitelist. +` + +const pathListWhitelistIdentitiesHelpDesc = ` +The entries in the identity whitelist is keyed off of the EC2 instance IDs. +This endpoint lists all the entries present in the identity whitelist, both +expired and un-expired entries. +` diff --git a/builtin/credential/aws/path_whitelist_identity_tidy.go b/builtin/credential/aws/path_whitelist_identity_tidy.go new file mode 100644 index 000000000..bbefba46d --- /dev/null +++ b/builtin/credential/aws/path_whitelist_identity_tidy.go @@ -0,0 +1,87 @@ +package aws + +import ( + "fmt" + "time" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathWhitelistIdentityTidy(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "whitelist/identity/tidy$", + Fields: map[string]*framework.FieldSchema{ + "safety_buffer": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Default: 259200, + Description: `The amount of extra time that must have passed beyond the identity's +expiration, before it is removed from the backend storage.`, + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathWhitelistIdentityTidyUpdate, + }, + + HelpSynopsis: pathWhitelistIdentityTidySyn, + HelpDescription: pathWhitelistIdentityTidyDesc, + } +} + +// pathWhitelistIdentityTidyUpdate is used to delete entries in the whitelist that are expired. +func (b *backend) pathWhitelistIdentityTidyUpdate( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + + safety_buffer := data.Get("safety_buffer").(int) + + bufferDuration := time.Duration(safety_buffer) * time.Second + + identities, err := req.Storage.List("whitelist/identity/") + if err != nil { + return nil, err + } + + for _, instanceID := range identities { + identityEntry, err := req.Storage.Get("whitelist/identity/" + instanceID) + if err != nil { + return nil, fmt.Errorf("error fetching identity of instanceID %s: %s", instanceID, err) + } + + if identityEntry == nil { + return nil, fmt.Errorf("identity entry for instanceID %s is nil", instanceID) + } + + if identityEntry.Value == nil || len(identityEntry.Value) == 0 { + return nil, fmt.Errorf("found identity entry for instanceID %s but actual identity is empty", instanceID) + } + + var result whitelistIdentity + if err := identityEntry.DecodeJSON(&result); err != nil { + return nil, err + } + + if time.Now().After(result.ExpirationTime.Add(bufferDuration)) { + if err := req.Storage.Delete("whitelist/identity" + instanceID); err != nil { + return nil, fmt.Errorf("error deleting identity of instanceID %s from storage: %s", instanceID, err) + } + } + } + + return nil, nil +} + +const pathWhitelistIdentityTidySyn = ` +Clean-up the whitelisted instance identity entries. +` + +const pathWhitelistIdentityTidyDesc = ` +When an instance identity is whitelisted, the expiration time of the whitelist +entry is set to the least amont 'max_ttl' of the registered AMI, 'max_ttl' of the +role tag and 'max_ttl' of the backend mount. + +When this endpoint is invoked all the entries that are expired will be deleted. + +A 'safety_buffer' (duration in seconds) can be provided, to ensure deletion of +only those entries that are expired before 'safety_buffer' seconds. +` diff --git a/builtin/credential/aws/role_tag_hmac_key.go b/builtin/credential/aws/role_tag_hmac_key.go new file mode 100644 index 000000000..7b608de7c --- /dev/null +++ b/builtin/credential/aws/role_tag_hmac_key.go @@ -0,0 +1,44 @@ +package aws + +import ( + "fmt" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/logical" +) + +const ( + RoleTagHMACKeyLocation = "role_tag_hmac_key" +) + +// hmacKey returns the key to HMAC the RoleTag value. The key is valid per backend mount. +// If a key is not created for the mount, a new key will be created. +func hmacKey(s logical.Storage) (string, error) { + raw, err := s.Get(RoleTagHMACKeyLocation) + if err != nil { + return "", fmt.Errorf("failed to read key: %v", err) + } + + key := "" + if raw != nil { + key = string(raw.Value) + } + + if key == "" { + key, err = uuid.GenerateUUID() + if err != nil { + return "", fmt.Errorf("failed to generate uuid: %v", err) + } + if s != nil { + entry := &logical.StorageEntry{ + Key: RoleTagHMACKeyLocation, + Value: []byte(key), + } + if err := s.Put(entry); err != nil { + return "", fmt.Errorf("failed to persist key: %v", err) + } + } + } + + return key, nil +} diff --git a/cli/commands.go b/cli/commands.go index 497b1697e..a2f84a691 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/vault/version" credAppId "github.com/hashicorp/vault/builtin/credential/app-id" + credAws "github.com/hashicorp/vault/builtin/credential/aws" credCert "github.com/hashicorp/vault/builtin/credential/cert" credGitHub "github.com/hashicorp/vault/builtin/credential/github" credLdap "github.com/hashicorp/vault/builtin/credential/ldap" @@ -63,6 +64,7 @@ func Commands(metaPtr *meta.Meta) map[string]cli.CommandFactory { }, CredentialBackends: map[string]logical.Factory{ "cert": credCert.Factory, + "aws": credAws.Factory, "app-id": credAppId.Factory, "github": credGitHub.Factory, "userpass": credUserpass.Factory,