diff --git a/internal/provider/resource_private_key.go b/internal/provider/resource_private_key.go index 4ba8bd16..4f512116 100644 --- a/internal/provider/resource_private_key.go +++ b/internal/provider/resource_private_key.go @@ -2,6 +2,7 @@ package provider import ( "crypto/ecdsa" + "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/rsa" @@ -35,6 +36,13 @@ var keyAlgos map[string]keyAlgo = map[string]keyAlgo{ return nil, fmt.Errorf("invalid ecdsa_curve; must be P224, P256, P384 or P521") } }, + "ED25519": func(d *schema.ResourceData) (interface{}, error) { + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate ed25519 key: %s", err) + } + return &key, err + }, } var keyParsers map[string]keyParser = map[string]keyParser{ @@ -44,6 +52,9 @@ var keyParsers map[string]keyParser = map[string]keyParser{ "ECDSA": func(der []byte) (interface{}, error) { return x509.ParseECPrivateKey(der) }, + "ED25519": func(der []byte) (interface{}, error) { + return x509.ParsePKCS8PrivateKey(der) + }, } func resourcePrivateKey() *schema.Resource { @@ -82,6 +93,12 @@ func resourcePrivateKey() *schema.Resource { Sensitive: true, }, + "private_key_openssh": { + Type: schema.TypeString, + Computed: true, + Sensitive: true, + }, + "public_key_pem": { Type: schema.TypeString, Computed: true, @@ -114,6 +131,7 @@ func CreatePrivateKey(d *schema.ResourceData, meta interface{}) error { } var keyPemBlock *pem.Block + var openSSHKeyPemBlock *pem.Block switch k := key.(type) { case *rsa.PrivateKey: keyPemBlock = &pem.Block{ @@ -129,12 +147,30 @@ func CreatePrivateKey(d *schema.ResourceData, meta interface{}) error { Type: "EC PRIVATE KEY", Bytes: keyBytes, } + case *ed25519.PrivateKey: + keyBytes, err := x509.MarshalPKCS8PrivateKey(*k) + if err != nil { + return fmt.Errorf("error encoding key to PEM: %s", err) + } + + keyPemBlock = &pem.Block{ + Type: "PRIVATE KEY", + Bytes: keyBytes, + } + openSSHKeyPemBlock = &pem.Block{ + Type: "OPENSSH PRIVATE KEY", + Bytes: marshalED25519PrivateKey(*k), + } default: return fmt.Errorf("unsupported private key type") } - keyPem := string(pem.EncodeToMemory(keyPemBlock)) + d.Set("private_key_pem", string(pem.EncodeToMemory(keyPemBlock))) + + if openSSHKeyPemBlock == nil { + openSSHKeyPemBlock = keyPemBlock + } - d.Set("private_key_pem", keyPem) + d.Set("private_key_openssh", string(pem.EncodeToMemory(openSSHKeyPemBlock))) return readPublicKey(d, key) } @@ -154,6 +190,8 @@ func publicKey(priv interface{}) interface{} { return &k.PublicKey case *ecdsa.PrivateKey: return &k.PublicKey + case *ed25519.PrivateKey: + return k.Public() default: return nil } diff --git a/internal/provider/resource_private_key_test.go b/internal/provider/resource_private_key_test.go index d8f7f50b..897b0003 100644 --- a/internal/provider/resource_private_key_test.go +++ b/internal/provider/resource_private_key_test.go @@ -222,3 +222,83 @@ func TestPrivateKeyECDSA(t *testing.T) { }, }) } + +func TestPrivateKeyED25519(t *testing.T) { + r.UnitTest(t, r.TestCase{ + Providers: testProviders, + Steps: []r.TestStep{ + { + Config: ` + resource "tls_private_key" "test" { + algorithm = "ED25519" + } + output "private_key_pem" { + value = "${tls_private_key.test.private_key_pem}" + } + output "private_key_openssh" { + value = "${tls_private_key.test.private_key_openssh}" + } + output "public_key_pem" { + value = "${tls_private_key.test.public_key_pem}" + } + output "public_key_openssh" { + value = "${tls_private_key.test.public_key_openssh}" + } + output "public_key_fingerprint_md5" { + value = "${tls_private_key.test.public_key_fingerprint_md5}" + } + `, + Check: func(s *terraform.State) error { + gotPrivateUntyped := s.RootModule().Outputs["private_key_pem"].Value + gotPrivate, ok := gotPrivateUntyped.(string) + if !ok { + return fmt.Errorf("output for \"private_key_pem\" is not a string") + } + + if !strings.HasPrefix(gotPrivate, "-----BEGIN PRIVATE KEY----") { + return fmt.Errorf("private key is missing ED25519 key PEM preamble") + } + + gotPrivateOpenSSHUntyped := s.RootModule().Outputs["private_key_openssh"].Value + gotPrivateOpenSSH, ok := gotPrivateOpenSSHUntyped.(string) + if !ok { + return fmt.Errorf("output for \"private_key_openssh\" is not a string") + } + + if !strings.HasPrefix(gotPrivateOpenSSH, "-----BEGIN OPENSSH PRIVATE KEY----") { + return fmt.Errorf("private key is missing OPENSSH key PEM preamble") + } + + gotPublicUntyped := s.RootModule().Outputs["public_key_pem"].Value + gotPublic, ok := gotPublicUntyped.(string) + if !ok { + return fmt.Errorf("output for \"public_key_pem\" is not a string") + } + if !strings.HasPrefix(gotPublic, "-----BEGIN PUBLIC KEY----") { + return fmt.Errorf("public key is missing public key PEM preamble") + } + + gotPublicSSHUntyped := s.RootModule().Outputs["public_key_openssh"].Value + gotPublicSSH, ok := gotPublicSSHUntyped.(string) + if !ok { + return fmt.Errorf("output for \"public_key_openssh\" is not a string") + } + if !strings.HasPrefix(gotPublicSSH, "ssh-ed25519 ") { + return fmt.Errorf("SSH public key is missing ssh-ed25519 prefix") + } + + gotPublicFingerprintUntyped := s.RootModule().Outputs["public_key_fingerprint_md5"].Value + gotPublicFingerprint, ok := gotPublicFingerprintUntyped.(string) + if !ok { + return fmt.Errorf("output for \"public_key_fingerprint_md5\" is not a string") + } + if !(gotPublicFingerprint[2] == ':') { + return fmt.Errorf("MD5 public key fingerprint is missing : in the correct place") + } + + return nil + }, + }, + }, + }) +} diff --git a/internal/provider/util.go b/internal/provider/util.go index 19c151fe..9d087352 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -1,9 +1,11 @@ package provider import ( + "crypto/ed25519" "crypto/x509" "encoding/pem" "fmt" + "math/rand" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "golang.org/x/crypto/ssh" @@ -103,3 +105,85 @@ func readPublicKey(d *schema.ResourceData, rsaKey interface{}) error { } return nil } + +// From https://github.com/mikesmitty/edkey/blob/master/edkey.go +// +// Writes ed25519 private keys into the new OpenSSH private key format. +// I have no idea why this isn't implemented anywhere yet, you can do seemingly +// everything except write it to disk in the OpenSSH private key format. +func marshalED25519PrivateKey(key ed25519.PrivateKey) []byte { + // Add our key header (followed by a null byte) + magic := append([]byte("openssh-key-v1"), 0) + + var w struct { + CipherName string + KdfName string + KdfOpts string + NumKeys uint32 + PubKey []byte + PrivKeyBlock []byte + } + + // Fill out the private key fields + pk1 := struct { + Check1 uint32 + Check2 uint32 + Keytype string + Pub []byte + Priv []byte + Comment string + Pad []byte `ssh:"rest"` + }{} + + // Set our check ints + ci := rand.Uint32() + pk1.Check1 = ci + pk1.Check2 = ci + + // Set our key type + pk1.Keytype = ssh.KeyAlgoED25519 + + // Add the pubkey to the optionally-encrypted block + pk, ok := key.Public().(ed25519.PublicKey) + if !ok { + //fmt.Fprintln(os.Stderr, "ed25519.PublicKey type assertion failed on an ed25519 public key. This should never ever happen.") + return nil + } + pubKey := []byte(pk) + pk1.Pub = pubKey + + // Add our private key + pk1.Priv = []byte(key) + + // Might be useful to put something in here at some point + pk1.Comment = "" + + // Add some padding to match the encryption block size within PrivKeyBlock (without Pad field) + // 8 doesn't match the documentation, but that's what ssh-keygen uses for unencrypted keys. *shrug* + bs := 8 + blockLen := len(ssh.Marshal(pk1)) + padLen := (bs - (blockLen % bs)) % bs + pk1.Pad = make([]byte, padLen) + + // Padding is a sequence of bytes like: 1, 2, 3... + for i := 0; i < padLen; i++ { + pk1.Pad[i] = byte(i + 1) + } + + // Generate the pubkey prefix "\0\0\0\nssh-ed25519\0\0\0 " + prefix := []byte{0x0, 0x0, 0x0, 0x0b} + prefix = append(prefix, []byte(ssh.KeyAlgoED25519)...) + prefix = append(prefix, []byte{0x0, 0x0, 0x0, 0x20}...) + + // Only going to support unencrypted keys for now + w.CipherName = "none" + w.KdfName = "none" + w.KdfOpts = "" + w.NumKeys = 1 + w.PubKey = append(prefix, pubKey...) + w.PrivKeyBlock = ssh.Marshal(pk1) + + magic = append(magic, ssh.Marshal(w)...) + + return magic +} diff --git a/website/docs/r/private_key.html.md b/website/docs/r/private_key.html.md index 916dfbf0..f3c61003 100644 --- a/website/docs/r/private_key.html.md +++ b/website/docs/r/private_key.html.md @@ -35,7 +35,7 @@ resource "tls_private_key" "example" { The following arguments are supported: * `algorithm` - (Required) The name of the algorithm to use for -the key. Currently-supported values are "RSA" and "ECDSA". +the key. Currently-supported values are "RSA", "ECDSA" and "ED25519". * `rsa_bits` - (Optional) When `algorithm` is "RSA", the size of the generated RSA key in bits. Defaults to 2048. @@ -50,12 +50,12 @@ The following attributes are exported: * `algorithm` - The algorithm that was selected for the key. * `private_key_pem` - The private key data in PEM format. +* `private_key_openssh` - The private key data in OpenSSH-compatible format. * `public_key_pem` - The public key data in PEM format. * `public_key_openssh` - The public key data in OpenSSH `authorized_keys` - format, if the selected private key format is compatible. All RSA keys - are supported, and ECDSA keys with curves "P256", "P384" and "P521" - are supported. This attribute is empty if an incompatible ECDSA curve - is selected. + format, if the selected private key format is compatible. All RSA and ED25519 + keys are supported, and ECDSA keys with curves "P256", "P384" and "P521" are + supported. This attribute is empty if an incompatible ECDSA curve is selected. * `public_key_fingerprint_md5` - The md5 hash of the public key data in OpenSSH MD5 hash format, e.g. `aa:bb:cc:...`. Only available if the selected private key format is compatible, as per the rules for