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:
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
+}