Skip to content

Commit

Permalink
Merge pull request #291 from xmidt-org/feature/password-hashing
Browse files Browse the repository at this point in the history
Feature/password hashing
  • Loading branch information
johnabass authored Sep 3, 2024
2 parents af4b32b + a94899c commit 2ff7372
Show file tree
Hide file tree
Showing 16 changed files with 515 additions and 11 deletions.
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

0 comments on commit 2ff7372

Please sign in to comment.