Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions agentpassword/agentpassword.go
Original file line number Diff line number Diff line change
@@ -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, "$")
Comment thread
Alphasite marked this conversation as resolved.
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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess i like that this is all in the agent, its nice, good change!

u, err := url.Parse(raw)
if err != nil {
return "", errors.New("invalid URL")
}
if u.Scheme != "https" {
Comment thread
Alphasite marked this conversation as resolved.
return raw, nil
}
if u.User == nil {
return "", errors.New("URL missing userinfo")
}
password, hasPassword := u.User.Password()
Comment thread
Alphasite marked this conversation as resolved.
if !hasPassword || password == "" {
return "", errors.New("URL missing password")
Comment thread
Alphasite marked this conversation as resolved.
}
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 {
Comment thread
Alphasite marked this conversation as resolved.
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
}
13 changes: 13 additions & 0 deletions agentpassword/agentpassword_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package agentpassword_test

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestAgentPassword(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Agent Password Suite")
}
221 changes: 221 additions & 0 deletions agentpassword/agentpassword_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package agentpassword_test

import (
"encoding/base64"
"net/url"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/cloudfoundry/bosh-agent/v2/agentpassword"
)

var _ = Describe("Agentpassword", func() {
Describe("Feature constant", func() {
It("exposes the expected feature identifier", func() {
Expect(agentpassword.Feature).To(Equal("http-password-hmac-sha256"))
})
})

Describe("IsHashedPassword", func() {
It("returns true for verifiers and malformed verifiers with the prefix", func() {
Expect(agentpassword.IsHashedPassword("bosh-hmac-sha256$bad")).To(BeTrue())
Expect(agentpassword.IsHashedPassword("bosh-hmac-sha256$AAECAwQFBgcICQoLDA0ODw$EEqYVo55l7MGfPvwVPSRCq7RN6JXw-TBbKyW9De5Grw")).To(BeTrue())
})

It("returns false when the prefix is missing or incomplete", func() {
Expect(agentpassword.IsHashedPassword("plain-password")).To(BeFalse())
Expect(agentpassword.IsHashedPassword("")).To(BeFalse())
Expect(agentpassword.IsHashedPassword("bosh-hmac-sha256")).To(BeFalse())
Expect(agentpassword.IsHashedPassword("other-prefix$secret")).To(BeFalse())
})
})

Describe("ParseHashedPassword and Matches", func() {
It("matches a fixed known-answer vector guarding the format", func() {
// salt: bytes 0..15 -> base64url "AAECAwQFBgcICQoLDA0ODw"
// password: "correct horse battery staple"
// HMAC-SHA256(key=salt, msg=password) -> base64url "EEqYVo55l7MGfPvwVPSRCq7RN6JXw-TBbKyW9De5Grw"
const knownVector = "bosh-hmac-sha256$AAECAwQFBgcICQoLDA0ODw$EEqYVo55l7MGfPvwVPSRCq7RN6JXw-TBbKyW9De5Grw"
v, err := agentpassword.ParseHashedPassword(knownVector)
Expect(err).NotTo(HaveOccurred())
Expect(v).NotTo(BeNil())
Expect(v.Matches("correct horse battery staple")).To(BeTrue())
Expect(v.Matches("wrong-password")).To(BeFalse())
})

It("rejects malformed verifiers", func() {
validSalt := base64.RawURLEncoding.EncodeToString(make([]byte, 16))
validKey := base64.RawURLEncoding.EncodeToString(make([]byte, 32))

// Missing prefix
for _, missingPrefix := range []string{
"",
"invalid-prefix$" + validSalt + "$" + validKey,
"bosh-hmac-sha256",
} {
v, err := agentpassword.ParseHashedPassword(missingPrefix)
Expect(err).To(HaveOccurred(), "expected error for missing prefix: %s", missingPrefix)
Expect(v).To(BeNil())
}

// Bad part count
for _, badParts := range []string{
"bosh-hmac-sha256$",
"bosh-hmac-sha256$" + validSalt,
"bosh-hmac-sha256$" + validSalt + "$" + validKey + "$extra",
"bosh-hmac-sha256$" + validSalt + "$" + validKey + "$extra$more",
} {
v, err := agentpassword.ParseHashedPassword(badParts)
Expect(err).To(HaveOccurred(), "expected error for bad part count: %s", badParts)
Expect(v).To(BeNil())
}

// Bad base64
for _, badB64 := range []string{
"bosh-hmac-sha256$invalid!salt$" + validKey,
"bosh-hmac-sha256$" + validSalt + "$invalid!key",
} {
v, err := agentpassword.ParseHashedPassword(badB64)
Expect(err).To(HaveOccurred(), "expected error for bad base64: %s", badB64)
Expect(v).To(BeNil())
}

// Std base64 alphabet (+ and /) rejected by RawURLEncoding.Strict()
for _, stdAlphabet := range []string{
// PR #487 vector used std base64 '+' in the key
"bosh-hmac-sha256$AAECAwQFBgcICQoLDA0ODw$EEqYVo55l7MGfPvwVPSRCq7RN6JXw+TBbKyW9De5Grw",
// Salt containing '/'
"bosh-hmac-sha256$AAECAwQFBgcICQoLDA0OD/$EEqYVo55l7MGfPvwVPSRCq7RN6JXw-TBbKyW9De5Grw",
} {
v, err := agentpassword.ParseHashedPassword(stdAlphabet)
Expect(err).To(HaveOccurred(), "expected error for std base64 alphabet: %s", stdAlphabet)
Expect(v).To(BeNil())
}

// Wrong salt length (must be 16 bytes)
shortSalt := base64.RawURLEncoding.EncodeToString(make([]byte, 15))
longSalt := base64.RawURLEncoding.EncodeToString(make([]byte, 17))
for _, badSalt := range []string{
"bosh-hmac-sha256$" + shortSalt + "$" + validKey,
"bosh-hmac-sha256$" + longSalt + "$" + validKey,
} {
v, err := agentpassword.ParseHashedPassword(badSalt)
Expect(err).To(HaveOccurred(), "expected error for wrong salt length: %s", badSalt)
Expect(v).To(BeNil())
}

// Wrong key length (must be 32 bytes)
shortKey := base64.RawURLEncoding.EncodeToString(make([]byte, 31))
longKey := base64.RawURLEncoding.EncodeToString(make([]byte, 33))
for _, badKey := range []string{
"bosh-hmac-sha256$" + validSalt + "$" + shortKey,
"bosh-hmac-sha256$" + validSalt + "$" + longKey,
} {
v, err := agentpassword.ParseHashedPassword(badKey)
Expect(err).To(HaveOccurred(), "expected error for wrong key length: %s", badKey)
Expect(v).To(BeNil())
}
})
})

Describe("HashURL", func() {
It("performs a complete HashURL -> ParseHashedPassword -> Matches round trip", func() {
raw := "https://vcap:mypassword@127.0.0.1:6868/path?foo=bar#frag"
hashed, err := agentpassword.HashURL(raw)
Expect(err).NotTo(HaveOccurred())

parsedURL, err := url.Parse(hashed)
Expect(err).NotTo(HaveOccurred())
Expect(parsedURL.Scheme).To(Equal("https"))
Expect(parsedURL.Host).To(Equal("127.0.0.1:6868"))
Expect(parsedURL.Path).To(Equal("/path"))
Expect(parsedURL.User.Username()).To(Equal("vcap"))

pw, ok := parsedURL.User.Password()
Expect(ok).To(BeTrue())

v, err := agentpassword.ParseHashedPassword(pw)
Expect(err).NotTo(HaveOccurred())
Expect(v.Matches("mypassword")).To(BeTrue())
Expect(v.Matches("wrong-password")).To(BeFalse())
})

It("produces different verifiers on two HashURL calls on the same input due to fresh salts", func() {
raw := "https://vcap:mypassword@127.0.0.1:6868"
h1, err := agentpassword.HashURL(raw)
Expect(err).NotTo(HaveOccurred())
h2, err := agentpassword.HashURL(raw)
Expect(err).NotTo(HaveOccurred())

Expect(h1).NotTo(Equal(h2))
})

It("ensures the password component contains only [A-Za-z0-9_$-] and appears unescaped in the returned string", func() {
raw := "https://vcap:plain-password-with-chars@127.0.0.1:6868"
hashed, err := agentpassword.HashURL(raw)
Expect(err).NotTo(HaveOccurred())

parsedURL, err := url.Parse(hashed)
Expect(err).NotTo(HaveOccurred())
pw, ok := parsedURL.User.Password()
Expect(ok).To(BeTrue())

Expect(pw).To(MatchRegexp(`^[A-Za-z0-9_$-]+$`))
// Verifier appears unescaped in the returned string
Expect(hashed).To(ContainSubstring(":" + pw + "@"))
})

It("passes non-https URLs through unchanged with nil error", func() {
for _, nonHTTPS := range []string{
"http://vcap:secret@127.0.0.1:6868",
"nats://user:pass@127.0.0.1:4222",
"custom://user:pass@127.0.0.1:8080/path",
"",
} {
result, err := agentpassword.HashURL(nonHTTPS)
Expect(err).NotTo(HaveOccurred())
Expect(result).To(Equal(nonHTTPS))
}
})

It("returns an error and does not echo the password for every HashURL error case", func() {
const secret = "very-secret-password-12345"

testCases := []struct {
name string
raw string
}{
{
name: "unparseable URL with control character",
raw: "https://vcap:" + secret + "@127.0.0.1:6868\n/invalid",
},
{
name: "missing userinfo",
raw: "https://127.0.0.1:6868",
},
{
name: "absent password",
raw: "https://vcap@127.0.0.1:6868",
},
{
name: "empty password",
raw: "https://vcap:@127.0.0.1:6868",
},
{
name: "password already has verifier prefix",
raw: "https://vcap:bosh-hmac-sha256$" + secret + "@127.0.0.1:6868",
},
}

for _, tc := range testCases {
By(tc.name)
hashed, err := agentpassword.HashURL(tc.raw)
Expect(err).To(HaveOccurred())
Expect(hashed).To(BeEmpty())
Expect(err.Error()).NotTo(ContainSubstring(secret))
Expect(err.Error()).NotTo(ContainSubstring("bosh-hmac-sha256$"))
}
})
})
})
6 changes: 5 additions & 1 deletion mbus/handler_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ func (p HandlerProvider) Get(
return NewNatsHandler(p.settingsService, f, p.logger, platform), nil
case "https":
mbusKeyPair := p.settingsService.GetSettings().GetMbusCerts()
return NewHTTPSHandler(mbusURL, mbusKeyPair, blobManager, p.logger, p.auditLogger), nil
handler, err := NewHTTPSHandler(mbusURL, mbusKeyPair, blobManager, p.logger, p.auditLogger)
if err != nil {
return nil, err
}
return handler, nil
default:
return nil, bosherr.Errorf("Message Bus Handler with scheme %s could not be found", mbusURL.Scheme)
}
Expand Down
6 changes: 4 additions & 2 deletions mbus/handler_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ var _ = Describe("HandlerProvider", func() {
settingsService.Settings.Mbus = "https://foo:bar@lol"
handler, err := provider.Get(platform, blobManager)
Expect(err).ToNot(HaveOccurred())
expectedHandler := mbus.NewHTTPSHandler(mbusURL, settings.CertKeyPair{}, blobManager, logger, auditLogger)
expectedHandler, constructorErr := mbus.NewHTTPSHandler(mbusURL, settings.CertKeyPair{}, blobManager, logger, auditLogger)
Expect(constructorErr).NotTo(HaveOccurred())
httpsHandler, ok := handler.(mbus.HTTPSHandler)
Expect(ok).To(BeTrue())
Expect(httpsHandler).To(Equal(expectedHandler))
Expand All @@ -73,13 +74,14 @@ var _ = Describe("HandlerProvider", func() {
settingsService.Settings.Env.Bosh.Mbus.Cert.PrivateKey = "private-key-pem-block"

handler, err := provider.Get(platform, blobManager)
expectedHandler := mbus.NewHTTPSHandler(
expectedHandler, constructorErr := mbus.NewHTTPSHandler(
mbusURL,
settingsService.Settings.Env.Bosh.Mbus.Cert,
blobManager,
logger,
auditLogger,
)
Expect(constructorErr).NotTo(HaveOccurred())
Expect(err).NotTo(HaveOccurred())
httpsHandler, ok := handler.(mbus.HTTPSHandler)
Expect(ok).To(BeTrue())
Expand Down
Loading
Loading