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

Discovery: opaque timestamp #2653

Merged
merged 6 commits into from
Dec 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions discovery/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,61 @@ package discovery
import (
"errors"
"github.com/nuts-foundation/go-did/vc"
"math"
"strconv"
"strings"
)

// Timestamp is value that references a point in the list.
// Tag is value that references a point in the list.
// It is used by clients to request new entries since their last query.
// It is opaque for clients: they should not try to interpret it.
// The server who issued the tag can interpret it as Lamport timestamp.
type Tag string

// Timestamp decodes the Tag into a Timestamp, which is a monotonically increasing integer value (Lamport timestamp).
// Tags should only be decoded by the server who issued it, so the server should provide the stored tag prefix.
// The tag prefix is a random value that is generated when the service is created.
// It is not a secret; it only makes sure clients receive the complete presentation list when they switch servers for a specific Discovery Service:
// servers return the complete list when the client passes a timestamp the server can't decode.
func (t Tag) Timestamp(tagPrefix string) *Timestamp {
trimmed := strings.TrimPrefix(string(t), tagPrefix)
if len(trimmed) == len(string(t)) {
// Invalid tag prefix
return nil
}
result, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
// Not a number
return nil
}
if result < 0 || result > math.MaxUint64 {
// Invalid uint64
return nil
}
lamport := Timestamp(result)
return &lamport
}

// Empty returns true if the Tag is empty.
func (t Tag) Empty() bool {
return len(t) == 0
}

// Timestamp is the interpreted Tag.
// It's implemented as lamport timestamp (https://en.wikipedia.org/wiki/Lamport_timestamp);
// it is incremented when a new entry is added to the list.
// Pass 0 to start at the beginning of the list.
type Timestamp uint64

// Tag returns the Timestamp as Tag.
func (l Timestamp) Tag(serviceSeed string) Tag {
reinkrul marked this conversation as resolved.
Show resolved Hide resolved
return Tag(serviceSeed + strconv.FormatUint(uint64(l), 10))
}

func (l Timestamp) Increment() Timestamp {
return l + 1
}

// ErrServiceNotFound is returned when a service (ID) is not found in the discovery service.
var ErrServiceNotFound = errors.New("discovery service not found")

Expand All @@ -43,7 +89,7 @@ type Server interface {
// If the presentation is not valid or it does not conform to the Service ServiceDefinition, it returns an error.
Add(serviceID string, presentation vc.VerifiablePresentation) error
// Get retrieves the presentations for the given service, starting at the given timestamp.
Get(serviceID string, startAt Timestamp) ([]vc.VerifiablePresentation, *Timestamp, error)
Get(serviceID string, startAt *Tag) ([]vc.VerifiablePresentation, *Tag, error)
}

// Client defines the API for Discovery Clients.
Expand Down
61 changes: 61 additions & 0 deletions discovery/interface_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2023 Nuts community
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

package discovery

import (
"github.com/stretchr/testify/assert"
"testing"
)

func TestTag_Empty(t *testing.T) {
t.Run("empty", func(t *testing.T) {
assert.True(t, Tag("").Empty())
})
t.Run("not empty", func(t *testing.T) {
assert.False(t, Tag("not empty").Empty())
})
}

func TestTag_Timestamp(t *testing.T) {
t.Run("invalid tag prefix", func(t *testing.T) {
assert.Nil(t, Tag("invalid tag prefix").Timestamp("tag prefix"))
})
t.Run("not a number", func(t *testing.T) {
assert.Nil(t, Tag("tag prefix").Timestamp("tag prefixnot a number"))
})
t.Run("invalid uint64", func(t *testing.T) {
assert.Nil(t, Tag("tag prefix").Timestamp("tag prefix"))
})
t.Run("valid (small number)", func(t *testing.T) {
assert.Equal(t, Timestamp(1), *Tag("tag prefix1").Timestamp("tag prefix"))
})
t.Run("valid (large number)", func(t *testing.T) {
assert.Equal(t, Timestamp(1234567890), *Tag("tag prefix1234567890").Timestamp("tag prefix"))
})
}

func TestTimestamp_Tag(t *testing.T) {
assert.Equal(t, Tag("tag prefix1"), Timestamp(1).Tag("tag prefix"))
}

