@@ -11,10 +11,12 @@ import (
1111 "strings"
1212
1313 "filippo.io/age"
14+ "filippo.io/age/agessh"
1415 "filippo.io/age/armor"
1516 "github.com/sirupsen/logrus"
1617
1718 "github.com/getsops/sops/v3/logging"
19+ "golang.org/x/crypto/ssh"
1820)
1921
2022const (
@@ -24,6 +26,9 @@ const (
2426 // SopsAgeKeyFileEnv can be set as an environment variable pointing to an
2527 // age keys file.
2628 SopsAgeKeyFileEnv = "SOPS_AGE_KEY_FILE"
29+ // SopsAgeSshPrivateKeyEnv can be set as an environment variable pointing to
30+ // a private SSH key file.
31+ SopsAgeSshPrivateKeyEnv = "SOPS_AGE_SSH_PRIVATE_KEY"
2732 // SopsAgeKeyUserConfigPath is the default age keys file path in
2833 // getUserConfigDir().
2934 SopsAgeKeyUserConfigPath = "sops/age/keys.txt"
@@ -60,7 +65,7 @@ type MasterKey struct {
6065 parsedIdentities []age.Identity
6166 // parsedRecipient contains a parsed age public key.
6267 // It is used to lazy-load the Recipient at-most once.
63- parsedRecipient * age.X25519Recipient
68+ parsedRecipient age.Recipient
6469}
6570
6671// MasterKeysFromRecipients takes a comma-separated list of Bech32-encoded
@@ -233,6 +238,98 @@ func (key *MasterKey) TypeToIdentifier() string {
233238 return KeyTypeIdentifier
234239}
235240
241+ // readPublicKeyFile attempts to read a public key based on the given private
242+ // key path. It assumes the public key is in the same directory, with the same
243+ // name, but with a ".pub" extension. If the public key cannot be read, an
244+ // error is returned.
245+ func readPublicKeyFile (privateKeyPath string ) (ssh.PublicKey , error ) {
246+ publicKeyPath := privateKeyPath + ".pub"
247+ f , err := os .Open (publicKeyPath )
248+ if err != nil {
249+ return nil , fmt .Errorf ("failed to obtain public %q key for %q SSH key: %w" , publicKeyPath , privateKeyPath , err )
250+ }
251+ defer f .Close ()
252+ contents , err := io .ReadAll (f )
253+ if err != nil {
254+ return nil , fmt .Errorf ("failed to read %q: %w" , publicKeyPath , err )
255+ }
256+ pubKey , _ , _ , _ , err := ssh .ParseAuthorizedKey (contents )
257+ if err != nil {
258+ return nil , fmt .Errorf ("failed to parse %q: %w" , publicKeyPath , err )
259+ }
260+ return pubKey , nil
261+ }
262+
263+ // parseSSHIdentityFromPrivateKeyFile returns an age.Identity from the given
264+ // private key file. If the private key file is encrypted, it will configure
265+ // the identity to prompt for a passphrase.
266+ func parseSSHIdentityFromPrivateKeyFile (keyPath string ) (age.Identity , error ) {
267+ keyFile , err := os .Open (keyPath )
268+ if err != nil {
269+ return nil , fmt .Errorf ("failed to open file: %w" , err )
270+ }
271+ defer keyFile .Close ()
272+ contents , err := io .ReadAll (keyFile )
273+ if err != nil {
274+ return nil , fmt .Errorf ("failed to read file: %w" , err )
275+ }
276+ id , err := agessh .ParseIdentity (contents )
277+ if sshErr , ok := err .(* ssh.PassphraseMissingError ); ok {
278+ pubKey := sshErr .PublicKey
279+ if pubKey == nil {
280+ pubKey , err = readPublicKeyFile (keyPath )
281+ if err != nil {
282+ return nil , err
283+ }
284+ }
285+ passphrasePrompt := func () ([]byte , error ) {
286+ pass , err := readPassphrase (fmt .Sprintf ("Enter passphrase for %q:" , keyPath ))
287+ if err != nil {
288+ return nil , fmt .Errorf ("could not read passphrase for %q: %v" , keyPath , err )
289+ }
290+ return pass , nil
291+ }
292+ i , err := agessh .NewEncryptedSSHIdentity (pubKey , contents , passphrasePrompt )
293+ if err != nil {
294+ return nil , fmt .Errorf ("could not create encrypted SSH identity: %w" , err )
295+ }
296+ return i , nil
297+ }
298+ if err != nil {
299+ return nil , fmt .Errorf ("malformed SSH identity in %q: %w" , keyPath , err )
300+ }
301+ return id , nil
302+ }
303+
304+ // loadAgeSSHIdentity attempts to load the age SSH identity based on an SSH
305+ // private key from the SopsAgeSshPrivateKeyEnv environment variable. If the
306+ // environment variable is not present, it will fall back to `~/.ssh/id_ed25519`
307+ // or `~/.ssh/id_rsa`. If no age SSH identity is found, it will return nil.
308+ func loadAgeSSHIdentity () (age.Identity , error ) {
309+ sshKeyFilePath , ok := os .LookupEnv (SopsAgeSshPrivateKeyEnv )
310+ if ok {
311+ return parseSSHIdentityFromPrivateKeyFile (sshKeyFilePath )
312+ }
313+
314+ userHomeDir , err := os .UserHomeDir ()
315+ if err != nil || userHomeDir == "" {
316+ log .Warnf ("could not determine the user home directory: %v" , err )
317+ return nil , nil
318+ }
319+
320+ sshEd25519PrivateKeyPath := filepath .Join (userHomeDir , ".ssh" , "id_ed25519" )
321+ if _ , err := os .Stat (sshEd25519PrivateKeyPath ); err == nil {
322+ return parseSSHIdentityFromPrivateKeyFile (sshEd25519PrivateKeyPath )
323+ }
324+
325+ sshRsaPrivateKeyPath := filepath .Join (userHomeDir , ".ssh" , "id_rsa" )
326+ if _ , err := os .Stat (sshRsaPrivateKeyPath ); err == nil {
327+ return parseSSHIdentityFromPrivateKeyFile (sshRsaPrivateKeyPath )
328+ }
329+
330+ return nil , nil
331+ }
332+
236333func getUserConfigDir () (string , error ) {
237334 if runtime .GOOS == "darwin" {
238335 if userConfigDir , ok := os .LookupEnv (xdgConfigHome ); ok && userConfigDir != "" {
@@ -244,9 +341,19 @@ func getUserConfigDir() (string, error) {
244341
245342// loadIdentities attempts to load the age identities based on runtime
246343// environment configurations (e.g. SopsAgeKeyEnv, SopsAgeKeyFileEnv,
247- // SopsAgeKeyUserConfigPath). It will load all found references, and expects
248- // at least one configuration to be present.
344+ // SopsAgeSshPrivateKeyEnv, SopsAgeKeyUserConfigPath). It will load all
345+ // found references, and expects at least one configuration to be present.
249346func (key * MasterKey ) loadIdentities () (ParsedIdentities , error ) {
347+ var identities ParsedIdentities
348+
349+ sshIdentity , err := loadAgeSSHIdentity ()
350+ if err != nil {
351+ return nil , fmt .Errorf ("failed to get SSH identity: %w" , err )
352+ }
353+ if sshIdentity != nil {
354+ identities = append (identities , sshIdentity )
355+ }
356+
250357 var readers = make (map [string ]io.Reader , 0 )
251358
252359 if ageKey , ok := os .LookupEnv (SopsAgeKeyEnv ); ok {
@@ -263,7 +370,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
263370 }
264371
265372 userConfigDir , err := getUserConfigDir ()
266- if err != nil && len (readers ) == 0 {
373+ if err != nil && len (readers ) == 0 && len ( identities ) == 0 {
267374 return nil , fmt .Errorf ("user config directory could not be determined: %w" , err )
268375 }
269376 if userConfigDir != "" {
@@ -272,7 +379,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
272379 if err != nil && ! errors .Is (err , os .ErrNotExist ) {
273380 return nil , fmt .Errorf ("failed to open file: %w" , err )
274381 }
275- if errors .Is (err , os .ErrNotExist ) && len (readers ) == 0 {
382+ if errors .Is (err , os .ErrNotExist ) && len (readers ) == 0 && len ( identities ) == 0 {
276383 // If we have no other readers, presence of the file is required.
277384 return nil , fmt .Errorf ("failed to open file: %w" , err )
278385 }
@@ -282,7 +389,6 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
282389 }
283390 }
284391
285- var identities ParsedIdentities
286392 for n , r := range readers {
287393 ids , err := age .ParseIdentities (r )
288394 if err != nil {
@@ -294,13 +400,25 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
294400}
295401
296402// parseRecipient attempts to parse a string containing an encoded age public
297- // key.
298- func parseRecipient (recipient string ) (* age.X25519Recipient , error ) {
299- parsedRecipient , err := age .ParseX25519Recipient (recipient )
300- if err != nil {
301- return nil , fmt .Errorf ("failed to parse input as Bech32-encoded age public key: %w" , err )
403+ // key or a public ssh key.
404+ func parseRecipient (recipient string ) (age.Recipient , error ) {
405+ switch {
406+ case strings .HasPrefix (recipient , "age1" ):
407+ parsedRecipient , err := age .ParseX25519Recipient (recipient )
408+ if err != nil {
409+ return nil , fmt .Errorf ("failed to parse input as Bech32-encoded age public key: %w" , err )
410+ }
411+
412+ return parsedRecipient , nil
413+ case strings .HasPrefix (recipient , "ssh-" ):
414+ parsedRecipient , err := agessh .ParseRecipient (recipient )
415+ if err != nil {
416+ return nil , fmt .Errorf ("failed to parse input as age-ssh public key: %w" , err )
417+ }
418+ return parsedRecipient , nil
302419 }
303- return parsedRecipient , nil
420+
421+ return nil , fmt .Errorf ("failed to parse input, unknown recipient type: %q" , recipient )
304422}
305423
306424// parseIdentities attempts to parse the string set of encoded age identities.
0 commit comments