diff --git a/changelog/30673.txt b/changelog/30673.txt new file mode 100644 index 0000000000..9fc1085b7f --- /dev/null +++ b/changelog/30673.txt @@ -0,0 +1,3 @@ +```release-note:improvement +plugins: Support registration of CE plugins with extracted artifact directory. +``` diff --git a/command/plugin_register.go b/command/plugin_register.go index d124b38b89..d8f2447a07 100644 --- a/command/plugin_register.go +++ b/command/plugin_register.go @@ -17,6 +17,12 @@ var ( _ cli.CommandAutocomplete = (*PluginRegisterCommand)(nil) ) +func NewPluginRegisterCommand(baseCommand *BaseCommand) cli.Command { + return &PluginRegisterCommand{ + BaseCommand: baseCommand, + } +} + type PluginRegisterCommand struct { *BaseCommand @@ -144,8 +150,8 @@ func (c *PluginRegisterCommand) Run(args []string) int { case len(args) > 2: c.UI.Error(fmt.Sprintf("Too many arguments (expected 1 or 2, got %d)", len(args))) return 1 - case c.flagSHA256 == "": - c.UI.Error("SHA256 is required for all plugins, please provide -sha256") + case c.flagSHA256 == "" && c.flagVersion == "": + c.UI.Error("One of -sha256 or -version is required. If registering with binary, please provide at least -sha256 (-version optional). If registering with extracted artifact directory, please provide -version only.") return 1 // These cases should come after invalid cases have been checked diff --git a/command/plugin_register_stubs_oss.go b/command/plugin_register_stubs_oss.go deleted file mode 100644 index dbcfb4a447..0000000000 --- a/command/plugin_register_stubs_oss.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -//go:build !enterprise - -package command - -import "github.com/hashicorp/cli" - -func NewPluginRegisterCommand(baseCommand *BaseCommand) cli.Command { - return &PluginRegisterCommand{ - BaseCommand: baseCommand, - } -} diff --git a/go.mod b/go.mod index 30f37bf5c0..fce3fbece7 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,8 @@ require ( github.com/Azure/azure-storage-blob-go v0.15.0 github.com/Azure/go-autorest/autorest v0.11.29 github.com/Azure/go-autorest/autorest/adal v0.9.23 - github.com/ProtonMail/go-crypto v1.1.5 + github.com/ProtonMail/go-crypto v1.2.0 + github.com/ProtonMail/gopenpgp/v3 v3.2.1 github.com/SAP/go-hdb v1.10.1 github.com/Sectorbob/mlab-ns2 v0.0.0-20171030222938-d3aa0c295a8a github.com/aerospike/aerospike-client-go/v5 v5.6.0 @@ -408,7 +409,7 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/google/uuid v1.6.0 github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gophercloud/gophercloud v0.1.0 // indirect diff --git a/go.sum b/go.sum index 77af4aaa57..3c2f0be1e2 100644 --- a/go.sum +++ b/go.sum @@ -753,8 +753,10 @@ github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb0 github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= -github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs= +github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/ProtonMail/gopenpgp/v3 v3.2.1 h1:ohRlKL5YwyIkN5kk7uBvijiMsyA57mK0yBEJg9xButU= +github.com/ProtonMail/gopenpgp/v3 v3.2.1/go.mod h1:x7RduTo/0n/2PjTFRoEHApaxye/8PFbhoCquwfYBUGM= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/SAP/go-hdb v1.10.1 h1:c9dGT5xHZNDwPL3NQcRpnNISn3MchwYaGoMZpCAllUs= diff --git a/sdk/database/dbplugin/v5/grpc_client.go b/sdk/database/dbplugin/v5/grpc_client.go index 438ab13f6b..cfa5f7659e 100644 --- a/sdk/database/dbplugin/v5/grpc_client.go +++ b/sdk/database/dbplugin/v5/grpc_client.go @@ -13,6 +13,7 @@ import ( "github.com/golang/protobuf/ptypes" "github.com/hashicorp/vault/sdk/database/dbplugin/v5/proto" + "github.com/hashicorp/vault/sdk/helper/consts" "github.com/hashicorp/vault/sdk/helper/pluginutil" "github.com/hashicorp/vault/sdk/logical" ) @@ -29,6 +30,9 @@ type gRPCClient struct { client proto.DatabaseClient versionClient logical.PluginVersionClient doneCtx context.Context + + // tier is the plugin tier + tier consts.PluginTier } func (c gRPCClient) PluginVersion() logical.PluginVersion { diff --git a/sdk/database/dbplugin/v5/plugin_client_oss.go b/sdk/database/dbplugin/v5/plugin_client_oss.go index 34097fc18c..bb1c97a10e 100644 --- a/sdk/database/dbplugin/v5/plugin_client_oss.go +++ b/sdk/database/dbplugin/v5/plugin_client_oss.go @@ -37,6 +37,7 @@ func NewPluginClient(ctx context.Context, sys pluginutil.RunnerUtil, config plug // order to enable multiplexing on multiplexed plugins c.client = proto.NewDatabaseClient(pluginClient.Conn()) c.versionClient = logical.NewPluginVersionClient(pluginClient.Conn()) + c.tier = config.Tier db = c default: diff --git a/sdk/database/dbplugin/v5/plugin_factory.go b/sdk/database/dbplugin/v5/plugin_factory.go index 210de35b02..49e2099231 100644 --- a/sdk/database/dbplugin/v5/plugin_factory.go +++ b/sdk/database/dbplugin/v5/plugin_factory.go @@ -59,8 +59,8 @@ func PluginFactoryVersion(ctx context.Context, pluginName string, pluginVersion IsMetadataMode: false, AutoMTLS: true, Wrapper: sys, + Tier: pluginRunner.Tier, } - config.EntUpdate(pluginRunner) // create a DatabasePluginClient instance db, err = NewPluginClient(ctx, sys, config) diff --git a/sdk/helper/consts/plugin_tiers.go b/sdk/helper/consts/plugin_tiers.go new file mode 100644 index 0000000000..7a756717db --- /dev/null +++ b/sdk/helper/consts/plugin_tiers.go @@ -0,0 +1,102 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package consts + +import ( + "encoding/json" + "fmt" +) + +var PluginTiers = []PluginTier{ + PluginTierUnknown, + PluginTierCommunity, + PluginTierPartner, + PluginTierOfficial, +} + +type PluginTier uint32 + +const ( + // PluginTierUnknown defines unknown plugin tier + // DO NOT change the order of the enum as it + // could cause the wrong plugin tier to be read + // from storage for a given underlying number + PluginTierUnknown PluginTier = iota + // PluginTierCommunity defines community plugin tier + // DO NOT change the order of the enum as it + // could cause the wrong plugin tier to be read + // from storage for a given underlying number + PluginTierCommunity + // PluginTierPartner defines partner plugin tier + // DO NOT change the order of the enum as it + // could cause the wrong plugin tier to be read + // from storage for a given underlying number + PluginTierPartner + // PluginTierOfficial defines enterprise plugin tier + // DO NOT change the order of the enum as it + // could cause the wrong plugin tier to be read + // from storage for a given underlying number + PluginTierOfficial +) + +func (p PluginTier) String() string { + switch p { + case PluginTierUnknown: + return "unknown" + case PluginTierCommunity: + return "community" + case PluginTierPartner: + return "partner" + case PluginTierOfficial: + return "official" + default: + return "unsupported" + } +} + +func ParsePluginTier(pluginTier string) (PluginTier, error) { + switch pluginTier { + case "unknown", "": + return PluginTierUnknown, nil + case "community": + return PluginTierCommunity, nil + case "partner": + return PluginTierPartner, nil + case "official": + return PluginTierOfficial, nil + default: + return PluginTierUnknown, fmt.Errorf("%q is not a supported plugin tier", pluginTier) + } +} + +// UnmarshalJSON implements json.Unmarshaler. It supports unmarshaling either a +// string or a uint32. All new serialization will be as a string, but we +// previously serialized as a uint32 so we need to support that for backwards +// compatibility. +func (p *PluginTier) UnmarshalJSON(data []byte) error { + var asString string + err := json.Unmarshal(data, &asString) + if err == nil { + *p, err = ParsePluginTier(asString) + return err + } + + var asUint32 uint32 + err = json.Unmarshal(data, &asUint32) + if err != nil { + return err + } + *p = PluginTier(asUint32) + switch *p { + case PluginTierUnknown, PluginTierCommunity, PluginTierPartner, PluginTierOfficial: + return nil + default: + return fmt.Errorf("%d is not a supported plugin tier", asUint32) + } +} + +// MarshalJSON implements json.Marshaler. +func (p PluginTier) MarshalJSON() ([]byte, error) { + return json.Marshal(p.String()) +} diff --git a/sdk/helper/consts/plugin_tiers_test.go b/sdk/helper/consts/plugin_tiers_test.go new file mode 100644 index 0000000000..2f4da9b8ec --- /dev/null +++ b/sdk/helper/consts/plugin_tiers_test.go @@ -0,0 +1,230 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package consts + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestParsePluginTier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pluginTier string + want PluginTier + wantErr bool + }{ + { + name: "unknown", + pluginTier: "unknown", + want: PluginTierUnknown, + }, + { + name: "empty unknown", + pluginTier: "", + want: PluginTierUnknown, + }, + { + name: "community", + pluginTier: "community", + want: PluginTierCommunity, + }, + { + name: "partner", + pluginTier: "partner", + want: PluginTierPartner, + }, + { + name: "official", + pluginTier: "official", + want: PluginTierOfficial, + }, + { + name: "unsupported", + pluginTier: "foo", + want: PluginTierUnknown, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParsePluginTier(tt.pluginTier) + if (err != nil) != tt.wantErr { + t.Fatalf("ParsePluginTier() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Fatalf("ParsePluginTier() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPluginTier_MarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + p PluginTier + want []byte + }{ + { + name: "unknown", + p: PluginTierUnknown, + want: []byte(`"unknown"`), + }, + { + name: "community", + p: PluginTierCommunity, + want: []byte(`"community"`), + }, + { + name: "partner", + p: PluginTierPartner, + want: []byte(`"partner"`), + }, + { + name: "offical", + p: PluginTierOfficial, + want: []byte(`"official"`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.p.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON() error = %v, want nil", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("MarshalJSON() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPluginTier_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantTier PluginTier + data []byte + wantErr bool + }{ + { + name: "unknown", + wantTier: PluginTierUnknown, + data: []byte(`"unknown"`), + }, + { + name: "community", + wantTier: PluginTierCommunity, + data: []byte(`"community"`), + }, + { + name: "partner", + wantTier: PluginTierPartner, + data: []byte(`"partner"`), + }, + { + name: "offical", + wantTier: PluginTierOfficial, + data: []byte(`"official"`), + }, + { + name: "unsupported", + wantTier: PluginTierUnknown, + data: []byte(`"foo"`), + wantErr: true, + }, + // The following test cases ensures new plugin tiers are added at the end of the enum list + // Inserting a new plugin tier in the middle of the enum list will fail + // New plugin tiers should be added at the end of the test case list + { + name: "0-unknown", + wantTier: PluginTierUnknown, + data: []byte(`0`), + }, + { + name: "1-community", + wantTier: PluginTierCommunity, + data: []byte(`1`), + }, + { + name: "2-partner", + wantTier: PluginTierPartner, + data: []byte(`2`), + }, + { + name: "3-official", + wantTier: PluginTierOfficial, + data: []byte(`3`), + }, + { + name: "tier number unsupported", + data: []byte(`2345678`), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tier PluginTier + err := tier.UnmarshalJSON(tt.data) + if (err != nil) != tt.wantErr { + t.Fatalf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && tier != tt.wantTier { + t.Fatalf("UnmarshalJSON() got = %v, want %v", tier, tt.wantTier) + } + }) + } +} + +// TestPluginTierJSONRoundTrip tests that PluginTier can be marshaled and unmarshaled +// to/from JSON in a round trip. +func TestPluginTierJSONRoundTrip(t *testing.T) { + type testTier struct { + PluginTier PluginTier `json:"plugin_tier"` + } + + for _, tier := range PluginTiers { + t.Run(tier.String(), func(t *testing.T) { + original := testTier{ + PluginTier: tier, + } + asBytes, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + + var roundTripped testTier + err = json.Unmarshal(asBytes, &roundTripped) + if err != nil { + t.Fatal(err) + } + + if original != roundTripped { + t.Fatalf("expected %v, got %v", original, roundTripped) + } + }) + } +} + +func TestUnknownTierExcludedWithOmitEmpty(t *testing.T) { + type testTierOmitEmpty struct { + Type PluginTier `json:"tier,omitempty"` + } + bytes, err := json.Marshal(testTierOmitEmpty{}) + if err != nil { + t.Fatal(err) + } + m := map[string]any{} + json.Unmarshal(bytes, &m) + if _, exists := m["tier"]; exists { + t.Fatal("tier should not be present") + } +} diff --git a/sdk/helper/pluginutil/run_config.go b/sdk/helper/pluginutil/run_config.go index 5e9da686d9..76e898e98e 100644 --- a/sdk/helper/pluginutil/run_config.go +++ b/sdk/helper/pluginutil/run_config.go @@ -30,7 +30,6 @@ const ( ) type PluginClientConfig struct { - EntPluginClientConfig Name string PluginType consts.PluginType Version string @@ -41,6 +40,7 @@ type PluginClientConfig struct { AutoMTLS bool MLock bool Wrapper RunnerUtil + Tier consts.PluginTier } type runConfig struct { diff --git a/sdk/helper/pluginutil/run_config_stubs_oss.go b/sdk/helper/pluginutil/run_config_stubs_oss.go deleted file mode 100644 index 0ec8f0a596..0000000000 --- a/sdk/helper/pluginutil/run_config_stubs_oss.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -//go:build !enterprise - -package pluginutil - -type EntPluginClientConfig struct{} - -func (p *PluginClientConfig) EntUpdate(_ *PluginRunner) { - // no-op -} diff --git a/sdk/helper/pluginutil/runner.go b/sdk/helper/pluginutil/runner.go index ecae61459b..8697de88a6 100644 --- a/sdk/helper/pluginutil/runner.go +++ b/sdk/helper/pluginutil/runner.go @@ -18,6 +18,13 @@ import ( "google.golang.org/grpc" ) +const ( + // ConfigPluginTier is the key for the plugin tier for Config of logical.BackendConfig + ConfigPluginTier = "plugin_tier" + // ConfigPluginVersion is the key for the plugin version for Config of logical.BackendConfig + ConfigPluginVersion = "plugin_version" +) + // ErrPluginNotFound is returned when a plugin does not have a pinned version. var ErrPinnedVersionNotFound = errors.New("pinned version not found") @@ -57,8 +64,6 @@ const MultiplexingCtxKey string = "multiplex_id" // PluginRunner defines the metadata needed to run a plugin securely with // go-plugin. type PluginRunner struct { - EntPluginRunner - Name string `json:"name" structs:"name"` Type consts.PluginType `json:"type" structs:"type"` Version string `json:"version" structs:"version"` @@ -69,6 +74,7 @@ type PluginRunner struct { Env []string `json:"env" structs:"env"` Sha256 []byte `json:"sha256" structs:"sha256"` Builtin bool `json:"builtin" structs:"builtin"` + Tier consts.PluginTier `json:"tier" structs:"tier"` BuiltinFactory func() (interface{}, error) `json:"-" structs:"-"` RuntimeConfig *prutil.PluginRuntimeConfig `json:"-" structs:"-"` Tmpdir string `json:"-" structs:"-"` diff --git a/sdk/helper/pluginutil/runner_stubs_oss.go b/sdk/helper/pluginutil/runner_stubs_oss.go deleted file mode 100644 index b5d390a44d..0000000000 --- a/sdk/helper/pluginutil/runner_stubs_oss.go +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -//go:build !enterprise - -package pluginutil - -type EntPluginRunner struct{} diff --git a/vault/auth.go b/vault/auth.go index 39dbc673ca..5b2510c12b 100644 --- a/vault/auth.go +++ b/vault/auth.go @@ -987,7 +987,7 @@ func (c *Core) newCredentialBackend(ctx context.Context, entry *MountEntry, sysV factory = wrapFactoryCheckPerms(c, plugin.Factory) } - entSetExternalPluginConfig(plug, conf) + setExternalPluginConfig(plug, conf) } // Set up conf to pass in plugin_name diff --git a/vault/logical_system.go b/vault/logical_system.go index fbc2aa2f80..d2c50ff3d3 100644 --- a/vault/logical_system.go +++ b/vault/logical_system.go @@ -541,9 +541,10 @@ func (b *SystemBackend) handlePluginCatalogUpdate(ctx context.Context, _ *logica sha256 := d.Get("sha256").(string) if sha256 == "" { sha256 = d.Get("sha_256").(string) - if resp := validateSHA256(sha256); resp.IsError() { - return resp, nil - } + } + + if sha256 == "" && pluginVersion == "" { + return logical.ErrorResponse("must provide at least one of sha256 or version: use sha256 for binary registration (version optional) or version only for artifact registration"), nil } if resp := validateSha256IsEmptyForEntPluginVersion(pluginVersion, sha256); resp.IsError() { diff --git a/vault/logical_system_plugins_stubs_oss.go b/vault/logical_system_plugins_stubs_oss.go index d135909892..3833f807f8 100644 --- a/vault/logical_system_plugins_stubs_oss.go +++ b/vault/logical_system_plugins_stubs_oss.go @@ -9,13 +9,6 @@ import ( "github.com/hashicorp/vault/sdk/logical" ) -func validateSHA256(sha256 string) *logical.Response { - if sha256 == "" { - return logical.ErrorResponse("missing SHA-256 value") - } - return nil -} - func validateSha256IsEmptyForEntPluginVersion(pluginVersion string, sha256 string) *logical.Response { return nil } diff --git a/vault/mount.go b/vault/mount.go index 2235fc303b..7bd5aeb60a 100644 --- a/vault/mount.go +++ b/vault/mount.go @@ -1717,7 +1717,7 @@ func (c *Core) newLogicalBackend(ctx context.Context, entry *MountEntry, sysView factory = wrapFactoryCheckPerms(c, factory) } - entSetExternalPluginConfig(plug, conf) + setExternalPluginConfig(plug, conf) } // Set up conf to pass in plugin_name diff --git a/vault/mount_util.go b/vault/mount_util.go index f71e98366a..6917afa820 100644 --- a/vault/mount_util.go +++ b/vault/mount_util.go @@ -10,7 +10,6 @@ import ( "path" "github.com/hashicorp/vault/helper/namespace" - "github.com/hashicorp/vault/sdk/helper/pluginutil" "github.com/hashicorp/vault/sdk/logical" ) @@ -77,8 +76,3 @@ func (c *Core) mountEntrySysView(entry *MountEntry) extendedSystemView { func (c *Core) entBuiltinPluginMetrics(ctx context.Context, entry *MountEntry, val float32) error { return nil } - -// entSetExternalPluginConfig (Vault Community edition) makes no changes to config for external plugins. -func entSetExternalPluginConfig(_ *pluginutil.PluginRunner, _ map[string]string) { - // No-op -} diff --git a/vault/mount_util_shared.go b/vault/mount_util_shared.go index 57a1c88023..6af58ee16a 100644 --- a/vault/mount_util_shared.go +++ b/vault/mount_util_shared.go @@ -4,6 +4,7 @@ package vault import ( + "github.com/hashicorp/vault/sdk/helper/pluginutil" "github.com/hashicorp/vault/sdk/logical" ) @@ -29,3 +30,8 @@ func collectBackendSpecialPaths(backend logical.Backend, viewPath string, access return ret } + +// setExternalPluginConfig sets key value pairs to config based on pluginutil.PluginRunner +func setExternalPluginConfig(runner *pluginutil.PluginRunner, config map[string]string) { + config[pluginutil.ConfigPluginTier] = runner.Tier.String() +} diff --git a/vault/plugincatalog/plugin_artifact.go b/vault/plugincatalog/plugin_artifact.go new file mode 100644 index 0000000000..0f53429aaa --- /dev/null +++ b/vault/plugincatalog/plugin_artifact.go @@ -0,0 +1,252 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package plugincatalog + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path" + "runtime" + "strings" + + "github.com/ProtonMail/gopenpgp/v3/crypto" +) + +// hashiCorpPGPPubKey is HashiCorp's PGP public key at https://www.hashicorp.com/.well-known/pgp-key.txt. +// This key is used to verify the authenticity of HashiCorp plugins. +const hashiCorpPGPPubKey = ` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +ZF5q4h4I33PSGDdSvGXn9UMY5Isjpg== +=7pIB +-----END PGP PUBLIC KEY BLOCK----- +` + +var defaultPGPPubKey = hashiCorpPGPPubKey + +const ( + extractedArtifactDirFmt = "%s_%s_%s_%s" // vault-plugin-database-oracle_1.2.3+ent_linux_amd64 + metadataFile = "metadata.json" + metadataSig = "metadata.json.sig" +) + +var ( + errExtractedArtifactDirNotFound = errors.New("extracted artifact directory not found") + errReadMetadata = errors.New("failed to read metadata") + errReadMetadataSig = errors.New("failed to read metadata signature") + errReadPlugin = errors.New("failed to read plugin binary") + errReadPluginMetadata = errors.New("failed to read plugin metadata") + errReadPluginPGPSig = errors.New("failed to read plugin binary PGP signature") + errVerifyMetadataSig = errors.New("failed to verify metadata detached signature") + errVerifyPluginSig = errors.New("failed to verify plugin binary PGP signature") +) + +func getExtractedArtifactDir(pluginName, pluginVersion string) string { + if strings.HasPrefix(pluginVersion, "v") { + pluginVersion = pluginVersion[1:] + } + + return fmt.Sprintf(extractedArtifactDirFmt, pluginName, pluginVersion, runtime.GOOS, runtime.GOARCH) +} + +func verifyPlugin(pluginDir, pluginName, pubKey string) (*pluginMetadata, error) { + // verify the extracted plugin artifact directory structure and each file inside + // vault-plugin-secrets-example_1.2.3+ent_darwin_arm64/EULA.txt (optional) + // vault-plugin-secrets-example_1.2.3+ent_darwin_arm64/TermsOfEvaluation.txt (optional) + // vault-plugin-secrets-example_1.2.3+ent_darwin_arm64/LICENSE (optional) + // vault-plugin-secrets-example_1.2.3+ent_darwin_arm64/vault-plugin-secrets-example + // vault-plugin-secrets-example_1.2.3+ent_darwin_arm64/metadata.json + // vault-plugin-secrets-example_1.2.3+ent_darwin_arm64/metadata.json.sig + + if _, err := os.Stat(pluginDir); os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %w", errExtractedArtifactDirNotFound, err) + } + + pgp := crypto.PGP() + key, err := crypto.NewKeyFromArmored(pubKey) + if err != nil { + return nil, err + } + + verifier, err := pgp.Verify().VerificationKey(key).New() + if err != nil { + return nil, err + } + + // verify metadata.json is untampered + metadataPath := path.Join(pluginDir, "metadata.json") + metadataBytes, err := os.ReadFile(metadataPath) + if err != nil { + return nil, fmt.Errorf("%w: %w", errReadMetadata, err) + } + + metadataSigBytes, err := os.ReadFile(path.Join(pluginDir, "metadata.json.sig")) + if err != nil { + return nil, fmt.Errorf("%w: %w", errReadMetadataSig, err) + } + + verifyResult, err := verifier.VerifyDetached(metadataBytes, metadataSigBytes, crypto.Armor) + if err != nil { + return nil, fmt.Errorf("%w: %w", errVerifyMetadataSig, err) + } + if sigErr := verifyResult.SignatureError(); sigErr != nil { + return nil, fmt.Errorf("%w: %w", errVerifyMetadataSig, sigErr) + } + + // verify plugin binary is untampred + pluginBytes, err := os.ReadFile(path.Join(pluginDir, pluginName)) + if err != nil { + return nil, fmt.Errorf("%w: %w", errReadPlugin, err) + } + + metadata, err := readPluginMetadata(metadataPath) + if err != nil { + return nil, fmt.Errorf("%w: %w", errReadPluginMetadata, err) + } + + verifyResult, err = verifier.VerifyDetached(pluginBytes, []byte(metadata.Plugin.PGPSig), crypto.Armor) + if err != nil { + return nil, fmt.Errorf("%w: %w", errVerifyPluginSig, err) + } + if sigErr := verifyResult.SignatureError(); sigErr != nil { + return nil, fmt.Errorf("%w: %w", errVerifyPluginSig, sigErr) + } + + return metadata, nil +} + +func pluginSHA256Sum(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + hash := sha256.New() + if _, err = io.Copy(hash, file); err != nil { + return nil, err + } + + return hash.Sum(nil), nil +} diff --git a/vault/plugincatalog/plugin_artifact_test.go b/vault/plugincatalog/plugin_artifact_test.go new file mode 100644 index 0000000000..6fe110de0f --- /dev/null +++ b/vault/plugincatalog/plugin_artifact_test.go @@ -0,0 +1,190 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package plugincatalog + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "runtime" + "testing" + + "github.com/ProtonMail/gopenpgp/v3/crypto" + "github.com/hashicorp/vault/sdk/helper/consts" + "github.com/stretchr/testify/assert" +) + +// Test_verifyPlugin tests the verifyPlugin function. +func Test_verifyPlugin(t *testing.T) { + t.Parallel() + + type args struct { + pluginName string + pluginVersion string + pluginType consts.PluginType + } + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "success", + args: args{ + pluginName: "vault-plugin-auth-example", + pluginVersion: "0.1.1+ent", + pluginType: consts.PluginTypeCredential, + }, + expectedErr: nil, + }, + { + name: "missing metadata", + args: args{ + pluginName: "vault-plugin-auth-example", + pluginVersion: "0.1.2+ent", + pluginType: consts.PluginTypeCredential, + }, + expectedErr: errReadMetadata, + }, + { + name: "missing metadata signature", + args: args{ + pluginName: "vault-plugin-auth-example", + pluginVersion: "0.1.3+ent", + pluginType: consts.PluginTypeCredential, + }, + expectedErr: errReadMetadataSig, + }, + { + name: "bad metadata signature verify", + args: args{ + pluginName: "vault-plugin-secret-example", + pluginVersion: "0.1.4+ent", + pluginType: consts.PluginTypeSecrets, + }, + expectedErr: errVerifyMetadataSig, + }, + { + name: "missing plugin binary", + args: args{ + pluginName: "vault-plugin-database-example", + pluginVersion: "0.1.5+ent", + pluginType: consts.PluginTypeDatabase, + }, + expectedErr: errReadPlugin, + }, + { + name: "bad plugin binary signature verify", + args: args{ + pluginName: "vault-plugin-database-example", + pluginVersion: "0.1.6+ent", + pluginType: consts.PluginTypeDatabase, + }, + expectedErr: errVerifyPluginSig, + }, + { + name: "bad extracted artifact directory", + args: args{ + pluginName: "vault-plugin-database-example", + pluginVersion: "0.1.6+ent", + pluginType: consts.PluginTypeDatabase, + }, + expectedErr: errExtractedArtifactDirNotFound, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + privKey, pubKeyArmored := generatePGPKeyPair(t) + + contents := generatePluginArtifactContents(t, tt.args.pluginName, + tt.args.pluginVersion, tt.args.pluginType, !errors.Is(tt.expectedErr, errReadPluginPGPSig), privKey) + + actualExtractedArtifactDir := getExtractedArtifactDir(tt.args.pluginName, tt.args.pluginVersion) + switch { + case tt.expectedErr == nil: + case errors.Is(tt.expectedErr, errReadPluginPGPSig): + // no-op + case errors.Is(tt.expectedErr, errReadMetadata): + delete(contents, metadataFile) + case errors.Is(tt.expectedErr, errReadMetadataSig): + delete(contents, metadataSig) + case errors.Is(tt.expectedErr, errVerifyMetadataSig): + contents[metadataFile] = []byte(`{"will not" : "match signature"}`) + case errors.Is(tt.expectedErr, errReadPlugin): + delete(contents, tt.args.pluginName) + case errors.Is(tt.expectedErr, errVerifyPluginSig): + contents[tt.args.pluginName] = []byte("will not match signature") + case errors.Is(tt.expectedErr, errExtractedArtifactDirNotFound): + actualExtractedArtifactDir += "not_found" + default: + t.Fatalf("unexpected error: %v", tt.expectedErr) + } + + tempDir := t.TempDir() + actualPluginDir := filepath.Join(tempDir, actualExtractedArtifactDir) + err := os.MkdirAll(actualPluginDir, 0o755) + assert.NoError(t, err, "expected successful create extracted plugin directory") + + // Write the files to the extracted plugin directory + for name, content := range contents { + err = os.WriteFile(filepath.Join(actualPluginDir, name), content, 0o644) + assert.NoError(t, err, "expected successful file write") + } + var metadata *pluginMetadata + metadata, err = verifyPlugin(path.Join(tempDir, getExtractedArtifactDir(tt.args.pluginName, tt.args.pluginVersion)), + tt.args.pluginName, pubKeyArmored) + assert.ErrorIs(t, err, tt.expectedErr, "expected verify plugin error to match") + + if tt.expectedErr == nil { + assert.NotNil(t, metadata) + assert.Equal(t, tt.args.pluginName, metadata.Plugin.Name) + assert.Equal(t, tt.args.pluginVersion, metadata.Plugin.Version) + } + }) + } +} + +// Test_getExtractedArtifactDir tests the getExtractedArtifactDir function. +func Test_getExtractedArtifactDir(t *testing.T) { + t.Parallel() + + type args struct { + command string + version string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "v-prefixed version", + args: args{"vault-plugin-auth-aws", "v0.18.0+ent"}, + want: fmt.Sprintf("vault-plugin-auth-aws_0.18.0+ent_%s_%s", runtime.GOOS, runtime.GOARCH), + }, + { + name: "un-prefixed version", + args: args{"vault-plugin-auth-aws", "0.18.0+ent"}, + want: fmt.Sprintf("vault-plugin-auth-aws_0.18.0+ent_%s_%s", runtime.GOOS, runtime.GOARCH), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, getExtractedArtifactDir(tt.args.command, tt.args.version)) + }) + } +} + +// TestPluginCatalog_hashiCorpPubPGPKey tests hashiCorpPubPGPKey read +// and verification key creation. +func TestPluginCatalog_hashiCorpPubPGPKey(t *testing.T) { + pgp := crypto.PGP() + key, err := crypto.NewKeyFromArmored(hashiCorpPGPPubKey) + assert.NoError(t, err) + + _, err = pgp.Verify().VerificationKey(key).New() + assert.NoError(t, err) +} diff --git a/vault/plugincatalog/plugin_catalog.go b/vault/plugincatalog/plugin_catalog.go index d38cf16761..3486a5f163 100644 --- a/vault/plugincatalog/plugin_catalog.go +++ b/vault/plugincatalog/plugin_catalog.go @@ -174,7 +174,7 @@ func SetupPluginCatalog(ctx context.Context, in *PluginCatalogInput) (*PluginCat } // Sanitize the plugin catalog - err = catalog.entValidate(ctx) + err = catalog.verifyOfficialPlugins(ctx) if err != nil { logger.Error("error while sanitizing plugin storage", "error", err) return nil, err @@ -864,6 +864,33 @@ func (c *PluginCatalog) upgradePlugins(ctx context.Context, logger log.Logger) e return retErr } +// verifyOfficialPlugins verifies all official HashiCorp plugins +func (c *PluginCatalog) verifyOfficialPlugins(ctx context.Context) error { + plugins, err := c.collectAllPlugins(ctx) + if err != nil { + return err + } + + var hasOfficialPlugins bool + for _, plugin := range plugins { + if plugin.Tier != consts.PluginTierOfficial { + continue + } + + hasOfficialPlugins = true + pluginDir := path.Join(c.directory, path.Dir(plugin.Command)) + if _, err = verifyPlugin(pluginDir, plugin.Name, hashiCorpPGPPubKey); err != nil { + return fmt.Errorf("failed to verify plugin %q version %q: %w", plugin.Name, plugin.Version, err) + } + } + + if hasOfficialPlugins { + c.logger.Info("all official plugins verified") + } + + return nil +} + // Get retrieves a plugin with the specified name from the catalog. It first // looks for external plugins with this name and then looks for builtin plugins. // It returns a PluginRunner or an error if no plugin was found. @@ -966,6 +993,165 @@ func (c *PluginCatalog) Set(ctx context.Context, plugin pluginutil.SetPluginInpu return err } +// setInternal sets a plugin entry in the catalog. In addition to its CE functionality, +// it will attempt to verify the plugin in its extracted artifact directory +// if the sha256 is not specified. +func (c *PluginCatalog) setInternal(ctx context.Context, plugin pluginutil.SetPluginInput) (*pluginutil.PluginRunner, error) { + command := plugin.Command + var pluginTier consts.PluginTier + + if plugin.OCIImage == "" { + // When OCIImage is empty, then we want to register with the binary either directly (if Sha256 is set) or via the extracted artifact directory (if Sha256 is unset). + + var expectedPluginDir string + if len(plugin.Sha256) > 0 { + // When Sha256 is set, we can assume the binary is already available. + expectedPluginDir = c.directory + command = filepath.Join(c.directory, plugin.Command) + } else { + // When Sha256 is unset, ensure Version is set then attempt to verify the plugin + // in its extracted artifact directory. + + if len(plugin.Version) == 0 { + return nil, fmt.Errorf("must specify sha256 to register plugin with binary or version to register plugin with extracted artifact directory") + } + + var err error + extractedArtifactDir := getExtractedArtifactDir(plugin.Name, plugin.Version) + expectedPluginDir = path.Join(c.directory, extractedArtifactDir) + plugin.Command = path.Join(extractedArtifactDir, plugin.Name) + + metadata, err := verifyPlugin(expectedPluginDir, plugin.Name, defaultPGPPubKey) + if err != nil { + return nil, fmt.Errorf("failed to verify plugin plugin %q version %q: %w", + plugin.Name, plugin.Version, err) + } + pluginTier = metadata.Plugin.Tier + + plugin.Sha256, err = pluginSHA256Sum(path.Join(c.directory, plugin.Command)) + if err != nil { + return nil, fmt.Errorf("failed to calculate SHA256 of plugin: %w", err) + } + + command = filepath.Join(c.directory, plugin.Command) + } + + sym, err := filepath.EvalSymlinks(command) + if err != nil { + return nil, fmt.Errorf("error while validating the command path: %w", err) + } + + // Best effort check to make sure the command isn't breaking out of the + // configured plugin directory. + + symAbs, err := filepath.Abs(filepath.Dir(sym)) + if err != nil { + return nil, fmt.Errorf("error while validating the command path: %w", err) + } + + if symAbs != expectedPluginDir { + return nil, fmt.Errorf("cannot execute files outside of configured plugin directory %s: expected %s, got %s", c.directory, expectedPluginDir, symAbs) + } + } + + // entryTmp should only be used for the below type and version checks. It uses the + // full command instead of the relative command because get() normally prepends + // the plugin directory to the command, but we can't use get() here. + entryTmp := &pluginutil.PluginRunner{ + Name: plugin.Name, + Command: command, + OCIImage: plugin.OCIImage, + Runtime: plugin.Runtime, + Args: plugin.Args, + Env: plugin.Env, + Sha256: plugin.Sha256, + Builtin: false, + Tmpdir: c.tmpdir, + Tier: pluginTier, + } + + if entryTmp.OCIImage != "" && entryTmp.Runtime != "" { + var err error + entryTmp.RuntimeConfig, err = c.runtimeCatalog.Get(ctx, entryTmp.Runtime, consts.PluginRuntimeTypeContainer) + if err != nil { + return nil, fmt.Errorf("failed to get configured runtime for plugin %q: %w", plugin.Name, err) + } + } + // If the plugin type is unknown, we want to attempt to determine the type + if plugin.Type == consts.PluginTypeUnknown { + var err error + plugin.Type, err = c.getPluginTypeFromUnknown(ctx, entryTmp) + if err != nil { + return nil, err + } + if plugin.Type == consts.PluginTypeUnknown { + return nil, ErrPluginBadType + } + } + + // getting the plugin version is best-effort, so errors are not fatal + runningVersion := logical.EmptyPluginVersion + var versionErr error + switch plugin.Type { + case consts.PluginTypeSecrets, consts.PluginTypeCredential: + runningVersion, versionErr = c.getBackendRunningVersion(ctx, entryTmp) + case consts.PluginTypeDatabase: + runningVersion, versionErr = c.getDatabaseRunningVersion(ctx, entryTmp) + default: + return nil, fmt.Errorf("unknown plugin type: %v", plugin.Type) + } + if versionErr != nil { + c.logger.Warn("Error determining plugin version", "error", versionErr) + if errors.Is(versionErr, ErrPluginUnableToRun) { + return nil, versionErr + } + } else if plugin.Version != "" && runningVersion.Version != "" && plugin.Version != runningVersion.Version { + c.logger.Error("Plugin self-reported version did not match requested version", + "plugin", plugin.Name, "requestedVersion", plugin.Version, "reportedVersion", runningVersion.Version) + return nil, fmt.Errorf("%w: %s reported version (%s) did not match requested version (%s)", + ErrPluginVersionMismatch, plugin.Name, runningVersion.Version, plugin.Version) + } else if plugin.Version == "" && runningVersion.Version != "" { + plugin.Version = runningVersion.Version + _, err := semver.NewVersion(plugin.Version) + if err != nil { + return nil, fmt.Errorf("plugin self-reported version %q is not a valid semantic version: %w", plugin.Version, err) + } + } + + entry := &pluginutil.PluginRunner{ + Name: plugin.Name, + Type: plugin.Type, + Version: plugin.Version, + Command: plugin.Command, + OCIImage: plugin.OCIImage, + Runtime: plugin.Runtime, + Args: plugin.Args, + Env: plugin.Env, + Sha256: plugin.Sha256, + Builtin: false, + Tmpdir: c.tmpdir, + Tier: pluginTier, + } + + buf, err := json.Marshal(entry) + if err != nil { + return nil, fmt.Errorf("failed to encode plugin entry: %w", err) + } + + storageKey := path.Join(plugin.Type.String(), plugin.Name) + if plugin.Version != "" { + storageKey = path.Join(storageKey, plugin.Version) + } + logicalEntry := logical.StorageEntry{ + Key: storageKey, + Value: buf, + } + if err := c.catalogView.Put(ctx, &logicalEntry); err != nil { + return nil, fmt.Errorf("failed to persist plugin entry: %w", err) + } + return entry, nil +} + // Delete is used to remove an external plugin from the catalog. Builtin plugins // can not be deleted. func (c *PluginCatalog) Delete(ctx context.Context, name string, pluginType consts.PluginType, pluginVersion string) error { diff --git a/vault/plugincatalog/plugin_catalog_stubs_oss.go b/vault/plugincatalog/plugin_catalog_stubs_oss.go deleted file mode 100644 index 5f15a10040..0000000000 --- a/vault/plugincatalog/plugin_catalog_stubs_oss.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -//go:build !enterprise - -package plugincatalog - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "path" - "path/filepath" - - semver "github.com/hashicorp/go-version" - "github.com/hashicorp/vault/sdk/helper/consts" - "github.com/hashicorp/vault/sdk/helper/pluginutil" - "github.com/hashicorp/vault/sdk/logical" -) - -// setInternal creates a new plugin entry in the catalog and persists it to storage -func (c *PluginCatalog) setInternal(ctx context.Context, plugin pluginutil.SetPluginInput) (*pluginutil.PluginRunner, error) { - command := plugin.Command - if plugin.OCIImage == "" { - // Best effort check to make sure the command isn't breaking out of the - // configured plugin directory. - command = filepath.Join(c.directory, plugin.Command) - sym, err := filepath.EvalSymlinks(command) - if err != nil { - return nil, fmt.Errorf("error while validating the command path: %w", err) - } - symAbs, err := filepath.Abs(filepath.Dir(sym)) - if err != nil { - return nil, fmt.Errorf("error while validating the command path: %w", err) - } - - if symAbs != c.directory { - return nil, errors.New("cannot execute files outside of configured plugin directory") - } - } - - // entryTmp should only be used for the below type and version checks. It uses the - // full command instead of the relative command because get() normally prepends - // the plugin directory to the command, but we can't use get() here. - entryTmp := &pluginutil.PluginRunner{ - Name: plugin.Name, - Command: command, - OCIImage: plugin.OCIImage, - Runtime: plugin.Runtime, - Args: plugin.Args, - Env: plugin.Env, - Sha256: plugin.Sha256, - Builtin: false, - Tmpdir: c.tmpdir, - } - if entryTmp.OCIImage != "" && entryTmp.Runtime != "" { - var err error - entryTmp.RuntimeConfig, err = c.runtimeCatalog.Get(ctx, entryTmp.Runtime, consts.PluginRuntimeTypeContainer) - if err != nil { - return nil, fmt.Errorf("failed to get configured runtime for plugin %q: %w", plugin.Name, err) - } - } - // If the plugin type is unknown, we want to attempt to determine the type - if plugin.Type == consts.PluginTypeUnknown { - var err error - plugin.Type, err = c.getPluginTypeFromUnknown(ctx, entryTmp) - if err != nil { - return nil, err - } - if plugin.Type == consts.PluginTypeUnknown { - return nil, ErrPluginBadType - } - } - - // getting the plugin version is best-effort, so errors are not fatal - runningVersion := logical.EmptyPluginVersion - var versionErr error - switch plugin.Type { - case consts.PluginTypeSecrets, consts.PluginTypeCredential: - runningVersion, versionErr = c.getBackendRunningVersion(ctx, entryTmp) - case consts.PluginTypeDatabase: - runningVersion, versionErr = c.getDatabaseRunningVersion(ctx, entryTmp) - default: - return nil, fmt.Errorf("unknown plugin type: %v", plugin.Type) - } - if versionErr != nil { - c.logger.Warn("Error determining plugin version", "error", versionErr) - if errors.Is(versionErr, ErrPluginUnableToRun) { - return nil, versionErr - } - } else if plugin.Version != "" && runningVersion.Version != "" && plugin.Version != runningVersion.Version { - c.logger.Error("Plugin self-reported version did not match requested version", - "plugin", plugin.Name, "requestedVersion", plugin.Version, "reportedVersion", runningVersion.Version) - return nil, fmt.Errorf("%w: %s reported version (%s) did not match requested version (%s)", - ErrPluginVersionMismatch, plugin.Name, runningVersion.Version, plugin.Version) - } else if plugin.Version == "" && runningVersion.Version != "" { - plugin.Version = runningVersion.Version - _, err := semver.NewVersion(plugin.Version) - if err != nil { - return nil, fmt.Errorf("plugin self-reported version %q is not a valid semantic version: %w", plugin.Version, err) - } - } - - entry := &pluginutil.PluginRunner{ - Name: plugin.Name, - Type: plugin.Type, - Version: plugin.Version, - Command: plugin.Command, - OCIImage: plugin.OCIImage, - Runtime: plugin.Runtime, - Args: plugin.Args, - Env: plugin.Env, - Sha256: plugin.Sha256, - Builtin: false, - Tmpdir: c.tmpdir, - } - - buf, err := json.Marshal(entry) - if err != nil { - return nil, fmt.Errorf("failed to encode plugin entry: %w", err) - } - - storageKey := path.Join(plugin.Type.String(), plugin.Name) - if plugin.Version != "" { - storageKey = path.Join(storageKey, plugin.Version) - } - logicalEntry := logical.StorageEntry{ - Key: storageKey, - Value: buf, - } - if err := c.catalogView.Put(ctx, &logicalEntry); err != nil { - return nil, fmt.Errorf("failed to persist plugin entry: %w", err) - } - return entry, nil -} - -func (c *PluginCatalog) entValidate(context.Context) error { - return nil -} diff --git a/vault/plugincatalog/plugin_metadata.go b/vault/plugincatalog/plugin_metadata.go new file mode 100644 index 0000000000..010552d956 --- /dev/null +++ b/vault/plugincatalog/plugin_metadata.go @@ -0,0 +1,43 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package plugincatalog + +import ( + "encoding/json" + "os" + + "github.com/hashicorp/vault/sdk/helper/consts" +) + +type pluginMetadata struct { + Version string `json:"version"` + Plugin Plugin `json:"plugin"` +} + +type Plugin struct { + Name string `json:"name"` + Type consts.PluginType `json:"type"` + Tier consts.PluginTier `json:"tier,omitempty"` + // By is the plugin author's GitHub account, following Terraform Registry's convention + By string `json:"by"` + Version string `json:"version"` + Platform string `json:"platform"` + Arch string `json:"arch"` + // PGPSig is PGP ASCII armored detached signature + PGPSig string `json:"pgp_sig"` +} + +func readPluginMetadata(metadataPath string) (*pluginMetadata, error) { + metadataBytes, err := os.ReadFile(metadataPath) + if err != nil { + return nil, err + } + + metadata := pluginMetadata{} + if err = json.Unmarshal(metadataBytes, &metadata); err != nil { + return nil, err + } + + return &metadata, nil +} diff --git a/vault/plugincatalog/testing_util.go b/vault/plugincatalog/testing_util.go new file mode 100644 index 0000000000..c66010d945 --- /dev/null +++ b/vault/plugincatalog/testing_util.go @@ -0,0 +1,87 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package plugincatalog + +import ( + "encoding/json" + "fmt" + "runtime" + "testing" + + "github.com/ProtonMail/gopenpgp/v3/crypto" + "github.com/google/uuid" + "github.com/hashicorp/vault/sdk/helper/consts" + "github.com/stretchr/testify/assert" +) + +// gereratePGPKeyPair generates a PGP key pair for testing purposes +func generatePGPKeyPair(t *testing.T) (*crypto.Key, string) { + pgp := crypto.PGP() + user := "test" + uuid.NewString()[:5] + privKey, err := pgp.KeyGeneration().AddUserId(user, fmt.Sprintf("%s@hashicorp.com", user)).New().GenerateKey() + assert.NoError(t, err) + + pubkey, err := privKey.ToPublic() + assert.NoError(t, err) + + armored, err := pubkey.Armor() + assert.NoError(t, err) + + return privKey, armored +} + +// generatePluginArtifactContents generates file contents for a plugin artifact for testing purposes +// If key is nil, signatures will carry a placeholder value +func generatePluginArtifactContents(t *testing.T, pluginName, pluginVersion string, pluginType consts.PluginType, + includeBinarySig bool, privKey *crypto.Key, +) map[string][]byte { + t.Helper() + + metadata := pluginMetadata{ + Version: "v0", + Plugin: Plugin{ + Name: pluginName, + Type: pluginType, + Tier: consts.PluginTierOfficial, + By: "hashicorp", + Version: pluginVersion, + Platform: runtime.GOOS, + Arch: runtime.GOARCH, + PGPSig: "signature-placeholder", + }, + } + metadataBytes, err := json.Marshal(metadata) + assert.NoError(t, err) + + pluginBytes := []byte("plugin-binary-placeholder") + metadataSigBytes := []byte("signature-placeholder") + pgp := crypto.PGP() + if privKey != nil { + signer, err := pgp.Sign().SigningKey(privKey).Detached().New() + defer signer.ClearPrivateParams() + assert.NoError(t, err) + + // exclude binary signature for bad plugin signature read test + metadata.Plugin.PGPSig = "" + if includeBinarySig { + signature, err := signer.Sign(pluginBytes, crypto.Armor) + assert.NoError(t, err) + + metadata.Plugin.PGPSig = string(signature) + } + + metadataBytes, err = json.Marshal(metadata) + assert.NoError(t, err) + + metadataSigBytes, err = signer.Sign(metadataBytes, crypto.Armor) + assert.NoError(t, err) + } + + return map[string][]byte{ + metadataFile: metadataBytes, + metadataSig: metadataSigBytes, + pluginName: pluginBytes, + "LICENSE": []byte("license-placeholder"), + } +}