Skip to content

Commit

Permalink
Merge pull request #63 from slok/slok/ssh-passphrase
Browse files Browse the repository at this point in the history
Add support for SSH private key passphrase
  • Loading branch information
slok authored Mar 19, 2021
2 parents 03de798 + fe356a8 commit 06af8c5
Show file tree
Hide file tree
Showing 10 changed files with 165 additions and 35 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
- `validate` cmd checks tracked secrets are not decrypted.
- `validate` cmd checks tracked secrets are encrypted.
- `validate` cmd optionally checks tracked secrets can be decrypted.
- Support for SSH passphrase using stdin.
- Support for SSH passphrase using cmd `--passphrase` flag.

## [v0.2.0] - 2021-03-13

Expand Down
12 changes: 11 additions & 1 deletion cmd/agebox/commands/cat.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package commands
import (
"context"
"fmt"
"io"
"os"
"strings"

"gopkg.in/alecthomas/kingpin.v2"

Expand All @@ -15,6 +18,7 @@ import (

type catCommand struct {
PrivateKeyPath string
SSHPassphrase string
Files []string
}

Expand All @@ -23,6 +27,7 @@ func NewCatCommand(app *kingpin.Application) Command {
c := &catCommand{}
cmd := app.Command("cat", "Decrypts any number of tracked files and prints them to stdout.")
cmd.Flag("private-key", "Path to private key.").Required().Short('i').StringVar(&c.PrivateKeyPath)
cmd.Flag("passphrase", "SSH private key passphrase, if required it will take this and not ask disabling interactive mode.").StringVar(&c.SSHPassphrase)
cmd.Arg("files", "Files to decrypt.").StringsVar(&c.Files)

return c
Expand All @@ -32,10 +37,15 @@ func (c catCommand) Name() string { return "cat" }
func (c catCommand) Run(ctx context.Context, config RootConfig) error {
logger := config.Logger

var passphraseR io.Reader = os.Stdin
if c.SSHPassphrase != "" {
passphraseR = strings.NewReader(c.SSHPassphrase)
}

// Create repositories
keyRepo, err := storagefs.NewKeyRepository(storagefs.KeyRepositoryConfig{
PrivateKeyPath: c.PrivateKeyPath,
KeyFactory: keyage.Factory,
KeyFactory: keyage.NewFactory(passphraseR, logger),
Logger: logger,
})
if err != nil {
Expand Down
11 changes: 10 additions & 1 deletion cmd/agebox/commands/decrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package commands
import (
"context"
"fmt"
"io"
"os"
"regexp"
"strings"

"gopkg.in/alecthomas/kingpin.v2"

Expand All @@ -23,6 +25,7 @@ type decryptCommand struct {
DecryptAll bool
Force bool
DryRun bool
SSHPassphrase string
RegexFilter *regexp.Regexp
}

Expand All @@ -31,6 +34,7 @@ func NewDecryptCommand(app *kingpin.Application) Command {
c := &decryptCommand{}
cmd := app.Command("decrypt", "Decrypts any number of tracked files.")
cmd.Flag("private-key", "Path to private key.").Required().Short('i').StringVar(&c.PrivateKeyPath)
cmd.Flag("passphrase", "SSH private key passphrase, if required it will take this and not ask disabling interactive mode.").StringVar(&c.SSHPassphrase)
cmd.Flag("all", "Decrypts all tracked files.").Short('a').BoolVar(&c.DecryptAll)
cmd.Flag("dry-run", "Enables dry run mode, write operations will be ignored.").BoolVar(&c.DryRun)
cmd.Flag("force", "Forces the decryption even if decrypted file exists.").BoolVar(&c.Force)
Expand All @@ -54,10 +58,15 @@ func (d decryptCommand) Run(ctx context.Context, config RootConfig) error {
secretRepo storage.SecretRepository
)

var passphraseR io.Reader = os.Stdin
if d.SSHPassphrase != "" {
passphraseR = strings.NewReader(d.SSHPassphrase)
}

// Create repositories
keyRepo, err := storagefs.NewKeyRepository(storagefs.KeyRepositoryConfig{
PrivateKeyPath: d.PrivateKeyPath,
KeyFactory: keyage.Factory,
KeyFactory: keyage.NewFactory(passphraseR, logger),
Logger: logger,
})
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/agebox/commands/encrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func (e encryptCommand) Run(ctx context.Context, config RootConfig) error {

keyRepo, err = storagefs.NewKeyRepository(storagefs.KeyRepositoryConfig{
PublicKeysPath: e.PubKeysPath,
KeyFactory: keyage.Factory,
KeyFactory: keyage.NewFactory(config.Stdin, logger),
Logger: logger,
})
if err != nil {
Expand Down
12 changes: 11 additions & 1 deletion cmd/agebox/commands/reencrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package commands
import (
"context"
"fmt"
"io"
"os"
"strings"

"gopkg.in/alecthomas/kingpin.v2"

Expand All @@ -17,6 +20,7 @@ import (
type reencryptCommand struct {
PubKeysPath string
PrivateKeyPath string
SSHPassphrase string
DryRun bool
}

Expand All @@ -28,6 +32,7 @@ func NewReencryptCommand(app *kingpin.Application) Command {
cmd.Alias("update")
cmd.Flag("public-keys", "Path to public keys.").Default("keys").Short('p').StringVar(&c.PubKeysPath)
cmd.Flag("private-key", "Path to private key.").Required().Short('i').StringVar(&c.PrivateKeyPath)
cmd.Flag("passphrase", "SSH private key passphrase, if required it will take this and not ask disabling interactive mode.").StringVar(&c.SSHPassphrase)
cmd.Flag("dry-run", "Enables dry run mode, write operations will be ignored.").BoolVar(&c.DryRun)

return c
Expand All @@ -50,10 +55,15 @@ func (r reencryptCommand) Run(ctx context.Context, config RootConfig) error {
return fmt.Errorf("could not create track repository: %w", err)
}

var passphraseR io.Reader = os.Stdin
if r.SSHPassphrase != "" {
passphraseR = strings.NewReader(r.SSHPassphrase)
}

keyRepo, err = storagefs.NewKeyRepository(storagefs.KeyRepositoryConfig{
PublicKeysPath: r.PubKeysPath,
PrivateKeyPath: r.PrivateKeyPath,
KeyFactory: keyage.Factory,
KeyFactory: keyage.NewFactory(passphraseR, logger),
Logger: logger,
})
if err != nil {
Expand Down
12 changes: 11 additions & 1 deletion cmd/agebox/commands/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package commands
import (
"context"
"fmt"
"io"
"os"
"strings"

"gopkg.in/alecthomas/kingpin.v2"

Expand All @@ -15,6 +18,7 @@ import (

type validateCommand struct {
PrivateKeyPath string
SSHPassphrase string
NoDecrypt bool
}

Expand All @@ -24,6 +28,7 @@ func NewValidateCommand(app *kingpin.Application) Command {
cmd := app.Command("validate", "Validates the files are in correct state (e.g encrypted and not decrypted).")
cmd.Alias("check")
cmd.Flag("private-key", "Path to private key.").Short('i').StringVar(&c.PrivateKeyPath)
cmd.Flag("passphrase", "SSH private key passphrase, if required it will take this and not ask disabling interactive mode.").StringVar(&c.SSHPassphrase)
cmd.Flag("no-decrypt", "Doesn't decrypt the tracked files.").BoolVar(&c.NoDecrypt)

return c
Expand All @@ -38,10 +43,15 @@ func (v validateCommand) Run(ctx context.Context, config RootConfig) error {
return fmt.Errorf("a private key is required to decrypt")
}

var passphraseR io.Reader = os.Stdin
if v.SSHPassphrase != "" {
passphraseR = strings.NewReader(v.SSHPassphrase)
}

// Create repositories.
keyRepo, err := storagefs.NewKeyRepository(storagefs.KeyRepositoryConfig{
PrivateKeyPath: v.PrivateKeyPath,
KeyFactory: keyage.Factory,
KeyFactory: keyage.NewFactory(passphraseR, logger),
Logger: logger,
})
if err != nil {
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/ghodss/yaml v1.0.0
github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.7.0
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 // indirect
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221
gopkg.in/alecthomas/kingpin.v2 v2.2.6
)
119 changes: 94 additions & 25 deletions internal/key/age/age.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ package age
import (
"context"
"fmt"
"io"
"os"

"filippo.io/age"
"filippo.io/age/agessh"
"golang.org/x/crypto/ssh"
"golang.org/x/term"

"github.com/slok/agebox/internal/key"
"github.com/slok/agebox/internal/log"
"github.com/slok/agebox/internal/model"
)

Expand Down Expand Up @@ -45,38 +50,42 @@ func (p PrivateKey) AgeIdentity() age.Identity { return p.identity }

var _ model.PrivateKey = &PrivateKey{}

// These are the key parsers used to load keys, they will work in
// brute force mode being used as a chain, if one fails we continue
// until one is correct.
//
// TODO(slok): We could optimize this as age does, checking
// the keys headers and selecting the correct one.
var (
publicKeyParsers = []func(string) (age.Recipient, error){
agessh.ParseRecipient,
func(d string) (age.Recipient, error) { return age.ParseX25519Recipient(d) },
}

privateKeyParsers = []func(string) (age.Identity, error){
func(d string) (age.Identity, error) { return agessh.ParseIdentity([]byte(d)) },
func(d string) (age.Identity, error) { return age.ParseX25519Identity(d) },
}
)

type factory bool
type factory struct {
// These are the key parsers used to load keys, they will work in
// brute force mode being used as a chain, if one fails we continue
// until one is correct.
//
// TODO(slok): We could optimize this as age does, checking
// the keys headers and selecting the correct one.
publicKeyParsers []func(string) (age.Recipient, error)
privateKeyParsers []func(string) (age.Identity, error)
}

// Factory is the key.Factory implementation for age supported keys.
// It supports:
// - RSA
// - Ed25519
// - X25519
const Factory = factory(true)
func NewFactory(passphraseReader io.Reader, logger log.Logger) key.Factory {
logger = logger.WithValues(log.Kv{"svc": "key.age.Factory"})

return factory{
publicKeyParsers: []func(string) (age.Recipient, error){
agessh.ParseRecipient,
func(d string) (age.Recipient, error) { return age.ParseX25519Recipient(d) },
},
privateKeyParsers: []func(string) (age.Identity, error){
parseSSHIdentityFunc(passphraseReader, logger),
func(d string) (age.Identity, error) { return age.ParseX25519Identity(d) },
},
}
}

var _ key.Factory = Factory
var _ key.Factory = factory{}

func (factory) GetPublicKey(ctx context.Context, data []byte) (model.PublicKey, error) {
func (f factory) GetPublicKey(ctx context.Context, data []byte) (model.PublicKey, error) {
sdata := string(data)
for _, f := range publicKeyParsers {
for _, f := range f.publicKeyParsers {
recipient, err := f(sdata)
// If no error, we have our public key.
if err == nil {
Expand All @@ -90,9 +99,9 @@ func (factory) GetPublicKey(ctx context.Context, data []byte) (model.PublicKey,
return nil, fmt.Errorf("invalid public key")
}

func (factory) GetPrivateKey(ctx context.Context, data []byte) (model.PrivateKey, error) {
func (f factory) GetPrivateKey(ctx context.Context, data []byte) (model.PrivateKey, error) {
sdata := string(data)
for _, f := range privateKeyParsers {
for _, f := range f.privateKeyParsers {
identity, err := f(sdata)
// If no error, we have our private key.
if err == nil {
Expand All @@ -105,3 +114,63 @@ func (factory) GetPrivateKey(ctx context.Context, data []byte) (model.PrivateKey

return nil, fmt.Errorf("invalid private key")
}

func parseSSHIdentityFunc(passphraseR io.Reader, logger log.Logger) func(string) (age.Identity, error) {
return func(d string) (age.Identity, error) {
// Get the SSH private key.
secretData := []byte(d)
id, err := agessh.ParseIdentity(secretData)
if err == nil {
return id, nil
}

// If passphrase required, ask for it.
sshErr, ok := err.(*ssh.PassphraseMissingError)
if !ok {
return nil, err
}

if sshErr.PublicKey == nil {
return nil, fmt.Errorf("passphrase required and public key can't be obtained from private key")
}

// Ask for passphrase and get identity.
i, err := agessh.NewEncryptedSSHIdentity(sshErr.PublicKey, secretData, askPasswordStdin(passphraseR, logger))
if err != nil {
return nil, err
}

return i, nil
}
}

func askPasswordStdin(r io.Reader, logger log.Logger) func() ([]byte, error) {
return func() ([]byte, error) {
// If not stdin just return the passphrase.
if r != os.Stdin {
return io.ReadAll(r)
}

// Check if is a valid terminal and try getting it.
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
tty, err := os.Open("/dev/tty")
if err != nil {
return nil, fmt.Errorf("standard input is not available or not a terminal, and opening /dev/tty failed: %v", err)
}
defer tty.Close()
fd = int(tty.Fd())
}

// Ask for password.
logger.Warningf("SSH key passphrase required")
logger.Infof("Enter passphrase for ssh key: ")

p, err := term.ReadPassword(fd)
if err != nil {
return nil, err
}

return p, nil
}
}
Loading

0 comments on commit 06af8c5

Please sign in to comment.