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/credentials #298

Merged
merged 9 commits into from
Sep 20, 2024
19 changes: 7 additions & 12 deletions basculehash/bcrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
package basculehash

import (
"io"

"golang.org/x/crypto/bcrypt"
)

Expand All @@ -20,19 +18,16 @@ type Bcrypt struct {
Cost int
}

var _ Hasher = Bcrypt{}
var _ Comparer = Bcrypt{}

// Hash executes the bcrypt algorithm and write the output to dst.
func (b Bcrypt) Hash(dst io.Writer, plaintext []byte) (n int, err error) {
func (b Bcrypt) Hash(plaintext []byte) (Digest, error) {
hashed, err := bcrypt.GenerateFromPassword(plaintext, b.Cost)
if err == nil {
n, err = dst.Write(hashed)
}

return
return Digest(hashed), err
}

// 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
func (b Bcrypt) Matches(plaintext []byte, hash Digest) error {
return bcrypt.CompareHashAndPassword(hash, plaintext)
}
72 changes: 21 additions & 51 deletions basculehash/bcrypt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,68 +4,34 @@
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()
TestSuite
}

func (suite *BcryptTestSuite) TestHash() {
suite.Run("DefaultCost", func() {
var (
o strings.Builder
hasher = Bcrypt{}

n, err = hasher.Hash(&o, []byte(bcryptPlaintext))
suite.goodHash(
Bcrypt{}.Hash(suite.plaintext),
)

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.goodHash(
Bcrypt{Cost: 12}.Hash(suite.plaintext),
)

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.badHash(
Bcrypt{Cost: bcrypt.MaxCost + 100}.Hash(suite.plaintext),
)

suite.Error(err)
})
}

Expand All @@ -74,13 +40,15 @@ func (suite *BcryptTestSuite) TestMatches() {
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)
hasher = Bcrypt{Cost: cost}
hashed = suite.goodHash(
hasher.Hash(suite.plaintext),
)
)

suite.True(ok)
suite.NoError(err)
suite.NoError(
hasher.Matches(suite.plaintext, hashed),
)
})
}
})
Expand All @@ -89,13 +57,15 @@ func (suite *BcryptTestSuite) TestMatches() {
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)
hasher = Bcrypt{Cost: cost}
hashed = suite.goodHash(
hasher.Hash(suite.plaintext),
)
)

suite.False(ok)
suite.Error(err)
suite.Error(
hasher.Matches([]byte("a different plaintext"), hashed),
)
})
}
})
Expand Down
19 changes: 0 additions & 19 deletions basculehash/comparer.go

This file was deleted.

25 changes: 25 additions & 0 deletions basculehash/credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC
// SPDX-License-Identifier: Apache-2.0

package basculehash

import "context"

// Credentials is a source of principals and their associated digests. A
// credentials instance may be in-memory or a remote system.
type Credentials interface {
// Get returns the Digest associated with the given Principal.
// This method returns false if the principal did not exist.
Get(ctx context.Context, principal string) (d Digest, exists bool)

// Set associates a principal with a Digest. If the principal already
// exists, its digest is replaced.
Set(ctx context.Context, principal string, d Digest)

// Delete removes one or more principals from this set.
Delete(ctx context.Context, principals ...string)

// Update performs a bulk update of these credentials. Any existing
// principals are replaced.
Update(ctx context.Context, p Principals)
}
110 changes: 110 additions & 0 deletions basculehash/credentials_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC
// SPDX-License-Identifier: Apache-2.0

package basculehash

import (
"context"

"golang.org/x/crypto/bcrypt"
)

// CredentialsTestSuite runs a standard battery of tests against
// a Credentials implementation.
//
// Tests of UnmarshalJSON need to be done in tests of concrete types
// due to the way unmarshalling works in golang.
type CredentialsTestSuite[C Credentials] struct {
TestSuite

// Implementations should supply SetupTest and SetupSubTest
// methods that populate this member. Don't forget to call
// TestSuite.SetupTest and TestSuite.SetupSubTest!
credentials C

testCtx context.Context
hasher Hasher
}

