Skip to content

Commit 4569875

Browse files
committed
feat: add SOPS_AGE_SSH_PRIVATE_KEY_CMD
Signed-off-by: Nate Scherer <376408+natescherer@users.noreply.github.com>
1 parent 09cf07a commit 4569875

3 files changed

Lines changed: 119 additions & 31 deletions

File tree

README.rst

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,16 @@ identity will be tried in sequence until one is able to decrypt the data.
250250

251251
Encrypting with SSH keys via age is also supported by SOPS. You can use SSH public keys
252252
("ssh-ed25519 AAAA...", "ssh-rsa AAAA...") as age recipients when encrypting a file.
253-
When decrypting a file, SOPS will look for ``~/.ssh/id_ed25519`` and falls back to
254-
``~/.ssh/id_rsa``. You can specify the location of the private key manually by setting
255-
the environment variable **SOPS_AGE_SSH_PRIVATE_KEY_FILE**.
253+
254+
When decrypting a file, SOPS will attempt to source the SSH private key as follows:
255+
256+
- From the path specified in environment variable **SOPS_AGE_SSH_PRIVATE_KEY_FILE**.
257+
- From the output of the command specified in environment variable **SOPS_AGE_SSH_PRIVATE_KEY_CMD**.
258+
259+
.. note:: The output of this command must provide a key that is not password protected.
260+
261+
- From ``~/.ssh/id_ed25519``.
262+
- From ``~/.ssh/id_rsa``.
256263

257264
Note that only ``ssh-rsa`` and ``ssh-ed25519`` are supported.
258265

age/keysource.go

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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"
@@ -36,6 +37,9 @@ const (
3637
// set in SopsAgeKeyCmdEnv and contains the Bech32-encoded age public key
3738
// for which the private key should be returned.
3839
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"
3943
// SopsAgeSshPrivateKeyFileEnv can be set as an environment variable pointing to
4044
// a private SSH key file.
4145
SopsAgeSshPrivateKeyFileEnv = "SOPS_AGE_SSH_PRIVATE_KEY_FILE"
@@ -290,11 +294,35 @@ func (key *MasterKey) TypeToIdentifier() string {
290294
return KeyTypeIdentifier
291295
}
292296

293-
// loadAgeSSHIdentity attempts to load the age SSH identity based on an SSH
294-
// private key from the SopsAgeSshPrivateKeyFileEnv environment variable. If the
295-
// environment variable is not present, it will fall back to `~/.ssh/id_ed25519`
296-
// or `~/.ssh/id_rsa`. If no age SSH identity is found, it will return nil.
297-
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) {
298326
var identities []age.Identity
299327
var unusedLocations []string
300328
var errs errSet
@@ -311,6 +339,23 @@ func loadAgeSSHIdentities() ([]age.Identity, []string, errSet) {
311339
unusedLocations = append(unusedLocations, SopsAgeSshPrivateKeyFileEnv)
312340
}
313341

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+
314359
userHomeDir, err := os.UserHomeDir()
315360
if err != nil {
316361
errs = append(errs, err)
@@ -359,7 +404,7 @@ func getUserConfigDir() (string, error) {
359404
// SopsAgeSshPrivateKeyFileEnv, SopsAgeKeyUserConfigPath). It will load all
360405
// found references, and expects at least one configuration to be present.
361406
func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) {
362-
identities, unusedLocations, errs := loadAgeSSHIdentities()
407+
identities, unusedLocations, errs := key.loadAgeSSHIdentities()
363408

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

@@ -382,18 +427,11 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) {
382427
}
383428

384429
if ageKeyCmd, ok := os.LookupEnv(SopsAgeKeyCmdEnv); ok {
385-
args, err := shlex.Split(ageKeyCmd)
430+
out, err := getOutputFromCmd(ageKeyCmd, []string{fmt.Sprintf("%s=%s", SopsAgeRecipientEnv, key.Recipient)})
386431
if err != nil {
387-
errs = append(errs, fmt.Errorf("failed to parse command %s from %s: %w", ageKeyCmd, SopsAgeKeyCmdEnv, err))
432+
errs = append(errs, err)
388433
} else {
389-
cmd := exec.Command(args[0], args[1:]...)
390-
cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", SopsAgeRecipientEnv, key.Recipient))
391-
out, err := cmd.Output()
392-
if err != nil {
393-
errs = append(errs, fmt.Errorf("failed to execute command %s from %s: %w", ageKeyCmd, SopsAgeKeyCmdEnv, err))
394-
} else {
395-
readers[SopsAgeKeyCmdEnv] = bytes.NewReader(out)
396-
}
434+
readers[SopsAgeKeyCmdEnv] = bytes.NewReader(out)
397435
}
398436
} else {
399437
unusedLocations = append(unusedLocations, SopsAgeKeyCmdEnv)
@@ -502,3 +540,16 @@ func parseIdentity(s string) (age.Identity, error) {
502540
return nil, fmt.Errorf("unknown identity type")
503541
}
504542
}
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+
}

age/keysource_test.go

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ func TestMasterKey_loadIdentities(t *testing.T) {
374374
got, unusedLocations, errs := key.loadIdentities()
375375
assert.Len(t, errs, 0)
376376
assert.Len(t, got, 1)
377-
assert.Len(t, unusedLocations, 5)
377+
assert.Len(t, unusedLocations, 6)
378378
})
379379

