diff --git a/go.mod b/go.mod index 8e3022b679..943bdef153 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/Masterminds/semver v1.5.0 github.com/MicahParks/keyfunc/v2 v2.1.0 github.com/Nerzal/gocloak/v13 v13.9.0 + github.com/alexedwards/argon2id v1.0.0 github.com/bbalet/stopwords v1.0.0 github.com/beevik/etree v1.8.0 github.com/blevesearch/bleve/v2 v2.6.1 @@ -136,7 +137,6 @@ require ( github.com/RoaringBitmap/roaring/v2 v2.14.5 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/ajg/form v1.5.1 // indirect - github.com/alexedwards/argon2id v1.0.0 // indirect github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 // indirect github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/armon/go-radix v1.0.0 // indirect diff --git a/services/auth-guest/pkg/service/pin/pin.go b/services/auth-guest/pkg/service/pin/pin.go new file mode 100644 index 0000000000..50e18e581c --- /dev/null +++ b/services/auth-guest/pkg/service/pin/pin.go @@ -0,0 +1,51 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package pin + +import ( + "crypto/rand" + "errors" + "fmt" + "math/big" + + "github.com/alexedwards/argon2id" +) + +const Length = 6 + +var ErrInvalidPin = errors.New("invalid pin") +var ErrInvalidHash = errors.New("invalid pin hash") + +func Generate() (string, error) { + n, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + return "", fmt.Errorf("could not generate random pin: %w", err) + } + + return fmt.Sprintf("%06d", n.Int64()), nil +} + +func Hash(p string) (string, error) { + if p == "" { + return "", ErrInvalidPin + } + + return argon2id.CreateHash(p, argon2id.DefaultParams) +} + +func Verify(candidate, hash string) (bool, error) { + if hash == "" { + return false, ErrInvalidHash + } + + match, err := argon2id.ComparePasswordAndHash(candidate, hash) + if err != nil { + if errors.Is(err, argon2id.ErrInvalidHash) { + return false, ErrInvalidHash + } + return false, err + } + + return match, nil +} diff --git a/services/auth-guest/pkg/service/pin/pin_test.go b/services/auth-guest/pkg/service/pin/pin_test.go new file mode 100644 index 0000000000..ae2a48d1c9 --- /dev/null +++ b/services/auth-guest/pkg/service/pin/pin_test.go @@ -0,0 +1,70 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package pin + +import ( + "strings" + "testing" + "unicode" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerate(t *testing.T) { + for range 50 { + got, err := Generate() + require.NoError(t, err) + assert.Len(t, got, Length) + for _, r := range got { + assert.True(t, unicode.IsDigit(r)) + } + } +} + +func TestGenerateUnique(t *testing.T) { + seen := make(map[string]struct{}) + for range 100 { + got, err := Generate() + require.NoError(t, err) + seen[got] = struct{}{} + } + + assert.GreaterOrEqual(t, len(seen), 2) +} + +func TestHashProducesArgon2id(t *testing.T) { + h, err := Hash("123456") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(h, "$argon2id$")) + assert.NotEqual(t, "123456", h) +} + +func TestHashEmptyPin(t *testing.T) { + _, err := Hash("") + assert.ErrorIs(t, err, ErrInvalidPin) +} + +func TestVerify(t *testing.T) { + h, err := Hash("123456") + require.NoError(t, err) + + match, err := Verify("123456", h) + require.NoError(t, err) + assert.True(t, match) + + match, err = Verify("654321", h) + require.NoError(t, err) + assert.False(t, match) +} + +func TestVerifyEmptyHash(t *testing.T) { + _, err := Verify("123456", "") + assert.ErrorIs(t, err, ErrInvalidHash) +} + +func TestVerifyMalformedHash(t *testing.T) { + _, err := Verify("123456", "not-a-hash") + assert.ErrorIs(t, err, ErrInvalidHash) +} diff --git a/services/auth-guest/pkg/service/storage/file_manager.go b/services/auth-guest/pkg/service/storage/file_manager.go index ca65dbffdd..82931a3dda 100644 --- a/services/auth-guest/pkg/service/storage/file_manager.go +++ b/services/auth-guest/pkg/service/storage/file_manager.go @@ -50,7 +50,18 @@ func (s *FileManager) Add(rec Record) error { return err } - return s.add(rec) + return s.upsert(rec) +} + +// Replace writes the record, overwriting an existing record for the same share id hash. +func (s *FileManager) Replace(rec Record) error { + lock, err := s.lockRecord(rec.ShareIDHash) + if err != nil { + return err + } + defer func() { _ = lock.Unlock() }() + + return s.upsert(rec) } // Get returns the record for the given share id hash. @@ -100,7 +111,7 @@ func (s *FileManager) Redeem(shareIDHash string) error { } rec.Redeemed = true - return s.add(rec) + return s.upsert(rec) } func (s *FileManager) lockRecord(shareIDHash string) (*flock.Flock, error) { @@ -121,7 +132,7 @@ func (s *FileManager) lockRecord(shareIDHash string) (*flock.Flock, error) { return lock, nil } -func (s *FileManager) add(rec Record) error { +func (s *FileManager) upsert(rec Record) error { p, err := s.path(rec.ShareIDHash) if err != nil { return err diff --git a/services/auth-guest/pkg/service/storage/file_manager_test.go b/services/auth-guest/pkg/service/storage/file_manager_test.go index 737f505000..169a59dc01 100644 --- a/services/auth-guest/pkg/service/storage/file_manager_test.go +++ b/services/auth-guest/pkg/service/storage/file_manager_test.go @@ -39,6 +39,36 @@ func TestFileManagerAddGet(t *testing.T) { assert.Equal(t, rec, got) } +func TestFileManagerReplaceCreates(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + rec := newRecord("e0123456-7890-abcd-ef01-234567890abc") + require.NoError(t, s.Replace(rec)) + + got, err := s.Get(rec.ShareIDHash) + require.NoError(t, err) + assert.Equal(t, rec, got) +} + +func TestFileManagerReplaceOverwrites(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + rec := newRecord("e0123456-7890-abcd-ef01-234567890abc") + require.NoError(t, s.Add(rec)) + + rec.SecretHash = "other" + rec.PinHash = "pinhash" + rec.PinExpiry = time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC) + rec.Redeemed = true + require.NoError(t, s.Replace(rec)) + + got, err := s.Get(rec.ShareIDHash) + require.NoError(t, err) + assert.Equal(t, rec, got) +} + func TestFileManagerGetMissing(t *testing.T) { dir := t.TempDir() s := NewFileManager(dir) @@ -70,6 +100,7 @@ func TestFileManagerInvalidHash(t *testing.T) { require.ErrorIs(t, s.Remove("ab"), ErrInvalidHash) require.ErrorIs(t, s.Add(Record{ShareIDHash: "ab"}), ErrInvalidHash) + require.ErrorIs(t, s.Replace(Record{ShareIDHash: "ab"}), ErrInvalidHash) } func TestFileManagerRemove(t *testing.T) { diff --git a/services/auth-guest/pkg/service/storage/mocks/manager.go b/services/auth-guest/pkg/service/storage/mocks/manager.go index 674c553c58..13585e4547 100644 --- a/services/auth-guest/pkg/service/storage/mocks/manager.go +++ b/services/auth-guest/pkg/service/storage/mocks/manager.go @@ -248,3 +248,54 @@ func (_c *Manager_Remove_Call) RunAndReturn(run func(shareIDHash string) error) _c.Call.Return(run) return _c } + +// Replace provides a mock function for the type Manager +func (_mock *Manager) Replace(rec storage.Record) error { + ret := _mock.Called(rec) + + if len(ret) == 0 { + panic("no return value specified for Replace") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(storage.Record) error); ok { + r0 = returnFunc(rec) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Manager_Replace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Replace' +type Manager_Replace_Call struct { + *mock.Call +} + +// Replace is a helper method to define mock.On call +// - rec storage.Record +func (_e *Manager_Expecter) Replace(rec any) *Manager_Replace_Call { + return &Manager_Replace_Call{Call: _e.mock.On("Replace", rec)} +} + +func (_c *Manager_Replace_Call) Run(run func(rec storage.Record)) *Manager_Replace_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 storage.Record + if args[0] != nil { + arg0 = args[0].(storage.Record) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Manager_Replace_Call) Return(err error) *Manager_Replace_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Manager_Replace_Call) RunAndReturn(run func(rec storage.Record) error) *Manager_Replace_Call { + _c.Call.Return(run) + return _c +} diff --git a/services/auth-guest/pkg/service/storage/storage.go b/services/auth-guest/pkg/service/storage/storage.go index b9c83bd72e..1facf03151 100644 --- a/services/auth-guest/pkg/service/storage/storage.go +++ b/services/auth-guest/pkg/service/storage/storage.go @@ -17,12 +17,15 @@ type Record struct { ShareID string `json:"shareid"` ShareIDHash string `json:"shareidhash"` SecretHash string `json:"secrethash"` + PinHash string `json:"pinhash"` Expiry time.Time `json:"expiry,omitzero"` + PinExpiry time.Time `json:"pinexpiry,omitzero"` Redeemed bool `json:"redeemed"` } type Manager interface { Add(rec Record) error + Replace(rec Record) error Get(shareIDHash string) (Record, error) Remove(shareIDHash string) error Redeem(shareIDHash string) error