Skip to content

Commit a9601d9

Browse files
committed
feat: add ssh support for age
Signed-off-by: Marvin Strangfeld <marvin@strangfeld.io>
1 parent 9124783 commit a9601d9

File tree

5 files changed

+294
-21
lines changed

5 files changed

+294
-21
lines changed

age/keysource.go

Lines changed: 128 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import (
55
"errors"
66
"fmt"
77
"io"
8+
"io/ioutil"
89
"os"
910
"path/filepath"
1011
"strings"
1112

1213
"filippo.io/age"
14+
"filippo.io/age/agessh"
1315
"filippo.io/age/armor"
1416
"github.com/sirupsen/logrus"
1517
"go.mozilla.org/sops/v3/logging"
18+
"golang.org/x/crypto/ssh"
1619
)
1720

1821
const (
@@ -22,6 +25,9 @@ const (
2225
// SopsAgeKeyFileEnv can be set as an environment variable pointing to an
2326
// age keys file.
2427
SopsAgeKeyFileEnv = "SOPS_AGE_KEY_FILE"
28+
// SopsAgeSshPrivateKeyEnv can be set as an environment variable pointing to
29+
// a private ssh key file
30+
SopsAgeSshPrivateKeyEnv = "SOPS_AGE_SSH_PRIVATE_KEY"
2531
// SopsAgeKeyUserConfigPath is the default age keys file path in
2632
// os.UserConfigDir.
2733
SopsAgeKeyUserConfigPath = "sops/age/keys.txt"
@@ -54,7 +60,7 @@ type MasterKey struct {
5460
parsedIdentities []age.Identity
5561
// parsedRecipient contains a parsed age public key.
5662
// It is used to lazy-load the Recipient at-most once.
57-
parsedRecipient *age.X25519Recipient
63+
parsedRecipient age.Recipient
5864
}
5965

6066
// MasterKeysFromRecipients takes a comma-separated list of Bech32-encoded
@@ -222,11 +228,109 @@ func (key *MasterKey) ToMap() map[string]interface{} {
222228
return out
223229
}
224230

231+
// Tries to find the public key file given the path of the private ssh key file
232+
func readPublicKeyFile(privateKeyPath string) (ssh.PublicKey, error) {
233+
publicKeyPath := privateKeyPath + ".pub"
234+
f, err := os.Open(publicKeyPath)
235+
if err != nil {
236+
return nil, fmt.Errorf("failed to obtain public %q key for %q SSH key: %w", publicKeyPath, privateKeyPath, err)
237+
}
238+
defer f.Close()
239+
contents, err := ioutil.ReadAll(f)
240+
if err != nil {
241+
return nil, fmt.Errorf("failed to read %q: %w", publicKeyPath, err)
242+
}
243+
pubKey, _, _, _, err := ssh.ParseAuthorizedKey(contents)
244+
if err != nil {
245+
return nil, fmt.Errorf("failed to parse %q: %w", publicKeyPath, err)
246+
}
247+
return pubKey, nil
248+
}
249+
250+
// Parses a private ssh key into an age identity
251+
// If the key is password secured it needs to add a passphrase promt
252+
func getAgeSshIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) {
253+
keyFile, err := os.Open(keyPath)
254+
if err != nil {
255+
return nil, fmt.Errorf("failed to open file: %w", err)
256+
}
257+
defer keyFile.Close()
258+
contents, err := ioutil.ReadAll(keyFile)
259+
if err != nil {
260+
return nil, fmt.Errorf("failed to read file: %w", err)
261+
}
262+
id, err := agessh.ParseIdentity(contents)
263+
if sshErr, ok := err.(*ssh.PassphraseMissingError); ok {
264+
pubKey := sshErr.PublicKey
265+
if pubKey == nil {
266+
pubKey, err = readPublicKeyFile(keyPath)
267+
if err != nil {
268+
return nil, err
269+
}
270+
}
271+
passphrasePrompt := func() ([]byte, error) {
272+
pass, err := readPassphrase(fmt.Sprintf("Enter passphrase for %q:", keyPath))
273+
if err != nil {
274+
return nil, fmt.Errorf("could not read passphrase for %q: %v", keyPath, err)
275+
}
276+
return pass, nil
277+
}
278+
i, err := agessh.NewEncryptedSSHIdentity(pubKey, contents, passphrasePrompt)
279+
if err != nil {
280+
return nil, fmt.Errorf("could not create encrypted SSH identity: %w", err)
281+
}
282+
return i, nil
283+
}
284+
if err != nil {
285+
return nil, fmt.Errorf("malformed SSH identity in %q: %w", keyPath, err)
286+
}
287+
return id, nil
288+
}
289+
290+
// loadAgeSshIdentity attempts to load the age ssh identity based on a ssh private key
291+
// the path to the private key can be specified by setting the SopsAgeSshPrivateKeyEnv
292+
// if the environment variable is not present it will fallback to
293+
// ~/.ssh/id_ed25519 or ~/.ssh/id_rsa
294+
func loadAgeSshIdentity() (age.Identity, error) {
295+
sshKeyFilePath, ok := os.LookupEnv(SopsAgeSshPrivateKeyEnv)
296+
if ok {
297+
return getAgeSshIdentityFromPrivateKeyFile(sshKeyFilePath)
298+
}
299+
300+
userHomeDir, err := os.UserHomeDir()
301+
if err != nil || userHomeDir == "" {
302+
log.Warnf("could not determine the user home directory: %v", err)
303+
return nil, nil
304+
}
305+
306+
sshEd25519PrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_ed25519")
307+
if _, err := os.Stat(sshEd25519PrivateKeyPath); err == nil {
308+
return getAgeSshIdentityFromPrivateKeyFile(sshEd25519PrivateKeyPath)
309+
}
310+
311+
sshRsaPrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_rsa")
312+
if _, err := os.Stat(sshEd25519PrivateKeyPath); err == nil {
313+
return getAgeSshIdentityFromPrivateKeyFile(sshRsaPrivateKeyPath)
314+
}
315+
316+
return nil, nil
317+
}
318+
225319
// loadIdentities attempts to load the age identities based on runtime
226320
// environment configurations (e.g. SopsAgeKeyEnv, SopsAgeKeyFileEnv,
227-
// SopsAgeKeyUserConfigPath). It will load all found references, and expects
228-
// at least one configuration to be present.
321+
// SopsAgeSshPrivateKeyEnv, SopsAgeKeyUserConfigPath). It will load all
322+
// found references, and expects at least one configuration to be present.
229323
func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
324+
var identities ParsedIdentities
325+
326+
sshIdentity, err := loadAgeSshIdentity()
327+
if err != nil {
328+
return nil, fmt.Errorf("failed to get SSH identity: %w", err)
329+
}
330+
if sshIdentity != nil {
331+
identities = append(identities, sshIdentity)
332+
}
333+
230334
var readers = make(map[string]io.Reader, 0)
231335

232336
if ageKey, ok := os.LookupEnv(SopsAgeKeyEnv); ok {
@@ -243,7 +347,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
243347
}
244348

245349
userConfigDir, err := os.UserConfigDir()
246-
if err != nil && len(readers) == 0 {
350+
if err != nil && len(readers) == 0 && len(identities) == 0 {
247351
return nil, fmt.Errorf("user config directory could not be determined: %w", err)
248352
}
249353
if userConfigDir != "" {
@@ -252,7 +356,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
252356
if err != nil && !errors.Is(err, os.ErrNotExist) {
253357
return nil, fmt.Errorf("failed to open file: %w", err)
254358
}
255-
if errors.Is(err, os.ErrNotExist) && len(readers) == 0 {
359+
if errors.Is(err, os.ErrNotExist) && len(readers) == 0 && len(identities) == 0 {
256360
// If we have no other readers, presence of the file is required.
257361
return nil, fmt.Errorf("failed to open file: %w", err)
258362
}
@@ -262,7 +366,6 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
262366
}
263367
}
264368

265-
var identities ParsedIdentities
266369
for n, r := range readers {
267370
ids, err := age.ParseIdentities(r)
268371
if err != nil {
@@ -274,18 +377,31 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
274377
}
275378

276379
// parseRecipient attempts to parse a string containing an encoded age public
277-
// key.
278-
func parseRecipient(recipient string) (*age.X25519Recipient, error) {
279-
parsedRecipient, err := age.ParseX25519Recipient(recipient)
280-
if err != nil {
281-
return nil, fmt.Errorf("failed to parse input as Bech32-encoded age public key: %w", err)
380+
// key or a public ssh key.
381+
func parseRecipient(recipient string) (age.Recipient, error) {
382+
switch {
383+
case strings.HasPrefix(recipient, "age1"):
384+
parsedRecipient, err := age.ParseX25519Recipient(recipient)
385+
if err != nil {
386+
return nil, fmt.Errorf("failed to parse input as Bech32-encoded age public key: %w", err)
387+
}
388+
389+
return parsedRecipient, nil
390+
case strings.HasPrefix(recipient, "ssh-"):
391+
parsedRecipient, err := agessh.ParseRecipient(recipient)
392+
if err != nil {
393+
return nil, fmt.Errorf("failed to parse input as age-ssh public key: %w", err)
394+
}
395+
return parsedRecipient, nil
282396
}
283-
return parsedRecipient, nil
397+
398+
return nil, fmt.Errorf("failed to parse input, unknown recipient type: %q", recipient)
284399
}
285400

286401
// parseIdentities attempts to parse the string set of encoded age identities.
287402
// A single identity argument is allowed to be a multiline string containing
288403
// multiple identities. Empty lines and lines starting with "#" are ignored.
404+
// TODO: Add capability to parse ssh identities
289405
func parseIdentities(identity ...string) (ParsedIdentities, error) {
290406
var identities []age.Identity
291407
for _, i := range identity {

0 commit comments

Comments
 (0)