Skip to content

Commit

Permalink
Merge pull request #2898 from Infisical/cli-ssh-agent
Browse files Browse the repository at this point in the history
Add SSH CLI capability to load issued SSH credentials into SSH Agent via addToAgent flag
dangtony98 authored Dec 19, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
2 parents 2843818 + f5a0641 commit 5ab0c66
Showing 4 changed files with 156 additions and 104 deletions.
10 changes: 5 additions & 5 deletions cli/go.mod
Original file line number Diff line number Diff line change
@@ -23,8 +23,8 @@ require (
github.com/spf13/cobra v1.6.1
github.com/spf13/viper v1.8.1
github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.25.0
golang.org/x/term v0.22.0
golang.org/x/crypto v0.31.0
golang.org/x/term v0.27.0
gopkg.in/yaml.v2 v2.4.0
)

@@ -93,9 +93,9 @@ require (
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/api v0.188.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect
20 changes: 10 additions & 10 deletions cli/go.sum
Original file line number Diff line number Diff line change
@@ -453,8 +453,8 @@ golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -564,8 +564,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -620,16 +620,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -643,8 +643,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
151 changes: 118 additions & 33 deletions cli/packages/cmd/ssh.go
Original file line number Diff line number Diff line change
@@ -6,16 +6,20 @@ package cmd
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"

"github.com/Infisical/infisical-merge/packages/api"
"github.com/Infisical/infisical-merge/packages/config"
"github.com/Infisical/infisical-merge/packages/util"
infisicalSdk "github.com/infisical/go-sdk"
infisicalSdkUtil "github.com/infisical/go-sdk/packages/util"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)

var sshCmd = &cobra.Command{
@@ -52,8 +56,8 @@ var algoToFileName = map[infisicalSdkUtil.CertKeyAlgorithm]string{
}

func isValidKeyAlgorithm(algo infisicalSdkUtil.CertKeyAlgorithm) bool {
_, exists := algoToFileName[algo]
return exists
_, exists := algoToFileName[algo]
return exists
}

func isValidCertType(certType infisicalSdkUtil.SshCertType) bool {
@@ -81,6 +85,71 @@ func writeToFile(filePath string, content string, perm os.FileMode) error {
return nil
}

func addCredentialsToAgent(privateKeyContent, certContent string) error {
// Parse the private key
privateKey, err := ssh.ParseRawPrivateKey([]byte(privateKeyContent))
if err != nil {
return fmt.Errorf("failed to parse private key: %w", err)
}

// Parse the certificate
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certContent))
if err != nil {
return fmt.Errorf("failed to parse certificate: %w", err)
}

cert, ok := pubKey.(*ssh.Certificate)
if !ok {
return fmt.Errorf("parsed key is not a certificate")
}
// Calculate LifetimeSecs based on certificate's valid-to time
validUntil := time.Unix(int64(cert.ValidBefore), 0)
now := time.Now()

// Handle ValidBefore as either a timestamp or an enumeration
// SSH certificates use ValidBefore as a timestamp unless set to 0 or ~0
if cert.ValidBefore == ssh.CertTimeInfinity {
// If certificate never expires, set default lifetime to 1 year (can adjust as needed)
validUntil = now.Add(365 * 24 * time.Hour)
}

// Calculate the duration until expiration
lifetime := validUntil.Sub(now)
if lifetime <= 0 {
return fmt.Errorf("certificate is already expired")
}

// Convert duration to seconds
lifetimeSecs := uint32(lifetime.Seconds())

// Connect to the SSH agent
socket := os.Getenv("SSH_AUTH_SOCK")
if socket == "" {
return fmt.Errorf("SSH_AUTH_SOCK not set")
}

conn, err := net.Dial("unix", socket)
if err != nil {
return fmt.Errorf("failed to connect to SSH agent: %w", err)
}
defer conn.Close()

agentClient := agent.NewClient(conn)

// Add the key with certificate to the agent
err = agentClient.Add(agent.AddedKey{
PrivateKey: privateKey,
Certificate: cert,
Comment: "Added via Infisical CLI",
LifetimeSecs: lifetimeSecs,
})
if err != nil {
return fmt.Errorf("failed to add key to agent: %w", err)
}

return nil
}

