Repository navigation
Harden HTTPS agent metadata with password verifiers #487
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Alphasite
wants to merge
4
commits into
main
Choose a base branch
from
tnz-129825-verify-http-agent-password
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
881bed5
Accept salted HMAC-SHA256 verifiers for HTTPS mbus auth
Alphasite 9d03e85
Name the shared password API around hashed passwords
Alphasite 414c6a0
Put each HTTP authentication rejection condition on its own line
Alphasite 50adbab
Reject passwords that exceed the shared HTTP authentication limit
Alphasite File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, "$") | ||
| 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) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" { | ||
|
Alphasite marked this conversation as resolved.
|
||
| return raw, nil | ||
| } | ||
| if u.User == nil { | ||
| return "", errors.New("URL missing userinfo") | ||
| } | ||
| password, hasPassword := u.User.Password() | ||
|
Alphasite marked this conversation as resolved.
|
||
| if !hasPassword || password == "" { | ||
| return "", errors.New("URL missing password") | ||
|
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 { | ||
|
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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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$")) | ||
| } | ||
| }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.