// SetupSuite initializes a hasher and comparer to use when verifying
// and creating digests.
func (suite *CredentialsTestSuite[C]) SetupSuite() {
suite.testCtx = context.Background()
suite.hasher = Bcrypt{Cost: bcrypt.MinCost}
}

// exists asserts that a given principal exists with the given Digest.
func (suite *CredentialsTestSuite[C]) exists(principal string, expected Digest) {
d, ok := suite.credentials.Get(suite.testCtx, principal)
suite.Require().True(ok)
suite.Require().Equal(expected, d)
}

// notExists asserts that the given principal did not exist.
func (suite *CredentialsTestSuite[C]) notExists(principal string) {
d, ok := suite.credentials.Get(suite.testCtx, principal)
suite.Require().False(ok)
suite.Require().Empty(d)
}

// defaultHash creates a distinct hash of the suite plaintext for testing.
func (suite *CredentialsTestSuite[C]) defaultHash() Digest {
return suite.goodHash(
suite.hasher.Hash(
suite.plaintext,
),
)
}

func (suite *CredentialsTestSuite[C]) TestGetSetDelete() {
suite.T().Log("delete from empty")
suite.credentials.Delete(suite.testCtx, "joe")

suite.T().Log("add")
joeDigest := suite.defaultHash()
suite.credentials.Set(suite.testCtx, "joe", joeDigest)
suite.exists("joe", joeDigest)

suite.T().Log("add another")
fredDigest := suite.defaultHash()
suite.credentials.Set(suite.testCtx, "fred", fredDigest)
suite.exists("joe", joeDigest)
suite.exists("fred", fredDigest)

suite.T().Log("replace")
newJoeDigest := suite.defaultHash()
suite.Require().NotEqual(newJoeDigest, joeDigest) // hashes should always generate salt to make them distinct
suite.credentials.Set(suite.testCtx, "joe", newJoeDigest)
suite.exists("joe", newJoeDigest)
suite.exists("fred", fredDigest)

suite.T().Log("delete a principal")
suite.credentials.Delete(suite.testCtx, "fred")
suite.notExists("fred")
suite.exists("joe", newJoeDigest)
}

func (suite *CredentialsTestSuite[C]) TestUpdate() {
suite.credentials.Update(suite.testCtx, nil)

joeDigest := suite.defaultHash()
fredDigest := suite.defaultHash()
suite.credentials.Update(suite.testCtx, Principals{
"joe": joeDigest,
"fred": fredDigest,
})

suite.exists("joe", joeDigest)
suite.exists("fred", fredDigest)

joeDigest = suite.defaultHash()
moeDigest := suite.defaultHash()
suite.credentials.Update(suite.testCtx, Principals{
"joe": joeDigest,
"moe": moeDigest,
})

suite.exists("joe", joeDigest)
suite.exists("fred", fredDigest)
suite.exists("moe", moeDigest)
}
42 changes: 42 additions & 0 deletions basculehash/digest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC
// SPDX-License-Identifier: Apache-2.0

package basculehash

import "io"

// Digest is the result of applying a Hasher to plaintext.
// A digest must be valid UTF-8, preferably using the format
// described by https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md.
type Digest []byte

// Copy returns a distinct copy of this digest.
func (d Digest) Copy() Digest {
clone := make(Digest, len(d))
copy(clone, d)
return clone
}

// String returns this Digest as is, but cast as a string.
func (d Digest) String() string {
return string(d)
}

// MarshalText simply returns this Digest as a byte slice. This method ensures
// that the digest is written as is instead of encoded as base64 or some other
// encoding.
func (d Digest) MarshalText() ([]byte, error) {
return []byte(d), nil
}

// UnmarshalText uses the given text as is.
func (d *Digest) UnmarshalText(text []byte) error {
*d = text
return nil
}

// WriteTo writes this digest to the given writer.
func (d Digest) WriteTo(dst io.Writer) (int64, error) {
c, err := dst.Write(d)
return int64(c), err
}
Loading