diff --git a/.gitignore b/.gitignore index 69dfc9b..6b8d899 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,7 @@ *.so *.dylib *.swp - -# Example binaries -examples/acquirer/acquirer -examples/basculehttp/basculehttp +cmd/hash/hash # Test binary, build with `go test -c` *.test diff --git a/basculehash/bcrypt.go b/basculehash/bcrypt.go new file mode 100644 index 0000000..1e9b790 --- /dev/null +++ b/basculehash/bcrypt.go @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package basculehash + +import ( + "io" + + "golang.org/x/crypto/bcrypt" +) + +// Bcrypt is a Hasher and Comparer based around the bcrypt hashing +// algorithm. +type Bcrypt struct { + // Cost is the cost parameter for bcrypt. If unset, the internal + // bcrypt cost is used. If this value is higher than the max, + // Hash will return an error. + // + // See: https://pkg.go.dev/golang.org/x/crypto/bcrypt#pkg-constants + Cost int +} + +// Hash executes the bcrypt algorithm and write the output to dst. +func (b Bcrypt) Hash(dst io.Writer, plaintext []byte) (n int, err error) { + hashed, err := bcrypt.GenerateFromPassword(plaintext, b.Cost) + if err == nil { + n, err = dst.Write(hashed) + } + + return +} + +// Matches attempts to match a plaintext against its bcrypt hashed value. +func (b Bcrypt) Matches(plaintext, hash []byte) (ok bool, err error) { + err = bcrypt.CompareHashAndPassword(hash, plaintext) + ok = (err == nil) + return +} diff --git a/basculehash/bcrypt_test.go b/basculehash/bcrypt_test.go new file mode 100644 index 0000000..3ff1939 --- /dev/null +++ b/basculehash/bcrypt_test.go @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package basculehash + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "golang.org/x/crypto/bcrypt" +) + +const bcryptPlaintext string = "bcrypt plaintext" + +type BcryptTestSuite struct { + suite.Suite +} + +// goodHash returns a hash that is expected to be successful. +// The plaintext() is hashed with the given cost. +func (suite *BcryptTestSuite) goodHash(cost int) []byte { + var ( + b bytes.Buffer + hasher = Bcrypt{Cost: cost} + _, err = hasher.Hash(&b, []byte(bcryptPlaintext)) + ) + + suite.Require().NoError(err) + return b.Bytes() +} + +func (suite *BcryptTestSuite) TestHash() { + suite.Run("DefaultCost", func() { + var ( + o strings.Builder + hasher = Bcrypt{} + + n, err = hasher.Hash(&o, []byte(bcryptPlaintext)) + ) + + suite.NoError(err) + suite.Equal(o.Len(), n) + }) + + suite.Run("CustomCost", func() { + var ( + o strings.Builder + hasher = Bcrypt{Cost: 12} + + n, err = hasher.Hash(&o, []byte(bcryptPlaintext)) + ) + + suite.NoError(err) + suite.Equal(o.Len(), n) + }) + + suite.Run("CostTooHigh", func() { + var ( + o strings.Builder + hasher = Bcrypt{Cost: bcrypt.MaxCost + 100} + + _, err = hasher.Hash(&o, []byte(bcryptPlaintext)) + ) + + suite.Error(err) + }) +} + +func (suite *BcryptTestSuite) TestMatches() { + suite.Run("Success", func() { + for _, cost := range []int{0 /* default */, 4, 8} { + suite.Run(fmt.Sprintf("cost=%d", cost), func() { + var ( + hashed = suite.goodHash(cost) + hasher = Bcrypt{Cost: cost} + ok, err = hasher.Matches([]byte(bcryptPlaintext), hashed) + ) + + suite.True(ok) + suite.NoError(err) + }) + } + }) + + suite.Run("Fail", func() { + for _, cost := range []int{0 /* default */, 4, 8} { + suite.Run(fmt.Sprintf("cost=%d", cost), func() { + var ( + hashed = suite.goodHash(cost) + hasher = Bcrypt{Cost: cost} + ok, err = hasher.Matches([]byte("a different plaintext"), hashed) + ) + + suite.False(ok) + suite.Error(err) + }) + } + }) +} + +func TestBcrypt(t *testing.T) { + suite.Run(t, new(BcryptTestSuite)) +} diff --git a/basculehash/comparer.go b/basculehash/comparer.go new file mode 100644 index 0000000..7e27e55 --- /dev/null +++ b/basculehash/comparer.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package basculehash + +// Comparer is a strategy for comparing plaintext values with a +// hash value from a Hasher. +type Comparer interface { + // Matches tests if the given plaintext matches the given hash. + // For example, this method can test if a password matches the + // one-way hashed password from a config file or database. + // + // If this method returns true, the error will always be nil. + // If this method returns false, the error may be non-nil to + // indicate that the match failed due to a problem, such as + // the hash not being parseable. Client code that is just + // interested in a yes/no answer can disregard the error return. + Matches(plaintext, hash []byte) (bool, error) +} diff --git a/basculehash/doc.go b/basculehash/doc.go new file mode 100644 index 0000000..26460ef --- /dev/null +++ b/basculehash/doc.go @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +/* +Package basculehash provides basic hash support for things like passwords +or other sensitive data that needs to be stored externally to the application. +*/ +package basculehash diff --git a/basculehash/hasher.go b/basculehash/hasher.go new file mode 100644 index 0000000..baeebe7 --- /dev/null +++ b/basculehash/hasher.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package basculehash + +import ( + "io" +) + +// Hasher is a strategy for one-way hashing. +type Hasher interface { + // Hash writes the hash of a plaintext to a writer. The number of + // bytes written along with any error is returned. + // + // The format of the written hash must be ASCII. The recommended + // format is the modular crypt format, which bcrypt uses. + Hash(dst io.Writer, plaintext []byte) (int, error) +} diff --git a/basculehttp/basic.go b/basculehttp/basic.go index f1704f8..9c926ff 100644 --- a/basculehttp/basic.go +++ b/basculehttp/basic.go @@ -14,7 +14,12 @@ import ( // BasicToken is the interface that Basic Auth tokens implement. type BasicToken interface { + // UserName is the user name in the basic auth string and will + // be e the same as Principal(). UserName() string + + // Password returns the password from the basic auth string. + // This also permits a BasicToken to be used with bascule.GetPassword. Password() string } diff --git a/cmd/hash/cli.go b/cmd/hash/cli.go new file mode 100644 index 0000000..62be675 --- /dev/null +++ b/cmd/hash/cli.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + + "github.com/alecthomas/kong" + "github.com/xmidt-org/bascule/basculehash" + "golang.org/x/crypto/bcrypt" +) + +const ( + // MaxBcryptPlaintextLength is the maximum length of the input that + // bcrypt will operate on. This value isn't exposed via the + // golang.org/x/crypto/bcrypt package. + MaxBcryptPlaintextLength = 72 +) + +// Bcrypt is the subcommand for the bcrypt algorithm. +type Bcrypt struct { + Cost int `default:"10" short:"c" help:"the cost parameter for bcrypt. Must be between 4 and 31, inclusive."` + Plaintext string `arg:"" required:"" help:"the plaintext (e.g. password) to hash. This cannot exceed 72 bytes in length."` +} + +func (cmd *Bcrypt) Validate() error { + switch { + case cmd.Cost < bcrypt.MinCost: + return fmt.Errorf("Cost cannot be less than %d", bcrypt.MinCost) + + case cmd.Cost > bcrypt.MaxCost: + return fmt.Errorf("Cost cannot be greater than %d", bcrypt.MaxCost) + + case len(cmd.Plaintext) > MaxBcryptPlaintextLength: + return fmt.Errorf("Plaintext length cannot exceed %d bytes", MaxBcryptPlaintextLength) + + default: + return nil + } +} + +func (cmd *Bcrypt) Run(kong *kong.Kong) error { + hasher := basculehash.Bcrypt{ + Cost: cmd.Cost, + } + + _, err := hasher.Hash(kong.Stdout, []byte(cmd.Plaintext)) + return err +} + +// CLI is the top grammar node for the command-line tool. +type CLI struct { + // Bcrypt is the bcrypt subcommand. This is the only supported hash + // algorithm right now. + Bcrypt Bcrypt `cmd:""` +} diff --git a/cmd/hash/main.go b/cmd/hash/main.go new file mode 100644 index 0000000..3fd52c1 --- /dev/null +++ b/cmd/hash/main.go @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + + "github.com/alecthomas/kong" +) + +func newKong(extra ...kong.Option) (*kong.Kong, error) { + return kong.New( + new(CLI), + append( + []kong.Option{ + kong.UsageOnError(), + kong.Description("hashes plaintext using bascule's infrastructure"), + }, + extra..., + )..., + ) +} + +func run(args []string, extra ...kong.Option) { + var ctx *kong.Context + k, err := newKong(extra...) + if err == nil { + ctx, err = k.Parse(args) + } + + if err == nil { + err = ctx.Run() + } + + k.FatalIfErrorf(err) +} + +func main() { + run(os.Args[1:]) +} diff --git a/cmd/hash/main_test.go b/cmd/hash/main_test.go new file mode 100644 index 0000000..9d41711 --- /dev/null +++ b/cmd/hash/main_test.go @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "strconv" + "testing" + + "github.com/alecthomas/kong" + "github.com/stretchr/testify/suite" + "golang.org/x/crypto/bcrypt" +) + +type RunTestSuite struct { + suite.Suite + + kongOptions []kong.Option + + stdout bytes.Buffer + stderr bytes.Buffer + exitCode int +} + +func (suite *RunTestSuite) exitFunc(code int) { + suite.exitCode = code +} + +func (suite *RunTestSuite) SetupSuite() { + suite.kongOptions = []kong.Option{ + kong.Writers(&suite.stdout, &suite.stderr), + kong.Exit(suite.exitFunc), + } +} + +func (suite *RunTestSuite) SetupTest() { + suite.stdout.Reset() + suite.stderr.Reset() + suite.exitCode = 0 +} + +func (suite *RunTestSuite) SetupSubTest() { + suite.SetupTest() +} + +func (suite *RunTestSuite) testBcryptSubcommandInvalidParameters() { + testCases := []struct { + args []string + }{ + { + args: []string{"bcrypt"}, + }, + { + args: []string{"bcrypt", "--cost", "123", "plaintext"}, + }, + { + args: []string{"bcrypt", "-c", "123", "plaintext"}, + }, + { + args: []string{"bcrypt", "--cost", "1", "plaintext"}, + }, + { + args: []string{"bcrypt", "-c", "1", "plaintext"}, + }, + { + args: []string{"bcrypt", "this plaintext is way to long ... asdfoiuwelrkjhsldkjfp983yu5pkljheflkajsodifuypwieuyrtplkahjsdflkajhsdf"}, + }, + } + + for i, testCase := range testCases { + suite.Run(strconv.Itoa(i), func() { + run(testCase.args, suite.kongOptions...) + + suite.NotEqual(0, suite.exitCode) + suite.Greater(suite.stdout.Len(), 0) // the usage on error goes to stdout + suite.Greater(suite.stderr.Len(), 0) + }) + } +} + +func (suite *RunTestSuite) testBcryptSubcommandSuccess() { + const plaintext string = "plaintext" + + testCases := []struct { + args []string + }{ + { + args: []string{"bcrypt", plaintext}, + }, + { + args: []string{"bcrypt", "-c", "5", plaintext}, + }, + { + args: []string{"bcrypt", "--cost", "9", plaintext}, + }, + } + + for i, testCase := range testCases { + suite.Run(strconv.Itoa(i), func() { + run(testCase.args, suite.kongOptions...) + + suite.Zero(suite.exitCode) + suite.Greater(suite.stdout.Len(), 0) + suite.Zero(suite.stderr.Len()) + + // what's written to stdout should be parseable as a bcrypt hash + suite.NoError( + bcrypt.CompareHashAndPassword( + suite.stdout.Bytes(), + []byte(plaintext), + ), + ) + }) + } +} + +func (suite *RunTestSuite) TestBcryptSubcommand() { + suite.Run("InvalidParameters", suite.testBcryptSubcommandInvalidParameters) + suite.Run("Success", suite.testBcryptSubcommandSuccess) +} + +func TestRun(t *testing.T) { + suite.Run(t, new(RunTestSuite)) +} diff --git a/doc.go b/doc.go index 8380f6e..3bdcceb 100644 --- a/doc.go +++ b/doc.go @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 /* -Package bascule provides a configurable way to validate an auth token. +Package bascule implements authentication and authorization workflows, along +with commonly needed supporting infrastructure. */ package bascule diff --git a/go.mod b/go.mod index 9da656a..07ea631 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module github.com/xmidt-org/bascule go 1.23 require ( + github.com/alecthomas/kong v0.9.0 github.com/lestrrat-go/jwx/v2 v2.1.1 github.com/stretchr/testify v1.9.0 go.uber.org/multierr v1.11.0 + golang.org/x/crypto v0.26.0 ) require ( @@ -22,8 +24,7 @@ require ( github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - golang.org/x/crypto v0.25.0 // indirect - golang.org/x/sys v0.22.0 // indirect + golang.org/x/sys v0.24.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 143d6b7..954729e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= +github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= +github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -6,6 +12,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnN github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -43,10 +51,10 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/mocks_test.go b/mocks_test.go index 0f8be0c..6bae719 100644 --- a/mocks_test.go +++ b/mocks_test.go @@ -21,6 +21,18 @@ func (m *mockToken) ExpectPrincipal(v string) *mock.Call { return m.On("Principal").Return(v) } +type mockTokenWithPassword struct { + mockToken +} + +func (m *mockTokenWithPassword) Password() string { + return m.Called().String(0) +} + +func (m *mockTokenWithPassword) ExpectPassword(v string) *mock.Call { + return m.On("Password").Return(v) +} + type mockTokenWithCapabilities struct { mockToken } diff --git a/password.go b/password.go new file mode 100644 index 0000000..135eb85 --- /dev/null +++ b/password.go @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package bascule + +// Passworder is an optional interface that a Token may implement that +// provides access to an associated password. Tokens derived from +// basic authentication will implement this interface. +type Passworder interface { + // Password returns the password associated with this Token. + Password() string +} + +// GetPassword returns any password associated with the given Token. +// +// If the token implements Passworder, the result of the Password() +// method is returned along with true. Otherwise, this function returns +// the empty string and false to indicate that the Token did not carry +// an associated password. +func GetPassword(t Token) (password string, exists bool) { + var p Passworder + if TokenAs(t, &p) { + password = p.Password() + exists = true + } + + return +} diff --git a/password_test.go b/password_test.go new file mode 100644 index 0000000..2e9a5fc --- /dev/null +++ b/password_test.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package bascule + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type PasswordTestSuite struct { + suite.Suite +} + +func (suite *PasswordTestSuite) TestGetPassword() { + suite.Run("NoPassword", func() { + token := new(mockToken) + password, exists := GetPassword(token) + + suite.Empty(password) + suite.False(exists) + token.AssertExpectations(suite.T()) + }) + + suite.Run("WithPassword", func() { + const expectedPassword = "this is an expected password" //nolint:gosec + token := new(mockTokenWithPassword) + token.ExpectPassword(expectedPassword) + + password, exists := GetPassword(token) + suite.Equal(expectedPassword, password) + suite.True(exists) + token.AssertExpectations(suite.T()) + }) +} + +func TestPassword(t *testing.T) { + suite.Run(t, new(PasswordTestSuite)) +}