Skip to content

Commit 9bd489a

Browse files
mstrangfeldhaoqixu
authored andcommitted
feat: add ssh support for age
Signed-off-by: Marvin Strangfeld <marvin@strangfeld.io>
1 parent fdc0cea commit 9bd489a

File tree

4 files changed

+292
-20
lines changed

4 files changed

+292
-20
lines changed

age/keysource.go

Lines changed: 130 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

2022
const (
@@ -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+
236333
func 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.
249346
func (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

Comments
 (0)