mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-10 00:27:02 +02:00
* Adding explicit MPL license for sub-package. This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Adding explicit MPL license for sub-package. This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Updating the license from MPL to Business Source License. Going forward, this project will be licensed under the Business Source License v1.1. Please see our blog post for more details at https://hashi.co/bsl-blog, FAQ at www.hashicorp.com/licensing-faq, and details of the license at www.hashicorp.com/bsl. * add missing license headers * Update copyright file headers to BUS-1.1 * Fix test that expected exact offset on hcl file --------- Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com> Co-authored-by: Sarah Thompson <sthompson@hashicorp.com> Co-authored-by: Brian Kassouf <bkassouf@hashicorp.com>
282 lines
8.0 KiB
Go
282 lines
8.0 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package ssh
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
|
|
uuid "github.com/hashicorp/go-uuid"
|
|
"github.com/hashicorp/vault/sdk/framework"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
)
|
|
|
|
type sshOTP struct {
|
|
Username string `json:"username" structs:"username" mapstructure:"username"`
|
|
IP string `json:"ip" structs:"ip" mapstructure:"ip"`
|
|
RoleName string `json:"role_name" structs:"role_name" mapstructure:"role_name"`
|
|
}
|
|
|
|
func pathCredsCreate(b *backend) *framework.Path {
|
|
return &framework.Path{
|
|
Pattern: "creds/" + framework.GenericNameWithAtRegex("role"),
|
|
|
|
DisplayAttrs: &framework.DisplayAttributes{
|
|
OperationPrefix: operationPrefixSSH,
|
|
OperationVerb: "generate",
|
|
OperationSuffix: "credentials",
|
|
},
|
|
|
|
Fields: map[string]*framework.FieldSchema{
|
|
"role": {
|
|
Type: framework.TypeString,
|
|
Description: "[Required] Name of the role",
|
|
},
|
|
"username": {
|
|
Type: framework.TypeString,
|
|
Description: "[Optional] Username in remote host",
|
|
},
|
|
"ip": {
|
|
Type: framework.TypeString,
|
|
Description: "[Required] IP of the remote host",
|
|
},
|
|
},
|
|
|
|
Callbacks: map[logical.Operation]framework.OperationFunc{
|
|
logical.UpdateOperation: b.pathCredsCreateWrite,
|
|
},
|
|
|
|
HelpSynopsis: pathCredsCreateHelpSyn,
|
|
HelpDescription: pathCredsCreateHelpDesc,
|
|
}
|
|
}
|
|
|
|
func (b *backend) pathCredsCreateWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
|
roleName := d.Get("role").(string)
|
|
if roleName == "" {
|
|
return logical.ErrorResponse("Missing role"), nil
|
|
}
|
|
|
|
ipRaw := d.Get("ip").(string)
|
|
if ipRaw == "" {
|
|
return logical.ErrorResponse("Missing ip"), nil
|
|
}
|
|
|
|
role, err := b.getRole(ctx, req.Storage, roleName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error retrieving role: %w", err)
|
|
}
|
|
if role == nil {
|
|
return logical.ErrorResponse(fmt.Sprintf("Role %q not found", roleName)), nil
|
|
}
|
|
|
|
// username is an optional parameter.
|
|
username := d.Get("username").(string)
|
|
|
|
// Set the default username
|
|
if username == "" {
|
|
if role.DefaultUser == "" {
|
|
return logical.ErrorResponse("No default username registered. Use 'username' option"), nil
|
|
}
|
|
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
|
|
}
|
|
} else if username != role.DefaultUser {
|
|
return logical.ErrorResponse("Username has to be either in allowed users list or has to be a default username"), nil
|
|
}
|
|
|
|
// Validate the IP address
|
|
ipAddr := net.ParseIP(ipRaw)
|
|
if ipAddr == nil {
|
|
return logical.ErrorResponse(fmt.Sprintf("Invalid IP %q", ipRaw)), nil
|
|
}
|
|
|
|
// Check if the IP belongs to the registered list of CIDR blocks under the role
|
|
ip := ipAddr.String()
|
|
|
|
zeroAddressEntry, err := b.getZeroAddressRoles(ctx, req.Storage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error retrieving zero-address roles: %w", err)
|
|
}
|
|
var zeroAddressRoles []string
|
|
if zeroAddressEntry != nil {
|
|
zeroAddressRoles = zeroAddressEntry.Roles
|
|
}
|
|
|
|
err = validateIP(ip, roleName, role.CIDRList, role.ExcludeCIDRList, zeroAddressRoles)
|
|
if err != nil {
|
|
return logical.ErrorResponse(fmt.Sprintf("Error validating IP: %v", err)), nil
|
|
}
|
|
|
|
var result *logical.Response
|
|
if role.KeyType == KeyTypeOTP {
|
|
// Generate an OTP
|
|
otp, err := b.GenerateOTPCredential(ctx, req, &sshOTP{
|
|
Username: username,
|
|
IP: ip,
|
|
RoleName: roleName,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Return the information relevant to user of OTP type and save
|
|
// the data required for later use in the internal section of secret.
|
|
// In this case, saving just the OTP is sufficient since there is
|
|
// no need to establish connection with the remote host.
|
|
result = b.Secret(SecretOTPType).Response(map[string]interface{}{
|
|
"key_type": role.KeyType,
|
|
"key": otp,
|
|
"username": username,
|
|
"ip": ip,
|
|
"port": role.Port,
|
|
}, map[string]interface{}{
|
|
"otp": otp,
|
|
})
|
|
} else if role.KeyType == KeyTypeDynamic {
|
|
return nil, fmt.Errorf("dynamic key types have been removed")
|
|
} else {
|
|
return nil, fmt.Errorf("key type unknown")
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Generates a UUID OTP and its salted value based on the salt of the backend.
|
|
func (b *backend) GenerateSaltedOTP(ctx context.Context) (string, string, error) {
|
|
str, err := uuid.GenerateUUID()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
salt, err := b.Salt(ctx)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
return str, salt.SaltID(str), nil
|
|
}
|
|
|
|
// Generates an UUID OTP and creates an entry for the same in storage backend with its salted string.
|
|
func (b *backend) GenerateOTPCredential(ctx context.Context, req *logical.Request, sshOTPEntry *sshOTP) (string, error) {
|
|
otp, otpSalted, err := b.GenerateSaltedOTP(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Check if there is an entry already created for the newly generated OTP.
|
|
entry, err := b.getOTP(ctx, req.Storage, otpSalted)
|
|
|
|
// If entry already exists for the OTP, make sure that new OTP is not
|
|
// replacing an existing one by recreating new ones until an unused
|
|
// OTP is generated. It is very unlikely that this is the case and this
|
|
// code is just for safety.
|
|
for err == nil && entry != nil {
|
|
otp, otpSalted, err = b.GenerateSaltedOTP(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
entry, err = b.getOTP(ctx, req.Storage, otpSalted)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
// Store an entry for the salt of OTP.
|
|
newEntry, err := logical.StorageEntryJSON("otp/"+otpSalted, sshOTPEntry)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := req.Storage.Put(ctx, newEntry); err != nil {
|
|
return "", err
|
|
}
|
|
return otp, nil
|
|
}
|
|
|
|
// ValidateIP first checks if the role belongs to the list of privileged
|
|
// roles that could allow any IP address and if there is a match, IP is
|
|
// accepted immediately. If not, IP is searched in the allowed CIDR blocks
|
|
// registered with the role. If there is a match, then it is searched in the
|
|
// excluded CIDR blocks and if IP is found there as well, an error is returned.
|
|
// IP is valid only if it is encompassed by allowed CIDR blocks and not by
|
|
// excluded CIDR blocks.
|
|
func validateIP(ip, roleName, cidrList, excludeCidrList string, zeroAddressRoles []string) error {
|
|
// Search IP in the zero-address list
|
|
for _, role := range zeroAddressRoles {
|
|
if roleName == role {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Search IP in allowed CIDR blocks
|
|
ipMatched, err := cidrListContainsIP(ip, cidrList)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ipMatched {
|
|
return fmt.Errorf("IP does not belong to role")
|
|
}
|
|
|
|
if len(excludeCidrList) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Search IP in exclude list
|
|
ipMatched, err = cidrListContainsIP(ip, excludeCidrList)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ipMatched {
|
|
return fmt.Errorf("IP does not belong to role")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
if allowedUsers == "" {
|
|
return fmt.Errorf("username not in allowed users list")
|
|
}
|
|
|
|
// Role was explicitly configured to allow any username.
|
|
if allowedUsers == "*" {
|
|
return nil
|
|
}
|
|
|
|
userList := strings.Split(allowedUsers, ",")
|
|
for _, user := range userList {
|
|
if strings.TrimSpace(user) == username {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("username not in allowed users list")
|
|
}
|
|
|
|
const pathCredsCreateHelpSyn = `
|
|
Creates a credential for establishing SSH connection with the remote host.
|
|
`
|
|
|
|
const pathCredsCreateHelpDesc = `
|
|
This path will generate a new key for establishing SSH session with
|
|
target host. The key can be a One Time Password (OTP) using 'key_type'
|
|
being 'otp'.
|
|
|
|
Keys will have a lease associated with them. The access keys can be
|
|
revoked by using the lease ID.
|
|
`
|