From c624abc0ad4f15d737d3bfd74de970227647d2f0 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 24 Nov 2019 19:15:53 -0500 Subject: [PATCH] cmd/age: add support for encrypted SSH key files --- cmd/age/encrypted_keys.go | 125 ++++++++++++++++++++++++++++++++++++++ cmd/age/parse.go | 34 +++++++++++ go.mod | 2 + go.sum | 6 +- internal/age/age.go | 30 +++++++-- internal/age/scrypt.go | 4 +- internal/age/ssh.go | 8 +-- internal/age/x25519.go | 4 +- internal/format/format.go | 1 + 9 files changed, 198 insertions(+), 16 deletions(-) create mode 100644 cmd/age/encrypted_keys.go diff --git a/cmd/age/encrypted_keys.go b/cmd/age/encrypted_keys.go new file mode 100644 index 0000000..fc6b26f --- /dev/null +++ b/cmd/age/encrypted_keys.go @@ -0,0 +1,125 @@ +// Copyright 2019 Google LLC +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd + +package main + +import ( + "bytes" + "crypto/ed25519" + "crypto/rsa" + "crypto/sha256" + "fmt" + "os" + + "github.com/FiloSottile/age/internal/age" + "github.com/FiloSottile/age/internal/format" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/terminal" +) + +type EncryptedSSHIdentity struct { + pubKey ssh.PublicKey + pemBytes []byte + passphrase func() ([]byte, error) + + decrypted age.Identity +} + +func NewEncryptedSSHIdentity(pubKey ssh.PublicKey, pemBytes []byte, passphrase func() ([]byte, error)) (*EncryptedSSHIdentity, error) { + switch t := pubKey.Type(); t { + case "ssh-ed25519", "ssh-rsa": + default: + return nil, fmt.Errorf("unsupported SSH key type: %v", t) + } + return &EncryptedSSHIdentity{ + pubKey: pubKey, + pemBytes: pemBytes, + passphrase: passphrase, + }, nil +} + +var _ age.IdentityMatcher = &EncryptedSSHIdentity{} + +func (i *EncryptedSSHIdentity) Type() string { + return i.pubKey.Type() +} + +func (i *EncryptedSSHIdentity) Unwrap(block *format.Recipient) (fileKey []byte, err error) { + if i.decrypted != nil { + return i.decrypted.Unwrap(block) + } + + passphrase, err := i.passphrase() + if err != nil { + return nil, fmt.Errorf("failed to obtain passphrase: %v", err) + } + k, err := ssh.ParseRawPrivateKeyWithPassphrase(i.pemBytes, passphrase) + if err != nil { + return nil, fmt.Errorf("failed to decrypt SSH key file: %v", err) + } + + switch k := k.(type) { + case *ed25519.PrivateKey: + i.decrypted, err = age.NewSSHEd25519Identity(*k) + case *rsa.PrivateKey: + i.decrypted, err = age.NewSSHRSAIdentity(k) + default: + return nil, fmt.Errorf("unexpected SSH key type: %T", k) + } + if err != nil { + return nil, fmt.Errorf("invalid SSH key: %v", err) + } + if i.decrypted.Type() != i.pubKey.Type() { + return nil, fmt.Errorf("mismatched SSH key type: got %q, expected %q", i.decrypted.Type(), i.pubKey.Type()) + } + + return i.decrypted.Unwrap(block) +} + +func (i *EncryptedSSHIdentity) Matches(block *format.Recipient) error { + if block.Type != i.Type() { + return age.ErrIncorrectIdentity + } + if len(block.Args) != 1 { + return fmt.Errorf("invalid %v recipient block", i.Type()) + } + hash, err := format.DecodeString(block.Args[0]) + if err != nil { + return fmt.Errorf("failed to parse %v recipient: %v", i.Type(), err) + } + if len(hash) != 4 { + return fmt.Errorf("invalid %v recipient block", i.Type()) + } + + sH := sha256.New() + sH.Write(i.pubKey.Marshal()) + hh := sH.Sum(nil) + if !bytes.Equal(hh[:4], hash) { + return age.ErrIncorrectIdentity + } + return nil +} + +func passphrasePrompt(name string) func() ([]byte, error) { + return func() ([]byte, error) { + fd := int(os.Stdin.Fd()) + if !terminal.IsTerminal(fd) { + tty, err := os.Open("/dev/tty") + if err != nil { + return nil, fmt.Errorf("could not read passphrase for %q: standard input is not a terminal, and opening /dev/tty failed: %v", name, err) + } + defer tty.Close() + fd = int(tty.Fd()) + } + fmt.Fprintf(os.Stderr, "Enter passphrase for %q: ", name) + defer fmt.Fprintf(os.Stderr, "\n") + p, err := terminal.ReadPassword(fd) + if err != nil { + return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err) + } + return p, nil + } +} diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 01c90d3..f028c7c 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -16,6 +16,7 @@ import ( "strings" "github.com/FiloSottile/age/internal/age" + "golang.org/x/crypto/ssh" ) func parseRecipient(arg string) (age.Recipient, error) { @@ -82,9 +83,42 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) { id, err := age.ParseSSHIdentity(pemBytes) + if sshErr, ok := err.(*ssh.PassphraseNeededError); ok { + pubKey := sshErr.PublicKey + if pubKey == nil { + pubKey, err = readPubFile(name) + if err != nil { + return nil, err + } + } + i, err := NewEncryptedSSHIdentity(pubKey, pemBytes, passphrasePrompt(name)) + if err != nil { + return nil, err + } + return []age.Identity{i}, nil + } if err != nil { return nil, fmt.Errorf("malformed SSH identity in %q: %v", name, err) } return []age.Identity{id}, nil } + +func readPubFile(name string) (ssh.PublicKey, error) { + f, err := os.Open(name + ".pub") + if err != nil { + return nil, fmt.Errorf(`failed to obtain public key for %q SSH key: %v + + Ensure %q exists, or convert the private key %q to a modern format with "ssh-keygen -p -m RFC4716"`, name, err, name+".pub", name) + } + defer f.Close() + contents, err := ioutil.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("failed to read %q: %v", name+".pub", err) + } + pubKey, _, _, _, err := ssh.ParseAuthorizedKey(contents) + if err != nil { + return nil, fmt.Errorf("failed to parse %q: %v", name+".pub", err) + } + return pubKey, nil +} diff --git a/go.mod b/go.mod index d9cfa78..e704aaa 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module github.com/FiloSottile/age go 1.13 require golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc + +replace golang.org/x/crypto => github.com/Filosottile/go v0.0.0-20191122011136-9090b284250b diff --git a/go.sum b/go.sum index 0fb9bd6..999988f 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,6 @@ -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc h1:c0o/qxkaO2LF5t6fQrT4b5hzyggAkLLlCUjqfRxd8Q4= -golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +github.com/Filosottile/go v0.0.0-20191122011136-9090b284250b h1:4AVIiSN9FRvfh7Oq7NhMHoU4oDhNkpfq4q9prQNlq7k= +github.com/Filosottile/go v0.0.0-20191122011136-9090b284250b/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/age/age.go b/internal/age/age.go index d24f0a9..2edaa56 100644 --- a/internal/age/age.go +++ b/internal/age/age.go @@ -4,6 +4,7 @@ // license that can be found in the LICENSE file or at // https://developers.google.com/open-source/licenses/bsd +// Package age implements age-tool.com file encryption. package age import ( @@ -22,6 +23,13 @@ type Identity interface { Unwrap(block *format.Recipient) (fileKey []byte, err error) } +type IdentityMatcher interface { + Identity + Matches(block *format.Recipient) error +} + +var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block") + type Recipient interface { Type() string Wrap(fileKey []byte) (*format.Recipient, error) @@ -89,15 +97,29 @@ RecipientsLoop: return nil, errors.New("an scrypt recipient must be the only one") } for _, i := range identities { - if i.Type() != r.Type { continue } - fileKey, err = i.Unwrap(r) - if err == nil { - break RecipientsLoop + if i, ok := i.(IdentityMatcher); ok { + err := i.Matches(r) + if err != nil { + if err == ErrIncorrectIdentity { + continue + } + return nil, err + } } + + fileKey, err = i.Unwrap(r) + if err != nil { + if err == ErrIncorrectIdentity { + continue + } + return nil, err + } + + break RecipientsLoop } } if fileKey == nil { diff --git a/internal/age/scrypt.go b/internal/age/scrypt.go index b702fcb..2b77bb8 100644 --- a/internal/age/scrypt.go +++ b/internal/age/scrypt.go @@ -104,7 +104,7 @@ func (i *ScryptIdentity) SetMaxWorkFactor(logN int) { func (i *ScryptIdentity) Unwrap(block *format.Recipient) ([]byte, error) { if block.Type != "scrypt" { - return nil, errors.New("wrong recipient block type") + return nil, ErrIncorrectIdentity } if len(block.Args) != 2 { return nil, errors.New("invalid scrypt recipient block") @@ -134,7 +134,7 @@ func (i *ScryptIdentity) Unwrap(block *format.Recipient) ([]byte, error) { fileKey, err := aeadDecrypt(k, block.Body) if err != nil { - return nil, fmt.Errorf("failed to decrypt file key: %v", err) + return nil, ErrIncorrectIdentity } return fileKey, nil } diff --git a/internal/age/ssh.go b/internal/age/ssh.go index 59a65db..6ae0818 100644 --- a/internal/age/ssh.go +++ b/internal/age/ssh.go @@ -98,7 +98,7 @@ func NewSSHRSAIdentity(key *rsa.PrivateKey) (*SSHRSAIdentity, error) { func (i *SSHRSAIdentity) Unwrap(block *format.Recipient) ([]byte, error) { if block.Type != "ssh-rsa" { - return nil, errors.New("wrong recipient block type") + return nil, ErrIncorrectIdentity } if len(block.Args) != 1 { return nil, errors.New("invalid ssh-rsa recipient block") @@ -115,7 +115,7 @@ func (i *SSHRSAIdentity) Unwrap(block *format.Recipient) ([]byte, error) { h.Write(i.sshKey.Marshal()) hh := h.Sum(nil) if !bytes.Equal(hh[:4], hash) { - return nil, errors.New("wrong ssh-rsa key") + return nil, ErrIncorrectIdentity } fileKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, i.k, @@ -304,7 +304,7 @@ func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) []byte { func (i *SSHEd25519Identity) Unwrap(block *format.Recipient) ([]byte, error) { // TODO: DRY this up with the X25519 implementation. if block.Type != "ssh-ed25519" { - return nil, errors.New("wrong recipient block type") + return nil, ErrIncorrectIdentity } if len(block.Args) != 2 { return nil, errors.New("invalid ssh-ed25519 recipient block") @@ -328,7 +328,7 @@ func (i *SSHEd25519Identity) Unwrap(block *format.Recipient) ([]byte, error) { sH.Write(i.sshKey.Marshal()) hh := sH.Sum(nil) if !bytes.Equal(hh[:4], hash) { - return nil, errors.New("wrong ssh-ed25519 key") + return nil, ErrIncorrectIdentity } var sharedSecret, theirPublicKey, tweak [32]byte diff --git a/internal/age/x25519.go b/internal/age/x25519.go index 409c286..71af804 100644 --- a/internal/age/x25519.go +++ b/internal/age/x25519.go @@ -145,7 +145,7 @@ func ParseX25519Identity(s string) (*X25519Identity, error) { func (i *X25519Identity) Unwrap(block *format.Recipient) ([]byte, error) { if block.Type != "X25519" { - return nil, errors.New("wrong recipient block type") + return nil, ErrIncorrectIdentity } if len(block.Args) != 1 { return nil, errors.New("invalid X25519 recipient block") @@ -174,7 +174,7 @@ func (i *X25519Identity) Unwrap(block *format.Recipient) ([]byte, error) { fileKey, err := aeadDecrypt(wrappingKey, block.Body) if err != nil { - return nil, fmt.Errorf("failed to decrypt file key: %v", err) + return nil, ErrIncorrectIdentity } return fileKey, nil } diff --git a/internal/format/format.go b/internal/format/format.go index 83d8edf..48449f9 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -4,6 +4,7 @@ // license that can be found in the LICENSE file or at // https://developers.google.com/open-source/licenses/bsd +// Package format implements the age file format. package format import (