func issueCredentials(cmd *cobra.Command, args []string) {

token, err := util.GetInfisicalToken(cmd)
@@ -166,21 +235,23 @@ func issueCredentials(cmd *cobra.Command, args []string) {
util.HandleError(err, "Unable to parse flag")
}

addToAgent, err := cmd.Flags().GetBool("addToAgent")
if err != nil {
util.HandleError(err, "Unable to parse addToAgent flag")
}

if outFilePath == "" && addToAgent == false {
util.PrintErrorMessageAndExit("You must provide either --outFilePath or --addToAgent flag to use this command")
}

var (
outputDir string
privateKeyPath string
publicKeyPath string
signedKeyPath string
)

if outFilePath == "" {
// Use current working directory
cwd, err := os.Getwd()
if err != nil {
util.HandleError(err, "Failed to get current working directory")
}
outputDir = cwd
} else {
if outFilePath != "" {
// Expand ~ to home directory if present
if strings.HasPrefix(outFilePath, "~") {
homeDir, err := os.UserHomeDir()
@@ -264,34 +335,47 @@ func issueCredentials(cmd *cobra.Command, args []string) {
util.HandleError(err, "Failed to issue SSH credentials")
}

// If signedKeyPath wasn't set in the directory scenario, set it now
if signedKeyPath == "" {
fileName := algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)]
signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub")
}
if outFilePath != "" {
// If signedKeyPath wasn't set in the directory scenario, set it now
if signedKeyPath == "" {
fileName := algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)]
signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub")
}

if privateKeyPath == "" {
privateKeyPath = filepath.Join(outputDir, algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)])
}
err = writeToFile(privateKeyPath, creds.PrivateKey, 0600)
if err != nil {
util.HandleError(err, "Failed to write Private Key to file")
}
if privateKeyPath == "" {
privateKeyPath = filepath.Join(outputDir, algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)])
}
err = writeToFile(privateKeyPath, creds.PrivateKey, 0600)
if err != nil {
util.HandleError(err, "Failed to write Private Key to file")
}

if publicKeyPath == "" {
publicKeyPath = privateKeyPath + ".pub"
}
err = writeToFile(publicKeyPath, creds.PublicKey, 0644)
if err != nil {
util.HandleError(err, "Failed to write Public Key to file")
}
if publicKeyPath == "" {
publicKeyPath = privateKeyPath + ".pub"
}
err = writeToFile(publicKeyPath, creds.PublicKey, 0644)
if err != nil {
util.HandleError(err, "Failed to write Public Key to file")
}

err = writeToFile(signedKeyPath, creds.SignedKey, 0644)
if err != nil {
util.HandleError(err, "Failed to write Signed Key to file")
err = writeToFile(signedKeyPath, creds.SignedKey, 0644)
if err != nil {
util.HandleError(err, "Failed to write Signed Key to file")
}

fmt.Println("Successfully wrote SSH certificate to:", signedKeyPath)
}

fmt.Println("Successfully wrote SSH certificate to:", signedKeyPath)
// Add SSH credentials to the SSH agent if needed
if addToAgent {
// Call the helper function to handle add-to-agent flow
err := addCredentialsToAgent(creds.PrivateKey, creds.SignedKey)
if err != nil {
util.HandleError(err, "Failed to add keys to SSH agent")
} else {
fmt.Println("The SSH key and certificate have been successfully added to your ssh-agent.")
}
}
}

