Skip to content

Commit

Permalink
Merge pull request #298 from xmidt-org/feature/credentials
Browse files Browse the repository at this point in the history
Feature/credentials
  • Loading branch information
johnabass authored Sep 20, 2024
2 parents e2127cf + 315f214 commit 4776c4a
Show file tree
Hide file tree
Showing 16 changed files with 751 additions and 92 deletions.
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

0 comments on commit 4776c4a

Please sign in to comment.