mirror of
https://github.com/FiloSottile/age.git
synced 2025-12-23 05:25:14 +00:00
cmd/age: add support for encrypted SSH key files
This commit is contained in:
125
cmd/age/encrypted_keys.go
Normal file
125
cmd/age/encrypted_keys.go
Normal file
@@ -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
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
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
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user