age

Simple, secure encryption with UNIX-style composability.
git clone git://git.sgregoratto.me/age
Log | Files | Refs | README | LICENSE

commit 022002171c914cfb4914254900f3a0b844664624
parent 0940f184fb8fdf8f729361a79443ddd4e62f6bb9
Author: Filippo Valsorda <hi@filippo.io>
Date:   Mon,  7 Oct 2019 00:45:59 -0400

internal/age: add ssh-ed25519 recipients

Diffstat:
Mgo.mod | 2+-
Minternal/age/recipients_test.go | 44++++++++++++++++++++++++++++++++++++++++++++
Minternal/age/ssh.go | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 248 insertions(+), 1 deletion(-)

diff --git a/go.mod b/go.mod @@ -1,5 +1,5 @@ module github.com/FiloSottile/age -go 1.12 +go 1.13 require golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc diff --git a/internal/age/recipients_test.go b/internal/age/recipients_test.go @@ -8,6 +8,7 @@ package age_test import ( "bytes" + "crypto/ed25519" "crypto/rand" "crypto/rsa" "testing" @@ -135,3 +136,46 @@ func TestSSHRSARoundTrip(t *testing.T) { t.Errorf("invalid output: %x, expected %x", out, fileKey[:]) } } + +func TestSSHEd25519RoundTrip(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + sshPubKey, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatal(err) + } + + r, err := age.NewSSHEd25519Recipient(sshPubKey) + if err != nil { + t.Fatal(err) + } + i, err := age.NewSSHEd25519Identity(priv) + if err != nil { + t.Fatal(err) + } + + if r.Type() != i.Type() || r.Type() != "ssh-ed25519" { + t.Errorf("invalid Type values: %v, %v", r.Type(), i.Type()) + } + + fileKey := make([]byte, 16) + if _, err := rand.Read(fileKey[:]); err != nil { + t.Fatal(err) + } + block, err := r.Wrap(fileKey[:]) + if err != nil { + t.Fatal(err) + } + t.Logf("%#v", block) + + out, err := i.Unwrap(block) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(fileKey[:], out) { + t.Errorf("invalid output: %x, expected %x", out, fileKey[:]) + } +} diff --git a/internal/age/ssh.go b/internal/age/ssh.go @@ -8,13 +8,20 @@ package age import ( "bytes" + "crypto/ed25519" "crypto/rand" "crypto/rsa" "crypto/sha256" + "crypto/sha512" "errors" "fmt" + "io" + "math/big" "github.com/FiloSottile/age/internal/format" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/hkdf" "golang.org/x/crypto/ssh" ) @@ -122,3 +129,199 @@ func (i *SSHRSAIdentity) Unwrap(block *format.Recipient) ([]byte, error) { } return fileKey, nil } + +type SSHEd25519Recipient struct { + sshKey ssh.PublicKey + theirPublicKey [32]byte +} + +var _ Recipient = &SSHEd25519Recipient{} + +func (*SSHEd25519Recipient) Type() string { return "ssh-ed25519" } + +func NewSSHEd25519Recipient(pk ssh.PublicKey) (*SSHEd25519Recipient, error) { + if pk.Type() != "ssh-ed25519" { + return nil, errors.New("SSH public key is not an Ed25519 key") + } + r := &SSHEd25519Recipient{ + sshKey: pk, + } + + if pk, ok := pk.(ssh.CryptoPublicKey); ok { + if pk, ok := pk.CryptoPublicKey().(ed25519.PublicKey); ok { + pubKey := ed25519PublicKeyToCurve25519(pk) + copy(r.theirPublicKey[:], pubKey) + } else { + return nil, errors.New("unexpected public key type") + } + } else { + return nil, errors.New("pk does not implement ssh.CryptoPublicKey") + } + return r, nil +} + +var curve25519P, _ = new(big.Int).SetString("57896044618658097711785492504343953926634992332820282019728792003956564819949", 10) + +func ed25519PublicKeyToCurve25519(pk ed25519.PublicKey) []byte { + // ed25519.PublicKey is a little endian representation of the y-coordinate, + // with the most significant bit set based on the sign of the x-ccordinate. + bigEndianY := make([]byte, ed25519.PublicKeySize) + for i, b := range pk { + bigEndianY[ed25519.PublicKeySize-i-1] = b + } + bigEndianY[0] &= 0b0111_1111 + + // The Montgomery u-coordinate is derived through the bilinear map + // + // u = (1 + y) / (1 - y) + // + // See https://blog.filippo.io/using-ed25519-keys-for-encryption. + y := new(big.Int).SetBytes(bigEndianY) + denom := big.NewInt(1) + denom.ModInverse(denom.Sub(denom, y), curve25519P) // 1 / (1 - y) + u := y.Mul(y.Add(y, big.NewInt(1)), denom) + u.Mod(u, curve25519P) + + out := make([]byte, 32) + uBytes := u.Bytes() + for i, b := range uBytes { + out[len(uBytes)-i-1] = b + } + + return out +} + +const ed25519Label = "age-tool.com ssh-ed25519" + +func (r *SSHEd25519Recipient) Wrap(fileKey []byte) (*format.Recipient, error) { + // TODO: DRY this up with the X25519 implementation. + var ephemeral, ourPublicKey [32]byte + if _, err := rand.Read(ephemeral[:]); err != nil { + return nil, err + } + curve25519.ScalarBaseMult(&ourPublicKey, &ephemeral) + + var sharedSecret, tweak [32]byte + tH := hkdf.New(sha256.New, nil, r.sshKey.Marshal(), []byte(ed25519Label)) + if _, err := io.ReadFull(tH, tweak[:]); err != nil { + return nil, err + } + curve25519.ScalarMult(&sharedSecret, &ephemeral, &r.theirPublicKey) + curve25519.ScalarMult(&sharedSecret, &tweak, &sharedSecret) + + sH := sha256.New() + sH.Write(r.sshKey.Marshal()) + hh := sH.Sum(nil) + + l := &format.Recipient{ + Type: "ssh-ed25519", + Args: []string{format.EncodeToString(hh[:4]), + format.EncodeToString(ourPublicKey[:])}, + } + + salt := make([]byte, 0, 32*2) + salt = append(salt, ourPublicKey[:]...) + salt = append(salt, r.theirPublicKey[:]...) + h := hkdf.New(sha256.New, sharedSecret[:], salt, []byte(ed25519Label)) + wrappingKey := make([]byte, chacha20poly1305.KeySize) + if _, err := io.ReadFull(h, wrappingKey); err != nil { + return nil, err + } + + wrappedKey, err := aeadEncrypt(wrappingKey, fileKey) + if err != nil { + return nil, err + } + l.Body = []byte(format.EncodeToString(wrappedKey) + "\n") + + return l, nil +} + +type SSHEd25519Identity struct { + secretKey, ourPublicKey [32]byte + sshKey ssh.PublicKey +} + +var _ Identity = &SSHEd25519Identity{} + +func (*SSHEd25519Identity) Type() string { return "ssh-ed25519" } + +func NewSSHEd25519Identity(key ed25519.PrivateKey) (*SSHEd25519Identity, error) { + s, err := ssh.NewSignerFromKey(key) + if err != nil { + return nil, err + } + i := &SSHEd25519Identity{ + sshKey: s.PublicKey(), + } + secretKey := ed25519PrivateKeyToCurve25519(key) + copy(i.secretKey[:], secretKey) + curve25519.ScalarBaseMult(&i.ourPublicKey, &i.secretKey) + return i, nil +} + +func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) []byte { + h := sha512.New() + h.Write(pk[:32]) + out := h.Sum(nil) + return out[:32] +} + +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") + } + if len(block.Args) != 2 { + return nil, errors.New("invalid ssh-ed25519 recipient block") + } + hash, err := format.DecodeString(block.Args[0]) + if err != nil { + return nil, fmt.Errorf("failed to parse ssh-ed25519 recipient: %v", err) + } + if len(hash) != 4 { + return nil, errors.New("invalid ssh-ed25519 recipient block") + } + publicKey, err := format.DecodeString(block.Args[1]) + if err != nil { + return nil, fmt.Errorf("failed to parse ssh-ed25519 recipient: %v", err) + } + if len(publicKey) != 32 { + return nil, errors.New("invalid ssh-ed25519 recipient block") + } + wrappedKey, err := format.DecodeString(string(block.Body)) + if err != nil { + return nil, fmt.Errorf("failed to parse ssh-ed25519 recipient: %v", err) + } + + sH := sha256.New() + sH.Write(i.sshKey.Marshal()) + hh := sH.Sum(nil) + if !bytes.Equal(hh[:4], hash) { + return nil, errors.New("wrong ssh-ed25519 key") + } + + var sharedSecret, theirPublicKey, tweak [32]byte + copy(theirPublicKey[:], publicKey) + tH := hkdf.New(sha256.New, nil, i.sshKey.Marshal(), []byte(ed25519Label)) + if _, err := io.ReadFull(tH, tweak[:]); err != nil { + return nil, err + } + curve25519.ScalarMult(&sharedSecret, &i.secretKey, &theirPublicKey) + curve25519.ScalarMult(&sharedSecret, &tweak, &sharedSecret) + + salt := make([]byte, 0, 32*2) + salt = append(salt, theirPublicKey[:]...) + salt = append(salt, i.ourPublicKey[:]...) + h := hkdf.New(sha256.New, sharedSecret[:], salt, []byte(ed25519Label)) + wrappingKey := make([]byte, chacha20poly1305.KeySize) + if _, err := io.ReadFull(h, wrappingKey); err != nil { + return nil, err + } + + fileKey, err := aeadDecrypt(wrappingKey, wrappedKey) + if err != nil { + return nil, fmt.Errorf("failed to decrypt file key: %v", err) + } + return fileKey, nil +}