@@ -17,6 +17,7 @@ import (
1717 "filippo.io/age/armor"
1818 "filippo.io/age/plugin"
1919 "github.com/sirupsen/logrus"
20+ "golang.org/x/crypto/ssh"
2021
2122 "github.com/getsops/sops/v3/logging"
2223 "github.com/google/shlex"
@@ -32,6 +33,13 @@ const (
3233 // SopsAgeKeyCmdEnv can be set as an environment variable with a command
3334 // to execute that returns the age keys.
3435 SopsAgeKeyCmdEnv = "SOPS_AGE_KEY_CMD"
36+ // SopsAgeRecipientEnv is passed as an environment variable to the command
37+ // set in SopsAgeKeyCmdEnv and contains the Bech32-encoded age public key
38+ // for which the private key should be returned.
39+ SopsAgeRecipientEnv = "SOPS_AGE_RECIPIENT"
40+ // SopsAgeSshPrivateKeyCmdEnv can be set as an environment variable with a command
41+ // to execute that returns the private SSH key.
42+ SopsAgeSshPrivateKeyCmdEnv = "SOPS_AGE_SSH_PRIVATE_KEY_CMD"
3543 // SopsAgeSshPrivateKeyFileEnv can be set as an environment variable pointing to
3644 // a private SSH key file.
3745 SopsAgeSshPrivateKeyFileEnv = "SOPS_AGE_SSH_PRIVATE_KEY_FILE"
@@ -286,11 +294,35 @@ func (key *MasterKey) TypeToIdentifier() string {
286294 return KeyTypeIdentifier
287295}
288296
289- // loadAgeSSHIdentity attempts to load the age SSH identity based on an SSH
290- // private key from the SopsAgeSshPrivateKeyFileEnv environment variable. If the
291- // environment variable is not present, it will fall back to `~/.ssh/id_ed25519`
292- // or `~/.ssh/id_rsa`. If no age SSH identity is found, it will return nil.
293- func loadAgeSSHIdentities () ([]age.Identity , []string , errSet ) {
297+ // getOutputFromCmd executes a shell command provided in param 'cmdString',
298+ // optionally adding env vars provided in param 'envVars',
299+ // and returns the command's output and error
300+ func getOutputFromCmd (cmdString string , envVars []string ) ([]byte , error ) {
301+ var out []byte
302+
303+ args , err := shlex .Split (cmdString )
304+ if err != nil {
305+ return nil , fmt .Errorf ("failed to parse command %s: %w" , cmdString , err )
306+ }
307+ cmd := exec .Command (args [0 ], args [1 :]... )
308+ if envVars != nil {
309+ cmd .Env = append (os .Environ (), envVars [0 :]... )
310+ }
311+ out , err = cmd .Output ()
312+ if err != nil {
313+ return nil , fmt .Errorf ("failed to execute command %s: %w" , cmdString , err )
314+ }
315+
316+ return out , nil
317+ }
318+
319+ // loadAgeSSHIdentity attempts to load age SSH identities in this order:
320+ // 1. An SSH private key from the SopsAgeSshPrivateKeyFileEnv environment variable.
321+ // 2. An SSH private key returned by executing the command from the
322+ // SopsAgeSshPrivateKeyCmdEnv environment variable
323+ // 3. `~/.ssh/id_ed25519` or `~/.ssh/id_rsa`.
324+ // If no age SSH identity is found, it will return nil.
325+ func (key * MasterKey ) loadAgeSSHIdentities () ([]age.Identity , []string , errSet ) {
294326 var identities []age.Identity
295327 var unusedLocations []string
296328 var errs errSet
@@ -307,6 +339,23 @@ func loadAgeSSHIdentities() ([]age.Identity, []string, errSet) {
307339 unusedLocations = append (unusedLocations , SopsAgeSshPrivateKeyFileEnv )
308340 }
309341
342+ sshKeyCmd , ok := os .LookupEnv (SopsAgeSshPrivateKeyCmdEnv )
343+ if ok {
344+ out , err := getOutputFromCmd (sshKeyCmd , []string {fmt .Sprintf ("%s=%s" , SopsAgeRecipientEnv , key .Recipient )})
345+ if err != nil {
346+ errs = append (errs , err )
347+ } else {
348+ identity , err := parseSSHIdentityFromPrivateKeyCmdOutput (out )
349+ if err != nil {
350+ errs = append (errs , err )
351+ } else {
352+ identities = append (identities , identity )
353+ }
354+ }
355+ } else {
356+ unusedLocations = append (unusedLocations , SopsAgeSshPrivateKeyCmdEnv )
357+ }
358+
310359 userHomeDir , err := os .UserHomeDir ()
311360 if err != nil {
312361 errs = append (errs , err )
@@ -355,7 +404,7 @@ func getUserConfigDir() (string, error) {
355404// SopsAgeSshPrivateKeyFileEnv, SopsAgeKeyUserConfigPath). It will load all
356405// found references, and expects at least one configuration to be present.
357406func (key * MasterKey ) loadIdentities () (ParsedIdentities , []string , errSet ) {
358- identities , unusedLocations , errs := loadAgeSSHIdentities ()
407+ identities , unusedLocations , errs := key . loadAgeSSHIdentities ()
359408
360409 var readers = make (map [string ]io.Reader , 0 )
361410
@@ -378,16 +427,11 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) {
378427 }
379428
380429 if ageKeyCmd , ok := os .LookupEnv (SopsAgeKeyCmdEnv ); ok {
381- args , err := shlex . Split (ageKeyCmd )
430+ out , err := getOutputFromCmd (ageKeyCmd , [] string { fmt . Sprintf ( "%s=%s" , SopsAgeRecipientEnv , key . Recipient )} )
382431 if err != nil {
383- errs = append (errs , fmt . Errorf ( "failed to parse command %s from %s: %w" , ageKeyCmd , SopsAgeKeyCmdEnv , err ) )
432+ errs = append (errs , err )
384433 } else {
385- out , err := exec .Command (args [0 ], args [1 :]... ).Output ()
386- if err != nil {
387- errs = append (errs , fmt .Errorf ("failed to execute command %s from %s: %w" , ageKeyCmd , SopsAgeKeyCmdEnv , err ))
388- } else {
389- readers [SopsAgeKeyCmdEnv ] = bytes .NewReader (out )
390- }
434+ readers [SopsAgeKeyCmdEnv ] = bytes .NewReader (out )
391435 }
392436 } else {
393437 unusedLocations = append (unusedLocations , SopsAgeKeyCmdEnv )
@@ -496,3 +540,16 @@ func parseIdentity(s string) (age.Identity, error) {
496540 return nil , fmt .Errorf ("unknown identity type" )
497541 }
498542}
543+
544+ // parseSSHIdentityFromPrivateKeyCmdOutput returns an age.Identity from the given
545+ // private key. Note that encrypted private keys are not supported.
546+ func parseSSHIdentityFromPrivateKeyCmdOutput (key []byte ) (age.Identity , error ) {
547+ id , err := agessh .ParseIdentity (key )
548+ if sshErr , ok := err .(* ssh.PassphraseMissingError ); ok {
549+ return nil , fmt .Errorf ("the SSH key returned by running SOPS_AGE_SSH_PRIVATE_KEY_CMD is password protected, which is unsupported. (%q)" , sshErr )
550+ }
551+ if err != nil {
552+ return nil , fmt .Errorf ("malformed SSH identity returned by running SOPS_AGE_SSH_PRIVATE_KEY_CMD: %q" , err )
553+ }
554+ return id , nil
555+ }
0 commit comments