From c0f44d8bba3beaa49c5fed7f327cf801f05ccaae Mon Sep 17 00:00:00 2001 From: Alexander Scheel Date: Sat, 16 Mar 2024 17:47:50 -0400 Subject: [PATCH] Allow customizing key export format in Transit Transit's export functionality didn't allow choosing the desired output key format and was largely inconsistent about typing. Symmetric keys (and ed25519!) were returned in raw (base64-encoded byte array) format, but RSA and EC keys were returned in their native container formats. This prevents easy interoperability as some tools will not read all of these formats easily; add "der" and "pem" forms for asymmetric keys, to allow native PKIX-typed exports (in typed SubjectPublicKeyInfo or PrivateKeyInfo containers). Resolves: #86 Signed-off-by: Alexander Scheel --- builtin/logical/transit/path_export.go | 187 +++++++++++++++++--- builtin/logical/transit/path_export_test.go | 169 ++++++++++++++++++ builtin/logical/transit/path_keys.go | 2 +- changelog/212.txt | 3 + website/content/api-docs/secret/transit.mdx | 9 + 5 files changed, 342 insertions(+), 28 deletions(-) create mode 100644 changelog/212.txt diff --git a/builtin/logical/transit/path_export.go b/builtin/logical/transit/path_export.go index 17ebe796a5..c36cac8042 100644 --- a/builtin/logical/transit/path_export.go +++ b/builtin/logical/transit/path_export.go @@ -7,6 +7,7 @@ import ( "context" "crypto" "crypto/ecdsa" + "crypto/ed25519" "crypto/elliptic" "crypto/x509" "encoding/base64" @@ -28,6 +29,13 @@ const ( exportTypePublicKey = "public-key" ) +const ( + formatTypeDefault = "" + formatTypeRaw = "raw" + formatTypeDer = "der" + formatTypePem = "pem" +) + func (b *backend) pathExportKeys() *framework.Path { return &framework.Path{ Pattern: "export/" + framework.GenericNameRegex("type") + "/" + framework.GenericNameRegex("name") + framework.OptionalParamRegex("version"), @@ -51,6 +59,10 @@ func (b *backend) pathExportKeys() *framework.Path { Type: framework.TypeString, Description: "Version of the key", }, + "format": { + Type: framework.TypeString, + Description: "Format to export the key in: `` for the default format dependent on the key type; `raw` for the raw key value in base64 (applicable to symmetric keys and ed25519); `der` for a base64 encoded PKIX (SubjectPublicKeyInfo or PKCS8/PrivateKeyInfo) format (applicable to asymmetric keys); or `pem` for a PEM-encoded PKIX format (applicable to asymmetric keys).", + }, }, Callbacks: map[logical.Operation]framework.OperationFunc{ @@ -66,6 +78,7 @@ func (b *backend) pathPolicyExportRead(ctx context.Context, req *logical.Request exportType := d.Get("type").(string) name := d.Get("name").(string) version := d.Get("version").(string) + format := d.Get("format").(string) switch exportType { case exportTypeEncryptionKey: @@ -76,6 +89,15 @@ func (b *backend) pathPolicyExportRead(ctx context.Context, req *logical.Request return logical.ErrorResponse(fmt.Sprintf("invalid export type: %s", exportType)), logical.ErrInvalidRequest } + switch format { + case formatTypeDefault: + case formatTypeRaw: + case formatTypeDer: + case formatTypePem: + default: + return logical.ErrorResponse(fmt.Sprintf("invalid format: %s", format)), logical.ErrInvalidRequest + } + p, _, err := b.GetPolicy(ctx, keysutil.PolicyRequest{ Storage: req.Storage, Name: name, @@ -114,7 +136,7 @@ func (b *backend) pathPolicyExportRead(ctx context.Context, req *logical.Request switch version { case "": for k, v := range p.Keys { - exportKey, err := getExportKey(p, &v, exportType) + exportKey, err := getExportKey(p, &v, exportType, format) if err != nil { return nil, err } @@ -141,7 +163,7 @@ func (b *backend) pathPolicyExportRead(ctx context.Context, req *logical.Request return logical.ErrorResponse("version does not exist or cannot be found"), logical.ErrInvalidRequest } - exportKey, err := getExportKey(p, &key, exportType) + exportKey, err := getExportKey(p, &key, exportType, format) if err != nil { return nil, err } @@ -160,7 +182,7 @@ func (b *backend) pathPolicyExportRead(ctx context.Context, req *logical.Request return resp, nil } -func getExportKey(policy *keysutil.Policy, key *keysutil.KeyEntry, exportType string) (string, error) { +func getExportKey(policy *keysutil.Policy, key *keysutil.KeyEntry, exportType string, format string) (string, error) { if policy == nil { return "", errors.New("nil policy provided") } @@ -171,21 +193,26 @@ func getExportKey(policy *keysutil.Policy, key *keysutil.KeyEntry, exportType st if policy.Type == keysutil.KeyType_HMAC { src = key.Key } - return strings.TrimSpace(base64.StdEncoding.EncodeToString(src)), nil + if format == "der" || format == "pem" { + return "", fmt.Errorf("unknown format for HMAC key; supported values are `` or `raw`") + } + return strings.TrimSpace(base64.StdEncoding.EncodeToString(src)), nil case exportTypeEncryptionKey: switch policy.Type { case keysutil.KeyType_AES128_GCM96, keysutil.KeyType_AES256_GCM96, keysutil.KeyType_ChaCha20_Poly1305, keysutil.KeyType_XChaCha20_Poly1305: - return strings.TrimSpace(base64.StdEncoding.EncodeToString(key.Key)), nil + if format == "der" || format == "pem" { + return "", fmt.Errorf("unknown format for HMAC key; supported values are `` or `raw`") + } + return strings.TrimSpace(base64.StdEncoding.EncodeToString(key.Key)), nil case keysutil.KeyType_RSA2048, keysutil.KeyType_RSA3072, keysutil.KeyType_RSA4096: - rsaKey, err := encodeRSAPrivateKey(key) + rsaKey, err := encodeRSAPrivateKey(key, format) if err != nil { return "", err } return rsaKey, nil } - case exportTypeSigningKey: switch policy.Type { case keysutil.KeyType_ECDSA_P256, keysutil.KeyType_ECDSA_P384, keysutil.KeyType_ECDSA_P521: @@ -198,21 +225,20 @@ func getExportKey(policy *keysutil.Policy, key *keysutil.KeyEntry, exportType st default: curve = elliptic.P256() } - ecKey, err := keyEntryToECPrivateKey(key, curve) + + ecKey, err := keyEntryToECPrivateKey(key, curve, format) if err != nil { return "", err } return ecKey, nil - case keysutil.KeyType_ED25519: if len(key.Key) == 0 { return "", nil } - return strings.TrimSpace(base64.StdEncoding.EncodeToString(key.Key)), nil - + return encodeED25519PrivateKey(key, format) case keysutil.KeyType_RSA2048, keysutil.KeyType_RSA3072, keysutil.KeyType_RSA4096: - rsaKey, err := encodeRSAPrivateKey(key) + rsaKey, err := encodeRSAPrivateKey(key, format) if err != nil { return "", err } @@ -230,17 +256,16 @@ func getExportKey(policy *keysutil.Policy, key *keysutil.KeyEntry, exportType st default: curve = elliptic.P256() } - ecKey, err := keyEntryToECPublicKey(key, curve) + + ecKey, err := keyEntryToECPublicKey(key, curve, format) if err != nil { return "", err } return ecKey, nil - case keysutil.KeyType_ED25519: - return strings.TrimSpace(key.FormattedPublicKey), nil - + return encodeED25519PublicKey(key, format) case keysutil.KeyType_RSA2048, keysutil.KeyType_RSA3072, keysutil.KeyType_RSA4096: - rsaKey, err := encodeRSAPublicKey(key) + rsaKey, err := encodeRSAPublicKey(key, format) if err != nil { return "", err } @@ -251,7 +276,11 @@ func getExportKey(policy *keysutil.Policy, key *keysutil.KeyEntry, exportType st return "", fmt.Errorf("unknown key type %v for export type %v", policy.Type, exportType) } -func encodeRSAPrivateKey(key *keysutil.KeyEntry) (string, error) { +func encodeRSAPrivateKey(key *keysutil.KeyEntry, format string) (string, error) { + if format == "raw" { + return "", fmt.Errorf("unknown key format for rsa key; supported values are ``, `der`, or `pem`") + } + if key == nil { return "", errors.New("nil KeyEntry provided") } @@ -260,10 +289,24 @@ func encodeRSAPrivateKey(key *keysutil.KeyEntry) (string, error) { return "", nil } - // When encoding PKCS1, the PEM header should be `RSA PRIVATE KEY`. When Go - // has PKCS8 encoding support, we may want to change this. - blockType := "RSA PRIVATE KEY" - derBytes := x509.MarshalPKCS1PrivateKey(key.RSAKey) + var derBytes []byte + var blockType string + var err error + if format == "" { + derBytes = x509.MarshalPKCS1PrivateKey(key.RSAKey) + blockType = "RSA PRIVATE KEY" + } else if format == "der" || format == "pem" { + derBytes, err = x509.MarshalPKCS8PrivateKey(key.RSAKey) + blockType = "PRIVATE KEY" + } + if err != nil { + return "", err + } + + if format == "der" { + return base64.StdEncoding.EncodeToString(derBytes), nil + } + pemBlock := pem.Block{ Type: blockType, Bytes: derBytes, @@ -273,7 +316,11 @@ func encodeRSAPrivateKey(key *keysutil.KeyEntry) (string, error) { return string(pemBytes), nil } -func encodeRSAPublicKey(key *keysutil.KeyEntry) (string, error) { +func encodeRSAPublicKey(key *keysutil.KeyEntry, format string) (string, error) { + if format == "raw" { + return "", fmt.Errorf("unknown key format for rsa key; supported values are ``, `der`, or `pem`") + } + if key == nil { return "", errors.New("nil KeyEntry provided") } @@ -294,6 +341,11 @@ func encodeRSAPublicKey(key *keysutil.KeyEntry) (string, error) { if err != nil { return "", fmt.Errorf("error marshaling RSA public key: %w", err) } + + if format == "der" { + return base64.StdEncoding.EncodeToString(derBytes), nil + } + pemBlock := &pem.Block{ Type: "PUBLIC KEY", Bytes: derBytes, @@ -306,7 +358,11 @@ func encodeRSAPublicKey(key *keysutil.KeyEntry) (string, error) { return string(pemBytes), nil } -func keyEntryToECPrivateKey(k *keysutil.KeyEntry, curve elliptic.Curve) (string, error) { +func keyEntryToECPrivateKey(k *keysutil.KeyEntry, curve elliptic.Curve, format string) (string, error) { + if format == "raw" { + return "", fmt.Errorf("unknown key format for ec key; supported values are ``, `der`, or `pem`") + } + if k == nil { return "", errors.New("nil KeyEntry provided") } @@ -321,16 +377,29 @@ func keyEntryToECPrivateKey(k *keysutil.KeyEntry, curve elliptic.Curve) (string, Y: k.EC_Y, } - blockType := "EC PRIVATE KEY" privKey := &ecdsa.PrivateKey{ PublicKey: pubKey, D: k.EC_D, } - derBytes, err := x509.MarshalECPrivateKey(privKey) + + var blockType string + var derBytes []byte + var err error + if format == "" { + derBytes, err = x509.MarshalECPrivateKey(privKey) + blockType = "EC PRIVATE KEY" + } else if format == "der" || format == "pem" { + derBytes, err = x509.MarshalPKCS8PrivateKey(privKey) + blockType = "PRIVATE KEY" + } if err != nil { return "", err } + if format == "der" { + return base64.StdEncoding.EncodeToString(derBytes), nil + } + pemBlock := pem.Block{ Type: blockType, Bytes: derBytes, @@ -339,7 +408,11 @@ func keyEntryToECPrivateKey(k *keysutil.KeyEntry, curve elliptic.Curve) (string, return strings.TrimSpace(string(pem.EncodeToMemory(&pemBlock))), nil } -func keyEntryToECPublicKey(k *keysutil.KeyEntry, curve elliptic.Curve) (string, error) { +func keyEntryToECPublicKey(k *keysutil.KeyEntry, curve elliptic.Curve, format string) (string, error) { + if format == "raw" { + return "", fmt.Errorf("unknown key format for ec key; supported values are ``, `der`, or `pem`") + } + if k == nil { return "", errors.New("nil KeyEntry provided") } @@ -356,6 +429,66 @@ func keyEntryToECPublicKey(k *keysutil.KeyEntry, curve elliptic.Curve) (string, return "", err } + if format == "der" { + return base64.StdEncoding.EncodeToString(derBytes), nil + } + + pemBlock := pem.Block{ + Type: blockType, + Bytes: derBytes, + } + + return strings.TrimSpace(string(pem.EncodeToMemory(&pemBlock))), nil +} + +func encodeED25519PrivateKey(k *keysutil.KeyEntry, format string) (string, error) { + if format == "" || format == "raw" { + return base64.StdEncoding.EncodeToString(k.Key), nil + } + + privKey := ed25519.PrivateKey(k.Key) + + blockType := "PRIVATE KEY" + derBytes, err := x509.MarshalPKCS8PrivateKey(privKey) + if err != nil { + return "", err + } + + if format == "der" { + return base64.StdEncoding.EncodeToString(derBytes), nil + } + + pemBlock := pem.Block{ + Type: blockType, + Bytes: derBytes, + } + + return strings.TrimSpace(string(pem.EncodeToMemory(&pemBlock))), nil +} + +func encodeED25519PublicKey(k *keysutil.KeyEntry, format string) (string, error) { + pubStr := strings.TrimSpace(k.FormattedPublicKey) + if format == "" || format == "raw" { + return pubStr, nil + } + + pubRaw, err := base64.StdEncoding.DecodeString(pubStr) + if err != nil { + return "", err + } + + pubKey := ed25519.PublicKey(pubRaw) + + blockType := "PUBLIC KEY" + derBytes, err := x509.MarshalPKIXPublicKey(pubKey) + if err != nil { + return "", err + } + + if format == "der" { + return base64.StdEncoding.EncodeToString(derBytes), nil + } + pemBlock := pem.Block{ Type: blockType, Bytes: derBytes, diff --git a/builtin/logical/transit/path_export_test.go b/builtin/logical/transit/path_export_test.go index d6b26fc918..fd485e64fe 100644 --- a/builtin/logical/transit/path_export_test.go +++ b/builtin/logical/transit/path_export_test.go @@ -5,9 +5,13 @@ package transit import ( "context" + "crypto/x509" + "encoding/base64" + "encoding/pem" "fmt" "reflect" "strconv" + "strings" "testing" "github.com/openbao/openbao/sdk/logical" @@ -386,3 +390,168 @@ func TestTransit_Export_EncryptionKey_DoesNotExportHMACKey(t *testing.T) { t.Fatal("Encryption key data matched hmac key data") } } + +func TestTransit_Export_CorrectFormat(t *testing.T) { + verifyExportsCorrectFormat(t, "encryption-key", "aes128-gcm96") + verifyExportsCorrectFormat(t, "encryption-key", "aes256-gcm96") + verifyExportsCorrectFormat(t, "encryption-key", "chacha20-poly1305") + verifyExportsCorrectFormat(t, "encryption-key", "xchacha20-poly1305") + verifyExportsCorrectFormat(t, "encryption-key", "rsa-2048") + verifyExportsCorrectFormat(t, "encryption-key", "rsa-3072") + verifyExportsCorrectFormat(t, "encryption-key", "rsa-4096") + verifyExportsCorrectFormat(t, "signing-key", "ecdsa-p256") + verifyExportsCorrectFormat(t, "signing-key", "ecdsa-p384") + verifyExportsCorrectFormat(t, "signing-key", "ecdsa-p521") + verifyExportsCorrectFormat(t, "signing-key", "ed25519") + verifyExportsCorrectFormat(t, "signing-key", "rsa-2048") + verifyExportsCorrectFormat(t, "signing-key", "rsa-3072") + verifyExportsCorrectFormat(t, "signing-key", "rsa-4096") + verifyExportsCorrectFormat(t, "public-key", "ecdsa-p256") + verifyExportsCorrectFormat(t, "public-key", "ecdsa-p384") + verifyExportsCorrectFormat(t, "public-key", "ecdsa-p521") + verifyExportsCorrectFormat(t, "public-key", "ed25519") + verifyExportsCorrectFormat(t, "public-key", "rsa-2048") + verifyExportsCorrectFormat(t, "public-key", "rsa-3072") + verifyExportsCorrectFormat(t, "public-key", "rsa-4096") + verifyExportsCorrectFormat(t, "hmac-key", "aes128-gcm96") + verifyExportsCorrectFormat(t, "hmac-key", "aes256-gcm96") + verifyExportsCorrectFormat(t, "hmac-key", "chacha20-poly1305") + verifyExportsCorrectFormat(t, "hmac-key", "xchacha20-poly1305") + verifyExportsCorrectFormat(t, "hmac-key", "ecdsa-p256") + verifyExportsCorrectFormat(t, "hmac-key", "ecdsa-p384") + verifyExportsCorrectFormat(t, "hmac-key", "ecdsa-p521") + verifyExportsCorrectFormat(t, "hmac-key", "rsa-2048") + verifyExportsCorrectFormat(t, "hmac-key", "rsa-3072") + verifyExportsCorrectFormat(t, "hmac-key", "rsa-4096") + verifyExportsCorrectFormat(t, "hmac-key", "ed25519") + verifyExportsCorrectFormat(t, "hmac-key", "hmac") +} + +func verifyExportsCorrectFormat(t *testing.T, exportType, keyType string) { + b, storage := createBackendWithSysView(t) + + // First create a key + req := &logical.Request{ + Storage: storage, + Operation: logical.UpdateOperation, + Path: "keys/foo", + } + req.Data = map[string]interface{}{ + "exportable": true, + "type": keyType, + } + if keyType == "hmac" { + req.Data["key_size"] = 32 + } + _, err := b.HandleRequest(context.Background(), req) + if err != nil { + t.Fatal(err) + } + + verifyFormat := func(formatRequest string) { + t.Logf("handling key: %v / %v / %v", exportType, keyType, formatRequest) + + req := &logical.Request{ + Storage: storage, + Operation: logical.ReadOperation, + Path: fmt.Sprintf("export/%s/foo", exportType), + Data: map[string]interface{}{ + "format": formatRequest, + }, + } + + rsp, err := b.HandleRequest(context.Background(), req) + if err != nil { + t.Fatalf("on req to %v: %v", req.Path, err) + } + + keysRaw, ok := rsp.Data["keys"] + if !ok { + t.Fatal("could not find keys value") + } + keys, ok := keysRaw.(map[string]string) + if !ok { + t.Fatal("could not cast to keys map") + } + if len(keys) != 1 { + t.Fatal("unexpected number of keys found") + } + + for _, k := range keys { + if exportType != "hmac-key" && formatRequest == "" && (strings.HasPrefix(keyType, "rsa") || strings.HasPrefix(keyType, "ecdsa")) { + block, rest := pem.Decode([]byte(k)) + if len(strings.TrimSpace(string(rest))) > 0 { + t.Fatalf("remainder when decoding raw %v key (%v): block=%v rest=%v", keyType, k, block, rest) + } + + if block == nil { + t.Fatalf("no pem block when decoding raw %v key (%v): block=%v rest=%v", keyType, k, block, rest) + } + + if exportType == "public-key" { + if _, err := x509.ParsePKIXPublicKey(block.Bytes); err != nil { + t.Fatalf("failed to parse raw rsa key (%v): %v", k, err) + } + } else if strings.HasPrefix(keyType, "rsa") { + if _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err != nil { + t.Fatalf("failed to parse raw rsa key (%v): %v", k, err) + } + } else { + if _, err := x509.ParseECPrivateKey(block.Bytes); err != nil { + t.Fatalf("failed to parse raw ec key (%v): %v", k, err) + } + } + } else if formatRequest == "" && strings.HasPrefix(keyType, "ec") { + } else if formatRequest == "der" || formatRequest == "pem" { + var keyData []byte + var err error + + if formatRequest == "der" { + keyData, err = base64.StdEncoding.DecodeString(k) + if err != nil { + t.Fatalf("error decoding der key (%v): %v", k, err) + } + } else { + block, rest := pem.Decode([]byte(k)) + if len(strings.TrimSpace(string(rest))) > 0 { + t.Fatalf("remainder when decoding pem key (%v): block=%v rest=%v", k, block, rest) + } + + if block == nil { + t.Fatalf("no pem block when decoding pem key (%v): block=%v rest=%v", k, block, rest) + } + + keyData = block.Bytes + } + + if exportType == "public-key" { + _, err := x509.ParsePKIXPublicKey(keyData) + if err != nil { + t.Fatalf("error decoding `%v` key (%v): %v", formatRequest, k, err) + } + } else { + _, err := x509.ParsePKCS8PrivateKey(keyData) + if err != nil { + t.Fatalf("error decoding `%v` key (%v): %v", formatRequest, k, err) + } + } + } else { + if _, err := base64.StdEncoding.DecodeString(k); err != nil { + t.Fatalf("error decoding raw key (%v / %v): %v", formatRequest, keyType, k) + } + } + } + } + + verifyFormat("") + if exportType == "hmac-key" || strings.Contains(keyType, "aes") || strings.Contains(keyType, "chacha20") || keyType == "hmac" { + verifyFormat("raw") + } else if keyType == "ed25519" { + verifyFormat("raw") + verifyFormat("der") + verifyFormat("pem") + } else { + verifyFormat("der") + verifyFormat("pem") + } +} diff --git a/builtin/logical/transit/path_keys.go b/builtin/logical/transit/path_keys.go index 4c4b79a7f6..ab98efda7d 100644 --- a/builtin/logical/transit/path_keys.go +++ b/builtin/logical/transit/path_keys.go @@ -455,7 +455,7 @@ func (b *backend) formatKeyPolicy(p *keysutil.Policy, context []byte) (*logical. key.Name = "rsa-4096" } - pubKey, err := encodeRSAPublicKey(&v) + pubKey, err := encodeRSAPublicKey(&v, "") if err != nil { return nil, err } diff --git a/changelog/212.txt b/changelog/212.txt new file mode 100644 index 0000000000..d399ab4315 --- /dev/null +++ b/changelog/212.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secret/transit: Allow choosing export key format, specifying format=der or format=pem for consistent PKIX encoded public keys. +``` diff --git a/website/content/api-docs/secret/transit.mdx b/website/content/api-docs/secret/transit.mdx index e56782e2a2..b2750c6997 100644 --- a/website/content/api-docs/secret/transit.mdx +++ b/website/content/api-docs/secret/transit.mdx @@ -659,6 +659,15 @@ be valid. all versions of the key will be returned. This is specified as part of the URL. If the version is set to `latest`, the current key will be returned. +- `format` `(string: "")` - Specifies the format of the exported key. The + empty string preserves existing behavior, with format unique to each key + type (`base64` encoded raw keys for symmetric keys or Ed25519 keys, PKCS#1 + for RSA private keys, SEC 1 for EC private keys, and PKIX format for + RSA or EC public keys). The `raw` format always returns a base64 encoded + raw key (only applicable to symmetric keys and Ed25519 keys). The `der` and + `pem` formats always returns a PKIX (SubjectPublicKeyInfo) or PrivateKeyInfo + encoded object for asymmetric keys. + ### Sample request ```shell-session