380380
t.Run(SopsAgeKeyEnv+" multiple", func(t *testing.T) {
@@ -388,7 +388,7 @@ func TestMasterKey_loadIdentities(t *testing.T) {
388388
got, unusedLocations, errs := key.loadIdentities()
389389
assert.Len(t, errs, 0)
390390
assert.Len(t, got, 2)
391-
assert.Len(t, unusedLocations, 5)
391+
assert.Len(t, unusedLocations, 6)
392392
})
393393

394394
t.Run(SopsAgeKeyFileEnv, func(t *testing.T) {
@@ -405,7 +405,7 @@ func TestMasterKey_loadIdentities(t *testing.T) {
405405
got, unusedLocations, errs := key.loadIdentities()
406406
assert.Len(t, errs, 0)
407407
assert.Len(t, got, 1)
408-
assert.Len(t, unusedLocations, 5)
408+
assert.Len(t, unusedLocations, 6)
409409
})
410410

411411
t.Run(SopsAgeKeyUserConfigPath, func(t *testing.T) {
@@ -424,7 +424,7 @@ func TestMasterKey_loadIdentities(t *testing.T) {
424424
got, unusedLocations, errs := (&MasterKey{}).loadIdentities()
425425
assert.Len(t, errs, 0)
426426
assert.Len(t, got, 1)
427-
assert.Len(t, unusedLocations, 6)
427+
assert.Len(t, unusedLocations, 7)
428428
})
429429

430430
t.Run(SopsAgeSshPrivateKeyFileEnv, func(t *testing.T) {
@@ -444,7 +444,7 @@ func TestMasterKey_loadIdentities(t *testing.T) {
444444
got, unusedLocations, errs := key.loadIdentities()
445445
assert.Len(t, errs, 0)
446446
assert.Len(t, got, 1)
447-
assert.Len(t, unusedLocations, 5)
447+
assert.Len(t, unusedLocations, 6)
448448
})
449449

450450
t.Run("no identity", func(t *testing.T) {
@@ -454,7 +454,7 @@ func TestMasterKey_loadIdentities(t *testing.T) {
454454
got, unusedLocations, errs := (&MasterKey{}).loadIdentities()
455455
assert.Len(t, errs, 0)
456456
assert.Nil(t, got)
457-
assert.Len(t, unusedLocations, 7)
457+
assert.Len(t, unusedLocations, 8)
458458
})
459459

460460
t.Run("multiple identities", func(t *testing.T) {
@@ -477,7 +477,7 @@ func TestMasterKey_loadIdentities(t *testing.T) {
477477
got, unusedLocations, errs := (&MasterKey{}).loadIdentities()
478478
assert.Len(t, errs, 0)
479479
assert.Len(t, got, 2)
480-
assert.Len(t, unusedLocations, 5)
480+
assert.Len(t, unusedLocations, 6)
481481
})
482482

483483
t.Run("parsing error", func(t *testing.T) {
@@ -493,7 +493,37 @@ func TestMasterKey_loadIdentities(t *testing.T) {
493493
assert.Error(t, errs[0])
494494
assert.ErrorContains(t, errs[0], fmt.Sprintf("failed to parse '%s' age identities", SopsAgeKeyEnv))
495495
assert.Nil(t, got)
496-
assert.Len(t, unusedLocations, 5)
496+
assert.Len(t, unusedLocations, 6)
497+
})
498+
499+
t.Run(SopsAgeSshPrivateKeyCmdEnv, func(t *testing.T) {
500+
tmpDir := t.TempDir()
501+
// Overwrite to ensure local config is not picked up by tests
502+
overwriteUserConfigDir(t, tmpDir)
503+
504+
t.Setenv(SopsAgeSshPrivateKeyCmdEnv, "echo '"+mockSshIdentity+"'")
505+
506+
key := &MasterKey{}
507+
got, unusedLocations, errs := key.loadIdentities()
508+
assert.Len(t, errs, 0)
509+
assert.Len(t, got, 1)
510+
assert.Len(t, unusedLocations, 6)
511+
})
512+
513+
t.Run("cmd error", func(t *testing.T) {
514+
tmpDir := t.TempDir()
515+
// Overwrite to ensure local config is not picked up by tests
516+
overwriteUserConfigDir(t, tmpDir)
517+
518+
t.Setenv(SopsAgeSshPrivateKeyCmdEnv, "meow")
519+
520+
key := &MasterKey{}
521+
got, unusedLocations, errs := key.loadIdentities()
522+
assert.Len(t, errs, 1)
523+
assert.Error(t, errs[0])
524+
assert.ErrorContains(t, errs[0], "failed to execute command meow")
525+
assert.Nil(t, got)
526+
assert.Len(t, unusedLocations, 7)
497527
})
498528

499529
t.Run(SopsAgeKeyCmdEnv, func(t *testing.T) {
@@ -507,7 +537,7 @@ func TestMasterKey_loadIdentities(t *testing.T) {
507537
got, unusedLocations, errs := key.loadIdentities()
508538
assert.Len(t, errs, 0)
509539
assert.Len(t, got, 1)
510-
assert.Len(t, unusedLocations, 5)
540+
assert.Len(t, unusedLocations, 6)
511541
})
512542

513543
t.Run(SopsAgeRecipientEnv, func(t *testing.T) {
@@ -521,13 +551,13 @@ func TestMasterKey_loadIdentities(t *testing.T) {
521551
got, unusedLocations, errs := key.loadIdentities()
522552
assert.Len(t, errs, 0)
523553
assert.Len(t, got, 1)
524-
assert.Len(t, unusedLocations, 5)
554+
assert.Len(t, unusedLocations, 6)
525555

526556
key = &MasterKey{Recipient: mockRecipient + "abc"}
527557
got, unusedLocations, errs = key.loadIdentities()
528558
assert.Len(t, errs, 0)
529559
assert.Len(t, got, 0)
530-
assert.Len(t, unusedLocations, 6)
560+
assert.Len(t, unusedLocations, 7)
531561
})
532562

533563
t.Run("cmd error", func(t *testing.T) {
@@ -543,7 +573,7 @@ func TestMasterKey_loadIdentities(t *testing.T) {
543573
assert.Error(t, errs[0])
544574
assert.ErrorContains(t, errs[0], "failed to execute command meow")
545575
assert.Nil(t, got)
546-
assert.Len(t, unusedLocations, 6)
576+
assert.Len(t, unusedLocations, 7)
547577
})
548578
}
549579

0 commit comments

Comments
 (0)