Skip to content

Commit a533ab6

Browse files
committed
Add support for openssh comments when generating a private key
1 parent 07b9ca4 commit a533ab6

13 files changed

+344
-20
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
kind: ENHANCEMENTS
2+
body: 'resource/tls_private_key: add openssh_comment attribute
3+
data-source/tls_public_key: append openssh comment to public_key_openssh if private_key_openssh contains a comment
4+
ephemeral/tls_private_key: add openssh_comment attribute
5+
ephemeral/tls_public_key: append openssh comment to public_key_openssh if private_key_openssh contains a comment'
6+
time: 2023-08-03T09:42:41.390232535Z
7+
custom:
8+
Issue: "395"

docs/data-sources/public_key.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ data "tls_public_key" "private_key_openssh-example" {
4343

4444
- `algorithm` (String) The name of the algorithm used by the given private key. Possible values are: `RSA`, `ECDSA`, `ED25519`.
4545
- `id` (String) Unique identifier for this data source: hexadecimal representation of the SHA1 checksum of the data source.
46+
- `openssh_comment` (String) The OpenSSH comment.
4647
- `public_key_fingerprint_md5` (String) The fingerprint 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 `public_key_openssh` and [ECDSA P224 limitations](../../docs#limitations).
4748
- `public_key_fingerprint_sha256` (String) The fingerprint of the public key data in OpenSSH SHA256 hash format, e.g. `SHA256:...`. Only available if the selected private key format is compatible, as per the rules for `public_key_openssh` and [ECDSA P224 limitations](../../docs#limitations).
4849
- `public_key_openssh` (String) The public key, in [OpenSSH PEM (RFC 4716)](https://datatracker.ietf.org/doc/html/rfc4716) format. This is also known as ['Authorized Keys'](https://www.ssh.com/academy/ssh/authorized_keys/openssh#format-of-the-authorized-keys-file) format. This is not populated for `ECDSA` with curve `P224`, as it is [not supported](../../docs#limitations). **NOTE**: the [underlying](https://pkg.go.dev/encoding/pem#Encode) [libraries](https://pkg.go.dev/golang.org/x/crypto/ssh#MarshalAuthorizedKey) that generate this value append a `\n` at the end of the PEM. In case this disrupts your use case, we recommend using [`trimspace()`](https://www.terraform.io/language/functions/trimspace).

docs/resources/private_key.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ resource "tls_private_key" "ed25519-example" {
5656
### Optional
5757

5858
- `ecdsa_curve` (String) When `algorithm` is `ECDSA`, the name of the elliptic curve to use. Currently-supported values are: `P224`, `P256`, `P384`, `P521`. (default: `P224`).
59+
- `openssh_comment` (String) Comment to add to the OpenSSH key (default: `""`).
5960
- `rsa_bits` (Number) When `algorithm` is `RSA`, the size of the generated RSA key, in bits (default: `2048`).
6061

6162
### Read-Only

internal/provider/common_key.go

Lines changed: 129 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import (
1313
"crypto/rsa"
1414
"crypto/x509"
1515
"encoding/pem"
16+
"errors"
1617
"fmt"
18+
"math/big"
19+
"strings"
1720

1821
"github.com/hashicorp/terraform-plugin-framework/diag"
1922
"github.com/hashicorp/terraform-plugin-framework/path"
@@ -118,18 +121,22 @@ func parsePrivateKeyPEM(keyPEMBytes []byte) (crypto.PrivateKey, Algorithm, error
118121
// parsePrivateKeyOpenSSHPEM takes a slide of bytes containing a private key
119122
// encoded in [OpenSSH PEM (RFC 4716)](https://datatracker.ietf.org/doc/html/rfc4716) format,
120123
// and returns a crypto.PrivateKey implementation, together with the Algorithm used by the key.
121-
func parsePrivateKeyOpenSSHPEM(keyOpenSSHPEMBytes []byte) (crypto.PrivateKey, Algorithm, error) {
124+
func parsePrivateKeyOpenSSHPEM(keyOpenSSHPEMBytes []byte) (crypto.PrivateKey, Algorithm, string, error) {
122125
prvKey, err := ssh.ParseRawPrivateKey(keyOpenSSHPEMBytes)
123126
if err != nil {
124-
return nil, "", fmt.Errorf("failed to parse openssh private key: %w", err)
127+
return nil, "", "", fmt.Errorf("failed to parse openssh private key: %w", err)
125128
}
126129

127-
algorithm, err := privateKeyToAlgorithm(prvKey)
130+
comment, err := getPrivateKeyComment(keyOpenSSHPEMBytes)
128131
if err != nil {
129-
return nil, "", fmt.Errorf("failed to determine key algorithm for private key of type %T: %w", prvKey, err)
132+
return nil, "", "", fmt.Errorf("failed to get private key comment: %w", err)
130133
}
131134

132-
return prvKey, algorithm, nil
135+
algorithm, err := privateKeyToAlgorithm(prvKey)
136+
if err != nil {
137+
return nil, "", "", fmt.Errorf("failed to determine key algorithm for private key of type %T: %w", prvKey, err)
138+
}
139+
return prvKey, algorithm, comment, nil
133140
}
134141

135142
// privateKeyToPublicKey takes a crypto.PrivateKey and extracts the corresponding crypto.PublicKey,
@@ -159,7 +166,7 @@ func privateKeyToAlgorithm(prvKey crypto.PrivateKey) (Algorithm, error) {
159166

160167
// setPublicKeyAttributes takes a crypto.PrivateKey, extracts the corresponding crypto.PublicKey and then
161168
// encodes related attributes on the given *tfsdk.State.
162-
func setPublicKeyAttributes(ctx context.Context, s *tfsdk.State, prvKey crypto.PrivateKey) diag.Diagnostics {
169+
func setPublicKeyAttributes(ctx context.Context, s *tfsdk.State, prvKey crypto.PrivateKey, openSSHComment string) diag.Diagnostics {
163170
var diags diag.Diagnostics
164171

165172
pubKey, err := privateKeyToPublicKey(prvKey)
@@ -199,8 +206,13 @@ func setPublicKeyAttributes(ctx context.Context, s *tfsdk.State, prvKey crypto.P
199206
var pubKeySSH, pubKeySSHFingerprintMD5, pubKeySSHFingerprintSHA256 string
200207
if err == nil {
201208
sshPubKeyBytes := ssh.MarshalAuthorizedKey(sshPubKey)
202-
203209
pubKeySSH = string(sshPubKeyBytes)
210+
211+
// Manually add the comment as MarshalAuthorizedKeys ignores it: https://github.com/golang/go/issues/46870
212+
if openSSHComment != "" {
213+
pubKeySSH = fmt.Sprintf("%s %s\n", strings.TrimSuffix(pubKeySSH, "\n"), openSSHComment)
214+
}
215+
204216
pubKeySSHFingerprintMD5 = ssh.FingerprintLegacyMD5(sshPubKey)
205217
pubKeySSHFingerprintSHA256 = ssh.FingerprintSHA256(sshPubKey)
206218
}
@@ -225,7 +237,7 @@ func setPublicKeyAttributes(ctx context.Context, s *tfsdk.State, prvKey crypto.P
225237

226238
// setPublicKeyAttributes takes a crypto.PrivateKey, extracts the corresponding crypto.PublicKey and then
227239
// encodes related attributes on the given *tfsdk.EphemeralResultData.
228-
func setPublicKeyAttributesEphemeral(ctx context.Context, d *tfsdk.EphemeralResultData, prvKey crypto.PrivateKey) diag.Diagnostics {
240+
func setPublicKeyAttributesEphemeral(ctx context.Context, d *tfsdk.EphemeralResultData, prvKey crypto.PrivateKey, openSSHComment string) diag.Diagnostics {
229241
var diags diag.Diagnostics
230242

231243
pubKey, err := privateKeyToPublicKey(prvKey)
@@ -265,8 +277,13 @@ func setPublicKeyAttributesEphemeral(ctx context.Context, d *tfsdk.EphemeralResu
265277
var pubKeySSH, pubKeySSHFingerprintMD5, pubKeySSHFingerprintSHA256 string
266278
if err == nil {
267279
sshPubKeyBytes := ssh.MarshalAuthorizedKey(sshPubKey)
268-
269280
pubKeySSH = string(sshPubKeyBytes)
281+
282+
// Manually add the comment as MarshalAuthorizedKeys ignores it: https://github.com/golang/go/issues/46870
283+
if openSSHComment != "" {
284+
pubKeySSH = fmt.Sprintf("%s %s\n", strings.TrimSuffix(pubKeySSH, "\n"), openSSHComment)
285+
}
286+
270287
pubKeySSHFingerprintMD5 = ssh.FingerprintLegacyMD5(sshPubKey)
271288
pubKeySSHFingerprintSHA256 = ssh.FingerprintSHA256(sshPubKey)
272289
}
@@ -288,3 +305,106 @@ func setPublicKeyAttributesEphemeral(ctx context.Context, d *tfsdk.EphemeralResu
288305

289306
return nil
290307
}
308+
309+
// Note: The SSH package does not currently expose the comment in the private key, so an adapted version of
310+
// parseOpenSSHPrivateKey from https://github.com/golang/crypto/blob/master/ssh/keys.go#L1532
311+
const privateKeyAuthMagic = "openssh-key-v1\x00"
312+
313+
type openSSHEncryptedPrivateKey struct {
314+
CipherName string
315+
KdfName string
316+
KdfOpts string
317+
NumKeys uint32
318+
PubKey []byte
319+
PrivKeyBlock []byte
320+
}
321+
322+
type openSSHPrivateKey struct {
323+
Check1 uint32
324+
Check2 uint32
325+
Keytype string
326+
Rest []byte `ssh:"rest"`
327+
}
328+
329+
type openSSHRSAPrivateKey struct {
330+
N *big.Int
331+
E *big.Int
332+
D *big.Int
333+
Iqmp *big.Int
334+
P *big.Int
335+
Q *big.Int
336+
Comment string
337+
Pad []byte `ssh:"rest"`
338+
}
339+
340+
type openSSHEd25519PrivateKey struct {
341+
Pub []byte
342+
Priv []byte
343+
Comment string
344+
Pad []byte `ssh:"rest"`
345+
}
346+
347+
type openSSHECDSAPrivateKey struct {
348+
Curve string
349+
Pub []byte
350+
D *big.Int
351+
Comment string
352+
Pad []byte `ssh:"rest"`
353+
}
354+
355+
func getPrivateKeyComment(pemBytes []byte) (string, error) {
356+
block, _ := pem.Decode(pemBytes)
357+
358+
if block == nil {
359+
return "", errors.New("ssh: no key found")
360+
}
361+
362+
key := block.Bytes
363+
364+
if len(key) < len(privateKeyAuthMagic) || string(key[:len(privateKeyAuthMagic)]) != privateKeyAuthMagic {
365+
return "", errors.New("ssh: invalid openssh private key format")
366+
}
367+
remaining := key[len(privateKeyAuthMagic):]
368+
369+
var w openSSHEncryptedPrivateKey
370+
if err := ssh.Unmarshal(remaining, &w); err != nil {
371+
return "", err
372+
}
373+
if w.NumKeys != 1 {
374+
// We only support single key files, and so does OpenSSH.
375+
// https://github.com/openssh/openssh-portable/blob/4103a3ec7/sshkey.c#L4171
376+
return "", errors.New("ssh: multi-key files are not supported")
377+
}
378+
379+
var pk1 openSSHPrivateKey
380+
if err := ssh.Unmarshal(w.PrivKeyBlock, &pk1); err != nil || pk1.Check1 != pk1.Check2 {
381+
if w.CipherName != "none" {
382+
return "", x509.IncorrectPasswordError
383+
}
384+
return "", errors.New("ssh: malformed OpenSSH key")
385+
}
386+
387+
switch pk1.Keytype {
388+
case ssh.KeyAlgoRSA:
389+
var key openSSHRSAPrivateKey
390+
if err := ssh.Unmarshal(pk1.Rest, &key); err != nil {
391+
return "", err
392+
}
393+
394+
return key.Comment, nil
395+
case ssh.KeyAlgoED25519:
396+
var key openSSHEd25519PrivateKey
397+
if err := ssh.Unmarshal(pk1.Rest, &key); err != nil {
398+
return "", err
399+
}
400+
return key.Comment, nil
401+
case ssh.KeyAlgoECDSA256, ssh.KeyAlgoECDSA384, ssh.KeyAlgoECDSA521:
402+
var key openSSHECDSAPrivateKey
403+
if err := ssh.Unmarshal(pk1.Rest, &key); err != nil {
404+
return "", err
405+
}
406+
return key.Comment, nil
407+
default:
408+
return "", errors.New("ssh: unhandled key type")
409+
}
410+
}

internal/provider/data_source_public_key.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,16 +120,18 @@ func (ds *publicKeyDataSource) Read(ctx context.Context, req datasource.ReadRequ
120120
var prvKey crypto.PrivateKey
121121
var algorithm Algorithm
122122
var err error
123+
var openSSHComment string
123124

124125
// Given the use of `ExactlyOneOf` in the Schema, we are guaranteed
125126
// that either `private_key_pem` or `private_key_openssh` will be set.
126127
var prvKeyArg types.String
128+
127129
if req.Config.GetAttribute(ctx, path.Root("private_key_pem"), &prvKeyArg); !prvKeyArg.IsNull() && !prvKeyArg.IsUnknown() {
128130
tflog.Debug(ctx, "Parsing private key from PEM")
129131
prvKey, algorithm, err = parsePrivateKeyPEM([]byte(prvKeyArg.ValueString()))
130132
} else if req.Config.GetAttribute(ctx, path.Root("private_key_openssh"), &prvKeyArg); !prvKeyArg.IsNull() && !prvKeyArg.IsUnknown() {
131133
tflog.Debug(ctx, "Parsing private key from OpenSSH PEM")
132-
prvKey, algorithm, err = parsePrivateKeyOpenSSHPEM([]byte(prvKeyArg.ValueString()))
134+
prvKey, algorithm, openSSHComment, err = parsePrivateKeyOpenSSHPEM([]byte(prvKeyArg.ValueString()))
133135
}
134136
if err != nil {
135137
res.Diagnostics.AddError("Unable to parse private key", err.Error())
@@ -143,5 +145,5 @@ func (ds *publicKeyDataSource) Read(ctx context.Context, req datasource.ReadRequ
143145
}
144146

145147
tflog.Debug(ctx, "Storing private key's public key info into the state")
146-
res.Diagnostics.Append(setPublicKeyAttributes(ctx, &res.State, prvKey)...)
148+
res.Diagnostics.Append(setPublicKeyAttributes(ctx, &res.State, prvKey, openSSHComment)...)
147149
}

internal/provider/data_source_public_key_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,51 @@ func TestPublicKey_dataSource_PKCS8PEM(t *testing.T) {
240240
})
241241
}
242242

243+
func TestPublicKey_dataSource_OpenSSHComment(t *testing.T) {
244+
r.UnitTest(t, r.TestCase{
245+
ProtoV5ProviderFactories: protoV5ProviderFactories(),
246+
Steps: []r.TestStep{
247+
{
248+
Config: `
249+
resource "tls_private_key" "prvKey" {
250+
algorithm = "RSA"
251+
openssh_comment = "test@test"
252+
}
253+
data "tls_public_key" "pubKey" {
254+
private_key_openssh = tls_private_key.prvKey.private_key_openssh
255+
}
256+
`,
257+
Check: r.TestMatchResourceAttr("data.tls_public_key.pubKey", "public_key_openssh", regexp.MustCompile(` test@test\n$`)),
258+
},
259+
{
260+
Config: `
261+
resource "tls_private_key" "prvKey" {
262+
algorithm = "ECDSA"
263+
ecdsa_curve = "P384"
264+
openssh_comment = "test@test"
265+
}
266+
data "tls_public_key" "pubKey" {
267+
private_key_openssh = tls_private_key.prvKey.private_key_openssh
268+
}
269+
`,
270+
Check: r.TestMatchResourceAttr("data.tls_public_key.pubKey", "public_key_openssh", regexp.MustCompile(` test@test\n$`)),
271+
},
272+
{
273+
Config: `
274+
resource "tls_private_key" "prvKey" {
275+
algorithm = "ED25519"
276+
openssh_comment = "test@test"
277+
}
278+
data "tls_public_key" "pubKey" {
279+
private_key_openssh = tls_private_key.prvKey.private_key_openssh
280+
}
281+
`,
282+
Check: r.TestMatchResourceAttr("data.tls_public_key.pubKey", "public_key_openssh", regexp.MustCompile(` test@test\n$`)),
283+
},
284+
},
285+
})
286+
}
287+
243288
func TestPublicKey_dataSource_errorCases(t *testing.T) {
244289
r.UnitTest(t, r.TestCase{
245290
ProtoV5ProviderFactories: protoV5ProviderFactories(),

internal/provider/ephemeral_private_key.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ func (p *privateKeyEphemeralResource) Schema(ctx context.Context, req ephemeral.
5050
},
5151

5252
// Optional attributes
53+
"openssh_comment": schema.StringAttribute{
54+
Optional: true,
55+
MarkdownDescription: "Comment to add to the OpenSSH key (default: `\"\"`).",
56+
},
5357
"rsa_bits": schema.Int64Attribute{
5458
Optional: true,
5559
Computed: true,
@@ -216,7 +220,7 @@ func (p *privateKeyEphemeralResource) Open(ctx context.Context, req ephemeral.Op
216220
tflog.Debug(ctx, "Marshalling private key to OpenSSH PEM (if supported)")
217221
data.PrivateKeyOpenSSH = types.StringValue("")
218222
if prvKeySupportsOpenSSHMarshalling(prvKey) {
219-
openSSHKeyPemBlock, err := ssh.MarshalPrivateKey(prvKey, "")
223+
openSSHKeyPemBlock, err := ssh.MarshalPrivateKey(prvKey, data.OpenSSHComment.ValueString())
220224
if err != nil {
221225
res.Diagnostics.AddError("Unable to marshal private key into OpenSSH format", err.Error())
222226
return

internal/provider/ephemeral_private_key_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,37 @@ func TestAccEphemeralPrivateKey_ED25519(t *testing.T) {
180180
})
181181
}
182182

183+
func TestAccEphemeralPrivateKey_OpenSSHComment(t *testing.T) {
184+
r.UnitTest(t, r.TestCase{
185+
// Ephemeral resources are only available in 1.10 and later
186+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
187+
tfversion.SkipBelow(tfversion.Version1_10_0),
188+
},
189+
ProtoV5ProviderFactories: protoV5ProviderFactories(),
190+
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
191+
"echo": echoprovider.NewProviderServer(),
192+
},
193+
Steps: []r.TestStep{
194+
{
195+
Config: ephemeralPrivateKeyWithEchoConfig(`ephemeral "tls_private_key" "test" {
196+
algorithm = "ED25519"
197+
openssh_comment = "test@test"
198+
}`),
199+
Check: r.ComposeAggregateTestCheckFunc(
200+
tu.TestCheckPEMFormat("echo.tls_private_key_test", "data.private_key_pem", PreamblePrivateKeyPKCS8.String()),
201+
tu.TestCheckPEMFormat("echo.tls_private_key_test", "data.public_key_pem", PreamblePublicKey.String()),
202+
tu.TestCheckPEMFormat("echo.tls_private_key_test", "data.private_key_openssh", PreamblePrivateKeyOpenSSH.String()),
203+
tu.TestCheckPEMFormat("echo.tls_private_key_test", "data.private_key_pem_pkcs8", PreamblePrivateKeyPKCS8.String()),
204+
r.TestMatchResourceAttr("echo.tls_private_key_test", "data.public_key_openssh", regexp.MustCompile(`^ssh-ed25519 `)),
205+
r.TestMatchResourceAttr("echo.tls_private_key_test", "data.public_key_openssh", regexp.MustCompile(` test@test\n$`)),
206+
r.TestMatchResourceAttr("echo.tls_private_key_test", "data.public_key_fingerprint_md5", regexp.MustCompile(`^([abcdef\d]{2}:){15}[abcdef\d]{2}`)),
207+
r.TestMatchResourceAttr("echo.tls_private_key_test", "data.public_key_fingerprint_sha256", regexp.MustCompile(`^SHA256:`)),
208+
),
209+
},
210+
},
211+
})
212+
}
213+
183214
// Adds the test echo provider to enable using state checks with ephemeral resources.
184215
func ephemeralPrivateKeyWithEchoConfig(cfg string) string {
185216
return fmt.Sprintf(`

internal/provider/ephemeral_public_key.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func (p *publicKeyEphemeralResource) Open(ctx context.Context, req ephemeral.Ope
122122
var prvKey crypto.PrivateKey
123123
var algorithm Algorithm
124124
var err error
125+
var openSSHComment string
125126

126127
// Given the use of `ExactlyOneOf` in the Schema, we are guaranteed
127128
// that either `private_key_pem` or `private_key_openssh` will be set.
@@ -131,7 +132,7 @@ func (p *publicKeyEphemeralResource) Open(ctx context.Context, req ephemeral.Ope
131132
prvKey, algorithm, err = parsePrivateKeyPEM([]byte(prvKeyArg.ValueString()))
132133
} else if req.Config.GetAttribute(ctx, path.Root("private_key_openssh"), &prvKeyArg); !prvKeyArg.IsNull() {
133134
tflog.Debug(ctx, "Parsing private key from OpenSSH PEM")
134-
prvKey, algorithm, err = parsePrivateKeyOpenSSHPEM([]byte(prvKeyArg.ValueString()))
135+
prvKey, algorithm, openSSHComment, err = parsePrivateKeyOpenSSHPEM([]byte(prvKeyArg.ValueString()))
135136
}
136137
if err != nil {
137138
resp.Diagnostics.AddError("Unable to parse private key", err.Error())
@@ -143,5 +144,5 @@ func (p *publicKeyEphemeralResource) Open(ctx context.Context, req ephemeral.Ope
143144
return
144145
}
145146

146-
resp.Diagnostics.Append(setPublicKeyAttributesEphemeral(ctx, &resp.Result, prvKey)...)
147+
resp.Diagnostics.Append(setPublicKeyAttributesEphemeral(ctx, &resp.Result, prvKey, openSSHComment)...)
147148
}

0 commit comments

Comments
 (0)