Skip to content

Commit d65f4e0

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

4 files changed

Lines changed: 94 additions & 19 deletions

File tree

README.rst

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,13 @@ 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+
1. From the path specified in environment variable **SOPS_AGE_SSH_PRIVATE_KEY_FILE**
256+
2. From the output of the command specified in environment variable **SOPS_AGE_SSH_PRIVATE_KEY_CMD**
257+
- Note: the output of this command must provide a key that is not password protected
258+
3. From ``~/.ssh/id_ed25519``
259+
4. From ``~/.ssh/id_rsa``.
256260

257261
Note that only ``ssh-rsa`` and ``ssh-ed25519`` are supported.
258262

age/keysource.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ const (
3636
// set in SopsAgeKeyCmdEnv and contains the Bech32-encoded age public key
3737
// for which the private key should be returned.
3838
SopsAgeRecipientEnv = "SOPS_AGE_RECIPIENT"
39+
// SopsAgeSshPrivateKeyCmdEnv can be set as an environment variable with a command
40+
// to execute that returns the private SSH key.
41+
SopsAgeSshPrivateKeyCmdEnv = "SOPS_AGE_SSH_PRIVATE_KEY_CMD"
3942
// SopsAgeSshPrivateKeyFileEnv can be set as an environment variable pointing to
4043
// a private SSH key file.
4144
SopsAgeSshPrivateKeyFileEnv = "SOPS_AGE_SSH_PRIVATE_KEY_FILE"
@@ -290,10 +293,12 @@ func (key *MasterKey) TypeToIdentifier() string {
290293
return KeyTypeIdentifier
291294
}
292295

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.
296+
// loadAgeSSHIdentity attempts to load the age SSH identity in this order:
297+
// 1. An SSH private key from the SopsAgeSshPrivateKeyFileEnv environment variable.
298+
// 2. An SSH private key returned by executing the command from the
299+
// SopsAgeSshPrivateKeyCmdEnv environment variable
300+
// 3. `~/.ssh/id_ed25519` or `~/.ssh/id_rsa`.
301+
// If no age SSH identity is found, it will return nil.
297302
func loadAgeSSHIdentities() ([]age.Identity, []string, errSet) {
298303
var identities []age.Identity
299304
var unusedLocations []string
@@ -311,6 +316,29 @@ func loadAgeSSHIdentities() ([]age.Identity, []string, errSet) {
311316
unusedLocations = append(unusedLocations, SopsAgeSshPrivateKeyFileEnv)
312317
}
313318

319+
sshKeyCmd, ok := os.LookupEnv(SopsAgeSshPrivateKeyCmdEnv)
320+
if ok {
321+
args, err := shlex.Split(sshKeyCmd)
322+
if err != nil {
323+
errs = append(errs, fmt.Errorf("failed to parse command %s from %s: %w", sshKeyCmd, SopsAgeSshPrivateKeyCmdEnv, err))
324+
} else {
325+
cmd := exec.Command(args[0], args[1:]...)
326+
out, err := cmd.Output()
327+
if err != nil {
328+
errs = append(errs, fmt.Errorf("failed to execute command %s from %s: %w", sshKeyCmd, SopsAgeSshPrivateKeyCmdEnv, err))
329+
} else {
330+
identity, err := parseSSHIdentityFromPrivateKeyCmdOutput(out)
331+
if err != nil {
332+
errs = append(errs, err)
333+
} else {
334+
identities = append(identities, identity)
335+
}
336+
}
337+
}
338+
} else {
339+
unusedLocations = append(unusedLocations, SopsAgeSshPrivateKeyCmdEnv)
340+
}
341+
314342
userHomeDir, err := os.UserHomeDir()
315343
if err != nil {
316344
errs = append(errs, err)

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

age/ssh_parse.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,16 @@ func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) {
8282
}
8383
return id, nil
8484
}
85+
86+
// parseSSHIdentityFromPrivateKeyCmdOutput returns an age.Identity from the given
87+
// private key. Note that encrypted private keys are not supported.
88+
func parseSSHIdentityFromPrivateKeyCmdOutput(key []byte) (age.Identity, error) {
89+
id, err := agessh.ParseIdentity(key)
90+
if sshErr, ok := err.(*ssh.PassphraseMissingError); ok {
91+
return nil, fmt.Errorf("the SSH key returned by running SOPS_AGE_SSH_PRIVATE_KEY_CMD is password protected, which is unsupported.: %q", sshErr)
92+
}
93+
if err != nil {
94+
return nil, fmt.Errorf("malformed SSH identity returned by running SOPS_AGE_SSH_PRIVATE_KEY_CMD: %q", err)
95+
}
96+
return id, nil
97+
}

0 commit comments

Comments
 (0)