Skip to content

Commit 0cea11f

Browse files
authored
Merge pull request #1692 from haoqixu/age-ssh
Add SSH support for age
2 parents ca6affe + 7363b9f commit 0cea11f

File tree

6 files changed

+323
-21
lines changed

6 files changed

+323
-21
lines changed

README.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,13 @@ The contents of this key file should be a list of age X25519 identities, one
233233
per line. Lines beginning with ``#`` are considered comments and ignored. Each
234234
identity will be tried in sequence until one is able to decrypt the data.
235235

236-
Encrypting with SSH keys via age is not yet supported by SOPS.
236+
Encrypting with SSH keys via age is also supported by SOPS. You can use SSH public keys
237+
("ssh-ed25519 AAAA...", "ssh-rsa AAAA...") as age recipients when encrypting a file.
238+
When decrypting a file, SOPS will look for ``~/.ssh/id_ed25519`` and falls back to
239+
``~/.ssh/id_rsa``. You can specify the location of the private key manually by setting
240+
the environment variableuse **SOPS_AGE_SSH_PRIVATE_KEY_FILE**.
241+
242+
Note that only ``ssh-rsa`` and ``ssh-ed25519`` are supported.
237243

238244
A list of age recipients can be added to the ``.sops.yaml``:
239245

age/keysource.go

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212

1313
"filippo.io/age"
14+
"filippo.io/age/agessh"
1415
"filippo.io/age/armor"
1516
"github.com/sirupsen/logrus"
1617