func signKey(cmd *cobra.Command, args []string) {
@@ -519,6 +603,7 @@ func init() {
sshIssueCredentialsCmd.Flags().String("ttl", "", "The ttl to issue SSH credentials for")
sshIssueCredentialsCmd.Flags().String("keyId", "", "The keyId to issue SSH credentials for")
sshIssueCredentialsCmd.Flags().String("outFilePath", "", "The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be saved to the current working directory")
sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent")
sshCmd.AddCommand(sshIssueCredentialsCmd)
rootCmd.AddCommand(sshCmd)
}
79 changes: 23 additions & 56 deletions docs/documentation/platform/ssh.mdx
Original file line number Diff line number Diff line change
@@ -159,7 +159,19 @@ as part of the SSH operation.

## Guide to Using Infisical SSH to Access a Host

We show how to obtain a SSH certificate (and optionally a new SSH key pair) for a client to access a host via CLI:
We show how to obtain a SSH certificate and use it for a client to access a host via CLI:

<Note>
The subsequent guide assumes the following prerequisites:

- SSH Agent is running: The `ssh-agent` must be actively running on the host machine.
- OpenSSH is installed: The system should have OpenSSH installed; this includes
both the `ssh` client and `ssh-agent`.
- `SSH_AUTH_SOCK` environment variable
is set; the `SSH_AUTH_SOCK` variable should point to the UNIX socket that
`ssh-agent` uses for communication.

</Note>

<Steps>
<Step title="Authenticate with Infisical">
@@ -169,70 +181,25 @@ infisical login
```

</Step>
<Step title="Obtain a SSH certificate (and optionally new key-pair)">
Depending on the use-case, a client may either request a SSH certificate along with a new SSH key pair or obtain a SSH certificate for an existing SSH key pair to access a host.

<AccordionGroup>
<Accordion title="Using New Key Pair (Recommended)">
If you wish to obtain a new SSH key pair in conjunction with the SSH certificate, then you can use the `infisical ssh issue-credentials` command.

```bash
infisical ssh issue-credentials --certificateTemplateId=<certificate-template-id> --principals=<username>
```

The following flags may be relevant:

- `certificateTemplateId`: The ID of the certificate template to use for issuing the SSH certificate.
- `principals`: The comma-delimited username(s) or hostname(s) to include in the SSH certificate.
- `outFilePath` (optional): The path to the file to write the SSH certificate to.

<Note>
If `outFilePath` is not specified, the SSH certificate will be written to the current working directory where the command is run.
</Note>

</Accordion>
<Accordion title="Using Existing Key Pair">
If you have an existing SSH key pair, then you can use the `infisical ssh sign-key` command with either
the `--publicKey` flag or the `--publicKeyFilePath` flag to obtain a SSH certificate corresponding to
the existing credential.

```bash
infisical ssh sign-key --publicKeyFilePath=<public-key-file-path> --certificateTemplateId=<certificate-template-id> --principals=<username>
```

The following flags may be relevant:

- `publicKey`: The public key to sign.
- `publicKeyFilePath`: The path to the public key file to sign.
- `certificateTemplateId`: The ID of the certificate template to use for issuing the SSH certificate.
- `principals`: The comma-delimited username(s) or hostname(s) to include in the SSH certificate.
- `outFilePath` (optional): The path to the file to write the SSH certificate to.

<Note>
If `outFilePath` is not specified but `publicKeyFilePath` is then the SSH certificate will be written to the directory of the public key file; if the public key file is called `id_rsa.pub`, then the file containing the SSH certificate will be called `id_rsa-cert.pub`.
<Step title="Obtain a SSH certificate and load it into the SSH agent">
Run the `infisical ssh issue-credentials` command, specifying the `--addToAgent` flag to automatically load the SSH certificate into the SSH agent.
```bash
infisical ssh issue-credentials --certificateTemplateId=<certificate-template-id> --principals=<username> --addToAgent
```

Otherwise, if `outFilePath` is not specified, the SSH certificate will be written to the current working directory where the command is run.
</Note>
Here's some guidance on each flag:

</Accordion>
</AccordionGroup>
- `certificateTemplateId`: The ID of the certificate template to use for issuing the SSH certificate.
- `principals`: The comma-delimited username(s) or hostname(s) to include in the SSH certificate.

</Step>
<Step title="SSH into the host">
Once you have obtained the SSH certificate, you can use it to SSH into the desired host.
Finally, SSH into the desired host; the SSH operation will be performed using the SSH certificate loaded into the SSH agent.

```bash
ssh -i /path/to/private_key.pem \
-o CertificateFile=/path/to/ssh-cert.pub \
username@hostname
ssh username@hostname
```

<Note>
We recommend setting up aliases so you can more easily SSH into the desired host.

For example, you may set up an SSH alias using the SSH client configuration file (usually `~/.ssh/config`), defining a host alias including the file path to the issued SSH credential(s).
</Note>

</Step>
</Steps>

0 comments on commit 5ab0c66

Please sign in to comment.