Skip to content

Commit

Permalink
feat: add ssh support for age
Browse files Browse the repository at this point in the history
Signed-off-by: Marvin Strangfeld <marvin@strangfeld.io>
  • Loading branch information
mstrangfeld authored and Albin Vass committed May 15, 2024
1 parent a465b8a commit 835c071
Show file tree
Hide file tree
Showing 5 changed files with 294 additions and 20 deletions.
142 changes: 130 additions & 12 deletions age/keysource.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import (
"strings"

"filippo.io/age"
"filippo.io/age/agessh"
"filippo.io/age/armor"
"github.com/sirupsen/logrus"

"github.com/getsops/sops/v3/logging"
"golang.org/x/crypto/ssh"
)

const (
Expand All @@ -24,6 +26,9 @@ const (
// SopsAgeKeyFileEnv can be set as an environment variable pointing to an
// age keys file.
SopsAgeKeyFileEnv = "SOPS_AGE_KEY_FILE"
// SopsAgeSshPrivateKeyEnv can be set as an environment variable pointing to
// a private SSH key file.
SopsAgeSshPrivateKeyEnv = "SOPS_AGE_SSH_PRIVATE_KEY"
// SopsAgeKeyUserConfigPath is the default age keys file path in
// getUserConfigDir().
SopsAgeKeyUserConfigPath = "sops/age/keys.txt"
Expand Down Expand Up @@ -60,7 +65,7 @@ type MasterKey struct {
parsedIdentities []age.Identity
// parsedRecipient contains a parsed age public key.
// It is used to lazy-load the Recipient at-most once.
parsedRecipient *age.X25519Recipient
parsedRecipient age.Recipient
}

// MasterKeysFromRecipients takes a comma-separated list of Bech32-encoded
Expand Down Expand Up @@ -233,6 +238,98 @@ func (key *MasterKey) TypeToIdentifier() string {
return KeyTypeIdentifier
}

// readPublicKeyFile attempts to read a public key based on the given private
// key path. It assumes the public key is in the same directory, with the same
// name, but with a ".pub" extension. If the public key cannot be read, an
// error is returned.
func readPublicKeyFile(privateKeyPath string) (ssh.PublicKey, error) {
publicKeyPath := privateKeyPath + ".pub"
f, err := os.Open(publicKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to obtain public %q key for %q SSH key: %w", publicKeyPath, privateKeyPath, err)
}
defer f.Close()
contents, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("failed to read %q: %w", publicKeyPath, err)
}
pubKey, _, _, _, err := ssh.ParseAuthorizedKey(contents)
if err != nil {
return nil, fmt.Errorf("failed to parse %q: %w", publicKeyPath, err)
}
return pubKey, nil
}

// parseSSHIdentityFromPrivateKeyFile returns an age.Identity from the given
// private key file. If the private key file is encrypted, it will configure
// the identity to prompt for a passphrase.
func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) {
keyFile, err := os.Open(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer keyFile.Close()
contents, err := io.ReadAll(keyFile)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
id, err := agessh.ParseIdentity(contents)
if sshErr, ok := err.(*ssh.PassphraseMissingError); ok {
pubKey := sshErr.PublicKey
if pubKey == nil {
pubKey, err = readPublicKeyFile(keyPath)
if err != nil {
return nil, err
}
}
passphrasePrompt := func() ([]byte, error) {
pass, err := readPassphrase(fmt.Sprintf("Enter passphrase for %q:", keyPath))
if err != nil {
return nil, fmt.Errorf("could not read passphrase for %q: %v", keyPath, err)
}
return pass, nil
}
i, err := agessh.NewEncryptedSSHIdentity(pubKey, contents, passphrasePrompt)
if err != nil {
return nil, fmt.Errorf("could not create encrypted SSH identity: %w", err)
}
return i, nil
}
if err != nil {
return nil, fmt.Errorf("malformed SSH identity in %q: %w", keyPath, err)
}
return id, nil
}

// loadAgeSSHIdentity attempts to load the age SSH identity based on an SSH
// private key from the SopsAgeSshPrivateKeyEnv environment variable. If the
// environment variable is not present, it will fall back to `~/.ssh/id_ed25519`
// or `~/.ssh/id_rsa`. If no age SSH identity is found, it will return nil.
func loadAgeSSHIdentity() (age.Identity, error) {
sshKeyFilePath, ok := os.LookupEnv(SopsAgeSshPrivateKeyEnv)
if ok {
return parseSSHIdentityFromPrivateKeyFile(sshKeyFilePath)
}

userHomeDir, err := os.UserHomeDir()
if err != nil || userHomeDir == "" {
log.Warnf("could not determine the user home directory: %v", err)
return nil, nil
}

sshEd25519PrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_ed25519")
if _, err := os.Stat(sshEd25519PrivateKeyPath); err == nil {
return parseSSHIdentityFromPrivateKeyFile(sshEd25519PrivateKeyPath)
}

sshRsaPrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_rsa")
if _, err := os.Stat(sshRsaPrivateKeyPath); err == nil {
return parseSSHIdentityFromPrivateKeyFile(sshRsaPrivateKeyPath)
}

return nil, nil
}

func getUserConfigDir() (string, error) {
if runtime.GOOS == "darwin" {
if userConfigDir, ok := os.LookupEnv(xdgConfigHome); ok && userConfigDir != "" {
Expand All @@ -244,9 +341,19 @@ func getUserConfigDir() (string, error) {

// loadIdentities attempts to load the age identities based on runtime
// environment configurations (e.g. SopsAgeKeyEnv, SopsAgeKeyFileEnv,
// SopsAgeKeyUserConfigPath). It will load all found references, and expects
// at least one configuration to be present.
// SopsAgeSshPrivateKeyEnv, SopsAgeKeyUserConfigPath). It will load all
// found references, and expects at least one configuration to be present.
func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
var identities ParsedIdentities

sshIdentity, err := loadAgeSSHIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get SSH identity: %w", err)
}
if sshIdentity != nil {
identities = append(identities, sshIdentity)
}

var readers = make(map[string]io.Reader, 0)

if ageKey, ok := os.LookupEnv(SopsAgeKeyEnv); ok {
Expand All @@ -263,7 +370,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
}

userConfigDir, err := getUserConfigDir()
if err != nil && len(readers) == 0 {
if err != nil && len(readers) == 0 && len(identities) == 0 {
return nil, fmt.Errorf("user config directory could not be determined: %w", err)
}
if userConfigDir != "" {
Expand All @@ -272,7 +379,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("failed to open file: %w", err)
}
if errors.Is(err, os.ErrNotExist) && len(readers) == 0 {
if errors.Is(err, os.ErrNotExist) && len(readers) == 0 && len(identities) == 0 {
// If we have no other readers, presence of the file is required.
return nil, fmt.Errorf("failed to open file: %w", err)
}
Expand All @@ -282,7 +389,6 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
}
}

var identities ParsedIdentities
for n, r := range readers {
ids, err := age.ParseIdentities(r)
if err != nil {
Expand All @@ -294,13 +400,25 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
}

// parseRecipient attempts to parse a string containing an encoded age public
// key.
func parseRecipient(recipient string) (*age.X25519Recipient, error) {
parsedRecipient, err := age.ParseX25519Recipient(recipient)
if err != nil {
return nil, fmt.Errorf("failed to parse input as Bech32-encoded age public key: %w", err)
// key or a public ssh key.
func parseRecipient(recipient string) (age.Recipient, error) {
switch {
case strings.HasPrefix(recipient, "age1"):
parsedRecipient, err := age.ParseX25519Recipient(recipient)
if err != nil {
return nil, fmt.Errorf("failed to parse input as Bech32-encoded age public key: %w", err)
}

return parsedRecipient, nil
case strings.HasPrefix(recipient, "ssh-"):
parsedRecipient, err := agessh.ParseRecipient(recipient)
if err != nil {
return nil, fmt.Errorf("failed to parse input as age-ssh public key: %w", err)
}
return parsedRecipient, nil
}
return parsedRecipient, nil

return nil, fmt.Errorf("failed to parse input, unknown recipient type: %q", recipient)
}

// parseIdentities attempts to parse the string set of encoded age identities.
Expand Down
Loading

0 comments on commit 835c071

Please sign in to comment.