From 3958136a78f2fbdd5f0fe9ca87f0af4bd7ddea73 Mon Sep 17 00:00:00 2001 From: vishalnayak Date: Thu, 13 Aug 2015 14:18:30 -0700 Subject: [PATCH] Vault SSH: Introduced allowed_users option. Added helpers getKey and getOTP --- builtin/logical/ssh/path_creds_create.go | 40 +++++++++++--- builtin/logical/ssh/path_keys.go | 19 +++++-- builtin/logical/ssh/path_lookup.go | 9 ++++ builtin/logical/ssh/path_roles.go | 53 +++++++++++++------ builtin/logical/ssh/path_verify.go | 29 +++++++--- .../logical/ssh/scripts/key-install-linux.sh | 4 +- builtin/logical/ssh/secret_dynamic_key.go | 7 ++- builtin/logical/ssh/secret_otp.go | 5 +- 8 files changed, 121 insertions(+), 45 deletions(-) diff --git a/builtin/logical/ssh/path_creds_create.go b/builtin/logical/ssh/path_creds_create.go index b51052ebbc..a43a84ddd7 100644 --- a/builtin/logical/ssh/path_creds_create.go +++ b/builtin/logical/ssh/path_creds_create.go @@ -3,6 +3,7 @@ package ssh import ( "fmt" "net" + "strings" "time" "github.com/hashicorp/vault/helper/uuid" @@ -40,6 +41,18 @@ func pathCredsCreate(b *backend) *framework.Path { } } +// Checks if the username supplied by the user is present in the list of +// allowed users registered which creation of role. +func validateUsername(username, allowedUsers string) error { + userList := strings.Split(allowedUsers, ",") + for _, user := range userList { + if user == username { + return nil + } + } + return fmt.Errorf("username not in allowed users list") +} + func (b *backend) pathCredsCreateWrite( req *logical.Request, d *framework.FieldData) (*logical.Response, error) { roleName := d.Get("role").(string) @@ -47,8 +60,6 @@ func (b *backend) pathCredsCreateWrite( return logical.ErrorResponse("Missing role"), nil } - username := d.Get("username").(string) - ipRaw := d.Get("ip").(string) if ipRaw == "" { return logical.ErrorResponse("Missing ip"), nil @@ -62,6 +73,7 @@ func (b *backend) pathCredsCreateWrite( return logical.ErrorResponse(fmt.Sprintf("Role '%s' not found", roleName)), nil } + username := d.Get("username").(string) // Set the default username if username == "" { if role.DefaultUser == "" { @@ -70,11 +82,25 @@ func (b *backend) pathCredsCreateWrite( username = role.DefaultUser } + if role.AllowedUsers != "" { + // Check if the username is present in allowed users list. + err := validateUsername(username, role.AllowedUsers) + + // If username is not present in allowed users list, check if it + // is the default username in the role. If neither is true, then + // that username is not allowed to generate a credential. + if err != nil && username != role.DefaultUser { + return logical.ErrorResponse("Username is not present is allowed users list."), nil + } + } + // Validate the IP address ipAddr := net.ParseIP(ipRaw) if ipAddr == nil { return logical.ErrorResponse(fmt.Sprintf("Invalid IP '%s'", ipRaw)), nil } + + // Check if the IP belongs to the registered list of CIDR blocks under the role ip := ipAddr.String() ipMatched, err := cidrContainsIP(ip, role.CIDRList) if err != nil { @@ -182,26 +208,26 @@ func (b *backend) GenerateSaltedOTP() (string, string) { return str, b.salt.SaltID(str) } -// Generates a salted OTP and creates an entry for the same in storage backend. +// Generates an UUID OTP and creates an entry for the same in storage backend with its salted string. func (b *backend) GenerateOTPCredential(req *logical.Request, username, ip string) (string, error) { otp, otpSalted := b.GenerateSaltedOTP() - entry, err := req.Storage.Get("otp/" + otpSalted) + entry, err := b.getOTP(req.Storage, otpSalted) // Make sure that new OTP is not replacing an existing one for err == nil && entry != nil { otp, otpSalted = b.GenerateSaltedOTP() - entry, err = req.Storage.Get("otp/" + otpSalted) + entry, err = b.getOTP(req.Storage, otpSalted) if err != nil { return "", err } } - entry, err = logical.StorageEntryJSON("otp/"+otpSalted, sshOTP{ + newEntry, err := logical.StorageEntryJSON("otp/"+otpSalted, sshOTP{ Username: username, IP: ip, }) if err != nil { return "", err } - if err := req.Storage.Put(entry); err != nil { + if err := req.Storage.Put(newEntry); err != nil { return "", err } return otp, nil diff --git a/builtin/logical/ssh/path_keys.go b/builtin/logical/ssh/path_keys.go index c5ae149cc7..cdd3810ba3 100644 --- a/builtin/logical/ssh/path_keys.go +++ b/builtin/logical/ssh/path_keys.go @@ -36,10 +36,8 @@ func pathKeys(b *backend) *framework.Path { } } -func (b *backend) pathKeysRead(req *logical.Request, d *framework.FieldData) (*logical.Response, error) { - keyName := d.Get("key_name").(string) - keyPath := fmt.Sprintf("keys/%s", keyName) - entry, err := req.Storage.Get(keyPath) +func (b *backend) getKey(s logical.Storage, n string) (*sshHostKey, error) { + entry, err := s.Get("keys/" + n) if err != nil { return nil, err } @@ -51,10 +49,21 @@ func (b *backend) pathKeysRead(req *logical.Request, d *framework.FieldData) (*l if err := entry.DecodeJSON(&result); err != nil { return nil, err } + return &result, nil +} + +func (b *backend) pathKeysRead(req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + key, err := b.getKey(req.Storage, d.Get("key_name").(string)) + if err != nil { + return nil, err + } + if key == nil { + return nil, nil + } return &logical.Response{ Data: map[string]interface{}{ - "key": result.Key, + "key": key.Key, }, }, nil } diff --git a/builtin/logical/ssh/path_lookup.go b/builtin/logical/ssh/path_lookup.go index 9c1a4aa6b0..e4643349ea 100644 --- a/builtin/logical/ssh/path_lookup.go +++ b/builtin/logical/ssh/path_lookup.go @@ -46,6 +46,15 @@ func (b *backend) pathLookupWrite(req *logical.Request, d *framework.FieldData) matchingRoles = append(matchingRoles, role) } } + + // This result may potentially reveal more information than it is supposed to. + // The roles for which the client is not authorized to will also be displayed. + // However, if the client tries to use the role for which the client is not + // authenticated, it will fail. There are no problems there. In a way this can + // be viewed as a feature. The client can ask for permissions to be given for + // a specific role if things are not working! + // Going forward, the role names should be filtered and only the roles which + // the client is authorized to see, should be returned. return &logical.Response{ Data: map[string]interface{}{ "roles": matchingRoles, diff --git a/builtin/logical/ssh/path_roles.go b/builtin/logical/ssh/path_roles.go index 2cd4c56520..9a3d49eb3c 100644 --- a/builtin/logical/ssh/path_roles.go +++ b/builtin/logical/ssh/path_roles.go @@ -9,8 +9,22 @@ import ( "github.com/hashicorp/vault/logical/framework" ) -const KeyTypeOTP = "otp" -const KeyTypeDynamic = "dynamic" +const ( + KeyTypeOTP = "otp" + KeyTypeDynamic = "dynamic" +) + +type sshRole struct { + KeyType string `mapstructure:"key_type" json:"key_type"` + KeyName string `mapstructure:"key" json:"key"` + KeyBits int `mapstructure:"key_bits" json:"key_bits"` + AdminUser string `mapstructure:"admin_user" json:"admin_user"` + DefaultUser string `mapstructure:"default_user" json:"default_user"` + CIDRList string `mapstructure:"cidr_list" json:"cidr_list"` + Port int `mapstructure:"port" json:"port"` + InstallScript string `mapstructure:"install_script" json:"install_script"` + AllowedUsers string `mapstructure:"allowed_users" json:"allowed_users"` +} func pathRoles(b *backend) *framework.Path { return &framework.Path{ @@ -81,6 +95,17 @@ func pathRoles(b *backend) *framework.Path { The inbuilt default install script will be for Linux hosts. For sample script, refer the project's documentation website.`, }, + "allowed_users": &framework.FieldSchema{ + Type: framework.TypeString, + Description: ` + [Optional for both types] + If this option is not specified, client can request for a credential for + any valid user at the remote host, including the admin user. If only certain + usernames are to be allowed, then this list enforces it. If this field is + set, then credentials can only be created for default_user and usernames + present in this list. + `, + }, }, Callbacks: map[logical.Operation]framework.OperationFunc{ @@ -100,6 +125,9 @@ func (b *backend) pathRoleWrite(req *logical.Request, d *framework.FieldData) (* return logical.ErrorResponse("Missing role name"), nil } + // Allowed users is an optional field, applicable for both otp and dynamic types. + allowedUsers := d.Get("allowed_users").(string) + defaultUser := d.Get("default_user").(string) if defaultUser == "" { return logical.ErrorResponse("Missing default user"), nil @@ -136,10 +164,11 @@ func (b *backend) pathRoleWrite(req *logical.Request, d *framework.FieldData) (* } roleEntry = sshRole{ - DefaultUser: defaultUser, - CIDRList: cidrList, - KeyType: KeyTypeOTP, - Port: port, + DefaultUser: defaultUser, + CIDRList: cidrList, + KeyType: KeyTypeOTP, + Port: port, + AllowedUsers: allowedUsers, } } else if keyType == KeyTypeDynamic { keyName := d.Get("key").(string) @@ -178,6 +207,7 @@ func (b *backend) pathRoleWrite(req *logical.Request, d *framework.FieldData) (* KeyType: KeyTypeDynamic, KeyBits: keyBits, InstallScript: installScript, + AllowedUsers: allowedUsers, } } else { return logical.ErrorResponse("Invalid key type"), nil @@ -252,17 +282,6 @@ func (b *backend) pathRoleDelete(req *logical.Request, d *framework.FieldData) ( return nil, nil } -type sshRole struct { - KeyType string `mapstructure:"key_type" json:"key_type"` - KeyName string `mapstructure:"key" json:"key"` - KeyBits int `mapstructure:"key_bits" json:"key_bits"` - AdminUser string `mapstructure:"admin_user" json:"admin_user"` - DefaultUser string `mapstructure:"default_user" json:"default_user"` - CIDRList string `mapstructure:"cidr_list" json:"cidr_list"` - Port int `mapstructure:"port" json:"port"` - InstallScript string `mapstructure:"install_script" json:"install_script"` -} - const pathRoleHelpSyn = ` Manage the 'roles' that can be created with this backend. ` diff --git a/builtin/logical/ssh/path_verify.go b/builtin/logical/ssh/path_verify.go index 6beb328bce..bd3682ec05 100644 --- a/builtin/logical/ssh/path_verify.go +++ b/builtin/logical/ssh/path_verify.go @@ -23,6 +23,23 @@ func pathVerify(b *backend) *framework.Path { } } +func (b *backend) getOTP(s logical.Storage, n string) (*sshOTP, error) { + entry, err := s.Get("otp/" + n) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + + var result sshOTP + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + + return &result, nil +} + func (b *backend) pathVerifyWrite(req *logical.Request, d *framework.FieldData) (*logical.Response, error) { otp := d.Get("otp").(string) @@ -38,23 +55,23 @@ func (b *backend) pathVerifyWrite(req *logical.Request, d *framework.FieldData) } otpSalted := b.salt.SaltID(otp) - entry, err := req.Storage.Get("otp/" + otpSalted) + + // Return nil if there is no entry found for the OTP + otpEntry, err := b.getOTP(req.Storage, otpSalted) if err != nil { return nil, err } - if entry == nil { - return nil, nil - } - var otpEntry sshOTP - if err := entry.DecodeJSON(&otpEntry); err != nil { + if otpEntry == nil { return nil, nil } + // Delete the OTP if found. This is what makes the key an OTP. err = req.Storage.Delete("otp/" + otpSalted) if err != nil { return nil, err } + // Return username and IP only if there were no problems uptill this point. return &logical.Response{ Data: map[string]interface{}{ "username": otpEntry.Username, diff --git a/builtin/logical/ssh/scripts/key-install-linux.sh b/builtin/logical/ssh/scripts/key-install-linux.sh index 9784e2b18f..74b445d9b4 100644 --- a/builtin/logical/ssh/scripts/key-install-linux.sh +++ b/builtin/logical/ssh/scripts/key-install-linux.sh @@ -25,11 +25,11 @@ fi grep -vFf $2 $3 > temp_$2 # Contents of temporary file will be the contents of authorized_keys file. -cat temp_$2 > $3 +cat temp_$2 | sudo tee $3 if [ $1 == "install" ]; then # New public key is appended to authorized_keys file -cat $2 >> $3 +cat $2 | sudo tee --append $3 fi # Auxiliary files are deleted diff --git a/builtin/logical/ssh/secret_dynamic_key.go b/builtin/logical/ssh/secret_dynamic_key.go index b20ff32548..92d6d0282f 100644 --- a/builtin/logical/ssh/secret_dynamic_key.go +++ b/builtin/logical/ssh/secret_dynamic_key.go @@ -104,13 +104,12 @@ func (b *backend) secretDynamicKeyRevoke(req *logical.Request, d *framework.Fiel port := int(portRaw.(float64)) // Fetch the host key using the key name - hostKeyEntry, err := req.Storage.Get(fmt.Sprintf("keys/%s", hostKeyName)) + hostKey, err := b.getKey(req.Storage, hostKeyName) if err != nil { return nil, fmt.Errorf("key '%s' not found error:%s", hostKeyName, err) } - var hostKey sshHostKey - if err := hostKeyEntry.DecodeJSON(&hostKey); err != nil { - return nil, fmt.Errorf("error reading the host key: %s", err) + if hostKey == nil { + return nil, fmt.Errorf("key '%s' not found", hostKeyName) } // Transfer the dynamic public key to target machine and use it to remove the entry from authorized_keys file diff --git a/builtin/logical/ssh/secret_otp.go b/builtin/logical/ssh/secret_otp.go index 1dc18b5f8c..a27656c316 100644 --- a/builtin/logical/ssh/secret_otp.go +++ b/builtin/logical/ssh/secret_otp.go @@ -35,10 +35,7 @@ func (b *backend) secretOTPRevoke(req *logical.Request, d *framework.FieldData) return nil, fmt.Errorf("secret is missing internal data") } - otpSalted := b.salt.SaltID(otp) - otpPath := fmt.Sprintf("otp/%s", otpSalted) - - err := req.Storage.Delete(otpPath) + err := req.Storage.Delete("otp/" + b.salt.SaltID(otp)) if err != nil { return nil, err }