@@ -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
1821const (
@@ -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.
229323func (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
289405func parseIdentities (identity ... string ) (ParsedIdentities , error ) {
290406 var identities []age.Identity
291407 for _ , i := range identity {
0 commit comments