diff --git a/cmd/agent_password.go b/cmd/agent_password.go new file mode 100644 index 000000000..f371f33f0 --- /dev/null +++ b/cmd/agent_password.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + + "github.com/cloudfoundry/bosh-agent/v2/agentpassword" + "github.com/cloudfoundry/bosh-utils/property" +) + +func hashCPIAgentMbus(props property.Map) error { + agent, _ := props["agent"].(property.Map) + mbus, ok := agent["mbus"].(string) + if !ok { + return nil + } + hashed, err := agentpassword.HashURL(mbus) + if err != nil { + return err + } + agent["mbus"] = hashed + return nil +} + +func hashResourcePoolMbusURLs(env property.Map) error { + boshRaw, ok := env["bosh"] + if !ok { + return nil + } + bosh, ok := boshRaw.(property.Map) + if !ok { + return fmt.Errorf("env.bosh must be a map") + } + mbusRaw, ok := bosh["mbus"] + if !ok { + return nil + } + mbus, ok := mbusRaw.(property.Map) + if !ok { + return fmt.Errorf("env.bosh.mbus must be a map") + } + urlsRaw, ok := mbus["urls"] + if !ok { + return nil + } + urls, ok := urlsRaw.(property.List) + if !ok { + return fmt.Errorf("env.bosh.mbus.urls must be a list") + } + for i, entryRaw := range urls { + entry, ok := entryRaw.(string) + if !ok { + return fmt.Errorf("env.bosh.mbus.urls entry must be a string") + } + hashed, err := agentpassword.HashURL(entry) + if err != nil { + return err + } + urls[i] = hashed + } + return nil +} diff --git a/cmd/agent_password_test.go b/cmd/agent_password_test.go new file mode 100644 index 000000000..af725aac3 --- /dev/null +++ b/cmd/agent_password_test.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "github.com/cloudfoundry/bosh-agent/v2/agentpassword" + "github.com/cloudfoundry/bosh-utils/property" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("hashCPIAgentMbus", func() { + It("hashes agent.mbus in place when present", func() { + raw := "https://vcap:original-password@0.0.0.0:6868" + properties := property.Map{ + "agent": property.Map{ + "mbus": raw, + }, + "unrelated": "preserved", + } + + err := hashCPIAgentMbus(properties) + Expect(err).NotTo(HaveOccurred()) + + hashed := properties["agent"].(property.Map)["mbus"].(string) + Expect(hashed).To(HavePrefix("https://vcap:bosh-hmac-sha256$")) + Expect(properties["unrelated"]).To(Equal("preserved")) + + verifierStr := "bosh-hmac-sha256$" + hashed[len("https://vcap:bosh-hmac-sha256$"):len(hashed)-len("@0.0.0.0:6868")] + verifier, err := agentpassword.ParseHashedPassword(verifierStr) + Expect(err).NotTo(HaveOccurred()) + Expect(verifier.Matches("original-password")).To(BeTrue()) + }) + + It("is a no-op when props is nil or empty", func() { + Expect(hashCPIAgentMbus(nil)).To(Succeed()) + Expect(hashCPIAgentMbus(property.Map{})).To(Succeed()) + }) + + It("is a no-op when agent is absent or not a property.Map", func() { + props := property.Map{"unrelated": "preserved"} + Expect(hashCPIAgentMbus(props)).To(Succeed()) + Expect(props["unrelated"]).To(Equal("preserved")) + + props = property.Map{"agent": "not-a-map"} + Expect(hashCPIAgentMbus(props)).To(Succeed()) + Expect(props["agent"]).To(Equal("not-a-map")) + }) + + It("is a no-op when agent.mbus is absent or not a string", func() { + props := property.Map{"agent": property.Map{"other": "preserved"}} + Expect(hashCPIAgentMbus(props)).To(Succeed()) + Expect(props["agent"].(property.Map)["other"]).To(Equal("preserved")) + + props = property.Map{"agent": property.Map{"mbus": 123}} + Expect(hashCPIAgentMbus(props)).To(Succeed()) + Expect(props["agent"].(property.Map)["mbus"]).To(Equal(123)) + }) + + It("returns error if HashURL fails", func() { + props := property.Map{"agent": property.Map{"mbus": "invalid-url-%%%"}} + Expect(hashCPIAgentMbus(props)).To(HaveOccurred()) + }) +}) + +var _ = Describe("hashResourcePoolMbusURLs", func() { + It("hashes urls entries in place and preserves unrelated keys", func() { + raw := "https://vcap:original-password@0.0.0.0:6868" + env := property.Map{ + "bosh": property.Map{ + "mbus": property.Map{ + "urls": property.List{raw}, + "cert": property.Map{"certificate": "preserved"}, + }, + }, + "unrelated": "preserved", + } + + err := hashResourcePoolMbusURLs(env) + Expect(err).NotTo(HaveOccurred()) + + boshMap := env["bosh"].(property.Map) + mbusMap := boshMap["mbus"].(property.Map) + hashedURLs := mbusMap["urls"].(property.List) + Expect(hashedURLs).To(HaveLen(1)) + + hashed := hashedURLs[0].(string) + Expect(hashed).To(HavePrefix("https://vcap:bosh-hmac-sha256$")) + Expect(mbusMap["cert"].(property.Map)["certificate"]).To(Equal("preserved")) + Expect(env["unrelated"]).To(Equal("preserved")) + + verifierStr := "bosh-hmac-sha256$" + hashed[len("https://vcap:bosh-hmac-sha256$"):len(hashed)-len("@0.0.0.0:6868")] + verifier, err := agentpassword.ParseHashedPassword(verifierStr) + Expect(err).NotTo(HaveOccurred()) + Expect(verifier.Matches("original-password")).To(BeTrue()) + }) + + It("is a no-op when env is nil or empty", func() { + Expect(hashResourcePoolMbusURLs(nil)).To(Succeed()) + Expect(hashResourcePoolMbusURLs(property.Map{})).To(Succeed()) + }) + + It("is a no-op when missing a key at any level", func() { + env := property.Map{"unrelated": "val"} + Expect(hashResourcePoolMbusURLs(env)).To(Succeed()) + + env = property.Map{"bosh": property.Map{"other": "val"}} + Expect(hashResourcePoolMbusURLs(env)).To(Succeed()) + + env = property.Map{"bosh": property.Map{"mbus": property.Map{"other": "val"}}} + Expect(hashResourcePoolMbusURLs(env)).To(Succeed()) + }) + + It("returns an error without URLs when bosh is not a map", func() { + env := property.Map{"bosh": "not-a-map"} + err := hashResourcePoolMbusURLs(env) + Expect(err).To(MatchError("env.bosh must be a map")) + }) + + It("returns an error without URLs when mbus is not a map", func() { + env := property.Map{"bosh": property.Map{"mbus": "not-a-map"}} + err := hashResourcePoolMbusURLs(env) + Expect(err).To(MatchError("env.bosh.mbus must be a map")) + }) + + It("returns an error without URLs when urls is not a list", func() { + env := property.Map{"bosh": property.Map{"mbus": property.Map{"urls": "not-a-list"}}} + err := hashResourcePoolMbusURLs(env) + Expect(err).To(MatchError("env.bosh.mbus.urls must be a list")) + }) + + It("returns an error without URLs when any entry in urls is not a string", func() { + env := property.Map{"bosh": property.Map{"mbus": property.Map{"urls": property.List{123}}}} + err := hashResourcePoolMbusURLs(env) + Expect(err).To(MatchError("env.bosh.mbus.urls entry must be a string")) + }) + + It("returns error if HashURL fails without leaking URL", func() { + env := property.Map{"bosh": property.Map{"mbus": property.Map{"urls": property.List{"invalid-url-%%%"}}}} + err := hashResourcePoolMbusURLs(env) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/cmd/create_env_test.go b/cmd/create_env_test.go index 06e5ebfc0..d934561cb 100644 --- a/cmd/create_env_test.go +++ b/cmd/create_env_test.go @@ -4,9 +4,11 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "path/filepath" "regexp" + "github.com/cloudfoundry/bosh-agent/v2/agentpassword" bosherr "github.com/cloudfoundry/bosh-utils/errors" boshlog "github.com/cloudfoundry/bosh-utils/logger" biproperty "github.com/cloudfoundry/bosh-utils/property" @@ -557,6 +559,201 @@ var _ = Describe("CreateEnvCmd", func() { Expect(mockCloudFactory.NewCloudCallCount()).To(Equal(1)) }) + Context("when stemcell advertises http agent password verifiers feature", func() { + JustBeforeEach(func() { + extractedStemcell = bistemcell.NewExtractedStemcell( + bistemcell.Manifest{ + Name: "fake-stemcell-name", + Version: "fake-stemcell-version", + SHA1: "fake-stemcell-sha1", + ApiVersion: stemcellApiVersion, + CloudProperties: biproperty.Map{}, + AgentFeatures: []string{agentpassword.Feature}, + }, + "fake-extracted-path", + nil, + fs, + ) + fakeStemcellExtractor.SetExtractBehavior(stemcellTarballPath, extractedStemcell, nil) + + installationManifest.Properties = biproperty.Map{ + "agent": biproperty.Map{ + "mbus": "https://vcap:some-secret-pw@127.0.0.1:6868", + }, + } + installationManifest.Mbus = "https://vcap:director-client-pw@127.0.0.1:6868" + fakeInstallationParser.ParseReturns(installationManifest, nil) + + boshDeploymentManifest.ResourcePools[0].Env = biproperty.Map{ + "bosh": biproperty.Map{ + "mbus": biproperty.Map{ + "urls": biproperty.List{"https://vcap:rp-secret-pw@127.0.0.1:6868"}, + }, + }, + } + fakeDeploymentParser.ParseReturns(boshDeploymentManifest, nil) + }) + + It("hashes installationManifest agent.mbus before passing to CPI installer, while preserving client mbus", func() { + err := command.Run(fakeStage, defaultCreateEnvOpts) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockInstaller.InstallCallCount()).To(Equal(1)) + installedManifest, _ := mockInstaller.InstallArgsForCall(0) + agentProps := installedManifest.Properties["agent"].(biproperty.Map) + Expect(agentProps["mbus"]).To(HavePrefix("https://vcap:bosh-hmac-sha256$")) + + Expect(mockAgentClientFactory.NewAgentClientCallCount()).To(Equal(1)) + _, agentClientMbus, _ := mockAgentClientFactory.NewAgentClientArgsForCall(0) + Expect(agentClientMbus).To(Equal("https://vcap:director-client-pw@127.0.0.1:6868")) + + Expect(mockBlobstoreFactory.CreateCallCount()).To(Equal(1)) + blobstoreMbus, _ := mockBlobstoreFactory.CreateArgsForCall(0) + Expect(blobstoreMbus).To(Equal("https://vcap:director-client-pw@127.0.0.1:6868")) + }) + + It("hashes every HTTPS mbus URL in every resource pool while preserving client mbus", func() { + originalURLs := map[string]biproperty.List{ + "unused-pool": { + "https://vcap:unused-first-password@127.0.0.1:6868", + "https://vcap:unused-second-password@127.0.0.2:6868", + }, + "selected-pool": { + "https://vcap:selected-first-password@127.0.0.3:6868", + "https://vcap:selected-second-password@127.0.0.4:6868", + }, + } + poolTemplate := boshDeploymentManifest.ResourcePools[0] + boshDeploymentManifest.ResourcePools = nil + boshDeploymentManifest.Jobs[0].ResourcePool = "selected-pool" + for _, name := range []string{"unused-pool", "selected-pool"} { + pool := poolTemplate + pool.Name = name + pool.Env = biproperty.Map{ + "bosh": biproperty.Map{ + "mbus": biproperty.Map{ + "urls": append(biproperty.List{}, originalURLs[name]...), + }, + }, + } + boshDeploymentManifest.ResourcePools = append(boshDeploymentManifest.ResourcePools, pool) + } + fakeDeploymentParser.ParseReturns(boshDeploymentManifest, nil) + + err := command.Run(fakeStage, defaultCreateEnvOpts) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockDeployer.DeployCallCount()).To(Equal(1)) + _, deployedManifest, _, _, _, _, _, _ := mockDeployer.DeployArgsForCall(0) + Expect(deployedManifest.ResourcePools).To(HaveLen(2)) + for _, pool := range deployedManifest.ResourcePools { + mbus := pool.Env["bosh"].(biproperty.Map)["mbus"].(biproperty.Map) + urls := mbus["urls"].(biproperty.List) + Expect(urls).To(HaveLen(2)) + for i, raw := range urls { + hashedURL, err := url.Parse(raw.(string)) + Expect(err).NotTo(HaveOccurred()) + hashedPassword, _ := hashedURL.User.Password() + verifier, err := agentpassword.ParseHashedPassword(hashedPassword) + Expect(err).NotTo(HaveOccurred()) + originalURL, err := url.Parse(originalURLs[pool.Name][i].(string)) + Expect(err).NotTo(HaveOccurred()) + originalPassword, _ := originalURL.User.Password() + Expect(verifier.Matches(originalPassword)).To(BeTrue()) + } + } + + Expect(mockAgentClientFactory.NewAgentClientCallCount()).To(Equal(1)) + _, agentClientMbus, _ := mockAgentClientFactory.NewAgentClientArgsForCall(0) + Expect(agentClientMbus).To(Equal("https://vcap:director-client-pw@127.0.0.1:6868")) + }) + + Context("when cloud_provider.properties.agent.mbus is an https URL with no password", func() { + JustBeforeEach(func() { + installationManifest.Properties = biproperty.Map{ + "agent": biproperty.Map{ + "mbus": "https://vcap@127.0.0.1:6868", + }, + } + fakeInstallationParser.ParseReturns(installationManifest, nil) + }) + + It("returns an error containing 'URL missing password' and does not install or deploy", func() { + err := command.Run(fakeStage, defaultCreateEnvOpts) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("URL missing password")) + Expect(mockInstaller.InstallCallCount()).To(Equal(0)) + Expect(mockDeployer.DeployCallCount()).To(Equal(0)) + }) + }) + + Context("when a resource pool env.bosh.mbus.urls entry is an https URL with no password", func() { + JustBeforeEach(func() { + boshDeploymentManifest.ResourcePools[0].Env = biproperty.Map{ + "bosh": biproperty.Map{ + "mbus": biproperty.Map{ + "urls": biproperty.List{"https://vcap@127.0.0.1:6868"}, + }, + }, + } + fakeDeploymentParser.ParseReturns(boshDeploymentManifest, nil) + }) + + It("returns an error containing 'URL missing password' and does not install or deploy", func() { + err := command.Run(fakeStage, defaultCreateEnvOpts) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("URL missing password")) + Expect(mockInstaller.InstallCallCount()).To(Equal(0)) + Expect(mockDeployer.DeployCallCount()).To(Equal(0)) + }) + }) + }) + + Context("when stemcell does not advertise http agent password verifiers feature", func() { + JustBeforeEach(func() { + installationManifest.Properties = biproperty.Map{ + "agent": biproperty.Map{ + "mbus": "https://vcap:some-secret-pw@127.0.0.1:6868", + }, + } + installationManifest.Mbus = "https://vcap:director-client-pw@127.0.0.1:6868" + fakeInstallationParser.ParseReturns(installationManifest, nil) + + boshDeploymentManifest.ResourcePools[0].Env = biproperty.Map{ + "bosh": biproperty.Map{ + "mbus": biproperty.Map{ + "urls": biproperty.List{"https://vcap:rp-secret-pw@127.0.0.1:6868"}, + }, + }, + } + fakeDeploymentParser.ParseReturns(boshDeploymentManifest, nil) + }) + + It("leaves installationManifest agent.mbus unchanged", func() { + err := command.Run(fakeStage, defaultCreateEnvOpts) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockInstaller.InstallCallCount()).To(Equal(1)) + installedManifest, _ := mockInstaller.InstallArgsForCall(0) + agentProps := installedManifest.Properties["agent"].(biproperty.Map) + Expect(agentProps["mbus"]).To(Equal("https://vcap:some-secret-pw@127.0.0.1:6868")) + }) + + It("passes resource pool URLs to the deployer unchanged", func() { + err := command.Run(fakeStage, defaultCreateEnvOpts) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockDeployer.DeployCallCount()).To(Equal(1)) + _, deployedManifest, _, _, _, _, _, _ := mockDeployer.DeployArgsForCall(0) + Expect(deployedManifest.ResourcePools).To(HaveLen(1)) + boshMap := deployedManifest.ResourcePools[0].Env["bosh"].(biproperty.Map) + mbusMap := boshMap["mbus"].(biproperty.Map) + urls := mbusMap["urls"].(biproperty.List) + Expect(urls).To(HaveLen(1)) + Expect(urls[0].(string)).To(Equal("https://vcap:rp-secret-pw@127.0.0.1:6868")) + }) + }) + It("adds a new 'installing CPI' event logger stage", func() { err := command.Run(fakeStage, defaultCreateEnvOpts) Expect(err).NotTo(HaveOccurred()) diff --git a/cmd/deployment_preparer.go b/cmd/deployment_preparer.go index a663407e9..808112b8b 100644 --- a/cmd/deployment_preparer.go +++ b/cmd/deployment_preparer.go @@ -2,6 +2,7 @@ package cmd import ( bihttpagent "github.com/cloudfoundry/bosh-agent/v2/agentclient/http" + "github.com/cloudfoundry/bosh-agent/v2/agentpassword" bosherr "github.com/cloudfoundry/bosh-utils/errors" bihttpclient "github.com/cloudfoundry/bosh-utils/httpclient" boshlog "github.com/cloudfoundry/bosh-utils/logger" @@ -201,6 +202,20 @@ func (c *DeploymentPreparer) PrepareDeployment(stage biui.Stage, opts Deployment return nil } + if extractedStemcell.Manifest().SupportsAgentFeature(agentpassword.Feature) { + err = hashCPIAgentMbus(installationManifest.Properties) + if err != nil { + return bosherr.WrapError(err, "Hashing CPI agent HTTP password") + } + // Hash all pools, including unused ones, so their HTTPS passwords are not left in plaintext. + for i := range deploymentManifest.ResourcePools { + err = hashResourcePoolMbusURLs(deploymentManifest.ResourcePools[i].Env) + if err != nil { + return bosherr.WrapError(err, "Hashing resource pool agent HTTP passwords") + } + } + } + err = c.cpiInstaller.WithInstalledCpiRelease(installationManifest, target, stage, func(installation biinstall.Installation) error { stemcellApiVersion := c.stemcellApiVersion(extractedStemcell) diff --git a/go.mod b/go.mod index 5a462ddd5..fea68c43c 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( code.cloudfoundry.org/clock v1.93.0 code.cloudfoundry.org/workpool v0.0.0-20250911194158-1489753f182e github.com/cheggaaa/pb/v3 v3.2.1 - github.com/cloudfoundry/bosh-agent/v2 v2.908.0 + github.com/cloudfoundry/bosh-agent/v2 v2.910.0 github.com/cloudfoundry/bosh-davcli v0.0.518 github.com/cloudfoundry/bosh-gcscli v0.0.426 github.com/cloudfoundry/bosh-s3cli v0.0.447 diff --git a/go.sum b/go.sum index 2b729d288..b8590a610 100644 --- a/go.sum +++ b/go.sum @@ -84,8 +84,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cloudfoundry/bosh-agent/v2 v2.908.0 h1:osrzAOCaAwHbV3niQI+a3Q5fNNh35bF2s/eU8jBjbns= -github.com/cloudfoundry/bosh-agent/v2 v2.908.0/go.mod h1:suwCWR5eoPDHwyULTcXWmOug8SKLvJJLeT9l49ZkBEs= +github.com/cloudfoundry/bosh-agent/v2 v2.910.0 h1:TTkfnFascR1smz1RkH2av3ytMoBCvf/+C41FdKyJusg= +github.com/cloudfoundry/bosh-agent/v2 v2.910.0/go.mod h1:suwCWR5eoPDHwyULTcXWmOug8SKLvJJLeT9l49ZkBEs= github.com/cloudfoundry/bosh-davcli v0.0.518 h1:4UndgPJrIzNSws3OXu7bW5kCz2ASOTh8r3ig8lygP5U= github.com/cloudfoundry/bosh-davcli v0.0.518/go.mod h1:DvyFOOWvT/92m8XAMHidtdDjb3PTJ5AalmLAIdeMIzE= github.com/cloudfoundry/bosh-gcscli v0.0.426 h1:yJtJyI55t6p+h+iKyEX9iBqqLe9KQfhlQ4UkydFWM4s= diff --git a/stemcell/agent_features_test.go b/stemcell/agent_features_test.go new file mode 100644 index 000000000..5463cd490 --- /dev/null +++ b/stemcell/agent_features_test.go @@ -0,0 +1,35 @@ +package stemcell_test + +import ( + "github.com/cloudfoundry/bosh-cli/v7/stemcell" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v2" +) + +var _ = Describe("Agent features", func() { + DescribeTable("detects supported features", + func(metadata string, supports bool) { + var manifest stemcell.Manifest + Expect(yaml.Unmarshal([]byte(metadata), &manifest)).To(Succeed()) + Expect(manifest.SupportsAgentFeature("http-password-hmac-sha256")).To(Equal(supports)) + }, + Entry("legacy metadata without a feature list", "name: legacy\napi_version: 3\n", false), + Entry("an unrelated feature", "agent_features: [unrelated-feature]\n", false), + Entry("the HTTP password feature", "agent_features: [http-password-hmac-sha256]\n", true), + ) + + DescribeTable("preserves agent features when repacked", + func(features []string) { + manifest := stemcell.Manifest{AgentFeatures: features} + packed, err := yaml.Marshal(manifest) + Expect(err).NotTo(HaveOccurred()) + var unpacked stemcell.Manifest + Expect(yaml.Unmarshal(packed, &unpacked)).To(Succeed()) + Expect(unpacked.AgentFeatures).To(Equal(features)) + }, + Entry("no feature list", []string(nil)), + Entry("an unrelated feature", []string{"unrelated-feature"}), + Entry("the HTTP password feature", []string{"http-password-hmac-sha256"}), + ) +}) diff --git a/stemcell/stemcell.go b/stemcell/stemcell.go index d86bd4d61..b7e30d47c 100644 --- a/stemcell/stemcell.go +++ b/stemcell/stemcell.go @@ -3,6 +3,7 @@ package stemcell import ( "fmt" "path/filepath" + "slices" boshfu "github.com/cloudfoundry/bosh-utils/fileutil" biproperty "github.com/cloudfoundry/bosh-utils/property" @@ -39,10 +40,17 @@ type Manifest struct { SHA1 string `yaml:"sha1"` BoshProtocol string `yaml:"bosh_protocol"` StemcellFormats []string `yaml:"stemcell_formats,omitempty"` + AgentFeatures []string `yaml:"agent_features,omitempty"` ApiVersion int `yaml:"api_version,omitempty"` CloudProperties biproperty.Map `yaml:"cloud_properties"` } +// SupportsAgentFeature reports capabilities advertised for the packaged agent. +// Absent capabilities return false. +func (m Manifest) SupportsAgentFeature(feature string) bool { + return slices.Contains(m.AgentFeatures, feature) +} + func NewExtractedStemcell( manifest Manifest, extractedPath string, diff --git a/vendor/github.com/cloudfoundry/bosh-agent/v2/agentpassword/agentpassword.go b/vendor/github.com/cloudfoundry/bosh-agent/v2/agentpassword/agentpassword.go new file mode 100644 index 000000000..03fc7b55a --- /dev/null +++ b/vendor/github.com/cloudfoundry/bosh-agent/v2/agentpassword/agentpassword.go @@ -0,0 +1,101 @@ +package agentpassword + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" +) + +const ( + Feature = "http-password-hmac-sha256" + + // MaxPasswordLength is the maximum plaintext password length in bytes. + MaxPasswordLength = 1024 + + prefix = "bosh-hmac-sha256$" + saltLength = 16 + keyLength = 32 +) + +type HashedPassword struct { + salt []byte + key []byte +} + +// IsHashedPassword identifies the reserved prefix; ParseHashedPassword validates its contents. +func IsHashedPassword(password string) bool { + return strings.HasPrefix(password, prefix) +} + +func ParseHashedPassword(s string) (*HashedPassword, error) { + if !strings.HasPrefix(s, prefix) { + return nil, errors.New("malformed HTTP password verifier: missing prefix") + } + parts := strings.Split(s, "$") + if len(parts) != 3 { + return nil, errors.New("malformed HTTP password verifier: invalid part count") + } + salt, err := base64.RawURLEncoding.Strict().DecodeString(parts[1]) + if err != nil || len(salt) != saltLength { + return nil, errors.New("malformed HTTP password verifier: invalid salt") + } + key, err := base64.RawURLEncoding.Strict().DecodeString(parts[2]) + if err != nil || len(key) != keyLength { + return nil, errors.New("malformed HTTP password verifier: invalid key") + } + return &HashedPassword{salt: salt, key: key}, nil +} + +func (v *HashedPassword) Matches(password string) bool { + if v == nil { + return false + } + mac := hmac.New(sha256.New, v.salt) + _, _ = mac.Write([]byte(password)) + return subtle.ConstantTimeCompare(v.key, mac.Sum(nil)) == 1 +} + +func HashURL(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", errors.New("invalid URL") + } + if u.Scheme != "https" { + return raw, nil + } + if u.User == nil { + return "", errors.New("URL missing userinfo") + } + password, hasPassword := u.User.Password() + if !hasPassword || password == "" { + return "", errors.New("URL missing password") + } + if len(password) > MaxPasswordLength { + return "", fmt.Errorf("URL password exceeds %d bytes", MaxPasswordLength) + } + if IsHashedPassword(password) { + return "", errors.New("URL password is already hashed; provide the original password") + } + + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", errors.New("failed to generate random salt") + } + + mac := hmac.New(sha256.New, salt) + _, _ = mac.Write([]byte(password)) + key := mac.Sum(nil) + + encodedSalt := base64.RawURLEncoding.EncodeToString(salt) + encodedKey := base64.RawURLEncoding.EncodeToString(key) + verifier := prefix + encodedSalt + "$" + encodedKey + + u.User = url.UserPassword(u.User.Username(), verifier) + return u.String(), nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 5e64d654d..ac486cbba 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -225,12 +225,13 @@ github.com/cheggaaa/pb/v3/termutil # github.com/clipperhouse/uax29/v2 v2.7.0 ## explicit; go 1.18 github.com/clipperhouse/uax29/v2/graphemes -# github.com/cloudfoundry/bosh-agent/v2 v2.908.0 +# github.com/cloudfoundry/bosh-agent/v2 v2.910.0 ## explicit; go 1.26.0 github.com/cloudfoundry/bosh-agent/v2/agentclient github.com/cloudfoundry/bosh-agent/v2/agentclient/applyspec github.com/cloudfoundry/bosh-agent/v2/agentclient/fakes github.com/cloudfoundry/bosh-agent/v2/agentclient/http +github.com/cloudfoundry/bosh-agent/v2/agentpassword # github.com/cloudfoundry/bosh-davcli v0.0.518 ## explicit; go 1.26.0 github.com/cloudfoundry/bosh-davcli/client