Skip to content

Commit

Permalink
Discovery: opaque timestamp (#2653)
Browse files Browse the repository at this point in the history
  • Loading branch information
reinkrul authored Dec 11, 2023
1 parent 2560a09 commit c14d8af
Show file tree
Hide file tree
Showing 9 changed files with 289 additions and 99 deletions.
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 {
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

0 comments on commit c14d8af

Please sign in to comment.