func TestTimestamp_Increment(t *testing.T) {
assert.Equal(t, Timestamp(1), Timestamp(0).Increment())
assert.Equal(t, Timestamp(2), Timestamp(1).Increment())
assert.Equal(t, Timestamp(1234567890), Timestamp(1234567889).Increment())
}
4 changes: 2 additions & 2 deletions discovery/mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 17 additions & 20 deletions discovery/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (m *Module) Configure(_ core.ServerConfig) error {

func (m *Module) Start() error {
var err error
m.store, err = newSQLStore(m.storageInstance.GetSQLDatabase(), m.services)
m.store, err = newSQLStore(m.storageInstance.GetSQLDatabase(), m.services, m.serverDefinitions)
if err != nil {
return err
}
Expand All @@ -109,11 +109,8 @@ func (m *Module) Config() interface{} {

func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) error {
// First, simple sanity checks
definition, serviceExists := m.services[serviceID]
if !serviceExists {
return ErrServiceNotFound
}
if _, isMaintainer := m.serverDefinitions[serviceID]; !isMaintainer {
definition, isServer := m.serverDefinitions[serviceID]
if !isServer {
return ErrServerModeDisabled
}
if presentation.Format() != vc.JWTPresentationProofFormat {
Expand Down Expand Up @@ -210,21 +207,11 @@ func (m *Module) validateRetraction(serviceID string, presentation vc.Verifiable
return nil
}

// validateAudience checks if the given audience of the presentation matches the service ID.
func validateAudience(service ServiceDefinition, audience []string) error {
for _, audienceID := range audience {
if audienceID == service.ID {
return nil
}
}
return errors.New("aud claim is missing or invalid")
}

func (m *Module) Get(serviceID string, startAt Timestamp) ([]vc.VerifiablePresentation, *Timestamp, error) {
if _, exists := m.services[serviceID]; !exists {
return nil, nil, ErrServiceNotFound
func (m *Module) Get(serviceID string, tag *Tag) ([]vc.VerifiablePresentation, *Tag, error) {
if _, exists := m.serverDefinitions[serviceID]; !exists {
return nil, nil, ErrServerModeDisabled
}
return m.store.get(serviceID, startAt)
return m.store.get(serviceID, tag)
}

func loadDefinitions(directory string) (map[string]ServiceDefinition, error) {
Expand Down Expand Up @@ -253,3 +240,13 @@ func loadDefinitions(directory string) (map[string]ServiceDefinition, error) {
}
return result, nil
}

// validateAudience checks if the given audience of the presentation matches the service ID.
func validateAudience(service ServiceDefinition, audience []string) error {
for _, audienceID := range audience {
if audienceID == service.ID {
return nil
}
}
return errors.New("aud claim is missing or invalid")
}
38 changes: 21 additions & 17 deletions discovery/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func Test_Module_Add(t *testing.T) {
storageEngine := storage.NewTestStorageEngine(t)
require.NoError(t, storageEngine.Start())

t.Run("not a maintainer", func(t *testing.T) {
t.Run("not a server", func(t *testing.T) {
m, _ := setupModule(t, storageEngine)

err := m.Add("other", vpAlice)
Expand All @@ -58,9 +58,10 @@ func Test_Module_Add(t *testing.T) {
err := m.Add(testServiceID, vpAlice)
require.EqualError(t, err, "presentation verification failed: failed")

_, timestamp, err := m.Get(testServiceID, 0)
_, tag, err := m.Get(testServiceID, nil)
require.NoError(t, err)
assert.Equal(t, Timestamp(0), *timestamp)
expectedTag := tagForTimestamp(t, m.store, testServiceID, 0)
assert.Equal(t, expectedTag, *tag)
})
t.Run("already exists", func(t *testing.T) {
m, presentationVerifier := setupModule(t, storageEngine)
Expand All @@ -76,6 +77,7 @@ func Test_Module_Add(t *testing.T) {
def := m.services[testServiceID]
def.PresentationMaxValidity = 1
m.services[testServiceID] = def
m.serverDefinitions[testServiceID] = def

err := m.Add(testServiceID, vpAlice)
assert.EqualError(t, err, "presentation is valid for too long (max 1s)")
Expand Down Expand Up @@ -103,11 +105,6 @@ func Test_Module_Add(t *testing.T) {
err := m.Add(testServiceID, vc.VerifiablePresentation{})
assert.EqualError(t, err, "only JWT presentations are supported")
})
t.Run("service unknown", func(t *testing.T) {
m, _ := setupModule(t, storageEngine)
err := m.Add("unknown", vpAlice)
assert.ErrorIs(t, err, ErrServiceNotFound)
})

t.Run("registration", func(t *testing.T) {
t.Run("ok", func(t *testing.T) {
Expand All @@ -117,9 +114,9 @@ func Test_Module_Add(t *testing.T) {
err := m.Add(testServiceID, vpAlice)
require.NoError(t, err)

_, timestamp, err := m.Get(testServiceID, 0)
_, tag, err := m.Get(testServiceID, nil)
require.NoError(t, err)
assert.Equal(t, Timestamp(1), *timestamp)
assert.Equal(t, "1", string(*tag)[tagPrefixLength:])
})
t.Run("valid longer than its credentials", func(t *testing.T) {
m, _ := setupModule(t, storageEngine)
Expand All @@ -144,8 +141,8 @@ func Test_Module_Add(t *testing.T) {
err := m.Add(testServiceID, otherVP)
require.ErrorContains(t, err, "presentation does not fulfill Presentation ServiceDefinition")

_, timestamp, _ := m.Get(testServiceID, 0)
assert.Equal(t, Timestamp(0), *timestamp)
_, tag, _ := m.Get(testServiceID, nil)
assert.Equal(t, "0", string(*tag)[tagPrefixLength:])
})
})
t.Run("retraction", func(t *testing.T) {
Expand Down Expand Up @@ -205,15 +202,22 @@ func Test_Module_Get(t *testing.T) {
t.Run("ok", func(t *testing.T) {
m, _ := setupModule(t, storageEngine)
require.NoError(t, m.store.add(testServiceID, vpAlice, nil))
presentations, timestamp, err := m.Get(testServiceID, 0)
presentations, tag, err := m.Get(testServiceID, nil)
assert.NoError(t, err)
assert.Equal(t, []vc.VerifiablePresentation{vpAlice}, presentations)
assert.Equal(t, Timestamp(1), *timestamp)
assert.Equal(t, "1", string(*tag)[tagPrefixLength:])
})
t.Run("ok - retrieve delta", func(t *testing.T) {
m, _ := setupModule(t, storageEngine)
require.NoError(t, m.store.add(testServiceID, vpAlice, nil))
presentations, _, err := m.Get(testServiceID, nil)
require.NoError(t, err)
require.Len(t, presentations, 1)
})
t.Run("service unknown", func(t *testing.T) {
t.Run("not a server for this service ID", func(t *testing.T) {
m, _ := setupModule(t, storageEngine)
_, _, err := m.Get("unknown", 0)
assert.ErrorIs(t, err, ErrServiceNotFound)
_, _, err := m.Get("other", nil)
assert.ErrorIs(t, err, ErrServerModeDisabled)
})
}

Expand Down
Loading
Loading