@@ -24,6 +25,9 @@ const (
2425
// SopsAgeKeyFileEnv can be set as an environment variable pointing to an
2526
// age keys file.
2627
SopsAgeKeyFileEnv = "SOPS_AGE_KEY_FILE"
28+
// SopsAgeSshPrivateKeyFileEnv can be set as an environment variable pointing to
29+
// a private SSH key file.
30+
SopsAgeSshPrivateKeyFileEnv = "SOPS_AGE_SSH_PRIVATE_KEY_FILE"
2731
// SopsAgeKeyUserConfigPath is the default age keys file path in
2832
// getUserConfigDir().
2933
SopsAgeKeyUserConfigPath = "sops/age/keys.txt"
@@ -60,7 +64,7 @@ type MasterKey struct {
6064
parsedIdentities []age.Identity
6165
// parsedRecipient contains a parsed age public key.
6266
// It is used to lazy-load the Recipient at-most once.
63-
parsedRecipient *age.X25519Recipient
67+
parsedRecipient age.Recipient
6468
}
6569

6670
// MasterKeysFromRecipients takes a comma-separated list of Bech32-encoded
@@ -233,6 +237,35 @@ func (key *MasterKey) TypeToIdentifier() string {
233237
return KeyTypeIdentifier
234238
}
235239

240+
// loadAgeSSHIdentity attempts to load the age SSH identity based on an SSH
241+
// private key from the SopsAgeSshPrivateKeyFileEnv environment variable. If the
242+
// environment variable is not present, it will fall back to `~/.ssh/id_ed25519`
243+
// or `~/.ssh/id_rsa`. If no age SSH identity is found, it will return nil.
244+
func loadAgeSSHIdentity() (age.Identity, error) {
245+
sshKeyFilePath, ok := os.LookupEnv(SopsAgeSshPrivateKeyFileEnv)
246+
if ok {
247+
return parseSSHIdentityFromPrivateKeyFile(sshKeyFilePath)
248+
}
249+
250+
userHomeDir, err := os.UserHomeDir()
251+
if err != nil || userHomeDir == "" {
252+
log.Warnf("could not determine the user home directory: %v", err)
253+
return nil, nil
254+
}
255+
256+
sshEd25519PrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_ed25519")
257+
if _, err := os.Stat(sshEd25519PrivateKeyPath); err == nil {
258+
return parseSSHIdentityFromPrivateKeyFile(sshEd25519PrivateKeyPath)
259+
}
260+
261+
sshRsaPrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_rsa")
262+
if _, err := os.Stat(sshRsaPrivateKeyPath); err == nil {
263+
return parseSSHIdentityFromPrivateKeyFile(sshRsaPrivateKeyPath)
264+
}
265+
266+
return nil, nil
267+
}
268+
236269
func getUserConfigDir() (string, error) {
237270
if runtime.GOOS == "darwin" {
238271
if userConfigDir, ok := os.LookupEnv(xdgConfigHome); ok && userConfigDir != "" {
@@ -244,9 +277,19 @@ func getUserConfigDir() (string, error) {
244277

245278
// loadIdentities attempts to load the age identities based on runtime
246279
// environment configurations (e.g. SopsAgeKeyEnv, SopsAgeKeyFileEnv,
247-
// SopsAgeKeyUserConfigPath). It will load all found references, and expects
248-
// at least one configuration to be present.
280+
// SopsAgeSshPrivateKeyFileEnv, SopsAgeKeyUserConfigPath). It will load all
281+
// found references, and expects at least one configuration to be present.
249282
func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
283+
var identities ParsedIdentities
284+
285+
sshIdentity, err := loadAgeSSHIdentity()
286+
if err != nil {
287+
return nil, fmt.Errorf("failed to get SSH identity: %w", err)
288+
}
289+
if sshIdentity != nil {
290+
identities = append(identities, sshIdentity)
291+
}
292+
250293
var readers = make(map[string]io.Reader, 0)
251294

252295
if ageKey, ok := os.LookupEnv(SopsAgeKeyEnv); ok {
@@ -263,7 +306,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
263306
}
264307

265308
userConfigDir, err := getUserConfigDir()
266-
if err != nil && len(readers) == 0 {
309+
if err != nil && len(readers) == 0 && len(identities) == 0 {
267310
return nil, fmt.Errorf("user config directory could not be determined: %w", err)
268311
}
269312
if userConfigDir != "" {
@@ -272,7 +315,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
272315
if err != nil && !errors.Is(err, os.ErrNotExist) {
273316
return nil, fmt.Errorf("failed to open file: %w", err)
274317
}
275-
if errors.Is(err, os.ErrNotExist) && len(readers) == 0 {
318+
if errors.Is(err, os.ErrNotExist) && len(readers) == 0 && len(identities) == 0 {
276319
// If we have no other readers, presence of the file is required.
277320
return nil, fmt.Errorf("failed to open file: %w", err)
278321
}
@@ -282,7 +325,6 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
282325
}
283326
}
284327

285-
var identities ParsedIdentities
286328
for n, r := range readers {
287329
ids, err := age.ParseIdentities(r)
288330
if err != nil {
@@ -294,13 +336,25 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
294336
}
295337

296338
// 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)
339+
// key or a public ssh key.
340+
func parseRecipient(recipient string) (age.Recipient, error) {
341+
switch {
342+
case strings.HasPrefix(recipient, "age1"):
343+
parsedRecipient, err := age.ParseX25519Recipient(recipient)
344+
if err != nil {
345+
return nil, fmt.Errorf("failed to parse input as Bech32-encoded age public key: %w", err)
346+
}
347+
348+
return parsedRecipient, nil
349+
case strings.HasPrefix(recipient, "ssh-"):
350+
parsedRecipient, err := agessh.ParseRecipient(recipient)
351+
if err != nil {
352+
return nil, fmt.Errorf("failed to parse input as age-ssh public key: %w", err)
353+
}
354+
return parsedRecipient, nil
302355
}
303-
return parsedRecipient, nil
356+
357+
return nil, fmt.Errorf("failed to parse input, unknown recipient type: %q", recipient)
304358
}
305359

306360
// parseIdentities attempts to parse the string set of encoded age identities.

age/keysource_test.go

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@ EylloI7MNGbadPGb
2828
-----END AGE ENCRYPTED FILE-----`
2929
// mockEncryptedKeyPlain is the plain value of mockEncryptedKey.
3030
mockEncryptedKeyPlain string = "data"
31+
// mockSshRecipient is a mock age ssh recipient, it matches mockSshIdentity
32+
mockSshRecipient string = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID+Wi8WZw2bXfBpcs/WECttCzP39OkenS6pHWHWGFJvN Test"
33+
// mockSshIdentity is a mock age identity based on an OpenSSH private key (ed25519)
34+
mockSshIdentity string = `-----BEGIN OPENSSH PRIVATE KEY-----
35+
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
36+
QyNTUxOQAAACA/lovFmcNm13waXLP1hArbQsz9/TpHp0uqR1h1hhSbzQAAAIgCXDMIAlwz
37+
CAAAAAtzc2gtZWQyNTUxOQAAACA/lovFmcNm13waXLP1hArbQsz9/TpHp0uqR1h1hhSbzQ
38+
AAAEBJdWTJ8dC0OnMcwy4gQ96sp6KG8GE9EiyhFGhKldKiST+Wi8WZw2bXfBpcs/WECttC
39+
zP39OkenS6pHWHWGFJvNAAAABFRlc3QB
40+
-----END OPENSSH PRIVATE KEY-----`
41+
mockEncryptedSshKey string = `-----BEGIN AGE ENCRYPTED FILE-----
42+
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IDJjd0R4dyB2R3Ns
43+
VUNHaXBiTEJaNU5BMFFQZUpCYWJqODFyTTZ4WWZoRVpUd2M2aTBFCkduUFJHb1U2
44+
K3RqWVQrLzE4anZKZ3h2T3c2MFpZTHlGaHprcElXenByWTAKLS0tIG56MHFSZERl
45+
em9PWmRMMTY4aytYTnVZN04yeER5Z2E3TWxWT3JTZWR2ekUKp/HZLy4MzQqoszGk
46+
+P0hSPPNhOhvFwv4AqCw1+A+WyeHGQPq
47+
-----END AGE ENCRYPTED FILE-----`
3148
)
3249

3350
func TestMasterKeysFromRecipients(t *testing.T) {
@@ -41,22 +58,32 @@ func TestMasterKeysFromRecipients(t *testing.T) {
4158
assert.Equal(t, got[0].Recipient, mockRecipient)
4259
})
4360

61+
t.Run("recipient-ssh", func(t *testing.T) {
62+
got, err := MasterKeysFromRecipients(mockSshRecipient)
63+
assert.NoError(t, err)
64+
65+
assert.Len(t, got, 1)
66+
assert.Equal(t, got[0].Recipient, mockSshRecipient)
67+
})
68+
4469
t.Run("recipients", func(t *testing.T) {
45-
got, err := MasterKeysFromRecipients(mockRecipient + "," + otherRecipient)
70+
got, err := MasterKeysFromRecipients(mockRecipient + "," + otherRecipient + "," + mockSshRecipient)
4671
assert.NoError(t, err)
4772

48-
assert.Len(t, got, 2)
73+
assert.Len(t, got, 3)
4974
assert.Equal(t, got[0].Recipient, mockRecipient)
5075
assert.Equal(t, got[1].Recipient, otherRecipient)
76+
assert.Equal(t, got[2].Recipient, mockSshRecipient)
5177
})
5278

5379
t.Run("leading and trailing spaces", func(t *testing.T) {
54-
got, err := MasterKeysFromRecipients(" " + mockRecipient + " , " + otherRecipient + " ")
80+
got, err := MasterKeysFromRecipients(" " + mockRecipient + " , " + otherRecipient + " , " + mockSshRecipient + " ")
5581
assert.NoError(t, err)
5682

57-
assert.Len(t, got, 2)
83+
assert.Len(t, got, 3)
5884
assert.Equal(t, got[0].Recipient, mockRecipient)
5985
assert.Equal(t, got[1].Recipient, otherRecipient)
86+
assert.Equal(t, got[2].Recipient, mockSshRecipient)
6087
})
6188

6289
t.Run("empty", func(t *testing.T) {
@@ -75,6 +102,14 @@ func TestMasterKeyFromRecipient(t *testing.T) {
75102
assert.Nil(t, got.parsedIdentities)
76103
})
77104

105+
t.Run("recipient-ssh", func(t *testing.T) {
106+
got, err := MasterKeyFromRecipient(mockSshRecipient)
107+
assert.NoError(t, err)
108+
assert.EqualValues(t, mockSshRecipient, got.Recipient)
109+
assert.NotNil(t, got.parsedRecipient)
110+
assert.Nil(t, got.parsedIdentities)
111+
})
112+
78113
t.Run("leading and trailing spaces", func(t *testing.T) {
79114
got, err := MasterKeyFromRecipient(" " + mockRecipient + " ")
80115
assert.NoError(t, err)
@@ -83,6 +118,14 @@ func TestMasterKeyFromRecipient(t *testing.T) {
83118
assert.Nil(t, got.parsedIdentities)
84119
})
85120

121+
t.Run("leading and trailing spaces - ssh", func(t *testing.T) {
122+
got, err := MasterKeyFromRecipient(" " + mockSshRecipient + " ")
123+
assert.NoError(t, err)
124+
assert.EqualValues(t, mockSshRecipient, got.Recipient)
125+
assert.NotNil(t, got.parsedRecipient)
126+
assert.Nil(t, got.parsedIdentities)
127+
})
128+
86129
t.Run("invalid recipient", func(t *testing.T) {
87130
got, err := MasterKeyFromRecipient("invalid")
88131
assert.Error(t, err)
@@ -111,6 +154,8 @@ func TestParsedIdentities_ApplyToMasterKey(t *testing.T) {
111154
func TestMasterKey_Encrypt(t *testing.T) {
112155
mockParsedRecipient, err := parseRecipient(mockRecipient)
113156
assert.NoError(t, err)
157+
mockSshParsedRecipient, err := parseRecipient(mockSshRecipient)
158+
assert.NoError(t, err)
114159

115160
t.Run("recipient", func(t *testing.T) {
116161
key := &MasterKey{
@@ -120,6 +165,14 @@ func TestMasterKey_Encrypt(t *testing.T) {
120165
assert.NotEmpty(t, key.EncryptedKey)
121166
})
122167

168+
t.Run("recipient ssh", func(t *testing.T) {
169+
key := &MasterKey{
170+
Recipient: mockSshRecipient,
171+
}
172+
assert.NoError(t, key.Encrypt([]byte(mockEncryptedKeyPlain)))
173+
assert.NotEmpty(t, key.EncryptedKey)
174+
})
175+
123176
t.Run("parsed recipient", func(t *testing.T) {
124177
key := &MasterKey{
125178
parsedRecipient: mockParsedRecipient,
@@ -128,13 +181,21 @@ func TestMasterKey_Encrypt(t *testing.T) {
128181
assert.NotEmpty(t, key.EncryptedKey)
129182
})
130183

184+
t.Run("parsed recipient ssh", func(t *testing.T) {
185+
key := &MasterKey{
186+
parsedRecipient: mockSshParsedRecipient,
187+
}
188+
assert.NoError(t, key.Encrypt([]byte(mockEncryptedKeyPlain)))
189+
assert.NotEmpty(t, key.EncryptedKey)
190+
})
191+
131192
t.Run("invalid recipient", func(t *testing.T) {
132193
key := &MasterKey{
133194
Recipient: "invalid",
134195
}
135196
err := key.Encrypt([]byte(mockEncryptedKeyPlain))
136197
assert.Error(t, err)
137-
assert.ErrorContains(t, err, "failed to parse input as Bech32-encoded age public key")
198+
assert.ErrorContains(t, err, "failed to parse input, unknown recipient type:")
138199
assert.Empty(t, key.EncryptedKey)
139200
})
140201

@@ -188,6 +249,25 @@ func TestMasterKey_Decrypt(t *testing.T) {
188249
assert.EqualValues(t, mockEncryptedKeyPlain, got)
189250
})
190251

252+
t.Run("loaded identities ssh", func(t *testing.T) {
253+
key := &MasterKey{EncryptedKey: mockEncryptedSshKey}
254+
tmp := t.TempDir()
255+
overwriteUserConfigDir(t, tmp)
256+
257+
homeDir, err := os.UserHomeDir()
258+
assert.NoError(t, err)
259+
keyPath := filepath.Join(homeDir, ".ssh/id_25519")
260+
assert.True(t, strings.HasPrefix(keyPath, homeDir))
261+
262+
assert.NoError(t, os.MkdirAll(filepath.Dir(keyPath), 0o700))
263+
assert.NoError(t, os.WriteFile(keyPath, []byte(mockSshIdentity), 0o644))
264+
t.Setenv(SopsAgeSshPrivateKeyFileEnv, keyPath)
265+
266+
got, err := key.Decrypt()
267+
assert.NoError(t, err)
268+
assert.EqualValues(t, mockEncryptedKeyPlain, got)
269+
})
270+
191271
t.Run("no identities", func(t *testing.T) {
192272
tmpDir := t.TempDir()
193273
overwriteUserConfigDir(t, tmpDir)
@@ -327,6 +407,25 @@ func TestMasterKey_loadIdentities(t *testing.T) {
327407
assert.Len(t, got, 1)
328408
})
329409

410+
t.Run(SopsAgeSshPrivateKeyFileEnv, func(t *testing.T) {
411+
tmpDir := t.TempDir()
412+
overwriteUserConfigDir(t, tmpDir)
413+
414+
homeDir, err := os.UserHomeDir()
415+
assert.NoError(t, err)
416+
keyPath := filepath.Join(homeDir, ".ssh/id_25519")
417+
assert.True(t, strings.HasPrefix(keyPath, homeDir))
418+
419+
assert.NoError(t, os.MkdirAll(filepath.Dir(keyPath), 0o700))
420+
assert.NoError(t, os.WriteFile(keyPath, []byte(mockSshIdentity), 0o644))
421+
t.Setenv(SopsAgeSshPrivateKeyFileEnv, keyPath)
422+
423+
key := &MasterKey{}
424+
got, err := key.loadIdentities()
425+
assert.NoError(t, err)
426+
assert.Len(t, got, 1)
427+
})
428+
330429
t.Run("no identity", func(t *testing.T) {
331430
tmpDir := t.TempDir()
332431
overwriteUserConfigDir(t, tmpDir)
@@ -374,8 +473,8 @@ func TestMasterKey_loadIdentities(t *testing.T) {
374473
})
375474
}
376475

377-
// overwriteUserConfigDir sets the user config directory based on the
378-
// os.UserConfigDir logic.
476+
// overwriteUserConfigDir sets the user config directory and the user home directory
477+
// based on the os.UserConfigDir logic.
379478
func overwriteUserConfigDir(t *testing.T, path string) {
380479
switch runtime.GOOS {
381480
case "windows":
@@ -384,6 +483,7 @@ func overwriteUserConfigDir(t *testing.T, path string) {
384483
t.Setenv("home", path)
385484
default: // Unix
386485
t.Setenv("XDG_CONFIG_HOME", path)
486+
t.Setenv("HOME", path)
387487
}
388488
}
389489

0 commit comments

Comments
 (0)