Skip to content
Permalink
Browse files

cmd/age: add support for encrypted SSH key files

  • Loading branch information
FiloSottile committed Nov 25, 2019
1 parent 2cc6291 commit c624abc0ad4f15d737d3bfd74de970227647d2f0
Showing with 197 additions and 15 deletions.
  1. +125 −0 cmd/age/encrypted_keys.go
  2. +34 −0 cmd/age/parse.go
  3. +2 −0 go.mod
  4. +2 −4 go.sum
  5. +25 −3 internal/age/age.go
  6. +2 −2 internal/age/scrypt.go
  7. +4 −4 internal/age/ssh.go
  8. +2 −2 internal/age/x25519.go
  9. +1 −0 internal/format/format.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
}
}
@@ -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
}
2 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
6 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=
@@ -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
}

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 {
break RecipientsLoop
if err != nil {
if err == ErrIncorrectIdentity {
continue
}
return nil, err
}

break RecipientsLoop
}
}
if fileKey == nil {
@@ -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
}
@@ -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
@@ -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
}
@@ -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 (

0 comments on commit c624abc

Please sign in to comment.
You can’t perform that action at this time.