diff --git a/builtin/logical/ssh/backend.go b/builtin/logical/ssh/backend.go index fe4f40b33..b7b1b5acf 100644 --- a/builtin/logical/ssh/backend.go +++ b/builtin/logical/ssh/backend.go @@ -53,7 +53,6 @@ func Backend(conf *logical.BackendConfig) (*backend, error) { Paths: []*framework.Path{ pathConfigZeroAddress(&b), - pathKeys(&b), pathListRoles(&b), pathRoles(&b), pathCredsCreate(&b), @@ -66,7 +65,6 @@ func Backend(conf *logical.BackendConfig) (*backend, error) { }, Secrets: []*framework.Secret{ - secretDynamicKey(&b), secretOTP(&b), }, @@ -112,8 +110,8 @@ const backendHelp = ` The SSH backend generates credentials allowing clients to establish SSH connections to remote hosts. -There are three variants of the backend, which generate different types of -credentials: dynamic keys, One-Time Passwords (OTPs) and certificate authority. The desired behavior +There are two variants of the backend, which generate different types of +credentials: One-Time Passwords (OTPs) and certificate authority. The desired behavior is role-specific and chosen at role creation time with the 'key_type' parameter. diff --git a/builtin/logical/ssh/backend_test.go b/builtin/logical/ssh/backend_test.go index 4ad4a9f3c..d46168618 100644 --- a/builtin/logical/ssh/backend_test.go +++ b/builtin/logical/ssh/backend_test.go @@ -26,17 +26,15 @@ import ( ) const ( - testIP = "127.0.0.1" - testUserName = "vaultssh" - testMultiUserName = "vaultssh,otherssh" - testAdminUser = "vaultssh" - testCaKeyType = "ca" - testOTPKeyType = "otp" - testDynamicKeyType = "dynamic" - testCIDRList = "127.0.0.1/32" - testAtRoleName = "test@RoleName" - testDynamicRoleName = "testDynamicRoleName" - testOTPRoleName = "testOTPRoleName" + testIP = "127.0.0.1" + testUserName = "vaultssh" + testMultiUserName = "vaultssh,otherssh" + testAdminUser = "vaultssh" + testCaKeyType = "ca" + testOTPKeyType = "otp" + testCIDRList = "127.0.0.1/32" + testAtRoleName = "test@RoleName" + testOTPRoleName = "testOTPRoleName" // testKeyName is the name of the entry that will be written to SSHMOUNTPOINT/ssh/keys testKeyName = "testKeyName" // testSharedPrivateKey is the value of the entry that will be written to SSHMOUNTPOINT/ssh/keys @@ -537,36 +535,22 @@ func TestSSHBackend_Lookup(t *testing.T) { "default_user": testUserName, "cidr_list": testCIDRList, } - testDynamicRoleData := map[string]interface{}{ - "key_type": testDynamicKeyType, - "key": testKeyName, - "admin_user": testAdminUser, - "default_user": testAdminUser, - "cidr_list": testCIDRList, - } data := map[string]interface{}{ "ip": testIP, } resp1 := []string(nil) resp2 := []string{testOTPRoleName} - resp3 := []string{testDynamicRoleName, testOTPRoleName} - resp4 := []string{testDynamicRoleName} - resp5 := []string{testAtRoleName} + resp3 := []string{testAtRoleName} logicaltest.Test(t, logicaltest.TestCase{ LogicalFactory: newTestingFactory(t), Steps: []logicaltest.TestStep{ testLookupRead(t, data, resp1), testRoleWrite(t, testOTPRoleName, testOTPRoleData), testLookupRead(t, data, resp2), - testNamedKeysWrite(t, testKeyName, testSharedPrivateKey), - testRoleWrite(t, testDynamicRoleName, testDynamicRoleData), - testLookupRead(t, data, resp3), testRoleDelete(t, testOTPRoleName), - testLookupRead(t, data, resp4), - testRoleDelete(t, testDynamicRoleName), testLookupRead(t, data, resp1), - testRoleWrite(t, testAtRoleName, testDynamicRoleData), - testLookupRead(t, data, resp5), + testRoleWrite(t, testAtRoleName, testOTPRoleData), + testLookupRead(t, data, resp3), testRoleDelete(t, testAtRoleName), testLookupRead(t, data, resp1), }, @@ -615,39 +599,6 @@ func TestSSHBackend_RoleList(t *testing.T) { }) } -func TestSSHBackend_DynamicKeyCreate(t *testing.T) { - cleanup, sshAddress := prepareTestContainer(t, "", "") - defer cleanup() - - host, port, err := net.SplitHostPort(sshAddress) - if err != nil { - t.Fatal(err) - } - - testDynamicRoleData := map[string]interface{}{ - "key_type": testDynamicKeyType, - "key": testKeyName, - "admin_user": testAdminUser, - "default_user": testAdminUser, - "cidr_list": host + "/32", - "port": port, - } - data := map[string]interface{}{ - "username": testUserName, - "ip": host, - } - logicaltest.Test(t, logicaltest.TestCase{ - LogicalFactory: newTestingFactory(t), - Steps: []logicaltest.TestStep{ - testNamedKeysWrite(t, testKeyName, testSharedPrivateKey), - testRoleWrite(t, testDynamicRoleName, testDynamicRoleData), - testCredsWrite(t, testDynamicRoleName, data, false, sshAddress), - testRoleWrite(t, testAtRoleName, testDynamicRoleData), - testCredsWrite(t, testAtRoleName, data, false, sshAddress), - }, - }) -} - func TestSSHBackend_OTPRoleCrud(t *testing.T) { testOTPRoleData := map[string]interface{}{ "key_type": testOTPKeyType, @@ -675,50 +626,6 @@ func TestSSHBackend_OTPRoleCrud(t *testing.T) { }) } -func TestSSHBackend_DynamicRoleCrud(t *testing.T) { - testDynamicRoleData := map[string]interface{}{ - "key_type": testDynamicKeyType, - "key": testKeyName, - "admin_user": testAdminUser, - "default_user": testAdminUser, - "cidr_list": testCIDRList, - } - respDynamicRoleData := map[string]interface{}{ - "cidr_list": testCIDRList, - "port": 22, - "install_script": DefaultPublicKeyInstallScript, - "key_bits": 1024, - "key": testKeyName, - "admin_user": testUserName, - "default_user": testUserName, - "key_type": testDynamicKeyType, - } - logicaltest.Test(t, logicaltest.TestCase{ - LogicalFactory: newTestingFactory(t), - Steps: []logicaltest.TestStep{ - testNamedKeysWrite(t, testKeyName, testSharedPrivateKey), - testRoleWrite(t, testDynamicRoleName, testDynamicRoleData), - testRoleRead(t, testDynamicRoleName, respDynamicRoleData), - testRoleDelete(t, testDynamicRoleName), - testRoleRead(t, testDynamicRoleName, nil), - testRoleWrite(t, testAtRoleName, testDynamicRoleData), - testRoleRead(t, testAtRoleName, respDynamicRoleData), - testRoleDelete(t, testAtRoleName), - testRoleRead(t, testAtRoleName, nil), - }, - }) -} - -func TestSSHBackend_NamedKeysCrud(t *testing.T) { - logicaltest.Test(t, logicaltest.TestCase{ - LogicalFactory: newTestingFactory(t), - Steps: []logicaltest.TestStep{ - testNamedKeysWrite(t, testKeyName, testSharedPrivateKey), - testNamedKeysDelete(t), - }, - }) -} - func TestSSHBackend_OTPCreate(t *testing.T) { cleanup, sshAddress := prepareTestContainer(t, "", "") defer func() { @@ -772,24 +679,14 @@ func TestSSHBackend_ConfigZeroAddressCRUD(t *testing.T) { "default_user": testUserName, "cidr_list": testCIDRList, } - testDynamicRoleData := map[string]interface{}{ - "key_type": testDynamicKeyType, - "key": testKeyName, - "admin_user": testAdminUser, - "default_user": testAdminUser, - "cidr_list": testCIDRList, - } req1 := map[string]interface{}{ "roles": testOTPRoleName, } resp1 := map[string]interface{}{ "roles": []string{testOTPRoleName}, } - req2 := map[string]interface{}{ - "roles": fmt.Sprintf("%s,%s", testOTPRoleName, testDynamicRoleName), - } resp2 := map[string]interface{}{ - "roles": []string{testOTPRoleName, testDynamicRoleName}, + "roles": []string{testOTPRoleName}, } resp3 := map[string]interface{}{ "roles": []string{}, @@ -801,11 +698,7 @@ func TestSSHBackend_ConfigZeroAddressCRUD(t *testing.T) { testRoleWrite(t, testOTPRoleName, testOTPRoleData), testConfigZeroAddressWrite(t, req1), testConfigZeroAddressRead(t, resp1), - testNamedKeysWrite(t, testKeyName, testSharedPrivateKey), - testRoleWrite(t, testDynamicRoleName, testDynamicRoleData), - testConfigZeroAddressWrite(t, req2), testConfigZeroAddressRead(t, resp2), - testRoleDelete(t, testDynamicRoleName), testConfigZeroAddressRead(t, resp1), testRoleDelete(t, testOTPRoleName), testConfigZeroAddressRead(t, resp3), @@ -839,43 +732,6 @@ func TestSSHBackend_CredsForZeroAddressRoles_otp(t *testing.T) { }) } -func TestSSHBackend_CredsForZeroAddressRoles_dynamic(t *testing.T) { - cleanup, sshAddress := prepareTestContainer(t, "", "") - defer cleanup() - - host, port, err := net.SplitHostPort(sshAddress) - if err != nil { - t.Fatal(err) - } - - dynamicRoleData := map[string]interface{}{ - "key_type": testDynamicKeyType, - "key": testKeyName, - "admin_user": testAdminUser, - "default_user": testAdminUser, - "port": port, - } - data := map[string]interface{}{ - "username": testUserName, - "ip": host, - } - req2 := map[string]interface{}{ - "roles": testDynamicRoleName, - } - logicaltest.Test(t, logicaltest.TestCase{ - LogicalFactory: newTestingFactory(t), - Steps: []logicaltest.TestStep{ - testNamedKeysWrite(t, testKeyName, testSharedPrivateKey), - testRoleWrite(t, testDynamicRoleName, dynamicRoleData), - testCredsWrite(t, testDynamicRoleName, data, true, sshAddress), - testConfigZeroAddressWrite(t, req2), - testCredsWrite(t, testDynamicRoleName, data, false, sshAddress), - testConfigZeroAddressDelete(t), - testCredsWrite(t, testDynamicRoleName, data, true, sshAddress), - }, - }) -} - func TestSSHBackend_CA(t *testing.T) { testCases := []struct { name string @@ -2414,23 +2270,6 @@ func testVerifyWrite(t *testing.T, data map[string]interface{}, expected map[str } } -func testNamedKeysWrite(t *testing.T, name, key string) logicaltest.TestStep { - return logicaltest.TestStep{ - Operation: logical.UpdateOperation, - Path: fmt.Sprintf("keys/%s", name), - Data: map[string]interface{}{ - "key": key, - }, - } -} - -func testNamedKeysDelete(t *testing.T) logicaltest.TestStep { - return logicaltest.TestStep{ - Operation: logical.DeleteOperation, - Path: fmt.Sprintf("keys/%s", testKeyName), - } -} - func testLookupRead(t *testing.T, data map[string]interface{}, expected []string) logicaltest.TestStep { return logicaltest.TestStep{ Operation: logical.UpdateOperation, @@ -2495,10 +2334,6 @@ func testRoleRead(t *testing.T, roleName string, expected map[string]interface{} if d.KeyType != expected["key_type"] || d.DefaultUser != expected["default_user"] || d.CIDRList != expected["cidr_list"] { return fmt.Errorf("data mismatch. bad: %#v", resp) } - case "dynamic": - if d.AdminUser != expected["admin_user"] || d.CIDRList != expected["cidr_list"] || d.KeyName != expected["key"] || d.KeyType != expected["key_type"] { - return fmt.Errorf("data mismatch. bad: %#v", resp) - } default: return fmt.Errorf("unknown key type. bad: %#v", resp) } @@ -2539,7 +2374,7 @@ func testCredsWrite(t *testing.T, roleName string, data map[string]interface{}, } return nil } - if roleName == testDynamicRoleName || roleName == testAtRoleName { + if roleName == testAtRoleName { var d struct { Key string `mapstructure:"key"` } diff --git a/builtin/logical/ssh/communicator.go b/builtin/logical/ssh/communicator.go deleted file mode 100644 index 8950c41e1..000000000 --- a/builtin/logical/ssh/communicator.go +++ /dev/null @@ -1,350 +0,0 @@ -package ssh - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "io" - "io/ioutil" - "net" - "os" - "path/filepath" - - log "github.com/hashicorp/go-hclog" - - "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/agent" -) - -type comm struct { - client *ssh.Client - config *SSHCommConfig - conn net.Conn - address string -} - -// SSHCommConfig is the structure used to configure the SSH communicator. -type SSHCommConfig struct { - // The configuration of the Go SSH connection - SSHConfig *ssh.ClientConfig - - // Connection returns a new connection. The current connection - // in use will be closed as part of the Close method, or in the - // case an error occurs. - Connection func() (net.Conn, error) - - // Pty, if true, will request a pty from the remote end. - Pty bool - - // DisableAgent, if true, will not forward the SSH agent. - DisableAgent bool - - // Logger for output - Logger log.Logger -} - -// Creates a new communicator implementation over SSH. This takes -// an already existing TCP connection and SSH configuration. -func SSHCommNew(address string, config *SSHCommConfig) (result *comm, err error) { - // Establish an initial connection and connect - result = &comm{ - config: config, - address: address, - } - - if err = result.reconnect(); err != nil { - result = nil - return - } - - return -} - -func (c *comm) Close() error { - var err error - if c.conn != nil { - err = c.conn.Close() - } - c.conn = nil - c.client = nil - return err -} - -func (c *comm) Upload(path string, input io.Reader, fi *os.FileInfo) error { - // The target directory and file for talking the SCP protocol - target_dir := filepath.Dir(path) - target_file := filepath.Base(path) - - // On windows, filepath.Dir uses backslash separators (ie. "\tmp"). - // This does not work when the target host is unix. Switch to forward slash - // which works for unix and windows - target_dir = filepath.ToSlash(target_dir) - - scpFunc := func(w io.Writer, stdoutR *bufio.Reader) error { - return scpUploadFile(target_file, input, w, stdoutR, fi) - } - - return c.scpSession("scp -vt "+target_dir, scpFunc) -} - -func (c *comm) NewSession() (session *ssh.Session, err error) { - if c.client == nil { - err = errors.New("client not available") - } else { - session, err = c.client.NewSession() - } - - if err != nil { - c.config.Logger.Error("ssh session open error, attempting reconnect", "error", err) - if err := c.reconnect(); err != nil { - c.config.Logger.Error("reconnect attempt failed", "error", err) - return nil, err - } - - return c.client.NewSession() - } - - return session, nil -} - -func (c *comm) reconnect() error { - // Close previous connection. - if c.conn != nil { - c.Close() - } - - var err error - c.conn, err = c.config.Connection() - if err != nil { - // Explicitly set this to the REAL nil. Connection() can return - // a nil implementation of net.Conn which will make the - // "if c.conn == nil" check fail above. Read here for more information - // on this psychotic language feature: - // - // http://golang.org/doc/faq#nil_error - c.conn = nil - c.config.Logger.Error("reconnection error", "error", err) - return err - } - - sshConn, sshChan, req, err := ssh.NewClientConn(c.conn, c.address, c.config.SSHConfig) - if err != nil { - c.config.Logger.Error("handshake error", "error", err) - c.Close() - return err - } - if sshConn != nil { - c.client = ssh.NewClient(sshConn, sshChan, req) - } - c.connectToAgent() - - return nil -} - -func (c *comm) connectToAgent() { - if c.client == nil { - return - } - - if c.config.DisableAgent { - return - } - - // open connection to the local agent - socketLocation := os.Getenv("SSH_AUTH_SOCK") - if socketLocation == "" { - return - } - agentConn, err := net.Dial("unix", socketLocation) - if err != nil { - c.config.Logger.Error("could not connect to local agent socket", "socket_path", socketLocation) - return - } - defer agentConn.Close() - - // create agent and add in auth - forwardingAgent := agent.NewClient(agentConn) - if forwardingAgent == nil { - c.config.Logger.Error("could not create agent client") - return - } - - // add callback for forwarding agent to SSH config - // XXX - might want to handle reconnects appending multiple callbacks - auth := ssh.PublicKeysCallback(forwardingAgent.Signers) - c.config.SSHConfig.Auth = append(c.config.SSHConfig.Auth, auth) - agent.ForwardToAgent(c.client, forwardingAgent) - - // Setup a session to request agent forwarding - session, err := c.NewSession() - if err != nil { - return - } - defer session.Close() - - err = agent.RequestAgentForwarding(session) - if err != nil { - c.config.Logger.Error("error requesting agent forwarding", "error", err) - return - } - return -} - -func (c *comm) scpSession(scpCommand string, f func(io.Writer, *bufio.Reader) error) error { - session, err := c.NewSession() - if err != nil { - return err - } - defer session.Close() - - // Get a pipe to stdin so that we can send data down - stdinW, err := session.StdinPipe() - if err != nil { - return err - } - - // We only want to close once, so we nil w after we close it, - // and only close in the defer if it hasn't been closed already. - defer func() { - if stdinW != nil { - stdinW.Close() - } - }() - - // Get a pipe to stdout so that we can get responses back - stdoutPipe, err := session.StdoutPipe() - if err != nil { - return err - } - stdoutR := bufio.NewReader(stdoutPipe) - - // Set stderr to a bytes buffer - stderr := new(bytes.Buffer) - session.Stderr = stderr - - // Start the sink mode on the other side - if err := session.Start(scpCommand); err != nil { - return err - } - - // Call our callback that executes in the context of SCP. We ignore - // EOF errors if they occur because it usually means that SCP prematurely - // ended on the other side. - if err := f(stdinW, stdoutR); err != nil && err != io.EOF { - return err - } - - // Close the stdin, which sends an EOF, and then set w to nil so that - // our defer func doesn't close it again since that is unsafe with - // the Go SSH package. - stdinW.Close() - stdinW = nil - - // Wait for the SCP connection to close, meaning it has consumed all - // our data and has completed. Or has errored. - err = session.Wait() - if err != nil { - if exitErr, ok := err.(*ssh.ExitError); ok { - // Otherwise, we have an ExitErorr, meaning we can just read - // the exit status - c.config.Logger.Error("got non-zero exit status", "exit_status", exitErr.ExitStatus()) - - // If we exited with status 127, it means SCP isn't available. - // Return a more descriptive error for that. - if exitErr.ExitStatus() == 127 { - return errors.New( - "SCP failed to start. This usually means that SCP is not\n" + - "properly installed on the remote system.") - } - } - - return err - } - return nil -} - -// checkSCPStatus checks that a prior command sent to SCP completed -// successfully. If it did not complete successfully, an error will -// be returned. -func checkSCPStatus(r *bufio.Reader) error { - code, err := r.ReadByte() - if err != nil { - return err - } - - if code != 0 { - // Treat any non-zero (really 1 and 2) as fatal errors - message, _, err := r.ReadLine() - if err != nil { - return fmt.Errorf("error reading error message: %w", err) - } - - return errors.New(string(message)) - } - - return nil -} - -func scpUploadFile(dst string, src io.Reader, w io.Writer, r *bufio.Reader, fi *os.FileInfo) error { - var mode os.FileMode - var size int64 - - if fi != nil && (*fi).Mode().IsRegular() { - mode = (*fi).Mode().Perm() - size = (*fi).Size() - } else { - // Create a temporary file where we can copy the contents of the src - // so that we can determine the length, since SCP is length-prefixed. - tf, err := ioutil.TempFile("", "vault-ssh-upload") - if err != nil { - return fmt.Errorf("error creating temporary file for upload: %w", err) - } - defer os.Remove(tf.Name()) - defer tf.Close() - - mode = 0o644 - - if _, err := io.Copy(tf, src); err != nil { - return err - } - - // Sync the file so that the contents are definitely on disk, then - // read the length of it. - if err := tf.Sync(); err != nil { - return fmt.Errorf("error creating temporary file for upload: %w", err) - } - - // Seek the file to the beginning so we can re-read all of it - if _, err := tf.Seek(0, 0); err != nil { - return fmt.Errorf("error creating temporary file for upload: %w", err) - } - - tfi, err := tf.Stat() - if err != nil { - return fmt.Errorf("error creating temporary file for upload: %w", err) - } - - size = tfi.Size() - src = tf - } - - // Start the protocol - perms := fmt.Sprintf("C%04o", mode) - - fmt.Fprintln(w, perms, size, dst) - if err := checkSCPStatus(r); err != nil { - return err - } - - if _, err := io.CopyN(w, src, size); err != nil { - return err - } - - fmt.Fprint(w, "\x00") - if err := checkSCPStatus(r); err != nil { - return err - } - - return nil -} diff --git a/builtin/logical/ssh/linux_install_script.go b/builtin/logical/ssh/linux_install_script.go deleted file mode 100644 index a2228b2fc..000000000 --- a/builtin/logical/ssh/linux_install_script.go +++ /dev/null @@ -1,71 +0,0 @@ -package ssh - -const ( - // This is a constant representing a script to install and uninstall public - // key in remote hosts. - DefaultPublicKeyInstallScript = ` -#!/bin/bash -# -# This is a default script which installs or uninstalls an RSA public key to/from -# authorized_keys file in a typical linux machine. -# -# If the platform differs or if the binaries used in this script are not available -# in target machine, use the 'install_script' parameter with 'roles/' endpoint to -# register a custom script (applicable for Dynamic type only). -# -# Vault server runs this script on the target machine with the following params: -# -# $1:INSTALL_OPTION: "install" or "uninstall" -# -# $2:PUBLIC_KEY_FILE: File name containing public key to be installed. Vault server -# uses UUID as name to avoid collisions with public keys generated for other requests. -# -# $3:AUTH_KEYS_FILE: Absolute path of the authorized_keys file. -# Currently, vault uses /home//.ssh/authorized_keys as the path. -# -# [Note: This script will be run by Vault using the registered admin username. -# Notice that some commands below are run as 'sudo'. For graceful execution of -# this script there should not be any password prompts. So, disable password -# prompt for the admin username registered with Vault. - -set -e - -# Storing arguments into variables, to increase readability of the script. -INSTALL_OPTION=$1 -PUBLIC_KEY_FILE=$2 -AUTH_KEYS_FILE=$3 - -# Delete the public key file and the temporary file -function cleanup -{ - rm -f "$PUBLIC_KEY_FILE" temp_$PUBLIC_KEY_FILE -} - -# 'cleanup' will be called if the script ends or if any command fails. -trap cleanup EXIT - -# Return if the option is anything other than 'install' or 'uninstall'. -if [ "$INSTALL_OPTION" != "install" ] && [ "$INSTALL_OPTION" != "uninstall" ]; then - exit 1 -fi - -# use locking to avoid parallel script execution -( - flock --timeout 10 200 - # Create the .ssh directory and authorized_keys file if it does not exist - SSH_DIR=$(dirname $AUTH_KEYS_FILE) - sudo mkdir -p "$SSH_DIR" - sudo touch "$AUTH_KEYS_FILE" - # Remove the key from authorized_keys file if it is already present. - # This step is common for both install and uninstall. Note that grep's - # return code is ignored, thus if grep fails all keys will be removed - # rather than none and it fails secure - sudo grep -vFf "$PUBLIC_KEY_FILE" "$AUTH_KEYS_FILE" > temp_$PUBLIC_KEY_FILE || true - cat temp_$PUBLIC_KEY_FILE | sudo tee "$AUTH_KEYS_FILE" - # Append the new public key to authorized_keys file - if [ "$INSTALL_OPTION" == "install" ]; then - cat "$PUBLIC_KEY_FILE" | sudo tee --append "$AUTH_KEYS_FILE" - fi -) 200> ${AUTH_KEYS_FILE}.lock -` -) diff --git a/builtin/logical/ssh/path_creds_create.go b/builtin/logical/ssh/path_creds_create.go index 6a644ab88..d8b8f8bbf 100644 --- a/builtin/logical/ssh/path_creds_create.go +++ b/builtin/logical/ssh/path_creds_create.go @@ -135,30 +135,7 @@ func (b *backend) pathCredsCreateWrite(ctx context.Context, req *logical.Request "otp": otp, }) } else if role.KeyType == KeyTypeDynamic { - // Generate an RSA key pair. This also installs the newly generated - // public key in the remote host. - dynamicPublicKey, dynamicPrivateKey, err := b.GenerateDynamicCredential(ctx, req, role, username, ip) - if err != nil { - return nil, err - } - - // Return the information relevant to user of dynamic type and save - // information required for later use in internal section of secret. - result = b.Secret(SecretDynamicKeyType).Response(map[string]interface{}{ - "key": dynamicPrivateKey, - "key_type": role.KeyType, - "username": username, - "ip": ip, - "port": role.Port, - }, map[string]interface{}{ - "admin_user": role.AdminUser, - "username": username, - "ip": ip, - "host_key_name": role.KeyName, - "dynamic_public_key": dynamicPublicKey, - "port": role.Port, - "install_script": role.InstallScript, - }) + return nil, fmt.Errorf("dynamic key types have been removed") } else { return nil, fmt.Errorf("key type unknown") } @@ -166,41 +143,6 @@ func (b *backend) pathCredsCreateWrite(ctx context.Context, req *logical.Request return result, nil } -// Generates a RSA key pair and installs it in the remote target -func (b *backend) GenerateDynamicCredential(ctx context.Context, req *logical.Request, role *sshRole, username, ip string) (string, string, error) { - // Fetch the host key to be used for dynamic key installation - keyEntry, err := req.Storage.Get(ctx, fmt.Sprintf("keys/%s", role.KeyName)) - if err != nil { - return "", "", fmt.Errorf("key %q not found: %w", role.KeyName, err) - } - - if keyEntry == nil { - return "", "", fmt.Errorf("key %q not found", role.KeyName) - } - - var hostKey sshHostKey - if err := keyEntry.DecodeJSON(&hostKey); err != nil { - return "", "", fmt.Errorf("error reading the host key: %w", err) - } - - // Generate a new RSA key pair with the given key length. - dynamicPublicKey, dynamicPrivateKey, err := generateRSAKeys(role.KeyBits) - if err != nil { - return "", "", fmt.Errorf("error generating key: %w", err) - } - - if len(role.KeyOptionSpecs) != 0 { - dynamicPublicKey = fmt.Sprintf("%s %s", role.KeyOptionSpecs, dynamicPublicKey) - } - - // Add the public key to authorized_keys file in target machine - err = b.installPublicKeyInTarget(ctx, role.AdminUser, username, ip, role.Port, hostKey.Key, dynamicPublicKey, role.InstallScript, true) - if err != nil { - return "", "", fmt.Errorf("failed to add public key to authorized_keys file in target: %w", err) - } - return dynamicPublicKey, dynamicPrivateKey, 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() @@ -319,12 +261,8 @@ 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 either be a long lived dynamic key or a One -Time Password (OTP), using 'key_type' parameter being 'dynamic' or -'otp' respectively. For dynamic keys, a named key should be supplied. -Create named key using the 'keys/' endpoint, and this represents the -shared SSH key of target host. If this backend is mounted at 'ssh', -then "ssh/creds/web" would generate a key for 'web' role. +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. diff --git a/builtin/logical/ssh/path_fetch.go b/builtin/logical/ssh/path_fetch.go index de5a3e60d..2f45c1c35 100644 --- a/builtin/logical/ssh/path_fetch.go +++ b/builtin/logical/ssh/path_fetch.go @@ -16,7 +16,7 @@ func pathFetchPublicKey(b *backend) *framework.Path { }, HelpSynopsis: `Retrieve the public key.`, - HelpDescription: `This allows the public key, that this backend has been configured with, to be fetched. This is a raw response endpoint without JSON encoding; use -format=raw or an external tool (e.g., curl) to fetch this value.`, + HelpDescription: `This allows the public key of the SSH CA certificate that this backend has been configured with to be fetched. This is a raw response endpoint without JSON encoding; use -format=raw or an external tool (e.g., curl) to fetch this value.`, } } diff --git a/builtin/logical/ssh/path_keys.go b/builtin/logical/ssh/path_keys.go deleted file mode 100644 index 6f0f7c9b2..000000000 --- a/builtin/logical/ssh/path_keys.go +++ /dev/null @@ -1,110 +0,0 @@ -package ssh - -import ( - "context" - "fmt" - - "golang.org/x/crypto/ssh" - - "github.com/hashicorp/vault/sdk/framework" - "github.com/hashicorp/vault/sdk/logical" -) - -type sshHostKey struct { - Key string `json:"key"` -} - -func pathKeys(b *backend) *framework.Path { - return &framework.Path{ - Pattern: "keys/" + framework.GenericNameRegex("key_name"), - Fields: map[string]*framework.FieldSchema{ - "key_name": { - Type: framework.TypeString, - Description: "[Required] Name of the key", - }, - "key": { - Type: framework.TypeString, - Description: "[Required] SSH private key with super user privileges in host", - }, - }, - Callbacks: map[logical.Operation]framework.OperationFunc{ - logical.UpdateOperation: b.pathKeysWrite, - logical.DeleteOperation: b.pathKeysDelete, - }, - HelpSynopsis: pathKeysSyn, - HelpDescription: pathKeysDesc, - } -} - -func (b *backend) getKey(ctx context.Context, s logical.Storage, n string) (*sshHostKey, error) { - entry, err := s.Get(ctx, "keys/"+n) - if err != nil { - return nil, err - } - if entry == nil { - return nil, nil - } - - var result sshHostKey - if err := entry.DecodeJSON(&result); err != nil { - return nil, err - } - return &result, nil -} - -func (b *backend) pathKeysDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { - keyName := d.Get("key_name").(string) - keyPath := fmt.Sprintf("keys/%s", keyName) - err := req.Storage.Delete(ctx, keyPath) - if err != nil { - return nil, err - } - return nil, nil -} - -func (b *backend) pathKeysWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { - keyName := d.Get("key_name").(string) - if keyName == "" { - return logical.ErrorResponse("Missing key_name"), nil - } - - keyString := d.Get("key").(string) - - // Check if the key provided is infact a private key - signer, err := ssh.ParsePrivateKey([]byte(keyString)) - if err != nil || signer == nil { - return logical.ErrorResponse("Invalid key"), nil - } - - if keyString == "" { - return logical.ErrorResponse("Missing key"), nil - } - - keyPath := fmt.Sprintf("keys/%s", keyName) - - // Store the key - entry, err := logical.StorageEntryJSON(keyPath, map[string]interface{}{ - "key": keyString, - }) - if err != nil { - return nil, err - } - if err := req.Storage.Put(ctx, entry); err != nil { - return nil, err - } - return nil, nil -} - -const pathKeysSyn = ` -Register a shared private key with Vault. -` - -const pathKeysDesc = ` -Vault uses this key to install and uninstall dynamic keys in remote hosts. This -key should have sudoer privileges in remote hosts. This enables installing keys -for unprivileged usernames. - -If this backend is mounted as "ssh", then the endpoint for registering shared -key is "ssh/keys/". The name given here can be associated with any number -of roles via the endpoint "ssh/roles/". -` diff --git a/builtin/logical/ssh/path_roles.go b/builtin/logical/ssh/path_roles.go index 6e525c42b..21aff6657 100644 --- a/builtin/logical/ssh/path_roles.go +++ b/builtin/logical/ssh/path_roles.go @@ -17,7 +17,7 @@ import ( const ( // KeyTypeOTP is an key of type OTP KeyTypeOTP = "otp" - // KeyTypeDynamic is dynamic key type + // KeyTypeDynamic is dynamic key type; removed. KeyTypeDynamic = "dynamic" // KeyTypeCA is an key of type CA KeyTypeCA = "ca" @@ -32,24 +32,19 @@ const ( ) // Structure that represents a role in SSH backend. This is a common role structure -// for both OTP and Dynamic roles. Not all the fields are mandatory for both type. +// for both OTP and CA roles. Not all the fields are mandatory for both type. // Some are applicable for one and not for other. It doesn't matter. 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"` DefaultUserTemplate bool `mapstructure:"default_user_template" json:"default_user_template"` CIDRList string `mapstructure:"cidr_list" json:"cidr_list"` ExcludeCIDRList string `mapstructure:"exclude_cidr_list" json:"exclude_cidr_list"` Port int `mapstructure:"port" json:"port"` - InstallScript string `mapstructure:"install_script" json:"install_script"` AllowedUsers string `mapstructure:"allowed_users" json:"allowed_users"` AllowedUsersTemplate bool `mapstructure:"allowed_users_template" json:"allowed_users_template"` AllowedDomains string `mapstructure:"allowed_domains" json:"allowed_domains"` AllowedDomainsTemplate bool `mapstructure:"allowed_domains_template" json:"allowed_domains_template"` - KeyOptionSpecs string `mapstructure:"key_option_specs" json:"key_option_specs"` MaxTTL string `mapstructure:"max_ttl" json:"max_ttl"` TTL string `mapstructure:"ttl" json:"ttl"` DefaultCriticalOptions map[string]string `mapstructure:"default_critical_options" json:"default_critical_options"` @@ -93,30 +88,10 @@ func pathRoles(b *backend) *framework.Path { [Required for all types] Name of the role being created.`, }, - "key": { - Type: framework.TypeString, - Description: ` - [Required for Dynamic type] [Not applicable for OTP type] [Not applicable for CA type] - Name of the registered key in Vault. Before creating the role, use the - 'keys/' endpoint to create a named key.`, - }, - "admin_user": { - Type: framework.TypeString, - Description: ` - [Required for Dynamic type] [Not applicable for OTP type] [Not applicable for CA type] - Admin user at remote host. The shared key being registered should be - for this user and should have root privileges. Everytime a dynamic - credential is being generated for other users, Vault uses this admin - username to login to remote host and install the generated credential - for the other user.`, - DisplayAttrs: &framework.DisplayAttributes{ - Name: "Admin Username", - }, - }, "default_user": { Type: framework.TypeString, Description: ` - [Required for Dynamic type] [Required for OTP type] [Optional for CA type] + [Required for OTP type] [Optional for CA type] Default username for which a credential will be generated. When the endpoint 'creds/' is used without a username, this value will be used as default username.`, @@ -127,7 +102,7 @@ func pathRoles(b *backend) *framework.Path { "default_user_template": { Type: framework.TypeBool, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] If set, Default user can be specified using identity template policies. Non-templated users are also permitted. `, @@ -136,7 +111,7 @@ func pathRoles(b *backend) *framework.Path { "cidr_list": { Type: framework.TypeString, Description: ` - [Optional for Dynamic type] [Optional for OTP type] [Not applicable for CA type] + [Optional for OTP type] [Not applicable for CA type] Comma separated list of CIDR blocks for which the role is applicable for. CIDR blocks can belong to more than one role.`, DisplayAttrs: &framework.DisplayAttributes{ @@ -146,7 +121,7 @@ func pathRoles(b *backend) *framework.Path { "exclude_cidr_list": { Type: framework.TypeString, Description: ` - [Optional for Dynamic type] [Optional for OTP type] [Not applicable for CA type] + [Optional for OTP type] [Not applicable for CA type] Comma separated list of CIDR blocks. IP addresses belonging to these blocks are not accepted by the role. This is particularly useful when big CIDR blocks are being used by the role and certain parts of it needs to be kept out.`, @@ -157,7 +132,7 @@ func pathRoles(b *backend) *framework.Path { "port": { Type: framework.TypeInt, Description: ` - [Optional for Dynamic type] [Optional for OTP type] [Not applicable for CA type] + [Optional for OTP type] [Not applicable for CA type] Port number for SSH connection. Default is '22'. Port number does not play any role in creation of OTP. For 'otp' type, this is just a way to inform client about the port number to use. Port number will be @@ -170,27 +145,13 @@ func pathRoles(b *backend) *framework.Path { Type: framework.TypeString, Description: ` [Required for all types] - Type of key used to login to hosts. It can be either 'otp', 'dynamic' or 'ca'. + Type of key used to login to hosts. It can be either 'otp' or 'ca'. 'otp' type requires agent to be installed in remote hosts.`, - AllowedValues: []interface{}{"otp", "dynamic", "ca"}, + AllowedValues: []interface{}{"otp", "ca"}, DisplayAttrs: &framework.DisplayAttributes{ Value: "ca", }, }, - "key_bits": { - Type: framework.TypeInt, - Description: ` - [Optional for Dynamic type] [Not applicable for OTP type] [Not applicable for CA type] - Length of the RSA dynamic key in bits. It is 1024 by default or it can be 2048.`, - }, - "install_script": { - Type: framework.TypeString, - Description: ` - [Optional for Dynamic type] [Not-applicable for OTP type] [Not applicable for CA type] - Script used to install and uninstall public keys in the target machine. - The inbuilt default install script will be for Linux hosts. For sample - script, refer the project documentation website.`, - }, "allowed_users": { Type: framework.TypeString, Description: ` @@ -210,7 +171,7 @@ func pathRoles(b *backend) *framework.Path { "allowed_users_template": { Type: framework.TypeBool, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] If set, Allowed users can be specified using identity template policies. Non-templated users are also permitted. `, @@ -219,7 +180,7 @@ func pathRoles(b *backend) *framework.Path { "allowed_domains": { Type: framework.TypeString, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] If this option is not specified, client can request for a signed certificate for any valid host. If only certain domains are allowed, then this list enforces it. `, @@ -227,25 +188,16 @@ func pathRoles(b *backend) *framework.Path { "allowed_domains_template": { Type: framework.TypeBool, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] If set, Allowed domains can be specified using identity template policies. Non-templated domains are also permitted. `, Default: false, }, - "key_option_specs": { - Type: framework.TypeString, - Description: ` - [Optional for Dynamic type] [Not applicable for OTP type] [Not applicable for CA type] - Comma separated option specifications which will be prefixed to RSA key in - authorized_keys file. Options should be valid and comply with authorized_keys - file format and should not contain spaces. - `, - }, "ttl": { Type: framework.TypeDurationSecond, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] The lease duration if no specific lease duration is requested. The lease duration controls the expiration of certificates issued by this backend. Defaults to @@ -257,7 +209,7 @@ func pathRoles(b *backend) *framework.Path { "max_ttl": { Type: framework.TypeDurationSecond, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] The maximum allowed lease duration `, DisplayAttrs: &framework.DisplayAttributes{ @@ -267,7 +219,7 @@ func pathRoles(b *backend) *framework.Path { "allowed_critical_options": { Type: framework.TypeString, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] A comma-separated list of critical options that certificates can have when signed. To allow any critical options, set this to an empty string. `, @@ -275,7 +227,7 @@ func pathRoles(b *backend) *framework.Path { "allowed_extensions": { Type: framework.TypeString, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] A comma-separated list of extensions that certificates can have when signed. An empty list means that no extension overrides are allowed by an end-user; explicitly specify '*' to allow any extensions to be set. @@ -284,8 +236,8 @@ func pathRoles(b *backend) *framework.Path { "default_critical_options": { Type: framework.TypeMap, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] - [Optional for CA type] Critical options certificates should + [Not applicable for OTP type] [Optional for CA type] + Critical options certificates should have if none are provided when signing. This field takes in key value pairs in JSON format. Note that these are not restricted by "allowed_critical_options". Defaults to none. @@ -294,8 +246,8 @@ func pathRoles(b *backend) *framework.Path { "default_extensions": { Type: framework.TypeMap, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] - [Optional for CA type] Extensions certificates should have if + [Not applicable for OTP type] [Optional for CA type] + Extensions certificates should have if none are provided when signing. This field takes in key value pairs in JSON format. Note that these are not restricted by "allowed_extensions". Defaults to none. @@ -304,7 +256,7 @@ func pathRoles(b *backend) *framework.Path { "default_extensions_template": { Type: framework.TypeBool, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] If set, Default extension values can be specified using identity template policies. Non-templated extension values are also permitted. `, @@ -313,7 +265,7 @@ func pathRoles(b *backend) *framework.Path { "allow_user_certificates": { Type: framework.TypeBool, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] If set, certificates are allowed to be signed for use as a 'user'. `, Default: false, @@ -321,7 +273,7 @@ func pathRoles(b *backend) *framework.Path { "allow_host_certificates": { Type: framework.TypeBool, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] If set, certificates are allowed to be signed for use as a 'host'. `, Default: false, @@ -329,7 +281,7 @@ func pathRoles(b *backend) *framework.Path { "allow_bare_domains": { Type: framework.TypeBool, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] If set, host certificates that are requested are allowed to use the base domains listed in "allowed_domains", e.g. "example.com". This is a separate option as in some cases this can be considered a security threat. @@ -338,14 +290,14 @@ func pathRoles(b *backend) *framework.Path { "allow_subdomains": { Type: framework.TypeBool, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] If set, host certificates that are requested are allowed to use subdomains of those listed in "allowed_domains". `, }, "allow_user_key_ids": { Type: framework.TypeBool, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] If true, users can override the key ID for a signed certificate with the "key_id" field. When false, the key ID will always be the token display name. The key ID is logged by the SSH server and can be useful for auditing. @@ -357,7 +309,7 @@ func pathRoles(b *backend) *framework.Path { "key_id_format": { Type: framework.TypeString, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + [Not applicable for OTP type] [Optional for CA type] When supplied, this value specifies a custom format for the key id of a signed certificate. The following variables are available for use: '{{token_display_name}}' - The display name of the token used to make the request. '{{role_name}}' - The name of the role signing the request. @@ -370,13 +322,14 @@ func pathRoles(b *backend) *framework.Path { "allowed_user_key_lengths": { Type: framework.TypeMap, Description: ` - [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] - If set, allows the enforcement of key types and minimum key sizes to be signed. - `, + [Not applicable for OTP type] [Optional for CA type] + If set, allows the enforcement of key types and minimum key sizes to be signed. + `, }, "algorithm_signer": { Type: framework.TypeString, Description: ` + [Not applicable for OTP type] [Optional for CA type] When supplied, this value specifies a signing algorithm for the key. Possible values: ssh-rsa, rsa-sha2-256, rsa-sha2-512, default, or the empty string. `, @@ -389,6 +342,7 @@ func pathRoles(b *backend) *framework.Path { Type: framework.TypeDurationSecond, Default: 30, Description: ` + [Not applicable for OTP type] [Optional for CA type] The duration that the SSH certificate should be backdated by at issuance.`, DisplayAttrs: &framework.DisplayAttributes{ Name: "Not before duration", @@ -414,7 +368,7 @@ func (b *backend) pathRoleWrite(ctx context.Context, req *logical.Request, d *fr return logical.ErrorResponse("missing role name"), nil } - // Allowed users is an optional field, applicable for both OTP and Dynamic types. + // Allowed users is an optional field, applicable for both OTP and CA types. allowedUsers := d.Get("allowed_users").(string) // Validate the CIDR blocks @@ -459,13 +413,6 @@ func (b *backend) pathRoleWrite(ctx context.Context, req *logical.Request, d *fr return logical.ErrorResponse("missing default user"), nil } - // Admin user is not used if OTP key type is used because there is - // no need to login to remote machine. - adminUser := d.Get("admin_user").(string) - if adminUser != "" { - return logical.ErrorResponse("admin user not required for OTP type"), nil - } - // Below are the only fields used from the role structure for OTP type. roleEntry = sshRole{ DefaultUser: defaultUser, @@ -477,59 +424,7 @@ func (b *backend) pathRoleWrite(ctx context.Context, req *logical.Request, d *fr Version: roleEntryVersion, } } else if keyType == KeyTypeDynamic { - defaultUser := d.Get("default_user").(string) - if defaultUser == "" { - return logical.ErrorResponse("missing default user"), nil - } - // Key name is required by dynamic type and not by OTP type. - keyName := d.Get("key").(string) - if keyName == "" { - return logical.ErrorResponse("missing key name"), nil - } - keyEntry, err := req.Storage.Get(ctx, fmt.Sprintf("keys/%s", keyName)) - if err != nil || keyEntry == nil { - return logical.ErrorResponse(fmt.Sprintf("invalid 'key': %q", keyName)), nil - } - - installScript := d.Get("install_script").(string) - keyOptionSpecs := d.Get("key_option_specs").(string) - - // Setting the default script here. The script will install the - // generated public key in the authorized_keys file of linux host. - if installScript == "" { - installScript = DefaultPublicKeyInstallScript - } - - adminUser := d.Get("admin_user").(string) - if adminUser == "" { - return logical.ErrorResponse("missing admin username"), nil - } - - // This defaults to 2048, but it can also be 1024, 3072, 4096, or 8192. - // In the near future, we should disallow 1024-bit SSH keys. - keyBits := d.Get("key_bits").(int) - if keyBits == 0 { - keyBits = 2048 - } - if keyBits != 1024 && keyBits != 2048 && keyBits != 3072 && keyBits != 4096 && keyBits != 8192 { - return logical.ErrorResponse("invalid key_bits field"), nil - } - - // Store all the fields required by dynamic key type - roleEntry = sshRole{ - KeyName: keyName, - AdminUser: adminUser, - DefaultUser: defaultUser, - CIDRList: cidrList, - ExcludeCIDRList: excludeCidrList, - Port: port, - KeyType: KeyTypeDynamic, - KeyBits: keyBits, - InstallScript: installScript, - AllowedUsers: allowedUsers, - KeyOptionSpecs: keyOptionSpecs, - Version: roleEntryVersion, - } + return logical.ErrorResponse("dynamic key type roles are no longer supported"), nil } else if keyType == KeyTypeCA { algorithmSigner := DefaultAlgorithmSigner algorithmSignerRaw, ok := d.GetOk("algorithm_signer") @@ -776,7 +671,6 @@ func (b *backend) parseRole(role *sshRole) (map[string]interface{}, error) { "allow_user_key_ids": role.AllowUserKeyIDs, "key_id_format": role.KeyIDFormat, "key_type": role.KeyType, - "key_bits": role.KeyBits, "default_critical_options": role.DefaultCriticalOptions, "default_extensions": role.DefaultExtensions, "default_extensions_template": role.DefaultExtensionsTemplate, @@ -785,23 +679,7 @@ func (b *backend) parseRole(role *sshRole) (map[string]interface{}, error) { "not_before_duration": int64(role.NotBeforeDuration.Seconds()), } case KeyTypeDynamic: - result = map[string]interface{}{ - "key": role.KeyName, - "admin_user": role.AdminUser, - "default_user": role.DefaultUser, - "cidr_list": role.CIDRList, - "exclude_cidr_list": role.ExcludeCIDRList, - "port": role.Port, - "key_type": role.KeyType, - "key_bits": role.KeyBits, - "allowed_users": role.AllowedUsers, - "key_option_specs": role.KeyOptionSpecs, - // Returning install script will make the output look messy. - // But this is one way for clients to see the script that is - // being used to install the key. If there is some problem, - // the script can be modified and configured by clients. - "install_script": role.InstallScript, - } + return nil, fmt.Errorf("dynamic key type roles are no longer supported") default: return nil, fmt.Errorf("invalid key type: %v", role.KeyType) } diff --git a/builtin/logical/ssh/secret_dynamic_key.go b/builtin/logical/ssh/secret_dynamic_key.go deleted file mode 100644 index 80b9c5ca0..000000000 --- a/builtin/logical/ssh/secret_dynamic_key.go +++ /dev/null @@ -1,70 +0,0 @@ -package ssh - -import ( - "context" - "fmt" - - "github.com/hashicorp/vault/sdk/framework" - "github.com/hashicorp/vault/sdk/logical" - "github.com/mitchellh/mapstructure" -) - -const SecretDynamicKeyType = "secret_dynamic_key_type" - -func secretDynamicKey(b *backend) *framework.Secret { - return &framework.Secret{ - Type: SecretDynamicKeyType, - Fields: map[string]*framework.FieldSchema{ - "username": { - Type: framework.TypeString, - Description: "Username in host", - }, - "ip": { - Type: framework.TypeString, - Description: "IP address of host", - }, - }, - - Renew: b.secretDynamicKeyRenew, - Revoke: b.secretDynamicKeyRevoke, - } -} - -func (b *backend) secretDynamicKeyRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { - return &logical.Response{Secret: req.Secret}, nil -} - -func (b *backend) secretDynamicKeyRevoke(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { - type sec struct { - AdminUser string `mapstructure:"admin_user"` - Username string `mapstructure:"username"` - IP string `mapstructure:"ip"` - HostKeyName string `mapstructure:"host_key_name"` - DynamicPublicKey string `mapstructure:"dynamic_public_key"` - InstallScript string `mapstructure:"install_script"` - Port int `mapstructure:"port"` - } - - intSec := &sec{} - err := mapstructure.Decode(req.Secret.InternalData, intSec) - if err != nil { - return nil, fmt.Errorf("secret internal data could not be decoded: %w", err) - } - - // Fetch the host key using the key name - hostKey, err := b.getKey(ctx, req.Storage, intSec.HostKeyName) - if err != nil { - return nil, fmt.Errorf("key %q not found error: %w", intSec.HostKeyName, err) - } - if hostKey == nil { - return nil, fmt.Errorf("key %q not found", intSec.HostKeyName) - } - - // Remove the public key from authorized_keys file in target machine - // The last param 'false' indicates that the key should be uninstalled. - err = b.installPublicKeyInTarget(ctx, intSec.AdminUser, intSec.Username, intSec.IP, intSec.Port, hostKey.Key, intSec.DynamicPublicKey, intSec.InstallScript, false) - if err != nil { - return nil, fmt.Errorf("error removing public key from authorized_keys file in target") - } - return nil, nil -} diff --git a/builtin/logical/ssh/util.go b/builtin/logical/ssh/util.go index 1923caa34..9eabfa1d8 100644 --- a/builtin/logical/ssh/util.go +++ b/builtin/logical/ssh/util.go @@ -1,7 +1,6 @@ package ssh import ( - "bytes" "context" "crypto/rand" "crypto/rsa" @@ -11,9 +10,7 @@ import ( "fmt" "net" "strings" - "time" - log "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-secure-stdlib/parseutil" "github.com/hashicorp/vault/sdk/logical" "golang.org/x/crypto/ssh" @@ -40,69 +37,6 @@ func generateRSAKeys(keyBits int) (publicKeyRsa string, privateKeyRsa string, er return } -// Public key and the script to install the key are uploaded to remote machine. -// Public key is either added or removed from authorized_keys file using the -// script. Default script is for a Linux machine and hence the path of the -// authorized_keys file is hard coded to resemble Linux. -// -// The last param 'install' if false, uninstalls the key. -func (b *backend) installPublicKeyInTarget(ctx context.Context, adminUser, username, ip string, port int, hostkey, dynamicPublicKey, installScript string, install bool) error { - // Transfer the newly generated public key to remote host under a random - // file name. This is to avoid name collisions from other requests. - _, publicKeyFileName, err := b.GenerateSaltedOTP(ctx) - if err != nil { - return err - } - - comm, err := createSSHComm(b.Logger(), adminUser, ip, port, hostkey) - if err != nil { - return err - } - defer comm.Close() - - err = comm.Upload(publicKeyFileName, bytes.NewBufferString(dynamicPublicKey), nil) - if err != nil { - return fmt.Errorf("error uploading public key: %w", err) - } - - // Transfer the script required to install or uninstall the key to the remote - // host under a random file name as well. This is to avoid name collisions - // from other requests. - scriptFileName := fmt.Sprintf("%s.sh", publicKeyFileName) - err = comm.Upload(scriptFileName, bytes.NewBufferString(installScript), nil) - if err != nil { - return fmt.Errorf("error uploading install script: %w", err) - } - - // Create a session to run remote command that triggers the script to install - // or uninstall the key. - session, err := comm.NewSession() - if err != nil { - return fmt.Errorf("unable to create SSH Session using public keys: %w", err) - } - if session == nil { - return fmt.Errorf("invalid session object") - } - defer session.Close() - - authKeysFileName := fmt.Sprintf("/home/%s/.ssh/authorized_keys", username) - - var installOption string - if install { - installOption = "install" - } else { - installOption = "uninstall" - } - - // Give execute permissions to install script, run and delete it. - chmodCmd := fmt.Sprintf("chmod +x %s", scriptFileName) - scriptCmd := fmt.Sprintf("./%s %s %s %s", scriptFileName, installOption, publicKeyFileName, authKeysFileName) - rmCmd := fmt.Sprintf("rm -f %s", scriptFileName) - targetCmd := fmt.Sprintf("%s;%s;%s", chmodCmd, scriptCmd, rmCmd) - - return session.Run(targetCmd) -} - // Takes an IP address and role name and checks if the IP is part // of CIDR blocks belonging to the role. func roleContainsIP(ctx context.Context, s logical.Storage, roleName string, ip string) (bool, error) { @@ -152,52 +86,6 @@ func cidrListContainsIP(ip, cidrList string) (bool, error) { return false, nil } -func insecureIgnoreHostWarning(logger log.Logger) ssh.HostKeyCallback { - return func(hostname string, remote net.Addr, key ssh.PublicKey) error { - logger.Warn("cannot verify server key: host key validation disabled") - return nil - } -} - -func createSSHComm(logger log.Logger, username, ip string, port int, hostkey string) (*comm, error) { - signer, err := ssh.ParsePrivateKey([]byte(hostkey)) - if err != nil { - return nil, err - } - - clientConfig := &ssh.ClientConfig{ - User: username, - Auth: []ssh.AuthMethod{ - ssh.PublicKeys(signer), - }, - HostKeyCallback: insecureIgnoreHostWarning(logger), - Timeout: 1 * time.Minute, - } - - connfunc := func() (net.Conn, error) { - c, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", ip, port), 15*time.Second) - if err != nil { - return nil, err - } - - if tcpConn, ok := c.(*net.TCPConn); ok { - tcpConn.SetKeepAlive(true) - tcpConn.SetKeepAlivePeriod(5 * time.Second) - } - - return c, nil - } - config := &SSHCommConfig{ - SSHConfig: clientConfig, - Connection: connfunc, - Pty: false, - DisableAgent: true, - Logger: logger, - } - - return SSHCommNew(fmt.Sprintf("%s:%d", ip, port), config) -} - func parsePublicSSHKey(key string) (ssh.PublicKey, error) { keyParts := strings.Split(key, " ") if len(keyParts) > 1 { diff --git a/changelog/18874.txt b/changelog/18874.txt new file mode 100644 index 000000000..7483c43f9 --- /dev/null +++ b/changelog/18874.txt @@ -0,0 +1,3 @@ +```release-note:security +secrets/ssh: removal of the deprecated dynamic keys mode. **When any remaining dynamic key leases expire**, an error stating `secret is unsupported by this backend` will be thrown by the lease manager. +``` diff --git a/website/content/api-docs/secret/ssh.mdx b/website/content/api-docs/secret/ssh.mdx index a42ce3450..97e5fa3cd 100644 --- a/website/content/api-docs/secret/ssh.mdx +++ b/website/content/api-docs/secret/ssh.mdx @@ -14,62 +14,6 @@ This documentation assumes the SSH secrets engine is enabled at the `/ssh` path in Vault. Since it is possible to enable secrets engines at any location, please update your API calls accordingly. -## Create/Update Key - -This endpoint creates or updates a named key. - -| Method | Path | -| :----- | :---------------- | -| `POST` | `/ssh/keys/:name` | - -### Parameters - -- `name` `(string: )` – Specifies the name of the key to create. This - is part of the request URL. - -- `key` `(string: )` – Specifies an SSH private key with appropriate - privileges on remote hosts. - -### Sample Payload - -```json -{ - "key": "..." -} -``` - -### Sample Request - -```shell-session -$ curl \ - --header "X-Vault-Token: ..." \ - --request POST \ - --data @payload.json \ - http://127.0.0.1:8200/v1/ssh/keys/my-key -``` - -## Delete Key - -This endpoint deletes a named key. - -| Method | Path | -| :------- | :---------------- | -| `DELETE` | `/ssh/keys/:name` | - -### Parameters - -- `name` `(string: )` – Specifies the name of the key to delete. This - is part of the request URL. - -### Sample Request - -```shell-session -$ curl \ - --header "X-Vault-Token: ..." \ - --request DELETE \ - http://127.0.0.1:8200/v1/ssh/keys/my-key -``` - ## Create Role This endpoint creates or updates a named role. @@ -83,21 +27,11 @@ This endpoint creates or updates a named role. - `name` `(string: )` – Specifies the name of the role to create. This is part of the request URL. -- `key` `(string: "")` – Specifies the name of the registered key in Vault. - Before creating the role, use the `keys/` endpoint to create a named key. This - is required for "Dynamic Key" type. - -- `admin_user` `(string: "")` – Specifies the admin user at remote host. The - shared key being registered should be for this user and should have root or - sudo privileges. Every time a dynamic credential is generated for a client, - Vault uses this admin username to login to remote host and install the - generated credential. This is required for Dynamic Key type. - - `default_user` `(string: "")` – Specifies the default username for which a credential will be generated. When the endpoint `creds/` is used without a username, this value will be used as default username. Its recommended to create individual roles for each username to ensure absolute isolation between - usernames. This is required for Dynamic Key type and OTP type. + usernames. This is required for OTP type roles. When `default_user_template` is set to `true`, this field can contain an identity template with any prefix or suffix, like `ssh-{{identity.entity.id}}-user`. @@ -126,13 +60,7 @@ This endpoint creates or updates a named role. will be returned to the client by Vault along with the OTP. - `key_type` `(string: )` – Specifies the type of credentials - generated by this role. This can be either `otp`, `dynamic` or `ca`. - -- `key_bits` `(int: 1024)` – Specifies the length of the RSA dynamic key in - bits. This can be either 1024 or 2048. - -- `install_script` `(string: "")` – Specifies the script used to install and - uninstall public keys in the target machine. Defaults to the built-in script. + generated by this role. This can be either `otp` or `ca`. - `allowed_users` `(string: "")` – If this option is not specified, or if it is `*`, the client can request a credential for any valid user at the remote @@ -158,10 +86,6 @@ This endpoint creates or updates a named role. specified using identity template policies. Non-templated domains are also permitted. -- `key_option_specs` `(string: "")` – Specifies a comma separated option - specification which will be prefixed to RSA keys in the remote host's - authorized_keys file. N.B.: Vault does not check this string for validity. - - `ttl` `(string: "")` – Specifies the Time To Live value provided as a string duration with time suffix. Hour is the largest suffix. If not set, uses the system default value or the value of `max_ttl`, whichever is shorter. @@ -304,19 +228,6 @@ $ curl \ ### Sample Response -For a dynamic key role: - -```json -{ - "admin_user": "username", - "cidr_list": "x.x.x.x/y", - "default_user": "username", - "key": "", - "key_type": "dynamic", - "port": 22 -} -``` - For an OTP role: ```json @@ -374,9 +285,6 @@ $ curl \ "key_info": { "dev": { "key_type": "ca" - }, - "prod": { - "key_type": "dynamic" } } }, @@ -527,31 +435,6 @@ $ curl \ ### Sample Response -For a dynamic key role: - -```json -{ - "lease_id": "", - "renewable": false, - "lease_duration": 0, - "data": { - "admin_user": "rajanadar", - "allowed_users": "", - "cidr_list": "x.x.x.x/y", - "default_user": "rajanadar", - "exclude_cidr_list": "x.x.x.x/y", - "install_script": "pretty_large_script", - "key": "5d9ee6a1-c787-47a9-9738-da243f4f69bf", - "key_bits": 1024, - "key_option_specs": "", - "key_type": "dynamic", - "port": 22 - }, - "warnings": null, - "auth": null -} -``` - For an OTP role: ```json diff --git a/website/content/docs/secrets/ssh/dynamic-ssh-keys.mdx b/website/content/docs/secrets/ssh/dynamic-ssh-keys.mdx deleted file mode 100644 index 5d2f43571..000000000 --- a/website/content/docs/secrets/ssh/dynamic-ssh-keys.mdx +++ /dev/null @@ -1,193 +0,0 @@ ---- -layout: docs -page_title: Dynamic SSH Keys - SSH - Secrets Engines -description: |- - When using this type, the administrator registers a secret key with - appropriate sudo privileges on the remote machines. For every authorized - credential request, Vault creates a new SSH key pair and appends the - newly-generated public key to the authorized_keys file for the configured - username on the remote host. Vault uses a configurable install script to - achieve this. ---- - -# Dynamic SSH Keys - -~> **Deprecated**: There are several serious drawbacks and security implications -inherent in this type. Because of these drawbacks, please use the SSH CA or OTP -types whenever possible. - -When using this type, the administrator registers a secret key with appropriate -`sudo` privileges on the remote machines; for every authorized credential -request, Vault creates a new SSH key pair and appends the newly-generated public -key to the `authorized_keys` file for the configured username on the remote -host. Vault uses a configurable install script to achieve this. - -The secrets engine does not prompt for `sudo` passwords; the `NOPASSWD` option for -sudoers should be enabled at all remote hosts for the Vault administrative -user. - -The private key returned to the user will be leased and can be renewed if -desired. Once the key is given to the user, Vault will not know when it gets -used or how many time it gets used. Therefore, Vault **WILL NOT** and cannot -audit the SSH session establishments. - -When the credential lease expires, Vault removes the secret key from the remote -machine. - -This page will show a quick start for this secrets engine. For detailed documentation -on every path, use `vault path-help` after mounting the secrets engine. - -### Drawbacks - -The dynamic key type has several serious drawbacks: - -1. _Audit logs are unreliable_: Vault can only log when users request - credentials, not when they use the given keys. If user A and user B both - request access to a machine, and are given a lease valid for five minutes, - it is impossible to know whether two accesses to that user account on the - remote machine were A, A; A, B; B, A; or B, B. -2. _Generating dynamic keys consumes entropy_: Unless equipped with a hardware - entropy generating device, a machine can quickly run out of entropy when - generating SSH keys. This will cause further requests for various Vault - operations to stall until more entropy is available, which could take a - significant amount of time, after which the next request for a new SSH key - will use the generated entropy and cause stalling again. -3. This type makes connections to client hosts; when this happens the host key - is _not_ verified. - -### sudo - -In order to adjust the `authorized_keys` file for the desired user, Vault -connects via SSH to the remote machine as a separate user, and uses `sudo` to -gain the privileges required. An example `sudoers` file is shown below. - -File: `/etc/sudoers` - -```hcl -# This is a sample sudoers statement; you should modify it -# as appropriate to satisfy your security needs. -vaultadmin ALL=(ALL)NOPASSWD: ALL -``` - -### Configuration - -Next, infrastructure configuration must be registered with Vault via roles. -First, however, the shared secret key must be specified. - -### Mount the secrets engine - -```shell-session -$ vault secrets enable ssh -Successfully mounted 'ssh' at 'ssh'! -``` - -#### Registering the shared secret key - -Register a key with a name; this key must have administrative capabilities on -the remote hosts. - -```shell-session -$ vault write ssh/keys/dev_key \ - key=@dev_shared_key.pem -``` - -#### Create a Role - -Next, create a role. All of the machines contained within this CIDR block list -should be accessible using the registered shared secret key. - -```shell-session -$ vault write ssh/roles/dynamic_key_role \ - key_type=dynamic \ - key=dev_key \ - admin_user=username \ - default_user=username \ - cidr_list=x.x.x.x/y -Success! Data written to: ssh/roles/dynamic_key_role -``` - -`cidr_list` is a comma separated list of CIDR blocks for which a role can -generate credentials. If this is empty, the role can only generate credentials -if it belongs to the set of zero-address roles. - -Zero-address roles, configured via `/ssh/config/zeroaddress` endpoint, takes -comma separated list of role names that can generate credentials for any IP -address. - -Use the `install_script` option to provide an install script if the remote -hosts do not resemble a typical Linux machine. The default script is compiled -into the Vault binary, but it is straight forward to specify an alternate. The -script takes three arguments which are explained in the comments. - -To see the default, see -[linux_install_script.go](https://github.com/hashicorp/vault/blob/main/builtin/logical/ssh/linux_install_script.go) - -### Create a credential - -Create a dynamic key for an IP of the remote host that is covered by -`dynamic_key_role`'s CIDR list. - -```shell-session -$ vault write ssh/creds/dynamic_key_role ip=x.x.x.x -Key Value -lease_id ssh/creds/dynamic_key_role/8c4d2042-23bc-d6a8-42c2-6ff01cb83cf8 -lease_duration 600 -lease_renewable true -ip x.x.x.x -key -----BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEA5V/Y95qfGaUXRPkKNK9jgDHXPD2n5Ein+QTNnLSGrHtJUH7+ -pgs/5Hc4//124P9qHNmjIYQVyvcLreFgSrQCq4K8193hmypBYtsvCgvpc+jEwaGA -zK0QV7uc1z8KL7FuRAxpHJwB6+nubOzzqM03xsViHRhaWhYVHw2Vl4oputSHE7R9 -ugaTRg67wge4Nyi5RRL0RQcmW15/Vop8B6HpBSmZQy3enjg+32KbOWCMMTAPuF9/ -DgxSgZQaFMjGN4RjDreZI8Vv5zIiFJzZ3KVOWy8piI0PblLnDpU4Q0QSQ9A+Vr7b -JS22Lbet1Zbapl/n947/r1wGObLCc5Lilu//1QIDAQABAoIBAHWLfdO9sETjHp6h -BULkkpgScpuTeSN6vGHXvUrOFKn1cCfJPNR4tWBuXI6LJM2+9nEccwXs+4IMwjZ0 -ZfVCdI/SKtZxBXmP2PxBGMUMP7G/mn0kN64sDlD3ezOvQZgZVEmZFpCrvixYsG+v -qlpZ+HhrlJEWds7tvBsyyfNjwWjVIpm08zBmteFj4zu7OEcmGXEHDoxDXxyVP2BG -eLU/fM5JA2UEjfCQ1MIZ3rBtPePdz4LRpb+ajklqrUj1OHoiDrXa8EAf0/wDP9re -c1iH4bn7ZjYK0+IhZ+Pmw6gUftzZNWSC2kOLnZLdN/K7hgh0l0r0K/1eeXt43upB -WALNuiECgYEA8PM2Ob3XXKALF86PUewne4fCz9iixr/cIpvrEGrh9lyQRO8X5Jxb -ug38jEql4a574C6TSXfzxURza4P6lnfa0LvymmW0bhxZ5nev9kcAVnLKvpOUArTR -32k9bKXd6zp8Q9ZyVNwHRxcVs4YgwfJlcx8geC4o6YRiIjvcBQ9RVHkCgYEA87OK -lZDFBeEY/HVOxAQNXS5fgTd4U4DbwEJLv7SPk02v9oDkGHkpgMs4PcsIpCzsTpJ0 -oXMfLSxZ1lmZiuUvAupKj/7RjJ0XyjSMfm1Zs81epWj+boVfM4amZNHVLIWgddmM -XzXEZKByvi1gs7qFcjQz2DEbZltWO6dX14O4Fz0CgYEAlWSWyHJWZ02r0xT1c7vS -NxtTxH7zXftzR9oYgtNiStfVc4gy7kGr9c3aOjnGZAlFMRhvpevDrxnj3lO0OTsS -5rzBjM1mc6cMboLjDPW01eTSpBroeE0Ym0arGQQ2djSK+5yowsixknhTsj2FbfsW -v6wa+6jTIQY9ujAXGOQIbzECgYAYuXlw7SwgCZNYYappFqQodQD5giAyEJu66L74 -px/96N7WWoNJvFkqmPOOyV+KEIi0/ATbMGvUUHCY36RFRDU9zXldHJQz+Ogl+qja -VsvIAyj8DSfrHJrpBlsxVVyUVMZPzo+ARVs0flbF1qK9+Ul6qbMs1uaZvuCD0tmF -ovZ1XQKBgQDB0s7SDmAMgVjG8UBZgUru9vsDrxERT2BloptnnAjSiarLF5M+qeZO -7L4NLyVP39Z83eerEonzDAHHbvhPyi6n2YmnYhGjeP+lPZIVqGF9cpZD3q48YHZc -3ePn2/oLZrXKWOMyMwp2Uj+0SArCW+xMnoNp50sYNVR/JK3BPIdkag== ------END RSA PRIVATE KEY----- -key_type dynamic -port 22 -username username -``` - -### Establish an SSH session - -Save the key to a file (e.g. `dyn_key.pem`) and then use it to establish an SSH -session. - -```shell-session -$ ssh -i dyn_key.pem username@ -username@:~$ -``` - -### Automate it! - -Creation of new key, saving to a file, and using it to establish an SSH session -can all be done with a single Vault CLI command. - -```shell-session -$ vault ssh -role dynamic_key_role username@ -username@:~$ -``` - -## API - -The SSH secret secrets engine has a full HTTP API. Please see the -[SSH secret secrets engine API](/vault/api-docs/secret/ssh) for more -details. diff --git a/website/content/docs/secrets/ssh/index.mdx b/website/content/docs/secrets/ssh/index.mdx index 5ab529728..ad9f9ab22 100644 --- a/website/content/docs/secrets/ssh/index.mdx +++ b/website/content/docs/secrets/ssh/index.mdx @@ -3,9 +3,8 @@ layout: docs page_title: SSH - Secrets Engines description: |- The Vault SSH secrets engine provides secure authentication and authorization - for access to machines via the SSH protocol. There are multiple modes to the - Vault SSH secrets engine including signed SSH certificates, dynamic SSH keys, - and one-time passwords. + for access to machines via the SSH protocol. There are two modes to the Vault + SSH secrets engine including signed SSH certificates and one-time passwords. --- # SSH Secrets Engine @@ -22,10 +21,14 @@ individually documented on its own page. - [Signed SSH Certificates](/vault/docs/secrets/ssh/signed-ssh-certificates) - [One-time SSH Passwords](/vault/docs/secrets/ssh/one-time-ssh-passwords) -- [Dynamic SSH Keys](/vault/docs/secrets/ssh/dynamic-ssh-keys) DEPRECATED All guides assume a basic familiarity with the SSH protocol. +## Removal of Dynamic Keys feature + +Per [Vault 1.12's deprecation notice page](/vault/docs/v1.12.x/deprecation), +the dynamic keys functionality of this engine has been removed in Vault 1.13. + ## API The SSH secrets engine has a full HTTP API. Please see the diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 6296859ae..2fb224fc0 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -1215,10 +1215,6 @@ { "title": "SSH OTP", "path": "secrets/ssh/one-time-ssh-passwords" - }, - { - "title": "Dynamic Key", - "path": "secrets/ssh/dynamic-ssh-keys" } ] },