diff --git a/builtin/logical/transit/backend.go b/builtin/logical/transit/backend.go index 3dbce416b..03c3b2fda 100644 --- a/builtin/logical/transit/backend.go +++ b/builtin/logical/transit/backend.go @@ -57,6 +57,7 @@ func Backend(ctx context.Context, conf *logical.BackendConfig) (*backend, error) b.pathImportVersion(), b.pathKeys(), b.pathListKeys(), + b.pathBYOKExportKeys(), b.pathExportKeys(), b.pathKeysConfig(), b.pathEncrypt(), diff --git a/builtin/logical/transit/path_byok.go b/builtin/logical/transit/path_byok.go new file mode 100644 index 000000000..40f7cac1a --- /dev/null +++ b/builtin/logical/transit/path_byok.go @@ -0,0 +1,206 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package transit + +import ( + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/keysutil" + "github.com/hashicorp/vault/sdk/logical" +) + +func (b *backend) pathBYOKExportKeys() *framework.Path { + return &framework.Path{ + Pattern: "byok-export/" + framework.GenericNameRegex("destination") + "/" + framework.GenericNameRegex("source") + framework.OptionalParamRegex("version"), + + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: operationPrefixTransit, + OperationVerb: "byok", + OperationSuffix: "key|key-version", + }, + + Fields: map[string]*framework.FieldSchema{ + "destination": { + Type: framework.TypeString, + Description: "Destination key to export to; usually the public wrapping key of another Transit instance.", + }, + "source": { + Type: framework.TypeString, + Description: "Source key to export; could be any present key within Transit.", + }, + "version": { + Type: framework.TypeString, + Description: "Optional version of the key to export, else all key versions are exported.", + }, + "hash": { + Type: framework.TypeString, + Description: "Hash function to use for inner OAEP encryption. Defaults to SHA256.", + Default: "SHA256", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ReadOperation: b.pathPolicyBYOKExportRead, + }, + + HelpSynopsis: pathBYOKExportHelpSyn, + HelpDescription: pathBYOKExportHelpDesc, + } +} + +func (b *backend) pathPolicyBYOKExportRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + dst := d.Get("destination").(string) + src := d.Get("source").(string) + version := d.Get("version").(string) + hash := d.Get("hash").(string) + + dstP, _, err := b.GetPolicy(ctx, keysutil.PolicyRequest{ + Storage: req.Storage, + Name: dst, + }, b.GetRandomReader()) + if err != nil { + return nil, err + } + if dstP == nil { + return nil, fmt.Errorf("no such destination key to export to") + } + if !b.System().CachingDisabled() { + dstP.Lock(false) + } + defer dstP.Unlock() + + srcP, _, err := b.GetPolicy(ctx, keysutil.PolicyRequest{ + Storage: req.Storage, + Name: src, + }, b.GetRandomReader()) + if err != nil { + return nil, err + } + if srcP == nil { + return nil, fmt.Errorf("no such source key for export") + } + if !b.System().CachingDisabled() { + srcP.Lock(false) + } + defer srcP.Unlock() + + if !srcP.Exportable { + return logical.ErrorResponse("key is not exportable"), nil + } + + retKeys := map[string]string{} + switch version { + case "": + for k, v := range srcP.Keys { + exportKey, err := getBYOKExportKey(dstP, srcP, &v, hash) + if err != nil { + return nil, err + } + retKeys[k] = exportKey + } + + default: + var versionValue int + if version == "latest" { + versionValue = srcP.LatestVersion + } else { + version = strings.TrimPrefix(version, "v") + versionValue, err = strconv.Atoi(version) + if err != nil { + return logical.ErrorResponse("invalid key version"), logical.ErrInvalidRequest + } + } + + if versionValue < srcP.MinDecryptionVersion { + return logical.ErrorResponse("version for export is below minimum decryption version"), logical.ErrInvalidRequest + } + key, ok := srcP.Keys[strconv.Itoa(versionValue)] + if !ok { + return logical.ErrorResponse("version does not exist or cannot be found"), logical.ErrInvalidRequest + } + + exportKey, err := getBYOKExportKey(dstP, srcP, &key, hash) + if err != nil { + return nil, err + } + + retKeys[strconv.Itoa(versionValue)] = exportKey + } + + resp := &logical.Response{ + Data: map[string]interface{}{ + "name": srcP.Name, + "type": srcP.Type.String(), + "keys": retKeys, + }, + } + + return resp, nil +} + +func getBYOKExportKey(dstP *keysutil.Policy, srcP *keysutil.Policy, key *keysutil.KeyEntry, hash string) (string, error) { + if dstP == nil || srcP == nil { + return "", errors.New("nil policy provided") + } + + var targetKey interface{} + switch srcP.Type { + case keysutil.KeyType_AES128_GCM96, keysutil.KeyType_AES256_GCM96, keysutil.KeyType_ChaCha20_Poly1305, keysutil.KeyType_HMAC: + targetKey = key.Key + case keysutil.KeyType_RSA2048, keysutil.KeyType_RSA3072, keysutil.KeyType_RSA4096: + targetKey = key.RSAKey + case keysutil.KeyType_ECDSA_P256, keysutil.KeyType_ECDSA_P384, keysutil.KeyType_ECDSA_P521: + var curve elliptic.Curve + switch srcP.Type { + case keysutil.KeyType_ECDSA_P384: + curve = elliptic.P384() + case keysutil.KeyType_ECDSA_P521: + curve = elliptic.P521() + default: + curve = elliptic.P256() + } + pubKey := ecdsa.PublicKey{ + Curve: curve, + X: key.EC_X, + Y: key.EC_Y, + } + targetKey = &ecdsa.PrivateKey{ + PublicKey: pubKey, + D: key.EC_D, + } + case keysutil.KeyType_ED25519: + targetKey = ed25519.PrivateKey(key.Key) + default: + return "", fmt.Errorf("unable to export to unknown key type: %v", srcP.Type) + } + + hasher, err := parseHashFn(hash) + if err != nil { + return "", err + } + + return dstP.WrapKey(0, targetKey, srcP.Type, hasher) +} + +const pathBYOKExportHelpSyn = `Securely export named encryption or signing key` + +const pathBYOKExportHelpDesc = ` +This path is used to export the named keys that are configured as +exportable. + +Unlike the regular /export/:name[/:version] paths, this path uses +the same encryption specification /import, allowing secure migration +of keys between clusters to enable workloads to communicate between +them. + +Presently this only works for RSA destination keys. +` diff --git a/builtin/logical/transit/path_byok_test.go b/builtin/logical/transit/path_byok_test.go new file mode 100644 index 000000000..7fc0c9946 --- /dev/null +++ b/builtin/logical/transit/path_byok_test.go @@ -0,0 +1,225 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package transit + +import ( + "context" + "testing" + + "github.com/hashicorp/vault/sdk/logical" +) + +func TestTransit_BYOKExportImport(t *testing.T) { + // Test encryption/decryption after a restore for supported keys + testBYOKExportImport(t, "aes128-gcm96", "encrypt-decrypt") + testBYOKExportImport(t, "aes256-gcm96", "encrypt-decrypt") + testBYOKExportImport(t, "chacha20-poly1305", "encrypt-decrypt") + testBYOKExportImport(t, "rsa-2048", "encrypt-decrypt") + testBYOKExportImport(t, "rsa-3072", "encrypt-decrypt") + testBYOKExportImport(t, "rsa-4096", "encrypt-decrypt") + + // Test signing/verification after a restore for supported keys + testBYOKExportImport(t, "ecdsa-p256", "sign-verify") + testBYOKExportImport(t, "ecdsa-p384", "sign-verify") + testBYOKExportImport(t, "ecdsa-p521", "sign-verify") + testBYOKExportImport(t, "ed25519", "sign-verify") + testBYOKExportImport(t, "rsa-2048", "sign-verify") + testBYOKExportImport(t, "rsa-3072", "sign-verify") + testBYOKExportImport(t, "rsa-4096", "sign-verify") + + // Unlike backup, we don't support importing HMAC keys here. +} + +func testBYOKExportImport(t *testing.T, keyType, feature string) { + var resp *logical.Response + var err error + + b, s := createBackendWithStorage(t) + + // Create a key + keyReq := &logical.Request{ + Path: "keys/test-source", + Operation: logical.UpdateOperation, + Storage: s, + Data: map[string]interface{}{ + "type": keyType, + "exportable": true, + }, + } + resp, err = b.HandleRequest(context.Background(), keyReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("resp: %#v\nerr: %v", resp, err) + } + + // Read the wrapping key. + wrapKeyReq := &logical.Request{ + Path: "wrapping_key", + Operation: logical.ReadOperation, + Storage: s, + } + resp, err = b.HandleRequest(context.Background(), wrapKeyReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("resp: %#v\nerr: %v", resp, err) + } + + // Import the wrapping key. + wrapKeyImportReq := &logical.Request{ + Path: "keys/wrapper/import", + Operation: logical.UpdateOperation, + Storage: s, + Data: map[string]interface{}{ + "public_key": resp.Data["public_key"], + "type": "rsa-4096", + }, + } + resp, err = b.HandleRequest(context.Background(), wrapKeyImportReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("resp: %#v\nerr: %v", resp, err) + } + + // Export the key + backupReq := &logical.Request{ + Path: "byok-export/wrapper/test-source", + Operation: logical.ReadOperation, + Storage: s, + } + resp, err = b.HandleRequest(context.Background(), backupReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("resp: %#v\nerr: %v", resp, err) + } + keys := resp.Data["keys"].(map[string]string) + + // Import the key to a new name. + restoreReq := &logical.Request{ + Path: "keys/test/import", + Operation: logical.UpdateOperation, + Storage: s, + Data: map[string]interface{}{ + "ciphertext": keys["1"], + "type": keyType, + }, + } + resp, err = b.HandleRequest(context.Background(), restoreReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("resp: %#v\nerr: %v", resp, err) + } + + plaintextB64 := "dGhlIHF1aWNrIGJyb3duIGZveA==" // "the quick brown fox" + // Perform encryption, signing or hmac-ing based on the set 'feature' + var encryptReq, signReq, hmacReq *logical.Request + var ciphertext, signature, hmac string + switch feature { + case "encrypt-decrypt": + encryptReq = &logical.Request{ + Path: "encrypt/test-source", + Operation: logical.UpdateOperation, + Storage: s, + Data: map[string]interface{}{ + "plaintext": plaintextB64, + }, + } + resp, err = b.HandleRequest(context.Background(), encryptReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("resp: %#v\nerr: %v", resp, err) + } + ciphertext = resp.Data["ciphertext"].(string) + + case "sign-verify": + signReq = &logical.Request{ + Path: "sign/test-source", + Operation: logical.UpdateOperation, + Storage: s, + Data: map[string]interface{}{ + "input": plaintextB64, + }, + } + resp, err = b.HandleRequest(context.Background(), signReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("resp: %#v\nerr: %v", resp, err) + } + signature = resp.Data["signature"].(string) + + case "hmac-verify": + hmacReq = &logical.Request{ + Path: "hmac/test-source", + Operation: logical.UpdateOperation, + Storage: s, + Data: map[string]interface{}{ + "input": plaintextB64, + }, + } + resp, err = b.HandleRequest(context.Background(), hmacReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("resp: %#v\nerr: %v", resp, err) + } + hmac = resp.Data["hmac"].(string) + } + + // validationFunc verifies the ciphertext, signature or hmac based on the + // set 'feature' + validationFunc := func(keyName string) { + var decryptReq *logical.Request + var verifyReq *logical.Request + switch feature { + case "encrypt-decrypt": + decryptReq = &logical.Request{ + Path: "decrypt/" + keyName, + Operation: logical.UpdateOperation, + Storage: s, + Data: map[string]interface{}{ + "ciphertext": ciphertext, + }, + } + resp, err = b.HandleRequest(context.Background(), decryptReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("resp: %#v\nerr: %v", resp, err) + } + + if resp.Data["plaintext"].(string) != plaintextB64 { + t.Fatalf("bad: plaintext; expected: %q, actual: %q", plaintextB64, resp.Data["plaintext"].(string)) + } + case "sign-verify": + verifyReq = &logical.Request{ + Path: "verify/" + keyName, + Operation: logical.UpdateOperation, + Storage: s, + Data: map[string]interface{}{ + "signature": signature, + "input": plaintextB64, + }, + } + resp, err = b.HandleRequest(context.Background(), verifyReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("resp: %#v\nerr: %v", resp, err) + } + if resp.Data["valid"].(bool) != true { + t.Fatalf("bad: signature verification failed for key type %q", keyType) + } + + case "hmac-verify": + verifyReq = &logical.Request{ + Path: "verify/" + keyName, + Operation: logical.UpdateOperation, + Storage: s, + Data: map[string]interface{}{ + "hmac": hmac, + "input": plaintextB64, + }, + } + resp, err = b.HandleRequest(context.Background(), verifyReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("resp: %#v\nerr: %v", resp, err) + } + if resp.Data["valid"].(bool) != true { + t.Fatalf("bad: HMAC verification failed for key type %q", keyType) + } + } + } + + // Ensure that the restored key is functional + validationFunc("test") + + // Ensure the original key is functional + validationFunc("test-source") +} diff --git a/changelog/20736.txt b/changelog/20736.txt new file mode 100644 index 000000000..1c4c3d4d2 --- /dev/null +++ b/changelog/20736.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/transit: Support BYOK-encrypted export of keys to securely allow synchronizing specific keys and version across clusters. +``` diff --git a/go.mod b/go.mod index f9cc2dfe2..25bf319d5 100644 --- a/go.mod +++ b/go.mod @@ -69,7 +69,7 @@ require ( github.com/google/go-cmp v0.5.9 github.com/google/go-github v17.0.0+incompatible github.com/google/go-metrics-stackdriver v0.2.0 - github.com/google/tink/go v1.6.1 + github.com/google/tink/go v1.7.0 github.com/hashicorp/cap v0.3.0 github.com/hashicorp/consul-template v0.32.0 github.com/hashicorp/consul/api v1.20.0 diff --git a/go.sum b/go.sum index 4e6d36abc..4d628a9b6 100644 --- a/go.sum +++ b/go.sum @@ -739,6 +739,7 @@ github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5 github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg= github.com/armon/go-metrics v0.3.0/go.mod h1:zXjbSimjXTd7vOpY8B0/2LpvNvDoXBuplAD+gJD3GYs= +github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= @@ -755,7 +756,7 @@ github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo github.com/aws/aws-sdk-go v1.25.41/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.34.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48= -github.com/aws/aws-sdk-go v1.36.29/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.43.9/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go v1.43.16/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go v1.44.268 h1:WoK20tlAvsvQzTcE6TajoprbXmTbcud6MjhErL4P/38= github.com/aws/aws-sdk-go v1.44.268/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= @@ -1614,8 +1615,8 @@ github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc= github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/google/tink/go v1.6.1 h1:t7JHqO8Ath2w2ig5vjwQYJzhGEZymedQc90lQXUBa4I= -github.com/google/tink/go v1.6.1/go.mod h1:IGW53kTgag+st5yPhKKwJ6u2l+SSp5/v9XF7spovjlY= +github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w= +github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -1752,6 +1753,7 @@ github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHh github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.4.3/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= github.com/hashicorp/go-plugin v1.4.8/go.mod h1:viDMjcLJuDui6pXb8U4HVfb8AamCWhHGUjr2IrTF67s= github.com/hashicorp/go-plugin v1.4.9 h1:ESiK220/qE0aGxWdzKIvRH69iLiuN/PjoLTm69RoWtU= github.com/hashicorp/go-plugin v1.4.9/go.mod h1:viDMjcLJuDui6pXb8U4HVfb8AamCWhHGUjr2IrTF67s= @@ -1776,6 +1778,7 @@ github.com/hashicorp/go-secure-stdlib/gatedwriter v0.1.1 h1:9um9R8i0+HbRHS9d64kd github.com/hashicorp/go-secure-stdlib/gatedwriter v0.1.1/go.mod h1:6RoRTSMDK2H/rKh3P/JIsk1tK8aatKTt3JyvIopi3GQ= github.com/hashicorp/go-secure-stdlib/kv-builder v0.1.2 h1:NS6BHieb/pDfx3M9jDdaPpGyyVp+aD4A3DjX3dgRmzs= github.com/hashicorp/go-secure-stdlib/kv-builder v0.1.2/go.mod h1:rf5JPE13wi+NwjgsmGkbg4b2CgHq8v7Htn/F0nDe/hg= +github.com/hashicorp/go-secure-stdlib/mlock v0.1.1/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I= github.com/hashicorp/go-secure-stdlib/mlock v0.1.2/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I= github.com/hashicorp/go-secure-stdlib/mlock v0.1.3 h1:kH3Rhiht36xhAfhuHyWJDgdXXEx9IIZhDGRk24CDhzg= github.com/hashicorp/go-secure-stdlib/mlock v0.1.3/go.mod h1:ov1Q0oEDjC3+A4BwsG2YdKltrmEw8sf9Pau4V9JQ4Vo= @@ -1808,6 +1811,7 @@ github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= @@ -2228,6 +2232,7 @@ github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= @@ -2428,6 +2433,7 @@ github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rK github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pierrec/lz4 v2.5.2+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= @@ -2941,6 +2947,7 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220313003712-b769efc7c000/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -3509,7 +3516,6 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.32.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= diff --git a/sdk/go.mod b/sdk/go.mod index 96ab9f03f..c4a2ec99f 100644 --- a/sdk/go.mod +++ b/sdk/go.mod @@ -14,6 +14,7 @@ require ( github.com/go-test/deep v1.1.0 github.com/golang/protobuf v1.5.2 github.com/golang/snappy v0.0.4 + github.com/google/tink/go v1.7.0 github.com/hashicorp/errwrap v1.1.0 github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-hclog v1.4.0 diff --git a/sdk/go.sum b/sdk/go.sum index 8929d377b..a6b0f59b0 100644 --- a/sdk/go.sum +++ b/sdk/go.sum @@ -76,6 +76,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w= +github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/sdk/helper/keysutil/policy.go b/sdk/helper/keysutil/policy.go index 36d5a9b26..99eadeac1 100644 --- a/sdk/helper/keysutil/policy.go +++ b/sdk/helper/keysutil/policy.go @@ -22,6 +22,7 @@ import ( "encoding/pem" "errors" "fmt" + "hash" "io" "math/big" "path" @@ -41,6 +42,8 @@ import ( "github.com/hashicorp/vault/sdk/helper/jsonutil" "github.com/hashicorp/vault/sdk/helper/kdf" "github.com/hashicorp/vault/sdk/logical" + + "github.com/google/tink/go/kwp/subtle" ) // Careful with iota; don't put anything before it in this const block because @@ -2295,3 +2298,88 @@ func (ke *KeyEntry) parseFromKey(PolKeyType KeyType, parsedKey any) error { return nil } + +func (p *Policy) WrapKey(ver int, targetKey interface{}, targetKeyType KeyType, hash hash.Hash) (string, error) { + if !p.Type.SigningSupported() { + return "", fmt.Errorf("message signing not supported for key type %v", p.Type) + } + + switch { + case ver == 0: + ver = p.LatestVersion + case ver < 0: + return "", errutil.UserError{Err: "requested version for key wrapping is negative"} + case ver > p.LatestVersion: + return "", errutil.UserError{Err: "requested version for key wrapping is higher than the latest key version"} + case p.MinEncryptionVersion > 0 && ver < p.MinEncryptionVersion: + return "", errutil.UserError{Err: "requested version for key wrapping is less than the minimum encryption key version"} + } + + keyEntry, err := p.safeGetKeyEntry(ver) + if err != nil { + return "", err + } + + return keyEntry.WrapKey(targetKey, targetKeyType, hash) +} + +func (ke *KeyEntry) WrapKey(targetKey interface{}, targetKeyType KeyType, hash hash.Hash) (string, error) { + // Presently this method implements a CKM_RSA_AES_KEY_WRAP-compatible + // wrapping interface and only works on RSA keyEntries as a result. + if ke.RSAPublicKey == nil { + return "", fmt.Errorf("unsupported key type in use; must be a rsa key") + } + + var preppedTargetKey []byte + switch targetKeyType { + case KeyType_AES128_GCM96, KeyType_AES256_GCM96, KeyType_ChaCha20_Poly1305, KeyType_HMAC: + var ok bool + preppedTargetKey, ok = targetKey.([]byte) + if !ok { + return "", fmt.Errorf("failed to wrap target key for import: symmetric key not provided in byte format (%T)", targetKey) + } + default: + var err error + preppedTargetKey, err = x509.MarshalPKCS8PrivateKey(targetKey) + if err != nil { + return "", fmt.Errorf("failed to wrap target key for import: %w", err) + } + } + + result, err := wrapTargetPKCS8ForImport(ke.RSAPublicKey, preppedTargetKey, hash) + if err != nil { + return result, fmt.Errorf("failed to wrap target key for import: %w", err) + } + + return result, nil +} + +func wrapTargetPKCS8ForImport(wrappingKey *rsa.PublicKey, preppedTargetKey []byte, hash hash.Hash) (string, error) { + // Generate an ephemeral AES-256 key + ephKey, err := uuid.GenerateRandomBytes(32) + if err != nil { + return "", fmt.Errorf("failed to generate an ephemeral AES wrapping key: %w", err) + } + + // Wrap ephemeral AES key with public wrapping key + ephKeyWrapped, err := rsa.EncryptOAEP(hash, rand.Reader, wrappingKey, ephKey, []byte{} /* label */) + if err != nil { + return "", fmt.Errorf("failed to encrypt ephemeral wrapping key with public key: %w", err) + } + + // Create KWP instance for wrapping target key + kwp, err := subtle.NewKWP(ephKey) + if err != nil { + return "", fmt.Errorf("failed to generate new KWP from AES key: %w", err) + } + + // Wrap target key with KWP + targetKeyWrapped, err := kwp.Wrap(preppedTargetKey) + if err != nil { + return "", fmt.Errorf("failed to wrap target key with KWP: %w", err) + } + + // Combined wrapped keys into a single blob and base64 encode + wrappedKeys := append(ephKeyWrapped, targetKeyWrapped...) + return base64.StdEncoding.EncodeToString(wrappedKeys), nil +} diff --git a/website/content/api-docs/secret/transit.mdx b/website/content/api-docs/secret/transit.mdx index bfff32770..1b7a9e201 100644 --- a/website/content/api-docs/secret/transit.mdx +++ b/website/content/api-docs/secret/transit.mdx @@ -510,6 +510,58 @@ $ curl \ http://127.0.0.1:8200/v1/transit/keys/my-key/rotate ``` +## Securely Export Key + +This endpoint returns a wrapped copy of the `source` key, protected by the +`destination` key using BYOK method accepted by the +`/transit/keys/:name/import` API. This allows an operator using two separate +Vault instances to secure established shared key material, withing exposing +either key in plaintext and needing to run a manual BYOK import using the +CLI helper utility. + +| Method | Path | +| :----- | :----------------------------------------------------- | +| `GET` | `/transit/byok-export/:destination/:source(/:version)` | + +### Parameters + +- `destination` `(string: )` - Specifies the name of the key to + encrypt the `source` key to: this is usually another mount or cluster's + wrapping key (from `/transit/wrapping_key`). This is specified as part of + the URL. + +~> Note: This destination key type must be an RSA key type. + +- `source` `(string: )` - Specifies the source key to encrypt, to + copy (encrypted) to another cluster. This is specified as part of the URL. + +- `version` `(string: "")` - Specifies the version of the source key to + wrap. If omitted, 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. + +### Sample Request + +```shell-session +$ curl \ + --header "X-Vault-Token: ..." \ + http://127.0.0.1:8200/v1/transit/byok-export/wrapping-key/to-be-shared-key/1 +``` + +### Sample Response + +```json +{ + "data": { + "name": "foo", + "keys": { + "1": "H/0T+CKQ8I82KJWpPk ... additional response elided ...", + } + } +} +``` + + ## Export Key This endpoint returns the named key. The `keys` object shows the value of the