Skip to content
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

Feature/password hashing #291

Merged
merged 13 commits into from
Sep 3, 2024
Merged
5 changes: 1 addition & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions basculehash/bcrypt.go
Original file line number Diff line number Diff line change
@@ -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
}
106 changes: 106 additions & 0 deletions basculehash/bcrypt_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
19 changes: 19 additions & 0 deletions basculehash/comparer.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 8 additions & 0 deletions basculehash/doc.go
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions basculehash/hasher.go
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 5 additions & 0 deletions basculehttp/basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
57 changes: 57 additions & 0 deletions cmd/hash/cli.go
Original file line number Diff line number Diff line change
@@ -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:""`
}
41 changes: 41 additions & 0 deletions cmd/hash/main.go
Original file line number Diff line number Diff line change
@@ -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:])
}
Loading
Loading