From 0ec3b1f64ba82c872c832b1ecea8309f6a0934a5 Mon Sep 17 00:00:00 2001 From: reinkrul Date: Fri, 3 Nov 2023 16:17:03 +0100 Subject: [PATCH] Support relative DID URLs for verification method IDs (#90) --- did/did.go | 133 ++---------- did/did_test.go | 378 +++------------------------------- did/didurl.go | 169 ++++++++++++++++ did/didurl_test.go | 458 ++++++++++++++++++++++++++++++++++++++++++ did/document.go | 96 ++++++--- did/document_test.go | 176 +++++++++++++--- did/validator.go | 4 +- did/validator_test.go | 40 ++-- 8 files changed, 917 insertions(+), 537 deletions(-) create mode 100644 did/didurl.go create mode 100644 did/didurl_test.go diff --git a/did/did.go b/did/did.go index b24f849..a13785d 100644 --- a/did/did.go +++ b/did/did.go @@ -7,15 +7,12 @@ import ( "fmt" "github.com/nuts-foundation/go-did" "net/url" - "regexp" "strings" ) var _ fmt.Stringer = DID{} var _ encoding.TextMarshaler = DID{} -var didPattern = regexp.MustCompile(`^did:([a-z0-9]+):((?:(?:[a-zA-Z0-9.\-_:])+|(?:%[0-9a-fA-F]{2})+)+)(/.*?|)(\?.*?|)(#.*|)$`) - // DIDContextV1 contains the JSON-LD context for a DID Document const DIDContextV1 = "https://www.w3.org/ns/did/v1" @@ -33,19 +30,6 @@ type DID struct { // DecodedID is the method-specific ID, in unescaped form. // It is only set during parsing, and not used by the String() method. DecodedID string - // Path is the DID path without the leading '/', in escaped form. - Path string - // DecodedPath is the DID path without the leading '/', in unescaped form. - // It is only set during parsing, and not used by the String() method. - DecodedPath string - // Query contains the DID query key-value pairs, in unescaped form. - // String() will escape the values again, and order the keys alphabetically. - Query url.Values - // Fragment is the DID fragment without the leading '#', in escaped form. - Fragment string - // DecodedFragment is the DID fragment without the leading '#', in unescaped form. - // It is only set during parsing, and not used by the String() method. - DecodedFragment string } // Empty checks whether the DID is set or not @@ -58,15 +42,9 @@ func (d DID) String() string { if d.Empty() { return "" } - result := "did:" + d.Method + ":" + d.ID - if d.Path != "" { - result += "/" + d.Path - } - if len(d.Query) > 0 { - result += "?" + d.Query.Encode() - } - if d.Fragment != "" { - result += "#" + d.Fragment + var result string + if d.Method != "" { + result += "did:" + d.Method + ":" + d.ID } return result } @@ -77,17 +55,9 @@ func (d DID) MarshalText() ([]byte, error) { } // Equals checks whether the DID equals to another DID. -// When the DIDs // The check is case-sensitive. func (d DID) Equals(other DID) bool { - return d.cleanup().String() == other.cleanup().String() -} - -func (d DID) cleanup() DID { - if len(d.Query) == 0 { - d.Query = nil - } - return d + return d.String() == other.String() } // UnmarshalJSON unmarshals a DID encoded as JSON string, e.g.: @@ -98,7 +68,7 @@ func (d *DID) UnmarshalJSON(bytes []byte) error { if err != nil { return ErrInvalidDID.wrap(err) } - tmp, err := ParseDIDURL(didString) + tmp, err := ParseDID(didString) if err != nil { return err } @@ -106,112 +76,47 @@ func (d *DID) UnmarshalJSON(bytes []byte) error { return nil } -func (d *DID) IsURL() bool { - return d.Fragment != "" || len(d.Query) != 0 || d.Path != "" -} - // MarshalJSON marshals the DID to a JSON string func (d DID) MarshalJSON() ([]byte, error) { return json.Marshal(d.String()) } -// URI converts the DID to an URI. +// URI converts the DID to a URI. // URIs are used in Verifiable Credentials func (d DID) URI() ssi.URI { return ssi.URI{ URL: url.URL{ - Scheme: "did", - Opaque: fmt.Sprintf("%s:%s", d.Method, url.PathEscape(d.ID)), - Fragment: d.Fragment, + Scheme: "did", + Opaque: fmt.Sprintf("%s:%s", d.Method, url.PathEscape(d.ID)), }, } } -// WithoutURL returns a copy of the DID without URL parts (fragment, query, path). -func (d DID) WithoutURL() DID { - return DID{ - Method: d.Method, - ID: d.ID, - DecodedID: d.DecodedID, - } -} - -// ParseDIDURL parses a DID URL. -// https://www.w3.org/TR/did-core/#did-url-syntax -// A DID URL is a URL that builds on the DID scheme. -func ParseDIDURL(input string) (*DID, error) { - // There are 6 submatches (base 0) - // 0. complete DID - // 1. method - // 2. id - // 3. path (starting with '/') - // 4. query (starting with '?') - // 5. fragment (starting with '#') - matches := didPattern.FindStringSubmatch(input) - if len(matches) == 0 { - return nil, ErrInvalidDID - } - - result := DID{ - Method: matches[1], - ID: matches[2], - Path: strings.TrimPrefix(matches[3], "/"), - Fragment: strings.TrimPrefix(matches[5], "#"), - } - var err error - result.DecodedID, err = url.PathUnescape(result.ID) - if err != nil { - return nil, ErrInvalidDID.wrap(fmt.Errorf("invalid ID: %w", err)) - } - result.DecodedPath, err = url.PathUnescape(result.Path) - if err != nil { - return nil, ErrInvalidDID.wrap(fmt.Errorf("invalid path: %w", err)) - } - result.DecodedFragment, err = url.PathUnescape(result.Fragment) - if err != nil { - return nil, ErrInvalidDID.wrap(fmt.Errorf("invalid fragment: %w", err)) - } - result.Query, err = url.ParseQuery(strings.TrimPrefix(matches[4], "?")) - if err != nil { - return nil, ErrInvalidDID.wrap(err) - } - result = result.cleanup() - return &result, nil -} - // ParseDID parses a raw DID. // If the input contains a path, query or fragment, use the ParseDIDURL instead. // If it can't be parsed, an error is returned. func ParseDID(input string) (*DID, error) { - did, err := ParseDIDURL(input) + didURL, err := ParseDIDURL(input) if err != nil { return nil, err } - if did.IsURL() { - return nil, ErrInvalidDID.wrap(errors.New("DID can not have path, fragment or query params")) + if !strings.HasPrefix(didURL.String(), "did:") { + return nil, ErrInvalidDID.wrap(errors.New("DID must start with 'did:'")) } - return did, nil -} - -// must accepts a function like Parse and returns the value without error or panics otherwise. -func must(fn func(string) (*DID, error), input string) DID { - v, err := fn(input) - if err != nil { - panic(err) + if !didURL.urlEmpty() { + return nil, ErrInvalidDID.wrap(errors.New("DID can not have path, fragment or query params")) } - return *v + return &didURL.DID, nil } // MustParseDID is like ParseDID but panics if the string cannot be parsed. // It simplifies safe initialization of global variables holding compiled UUIDs. func MustParseDID(input string) DID { - return must(ParseDID, input) -} - -// MustParseDIDURL is like ParseDIDURL but panics if the string cannot be parsed. -// It simplifies safe initialization of global variables holding compiled UUIDs. -func MustParseDIDURL(input string) DID { - return must(ParseDIDURL, input) + result, err := ParseDID(input) + if err != nil { + panic(err) + } + return *result } // ErrInvalidDID is returned when a parser function is supplied with a string that can't be parsed as DID. diff --git a/did/did_test.go b/did/did_test.go index 6dc692f..6d0acc9 100644 --- a/did/did_test.go +++ b/did/did_test.go @@ -5,7 +5,6 @@ import ( "errors" "github.com/stretchr/testify/require" "io" - "net/url" "testing" "github.com/stretchr/testify/assert" @@ -45,8 +44,18 @@ func TestParseDID(t *testing.T) { assert.Nil(t, id) assert.ErrorIs(t, err, ErrInvalidDID) }) - t.Run("error - input is a DID URL", func(t *testing.T) { - id, err := ParseDID("did:nuts:123/path?query#fragment") + t.Run("error - is empty", func(t *testing.T) { + id, err := ParseDID("") + assert.Nil(t, id) + assert.EqualError(t, err, "invalid DID: DID must start with 'did:'") + }) + t.Run("error - is a DID URL, without DID", func(t *testing.T) { + id, err := ParseDID("#fragment") + assert.Nil(t, id) + assert.EqualError(t, err, "invalid DID: DID must start with 'did:'") + }) + t.Run("error - is a DID URL", func(t *testing.T) { + id, err := ParseDID("did:example:123/foo") assert.Nil(t, id) assert.EqualError(t, err, "invalid DID: DID can not have path, fragment or query params") }) @@ -58,210 +67,6 @@ func TestMustParseDID(t *testing.T) { }) } -func TestParseDIDURL(t *testing.T) { - t.Run("parse a DID URL", func(t *testing.T) { - id, err := ParseDIDURL("did:nuts:123/path?query#fragment") - assert.Equal(t, "did:nuts:123/path?query=#fragment", id.String()) - assert.NoError(t, err) - }) - t.Run("with escaped ID", func(t *testing.T) { - id, err := ParseDIDURL("did:example:fizz%20buzz") - require.NoError(t, err) - assert.Equal(t, "did:example:fizz%20buzz", id.String()) - assert.Equal(t, "fizz%20buzz", id.ID) - assert.Equal(t, "fizz buzz", id.DecodedID) - }) - t.Run("with fragment", func(t *testing.T) { - id, err := ParseDIDURL("did:example:123#fragment") - require.NoError(t, err) - assert.Equal(t, "did:example:123#fragment", id.String()) - assert.Equal(t, "fragment", id.Fragment) - }) - t.Run("with escaped fragment", func(t *testing.T) { - id, err := ParseDIDURL("did:example:123#frag%20ment") - require.NoError(t, err) - assert.Equal(t, "did:example:123#frag%20ment", id.String()) - assert.Equal(t, "frag%20ment", id.Fragment) - assert.Equal(t, "frag ment", id.DecodedFragment) - }) - t.Run("with path", func(t *testing.T) { - id, err := ParseDIDURL("did:example:123/subpath") - require.NoError(t, err) - assert.Equal(t, "123", id.ID) - assert.Equal(t, "subpath", id.Path) - }) - t.Run("escaped path", func(t *testing.T) { - id, err := ParseDIDURL("did:example:123/sub%20path") - require.NoError(t, err) - assert.Equal(t, "123", id.ID) - assert.Equal(t, "sub%20path", id.Path) - assert.Equal(t, "sub path", id.DecodedPath) - }) - t.Run("empty path", func(t *testing.T) { - id, err := ParseDIDURL("did:example:123/") - require.NoError(t, err) - assert.Equal(t, "123", id.ID) - assert.Equal(t, "", id.Path) - }) - t.Run("path and query", func(t *testing.T) { - id, err := ParseDIDURL("did:example:123/subpath?param=value") - require.NoError(t, err) - assert.Equal(t, "123", id.ID) - assert.Equal(t, "subpath", id.Path) - assert.Len(t, id.Query, 1) - assert.Equal(t, "value", id.Query.Get("param")) - }) - t.Run("did:web", func(t *testing.T) { - t.Run("root without port", func(t *testing.T) { - id, err := ParseDID("did:web:example.com") - require.NoError(t, err) - assert.Equal(t, "did:web:example.com", id.String()) - }) - t.Run("root with port", func(t *testing.T) { - id, err := ParseDID("did:web:example.com%3A3000") - require.NoError(t, err) - assert.Equal(t, "did:web:example.com%3A3000", id.String()) - }) - t.Run("subpath", func(t *testing.T) { - id, err := ParseDID("did:web:example.com%3A3000:user:alice") - require.NoError(t, err) - assert.Equal(t, "did:web:example.com%3A3000:user:alice", id.String()) - assert.Equal(t, "web", id.Method) - assert.Equal(t, "example.com%3A3000:user:alice", id.ID) - }) - t.Run("subpath without port", func(t *testing.T) { - id, err := ParseDID("did:web:example.com:u:5") - require.NoError(t, err) - assert.Equal(t, "did:web:example.com:u:5", id.String()) - assert.Equal(t, "web", id.Method) - assert.Equal(t, "example.com:u:5", id.ID) - }) - t.Run("path, query and fragment", func(t *testing.T) { - id, err := ParseDIDURL("did:web:example.com%3A3000:user:alice/foo/bar?param=value#fragment") - require.NoError(t, err) - assert.Equal(t, "did:web:example.com%3A3000:user:alice/foo/bar?param=value#fragment", id.String()) - assert.Equal(t, "web", id.Method) - assert.Equal(t, "example.com%3A3000:user:alice", id.ID) - assert.Equal(t, "foo/bar", id.Path) - assert.Len(t, id.Query, 1) - assert.Equal(t, "value", id.Query.Get("param")) - assert.Equal(t, "fragment", id.Fragment) - }) - }) - - t.Run("ok - parsed DID URL equals constructed one", func(t *testing.T) { - parsed, err := ParseDIDURL("did:nuts:123/path?key=value#fragment") - require.NoError(t, err) - constructed := DID{ - Method: "nuts", - ID: "123", - DecodedID: "123", - Path: "path", - DecodedPath: "path", - Query: url.Values{ - "key": []string{"value"}, - }, - Fragment: "fragment", - DecodedFragment: "fragment", - } - assert.Equal(t, constructed, *parsed) - }) - t.Run("ok - parsed DID URL equals constructed one (no query)", func(t *testing.T) { - parsed, err := ParseDIDURL("did:nuts:123/path#fragment") - require.NoError(t, err) - constructed := DID{ - Method: "nuts", - ID: "123", - DecodedID: "123", - Path: "path", - DecodedPath: "path", - Fragment: "fragment", - DecodedFragment: "fragment", - } - assert.Equal(t, constructed, *parsed) - }) - - t.Run("percent-encoded characters in ID are allowed", func(t *testing.T) { - parsed, err := ParseDIDURL("did:example:123%f8") - require.NoError(t, err) - constructed := DID{ - Method: "example", - ID: "123%f8", - DecodedID: "123\xf8", - } - assert.Equal(t, constructed, *parsed) - }) - - t.Run("format validation", func(t *testing.T) { - type testCase struct { - name string - did string - } - t.Run("valid DIDs", func(t *testing.T) { - testCases := []testCase{ - {name: "basic DID", did: "did:example:123"}, - {name: "with query", did: "did:example:123?foo=bar"}, - {name: "with fragment", did: "did:example:123#foo"}, - {name: "with path", did: "did:example:123/foo"}, - {name: "with query, fragment and path", did: "did:example:123/foo?key=value#fragment"}, - {name: "with semicolons", did: "did:example:123/foo?key=value#fragment"}, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - id, err := ParseDIDURL(tc.did) - assert.NoError(t, err, "expected no error for DID: "+tc.did) - assert.Equal(t, tc.did, id.String()) - }) - } - }) - t.Run("invalid DIDs", func(t *testing.T) { - testCases := []testCase{ - { - name: "no method", - did: "did:", - }, - { - name: "does not begin with 'did:' prefix", - did: "example:123", - }, - { - name: "method contains invalid character", - did: "did:example_:1234", - }, - { - name: "ID is empty", - did: "did:example:", - }, - { - name: "ID is empty, with path", - did: "did:example:/path", - }, - { - name: "ID is empty, with fragment", - did: "did:example:#fragment", - }, - { - name: "ID contains invalid chars", - did: "did:example:te@st", - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - id, err := ParseDIDURL(tc.did) - assert.Error(t, err, "expected an error for DID: "+tc.did) - assert.Nil(t, id) - }) - } - }) - }) -} - -func TestMustParseDIDURL(t *testing.T) { - assert.Panics(t, func() { - MustParseDIDURL("invalidDID") - }) -} - func TestDID_MarshalText(t *testing.T) { expected := "did:nuts:123" id, _ := ParseDID(expected) @@ -271,71 +76,21 @@ func TestDID_MarshalText(t *testing.T) { } func TestDID_Equal(t *testing.T) { - t.Run("DID", func(t *testing.T) { - const did = "did:example:123" - t.Run("equal", func(t *testing.T) { - assert.True(t, MustParseDID(did).Equals(MustParseDID(did))) - }) - t.Run("method differs", func(t *testing.T) { - assert.False(t, MustParseDID("did:example1:123").Equals(MustParseDID(did))) - }) - t.Run("ID differs", func(t *testing.T) { - assert.False(t, MustParseDID("did:example:1234").Equals(MustParseDID(did))) - }) - t.Run("one DID is empty", func(t *testing.T) { - assert.False(t, MustParseDID("did:example:1234").Equals(DID{})) - }) - t.Run("both DIDs are empty", func(t *testing.T) { - assert.True(t, DID{}.Equals(DID{})) - }) - t.Run("empty query (self)", func(t *testing.T) { - d1 := DID{ - Method: "example", - ID: "123", - Query: nil, - } - d2 := DID{ - Method: "example", - ID: "123", - Query: map[string][]string{}, - } - assert.True(t, d1.Equals(d2)) - }) - t.Run("empty query (other)", func(t *testing.T) { - d1 := DID{ - Method: "example", - ID: "123", - Query: map[string][]string{}, - } - d2 := DID{ - Method: "example", - ID: "123", - Query: nil, - } - assert.True(t, d1.Equals(d2)) - }) + const did = "did:example:123" + t.Run("equal", func(t *testing.T) { + assert.True(t, MustParseDID(did).Equals(MustParseDID(did))) }) - t.Run("DID URL", func(t *testing.T) { - t.Run("equal", func(t *testing.T) { - d1 := MustParseDIDURL("did:example:123/foo?key=value#fragment") - d2 := MustParseDIDURL("did:example:123/foo?key=value#fragment") - assert.True(t, d1.Equals(d2)) - }) - t.Run("fragment differs", func(t *testing.T) { - d1 := MustParseDIDURL("did:example:123/foo?key=value") - d2 := MustParseDIDURL("did:example:123/foo?key=value#fragment") - assert.False(t, d1.Equals(d2)) - }) - t.Run("query in different order", func(t *testing.T) { - d1 := MustParseDIDURL("did:example:123/foo?k1=a&k2=b") - d2 := MustParseDIDURL("did:example:123/foo?k2=b&k1=a") - assert.True(t, d1.Equals(d2)) - }) - t.Run("path differs", func(t *testing.T) { - d1 := MustParseDIDURL("did:example:123/fuzz?key=value#fragment") - d2 := MustParseDIDURL("did:example:123/fizz?key=value#fragment") - assert.False(t, d1.Equals(d2)) - }) + t.Run("method differs", func(t *testing.T) { + assert.False(t, MustParseDID("did:example1:123").Equals(MustParseDID(did))) + }) + t.Run("ID differs", func(t *testing.T) { + assert.False(t, MustParseDID("did:example:1234").Equals(MustParseDID(did))) + }) + t.Run("one DID is empty", func(t *testing.T) { + assert.False(t, MustParseDID("did:example:1234").Equals(DID{})) + }) + t.Run("both DIDs are empty", func(t *testing.T) { + assert.True(t, DID{}.Equals(DID{})) }) } @@ -359,62 +114,6 @@ func TestDID_String(t *testing.T) { expected: "", did: DID{}, }, - { - name: "with path", - expected: "did:example:123/foo", - did: DID{ - Method: "example", - ID: "123", - Path: "foo", - }, - }, - { - name: "with escapable characters in path", - expected: "did:example:123/fizz%20buzz", - did: DID{ - Method: "example", - ID: "123", - Path: "fizz%20buzz", - }, - }, - { - name: "with fragment", - expected: "did:example:123#fragment", - did: DID{ - Method: "example", - ID: "123", - Fragment: "fragment", - }, - }, - { - name: "with escapable characters in fragment", - expected: "did:example:123#fizz%20buzz", - did: DID{ - Method: "example", - ID: "123", - Fragment: "fizz%20buzz", - }, - }, - { - name: "with query", - expected: "did:example:123?key=value", - did: DID{ - Method: "example", - ID: "123", - Query: url.Values{"key": []string{"value"}}, - }, - }, - { - name: "with everything", - expected: "did:example:123/foo?key=value#fragment", - did: DID{ - Method: "example", - ID: "123", - Path: "foo", - Fragment: "fragment", - Query: url.Values{"key": []string{"value"}}, - }, - }, } for _, tc := range testCases { @@ -425,15 +124,9 @@ func TestDID_String(t *testing.T) { } func TestDID_Empty(t *testing.T) { - t.Run("not empty for filled did", func(t *testing.T) { - id, err := ParseDID("did:nuts:123") - if err != nil { - t.Errorf("unexpected error: %s", err) - return - } - assert.False(t, id.Empty()) + t.Run("DID", func(t *testing.T) { + assert.False(t, MustParseDID("did:nuts:123").Empty()) }) - t.Run("empty when just generated", func(t *testing.T) { id := DID{} assert.True(t, id.Empty()) @@ -458,18 +151,3 @@ func TestError(t *testing.T) { assert.True(t, errors.Is(actual, io.EOF)) assert.False(t, errors.Is(actual, io.ErrShortBuffer)) } - -func TestDID_WithoutURL(t *testing.T) { - t.Run("with encoded ID", func(t *testing.T) { - id := MustParseDIDURL("did:example:123%20/path?key=value#fragment").WithoutURL() - assert.Equal(t, "did:example:123%20", id.String()) - assert.Equal(t, "123 ", id.DecodedID) - assert.Empty(t, id.Path) - assert.Empty(t, id.Fragment) - assert.Empty(t, id.Query) - }) - t.Run("equalness", func(t *testing.T) { - id := MustParseDIDURL("did:example:123/path?key=value#fragment").WithoutURL() - assert.True(t, MustParseDID("did:example:123").Equals(id)) - }) -} diff --git a/did/didurl.go b/did/didurl.go new file mode 100644 index 0000000..46aeebf --- /dev/null +++ b/did/didurl.go @@ -0,0 +1,169 @@ +package did + +import ( + "encoding" + "encoding/json" + "fmt" + "github.com/nuts-foundation/go-did" + "net/url" + "regexp" + "strings" +) + +var _ fmt.Stringer = DIDURL{} +var _ encoding.TextMarshaler = DIDURL{} + +var didURLPattern = regexp.MustCompile(`^(did:([a-z0-9]+):((?:(?:[a-zA-Z0-9.\-_:])+|(?:%[0-9a-fA-F]{2})+)+)|)(/.*?|)(\?.*?|)(#.*|)$`) + +type DIDURL struct { + DID + + // Path is the DID path without the leading '/', in escaped form. + Path string + // DecodedPath is the DID path without the leading '/', in unescaped form. + // It is only set during parsing, and not used by the String() method. + DecodedPath string + // Query contains the DID query key-value pairs, in unescaped form. + // String() will escape the values again, and order the keys alphabetically. + Query url.Values + // Fragment is the DID fragment without the leading '#', in escaped form. + Fragment string + // DecodedFragment is the DID fragment without the leading '#', in unescaped form. + // It is only set during parsing, and not used by the String() method. + DecodedFragment string +} + +// Equals checks whether the DIDURL equals to another DIDURL. +// The check is case-sensitive. +func (d DIDURL) Equals(other DIDURL) bool { + return d.cleanup().String() == other.cleanup().String() +} + +// UnmarshalJSON unmarshals a DID URL encoded as JSON string, e.g.: +// "did:nuts:c0dc584345da8a0e1e7a584aa4a36c30ebdb79d907aff96fe0e90ee972f58a17#key-1" +func (d *DIDURL) UnmarshalJSON(bytes []byte) error { + var didString string + err := json.Unmarshal(bytes, &didString) + if err != nil { + return ErrInvalidDID.wrap(err) + } + tmp, err := ParseDIDURL(didString) + if err != nil { + return err + } + *d = *tmp + return nil +} + +// MarshalJSON marshals the DIDURL to a JSON string +func (d DIDURL) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) +} + +// Empty checks whether the DID is set or not +func (d DIDURL) Empty() bool { + return d.DID.Empty() && d.urlEmpty() +} + +// urlEmpty checks whether the URL part of the DID URL is set or not (path, fragment, or query). +func (d DIDURL) urlEmpty() bool { + return d.Path == "" && d.Fragment == "" && len(d.Query) == 0 +} + +// String returns the DID as formatted string. +func (d DIDURL) String() string { + if d.Empty() { + return "" + } + result := d.DID.String() + if d.Path != "" { + result += "/" + d.Path + } + if len(d.Query) > 0 { + result += "?" + d.Query.Encode() + } + if d.Fragment != "" { + result += "#" + d.Fragment + } + return result +} + +func (d DIDURL) cleanup() DIDURL { + if len(d.Query) == 0 { + d.Query = nil + } + return d +} + +// URI converts the DIDURL to a URI. +// URIs are used in Verifiable Credentials +func (d DIDURL) URI() ssi.URI { + var result ssi.URI + if !d.DID.Empty() { + result = d.DID.URI() + } + if d.Path != "" { + result.Opaque += "/" + d.Path + } + if len(d.Query) != 0 { + result.Opaque += "?" + d.Query.Encode() + } + result.Fragment = d.Fragment + return result +} + +// ParseDIDURL parses a DID URL. +// https://www.w3.org/TR/did-core/#did-url-syntax +// A DID URL is a URL that builds on the DID scheme. +func ParseDIDURL(input string) (*DIDURL, error) { + // There are 6 submatches (base 0) + // 0. DID + path + query + fragment + // 1. DID + // 2. method + // 3. id + // 4. path (starting with '/') + // 5. query (starting with '?') + // 6. fragment (starting with '#') + matches := didURLPattern.FindStringSubmatch(input) + if len(matches) == 0 { + return nil, ErrInvalidDID + } + + result := DIDURL{ + DID: DID{ + Method: matches[2], + ID: matches[3], + }, + Path: strings.TrimPrefix(matches[4], "/"), + Fragment: strings.TrimPrefix(matches[6], "#"), + } + var err error + result.DecodedID, err = url.PathUnescape(result.ID) + if err != nil { + return nil, ErrInvalidDID.wrap(fmt.Errorf("invalid ID: %w", err)) + } + result.DecodedPath, err = url.PathUnescape(result.Path) + if err != nil { + return nil, ErrInvalidDID.wrap(fmt.Errorf("invalid path: %w", err)) + } + result.DecodedFragment, err = url.PathUnescape(result.Fragment) + if err != nil { + return nil, ErrInvalidDID.wrap(fmt.Errorf("invalid fragment: %w", err)) + } + result.Query, err = url.ParseQuery(strings.TrimPrefix(matches[5], "?")) + if err != nil { + return nil, ErrInvalidDID.wrap(err) + } + result = result.cleanup() + return &result, nil +} + +// MustParseDIDURL is like ParseDIDURL but panics if the string cannot be parsed. +// It simplifies safe initialization of global variables holding compiled UUIDs. +func MustParseDIDURL(input string) DIDURL { + result, err := ParseDIDURL(input) + if err != nil { + panic(err) + } + return *result +} diff --git a/did/didurl_test.go b/did/didurl_test.go new file mode 100644 index 0000000..72cc9d6 --- /dev/null +++ b/did/didurl_test.go @@ -0,0 +1,458 @@ +package did + +import ( + "encoding/json" + "github.com/stretchr/testify/require" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDIDURL_UnmarshalJSON(t *testing.T) { + const input = `"did:example:123"` + id := DIDURL{} + err := json.Unmarshal([]byte(input), &id) + require.NoError(t, err) + assert.Equal(t, "did:example:123", id.String()) +} + +func TestDIDURL_MarshalJSON(t *testing.T) { + actual, err := json.Marshal(MustParseDIDURL("did:example:123")) + require.NoError(t, err) + assert.Equal(t, `"did:example:123"`, string(actual)) +} + +func TestParseDIDURL(t *testing.T) { + t.Run("parse a DID URL", func(t *testing.T) { + id, err := ParseDIDURL("did:example:123/path?query#fragment") + assert.Equal(t, "did:example:123/path?query=#fragment", id.String()) + assert.NoError(t, err) + }) + t.Run("with escaped ID", func(t *testing.T) { + id, err := ParseDIDURL("did:example:fizz%20buzz") + require.NoError(t, err) + assert.Equal(t, "did:example:fizz%20buzz", id.String()) + assert.Equal(t, "fizz%20buzz", id.ID) + assert.Equal(t, "fizz buzz", id.DecodedID) + }) + t.Run("with fragment", func(t *testing.T) { + id, err := ParseDIDURL("did:example:123#fragment") + require.NoError(t, err) + assert.Equal(t, "did:example:123#fragment", id.String()) + assert.Equal(t, "fragment", id.Fragment) + }) + t.Run("with escaped fragment", func(t *testing.T) { + id, err := ParseDIDURL("did:example:123#frag%20ment") + require.NoError(t, err) + assert.Equal(t, "did:example:123#frag%20ment", id.String()) + assert.Equal(t, "frag%20ment", id.Fragment) + assert.Equal(t, "frag ment", id.DecodedFragment) + }) + t.Run("with path", func(t *testing.T) { + id, err := ParseDIDURL("did:example:123/subpath") + require.NoError(t, err) + assert.Equal(t, "123", id.ID) + assert.Equal(t, "subpath", id.Path) + }) + t.Run("escaped path", func(t *testing.T) { + id, err := ParseDIDURL("did:example:123/sub%20path") + require.NoError(t, err) + assert.Equal(t, "123", id.ID) + assert.Equal(t, "sub%20path", id.Path) + assert.Equal(t, "sub path", id.DecodedPath) + }) + t.Run("empty path", func(t *testing.T) { + id, err := ParseDIDURL("did:example:123/") + require.NoError(t, err) + assert.Equal(t, "123", id.ID) + assert.Equal(t, "", id.Path) + }) + t.Run("path and query", func(t *testing.T) { + id, err := ParseDIDURL("did:example:123/subpath?param=value") + require.NoError(t, err) + assert.Equal(t, "123", id.ID) + assert.Equal(t, "subpath", id.Path) + assert.Len(t, id.Query, 1) + assert.Equal(t, "value", id.Query.Get("param")) + }) + t.Run("did:web", func(t *testing.T) { + t.Run("root without port", func(t *testing.T) { + id, err := ParseDID("did:web:example.com") + require.NoError(t, err) + assert.Equal(t, "did:web:example.com", id.String()) + }) + t.Run("root with port", func(t *testing.T) { + id, err := ParseDID("did:web:example.com%3A3000") + require.NoError(t, err) + assert.Equal(t, "did:web:example.com%3A3000", id.String()) + }) + t.Run("subpath", func(t *testing.T) { + id, err := ParseDID("did:web:example.com%3A3000:user:alice") + require.NoError(t, err) + assert.Equal(t, "did:web:example.com%3A3000:user:alice", id.String()) + assert.Equal(t, "web", id.Method) + assert.Equal(t, "example.com%3A3000:user:alice", id.ID) + }) + t.Run("subpath without port", func(t *testing.T) { + id, err := ParseDID("did:web:example.com:u:5") + require.NoError(t, err) + assert.Equal(t, "did:web:example.com:u:5", id.String()) + assert.Equal(t, "web", id.Method) + assert.Equal(t, "example.com:u:5", id.ID) + }) + t.Run("path, query and fragment", func(t *testing.T) { + id, err := ParseDIDURL("did:web:example.com%3A3000:user:alice/foo/bar?param=value#fragment") + require.NoError(t, err) + assert.Equal(t, "did:web:example.com%3A3000:user:alice/foo/bar?param=value#fragment", id.String()) + assert.Equal(t, "web", id.Method) + assert.Equal(t, "example.com%3A3000:user:alice", id.ID) + assert.Equal(t, "foo/bar", id.Path) + assert.Len(t, id.Query, 1) + assert.Equal(t, "value", id.Query.Get("param")) + assert.Equal(t, "fragment", id.Fragment) + }) + }) + + t.Run("ok - parsed DID URL equals constructed one", func(t *testing.T) { + parsed, err := ParseDIDURL("did:example:123/path?key=value#fragment") + require.NoError(t, err) + constructed := DIDURL{ + DID: DID{ + Method: "example", + ID: "123", + DecodedID: "123", + }, + Path: "path", + DecodedPath: "path", + Query: url.Values{ + "key": []string{"value"}, + }, + Fragment: "fragment", + DecodedFragment: "fragment", + } + assert.Equal(t, constructed, *parsed) + }) + t.Run("ok - parsed DID URL equals constructed one (no query)", func(t *testing.T) { + parsed, err := ParseDIDURL("did:example:123/path#fragment") + require.NoError(t, err) + constructed := DIDURL{ + DID: DID{ + Method: "example", + ID: "123", + DecodedID: "123", + }, + Path: "path", + DecodedPath: "path", + Fragment: "fragment", + DecodedFragment: "fragment", + } + assert.Equal(t, constructed, *parsed) + }) + + t.Run("percent-encoded characters in ID are allowed", func(t *testing.T) { + parsed, err := ParseDIDURL("did:example:123%f8") + require.NoError(t, err) + constructed := DIDURL{ + DID: DID{ + Method: "example", + ID: "123%f8", + DecodedID: "123\xf8", + }, + } + assert.Equal(t, constructed, *parsed) + }) + + t.Run("format validation", func(t *testing.T) { + type testCase struct { + name string + did string + } + t.Run("valid DIDs", func(t *testing.T) { + testCases := []testCase{ + {name: "basic DID", did: "did:example:123"}, + {name: "with query", did: "did:example:123?foo=bar"}, + {name: "with fragment", did: "did:example:123#foo"}, + {name: "with path", did: "did:example:123/foo"}, + {name: "with query, fragment and path", did: "did:example:123/foo?key=value#fragment"}, + {name: "with semicolons", did: "did:example:123/foo?key=value#fragment"}, + {name: "only fragment", did: "#fragment"}, + {name: "only query", did: "?key=value"}, + {name: "only query and fragment", did: "?key=value#fragment"}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + id, err := ParseDIDURL(tc.did) + assert.NoError(t, err, "expected no error for DID: "+tc.did) + assert.Equal(t, tc.did, id.String()) + }) + } + }) + t.Run("invalid DIDs", func(t *testing.T) { + testCases := []testCase{ + { + name: "no method", + did: "did:", + }, + { + name: "does not begin with 'did:' prefix", + did: "example:123", + }, + { + name: "method contains invalid character", + did: "did:example_:1234", + }, + { + name: "ID is empty", + did: "did:example:", + }, + { + name: "ID is empty, with path", + did: "did:example:/path", + }, + { + name: "ID is empty, with fragment", + did: "did:example:#fragment", + }, + { + name: "ID contains invalid chars", + did: "did:example:te@st", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + id, err := ParseDIDURL(tc.did) + assert.Error(t, err, "expected an error for DID: "+tc.did) + assert.Nil(t, id) + }) + } + }) + }) +} + +func TestMustParseDIDURL(t *testing.T) { + assert.Panics(t, func() { + MustParseDIDURL("invalidDID") + }) +} + +func TestDIDURL_MarshalText(t *testing.T) { + const expected = "did:example:123" + id := MustParseDIDURL(expected) + actual, err := id.MarshalText() + assert.NoError(t, err) + assert.Equal(t, []byte(expected), actual) +} + +func TestDIDURL_Equal(t *testing.T) { + t.Run("equal", func(t *testing.T) { + d1 := MustParseDIDURL("did:example:123/foo?key=value#fragment") + d2 := MustParseDIDURL("did:example:123/foo?key=value#fragment") + assert.True(t, d1.Equals(d2)) + }) + t.Run("method differs", func(t *testing.T) { + assert.False(t, MustParseDIDURL("did:example1:123").Equals(MustParseDIDURL("did:example:123"))) + }) + t.Run("ID differs", func(t *testing.T) { + assert.False(t, MustParseDIDURL("did:example:1234").Equals(MustParseDIDURL("did:example:123"))) + }) + t.Run("one DID is empty", func(t *testing.T) { + assert.False(t, MustParseDIDURL("did:example:1234").Equals(DIDURL{})) + }) + t.Run("both DIDs are empty", func(t *testing.T) { + assert.True(t, DIDURL{}.Equals(DIDURL{})) + }) + t.Run("fragment differs", func(t *testing.T) { + d1 := MustParseDIDURL("did:example:123/foo?key=value") + d2 := MustParseDIDURL("did:example:123/foo?key=value#fragment") + assert.False(t, d1.Equals(d2)) + }) + t.Run("query in different order", func(t *testing.T) { + d1 := MustParseDIDURL("did:example:123/foo?k1=a&k2=b") + d2 := MustParseDIDURL("did:example:123/foo?k2=b&k1=a") + assert.True(t, d1.Equals(d2)) + }) + t.Run("empty query (self)", func(t *testing.T) { + d1 := MustParseDIDURL("did:example:123/foo") + d2 := MustParseDIDURL("did:example:123/foo?") + assert.True(t, d1.Equals(d2)) + }) + t.Run("empty query (self)", func(t *testing.T) { + d1 := MustParseDIDURL("did:example:123/foo?") + d2 := MustParseDIDURL("did:example:123/foo") + assert.True(t, d1.Equals(d2)) + }) + t.Run("path differs", func(t *testing.T) { + d1 := MustParseDIDURL("did:example:123/fuzz?key=value#fragment") + d2 := MustParseDIDURL("did:example:123/fizz?key=value#fragment") + assert.False(t, d1.Equals(d2)) + }) +} + +func TestDIDURL_String(t *testing.T) { + type testCase struct { + name string + expected string + did DIDURL + } + testCases := []testCase{ + { + name: "basic DID", + expected: "did:example:123", + did: DIDURL{ + DID: DID{ + Method: "example", + ID: "123", + }, + }, + }, + { + name: "empty DID", + expected: "", + did: DIDURL{}, + }, + { + name: "with path", + expected: "did:example:123/foo", + did: DIDURL{ + DID: DID{ + Method: "example", + ID: "123", + }, + Path: "foo", + }, + }, + { + name: "with escapable characters in path", + expected: "did:example:123/fizz%20buzz", + did: DIDURL{ + DID: DID{ + Method: "example", + ID: "123", + }, + Path: "fizz%20buzz", + }, + }, + { + name: "with fragment", + expected: "did:example:123#fragment", + did: DIDURL{ + DID: DID{ + Method: "example", + ID: "123", + }, + Fragment: "fragment", + }, + }, + { + name: "with escapable characters in fragment", + expected: "did:example:123#fizz%20buzz", + did: DIDURL{ + DID: DID{ + Method: "example", + ID: "123", + }, + Fragment: "fizz%20buzz", + }, + }, + { + name: "with query", + expected: "did:example:123?key=value", + did: DIDURL{ + DID: DID{ + Method: "example", + ID: "123", + }, + Query: url.Values{"key": []string{"value"}}, + }, + }, + { + name: "with everything", + expected: "did:example:123/foo?key=value#fragment", + did: DIDURL{ + DID: DID{ + Method: "example", + ID: "123", + }, + Path: "foo", + Fragment: "fragment", + Query: url.Values{"key": []string{"value"}}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, tc.did.String()) + }) + } +} + +func TestDIDURL_Empty(t *testing.T) { + t.Run("DID", func(t *testing.T) { + assert.False(t, MustParseDIDURL("did:example:123").Empty()) + }) + t.Run("DID URL", func(t *testing.T) { + assert.False(t, MustParseDIDURL("#fragment").Empty()) + }) + t.Run("empty when just generated", func(t *testing.T) { + id := DIDURL{} + assert.True(t, id.Empty()) + }) +} + +func TestDIDURL_URI(t *testing.T) { + type testCase struct { + name string + expected string + } + testCases := []testCase{ + { + name: "just DID", + expected: "did:example:123", + }, + { + name: "with path", + expected: "did:example:123/foo", + }, + { + name: "with query", + expected: "did:example:123?key=value", + }, + { + name: "with fragment", + expected: "did:example:123#fragment", + }, + { + name: "with everything", + expected: "did:example:123/foo?key=value#fragment", + }, + { + name: "without DID", + expected: "/foo?key=value#fragment", + }, + { + name: "just fragment", + expected: "#fragment", + }, + { + name: "just query", + expected: "?key=value", + }, + { + name: "just path", + expected: "/foo", + }, + { + name: "with escaped path", + expected: "/foo%20bar?key=value#fragment", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + id := MustParseDIDURL(tc.expected) + assert.Equal(t, tc.expected, id.URI().String()) + }) + } +} diff --git a/did/document.go b/did/document.go index b551e4b..02fdc16 100644 --- a/did/document.go +++ b/did/document.go @@ -11,6 +11,8 @@ import ( "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/multiformats/go-multibase" + "strings" + "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/internal/marshal" "github.com/shengdoushi/base58" @@ -31,19 +33,19 @@ func ParseDocument(raw string) (*Document, error) { d := Document(doc) const errMsg = "unable to resolve all '%s' references: %w" - if err = resolveVerificationRelationships(d.Authentication, d.VerificationMethod); err != nil { + if err = resolveVerificationRelationships(doc.ID, d.Authentication, d.VerificationMethod); err != nil { return nil, fmt.Errorf(errMsg, authenticationKey, err) } - if err = resolveVerificationRelationships(d.AssertionMethod, d.VerificationMethod); err != nil { + if err = resolveVerificationRelationships(doc.ID, d.AssertionMethod, d.VerificationMethod); err != nil { return nil, fmt.Errorf(errMsg, assertionMethodKey, err) } - if err = resolveVerificationRelationships(d.KeyAgreement, d.VerificationMethod); err != nil { + if err = resolveVerificationRelationships(doc.ID, d.KeyAgreement, d.VerificationMethod); err != nil { return nil, fmt.Errorf(errMsg, keyAgreementKey, err) } - if err = resolveVerificationRelationships(d.CapabilityInvocation, d.VerificationMethod); err != nil { + if err = resolveVerificationRelationships(doc.ID, d.CapabilityInvocation, d.VerificationMethod); err != nil { return nil, fmt.Errorf(errMsg, capabilityInvocationKey, err) } - if err = resolveVerificationRelationships(d.CapabilityDelegation, d.VerificationMethod); err != nil { + if err = resolveVerificationRelationships(doc.ID, d.CapabilityDelegation, d.VerificationMethod); err != nil { return nil, fmt.Errorf(errMsg, capabilityDelegationKey, err) } return &d, nil @@ -68,7 +70,7 @@ type VerificationMethods []*VerificationMethod // FindByID find the first VerificationMethod which matches the provided DID. // Returns nil when not found -func (vms VerificationMethods) FindByID(id DID) *VerificationMethod { +func (vms VerificationMethods) FindByID(id DIDURL) *VerificationMethod { for _, vm := range vms { if vm.ID.Equals(id) { return vm @@ -78,7 +80,7 @@ func (vms VerificationMethods) FindByID(id DID) *VerificationMethod { } // remove a VerificationMethod from the slice. -func (vms *VerificationMethods) remove(id DID) { +func (vms *VerificationMethods) remove(id DIDURL) { var filteredVMS []*VerificationMethod for _, vm := range *vms { if !vm.ID.Equals(id) { @@ -107,7 +109,7 @@ type VerificationRelationships []VerificationRelationship // FindByID returns the first VerificationRelationship that matches with the id. // For comparison both the ID of the embedded VerificationMethod and reference is used. -func (vmr VerificationRelationships) FindByID(id DID) *VerificationMethod { +func (vmr VerificationRelationships) FindByID(id DIDURL) *VerificationMethod { for _, r := range vmr { if r.VerificationMethod != nil { if r.VerificationMethod.ID.Equals(id) { @@ -120,7 +122,7 @@ func (vmr VerificationRelationships) FindByID(id DID) *VerificationMethod { // Remove removes a VerificationRelationship from the slice. // If a VerificationRelationship was removed with the given DID, it will be returned -func (vmr *VerificationRelationships) Remove(id DID) *VerificationRelationship { +func (vmr *VerificationRelationships) Remove(id DIDURL) *VerificationRelationship { var ( filteredVMRels []VerificationRelationship removedRel *VerificationRelationship @@ -149,7 +151,7 @@ func (vmr *VerificationRelationships) Add(vm *VerificationMethod) { // RemoveVerificationMethod from the document if present. // It'll also remove all references to the VerificationMethod -func (d *Document) RemoveVerificationMethod(vmId DID) { +func (d *Document) RemoveVerificationMethod(vmId DIDURL) { d.VerificationMethod.remove(vmId) d.AssertionMethod.Remove(vmId) d.Authentication.Remove(vmId) @@ -314,7 +316,7 @@ func (s Service) UnmarshalServiceEndpoint(target interface{}) error { // VerificationMethod represents a DID Verification Method as specified by the DID Core specification (https://www.w3.org/TR/did-core/#verification-methods). type VerificationMethod struct { - ID DID `json:"id"` + ID DIDURL `json:"id"` Type ssi.KeyType `json:"type,omitempty"` Controller DID `json:"controller,omitempty"` PublicKeyMultibase string `json:"publicKeyMultibase,omitempty"` @@ -325,7 +327,7 @@ type VerificationMethod struct { // NewVerificationMethod is a convenience method to easily create verificationMethods based on a set of given params. // It automatically encodes the provided public key based on the keyType. -func NewVerificationMethod(id DID, keyType ssi.KeyType, controller DID, key crypto.PublicKey) (*VerificationMethod, error) { +func NewVerificationMethod(id DIDURL, keyType ssi.KeyType, controller DID, key crypto.PublicKey) (*VerificationMethod, error) { vm := &VerificationMethod{ ID: id, Type: keyType, @@ -432,7 +434,7 @@ func (v VerificationMethod) PublicKey() (crypto.PublicKey, error) { // VerificationRelationship represents the usage of a VerificationMethod e.g. in authentication, assertionMethod, or keyAgreement. type VerificationRelationship struct { *VerificationMethod - reference DID + reference DIDURL } func (v VerificationRelationship) MarshalJSON() ([]byte, error) { @@ -455,23 +457,47 @@ func (v *VerificationRelationship) UnmarshalJSON(b []byte) error { } *v = (VerificationRelationship)(tmp) case '"': - err := json.Unmarshal(b, &v.reference) + keyID, err := parseKeyID(b) if err != nil { return fmt.Errorf("could not parse verificationRelation key relation DID: %w", err) } + v.reference = *keyID default: return errors.New("verificationRelation is invalid") } return nil } -func (v *VerificationMethod) UnmarshalJSON(bytes []byte) error { - type Alias VerificationMethod - tmp := Alias{} - err := json.Unmarshal(bytes, &tmp) +func parseKeyID(b []byte) (*DIDURL, error) { + var keyIDString string + err := json.Unmarshal(b, &keyIDString) if err != nil { + return nil, err + } + // 2 possible formats: + // - Fully qualified, includes the DID of the key controller, e.g.: did:example:123456789abcdefghi#key-1 + // - Relative, only includes the key ID, e.g.: #key-1 + if strings.HasPrefix(keyIDString, "#") { + return &DIDURL{Fragment: keyIDString[1:]}, nil + } + return ParseDIDURL(keyIDString) +} + +func (v *VerificationMethod) UnmarshalJSON(bytes []byte) error { + // Use an alias since ID should conform to DID URL syntax, not DID syntax + type alias struct { + ID string `json:"id"` + Type ssi.KeyType `json:"type,omitempty"` + Controller DID `json:"controller,omitempty"` + PublicKeyMultibase string `json:"publicKeyMultibase,omitempty"` + PublicKeyBase58 string `json:"publicKeyBase58,omitempty"` + PublicKeyJwk map[string]interface{} `json:"publicKeyJwk,omitempty"` + } + var tmp alias + if err := json.Unmarshal(bytes, &tmp); err != nil { return err } + // publicKeyJWK, publicKeyBase58 and publicKeyMultibase are all mutually exclusive countPresent := 0 if len(tmp.PublicKeyJwk) > 0 { @@ -486,16 +512,29 @@ func (v *VerificationMethod) UnmarshalJSON(bytes []byte) error { if countPresent > 1 { return errors.New("only one of publicKeyJWK, publicKeyBase58 and publicKeyMultibase can be present") } - *v = (VerificationMethod)(tmp) + + id, err := ParseDIDURL(tmp.ID) + if err != nil { + return fmt.Errorf("invalid id: %w", err) + } + *v = VerificationMethod{ + ID: *id, + Type: tmp.Type, + Controller: tmp.Controller, + PublicKeyMultibase: tmp.PublicKeyMultibase, + PublicKeyBase58: tmp.PublicKeyBase58, + PublicKeyJwk: tmp.PublicKeyJwk, + } return nil } -func resolveVerificationRelationships(relationships []VerificationRelationship, methods []*VerificationMethod) error { +func resolveVerificationRelationships(baseURI DID, relationships []VerificationRelationship, methods []*VerificationMethod) error { for i, relationship := range relationships { if relationship.reference.Empty() { continue } - if resolved := resolveVerificationRelationship(relationship.reference, methods); resolved == nil { + ref := relativeURLToAbsoluteURL(baseURI, relationship.reference) + if resolved := resolveVerificationRelationship(baseURI, ref, methods); resolved == nil { return fmt.Errorf("unable to resolve %s: %s", verificationMethodKey, relationship.reference.String()) } else { relationships[i] = *resolved @@ -505,9 +544,20 @@ func resolveVerificationRelationships(relationships []VerificationRelationship, return nil } -func resolveVerificationRelationship(reference DID, methods []*VerificationMethod) *VerificationRelationship { +// relativeURLToAbsoluteURL converts the reference to an absolute URL if it is relative. +// This means it copies the base DID to the reference (if not set in the reference). +func relativeURLToAbsoluteURL(baseURI DID, ref DIDURL) DIDURL { + if ref.ID == "" { + // reference is relative to base URI (DID subject ID) + ref.Method = baseURI.Method + ref.ID = baseURI.ID + } + return ref +} + +func resolveVerificationRelationship(baseURI DID, reference DIDURL, methods []*VerificationMethod) *VerificationRelationship { for _, method := range methods { - if method.ID.Equals(reference) { + if relativeURLToAbsoluteURL(baseURI, method.ID).Equals(reference) { return &VerificationRelationship{VerificationMethod: method} } } diff --git a/did/document_test.go b/did/document_test.go index bb041f5..50cc7be 100644 --- a/did/document_test.go +++ b/did/document_test.go @@ -6,6 +6,7 @@ import ( "crypto/elliptic" "crypto/rand" "encoding/json" + "fmt" "github.com/decred/dcrd/dcrec/secp256k1/v4" ssi "github.com/nuts-foundation/go-did" "github.com/stretchr/testify/require" @@ -110,11 +111,11 @@ func Test_Document(t *testing.T) { }) t.Run("it can add assertionMethods with json web key", func(t *testing.T) { - id := actual.ID - id.Fragment = "added-assertion-method-1" + keyID := DIDURL{DID: actual.ID} + keyID.Fragment = "added-assertion-method-1" keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - vm, err := NewVerificationMethod(id, ssi.JsonWebKey2020, actual.ID, keyPair.PublicKey) + vm, err := NewVerificationMethod(keyID, ssi.JsonWebKey2020, actual.ID, keyPair.PublicKey) if !assert.NoError(t, err) { return } @@ -126,11 +127,11 @@ func Test_Document(t *testing.T) { }) t.Run("ED25519VerificationKey2018", func(t *testing.T) { - id := actual.ID - id.Fragment = "1" + keyID := DIDURL{DID: actual.ID} + keyID.Fragment = "added-assertion-method-1" pubKey, _, _ := ed25519.GenerateKey(rand.Reader) - vm, err := NewVerificationMethod(id, ssi.ED25519VerificationKey2018, actual.ID, pubKey) + vm, err := NewVerificationMethod(keyID, ssi.ED25519VerificationKey2018, actual.ID, pubKey) require.NoError(t, err) publicKey, err := vm.PublicKey() @@ -140,12 +141,12 @@ func Test_Document(t *testing.T) { t.Run("ECDSASECP256K1VerificationKey2019", func(t *testing.T) { t.Run("generated key", func(t *testing.T) { - id := actual.ID - id.Fragment = "1" + keyID := DIDURL{DID: actual.ID} + keyID.Fragment = "added-assertion-method-1" privateKey, err := secp256k1.GeneratePrivateKey() require.NoError(t, err) - vm, err := NewVerificationMethod(id, ssi.ECDSASECP256K1VerificationKey2019, actual.ID, privateKey.ToECDSA()) + vm, err := NewVerificationMethod(keyID, ssi.ECDSASECP256K1VerificationKey2019, actual.ID, privateKey.ToECDSA()) require.NoError(t, err) publicKey, err := vm.PublicKey() @@ -374,6 +375,86 @@ func TestDocument_UnmarshallJSON(t *testing.T) { }) } +func TestParseDocument(t *testing.T) { + t.Run("services", func(t *testing.T) { + t.Run("with relative ID", func(t *testing.T) { + documentAsMap := map[string]interface{}{ + "id": "did:example:123", + "services": []Service{ + { + ID: ssi.MustParseURI("#api"), + }, + }, + } + document, err := mapToDocument(documentAsMap) + require.NoError(t, err) + require.NotNil(t, document) + }) + }) + t.Run("verification method relationship resolving", func(t *testing.T) { + type testCase struct { + name string + verificationMethodID string + relationshipID string + mustResolve bool + } + testCases := []testCase{ + { + name: "resolve, both absolute", + verificationMethodID: "did:example:123#abc", + relationshipID: "did:example:123#abc", + mustResolve: true, + }, + { + name: "resolve, relative relationship", + verificationMethodID: "did:example:123#abc", + relationshipID: "#abc", + mustResolve: true, + }, + { + name: "resolve, relative verification method ID", + verificationMethodID: "#abc", + relationshipID: "did:example:123#abc", + mustResolve: true, + }, + { + name: "resolve, relative verification method ID and relationship", + verificationMethodID: "#abc", + relationshipID: "#abc", + mustResolve: true, + }, + { + name: "no resolve, relationship ID does not match verification method ID", + verificationMethodID: "did:example:123#abc", + relationshipID: "#def", + mustResolve: false, + }, + } + subjectID := MustParseDID("did:example:123") + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + documentAsMap := map[string]interface{}{ + "id": subjectID, + "verificationMethod": []interface{}{VerificationMethod{ + ID: MustParseDIDURL(tc.verificationMethodID), + Type: ssi.ECDSASECP256K1VerificationKey2019, + Controller: subjectID, + }}, + "authentication": []string{tc.relationshipID}, + } + document, err := mapToDocument(documentAsMap) + if tc.mustResolve { + assert.NoError(t, err, "document parse failure") + require.NotNil(t, document) + assert.Equal(t, ssi.ECDSASECP256K1VerificationKey2019, document.Authentication[0].Type) + } else { + assert.EqualError(t, err, fmt.Sprintf("unable to resolve all 'authentication' references: unable to resolve verificationMethod: %s", tc.relationshipID)) + } + }) + } + }) +} + func TestRoundTripMarshalling(t *testing.T) { testCases := []string{ "did1", @@ -416,32 +497,32 @@ func TestRoundTripMarshalling(t *testing.T) { } func TestDocument_RemoveVerificationMethod(t *testing.T) { - id123, _ := ParseDID("did:example:123") + id123 := MustParseDIDURL("did:example:123#key-1") t.Run("ok", func(t *testing.T) { doc := Document{} - vm := &VerificationMethod{ID: *id123} + vm := &VerificationMethod{ID: id123} doc.AddAssertionMethod(vm) doc.AddAuthenticationMethod(vm) doc.AddCapabilityDelegation(vm) doc.AddCapabilityInvocation(vm) doc.AddKeyAgreement(vm) - doc.RemoveVerificationMethod(*id123) + doc.RemoveVerificationMethod(id123) assert.Len(t, doc.VerificationMethod, 0, "the verification method should have been deleted") - assert.Nil(t, doc.AssertionMethod.FindByID(*id123)) - assert.Nil(t, doc.Authentication.FindByID(*id123)) - assert.Nil(t, doc.CapabilityDelegation.FindByID(*id123)) - assert.Nil(t, doc.CapabilityInvocation.FindByID(*id123)) - assert.Nil(t, doc.KeyAgreement.FindByID(*id123)) + assert.Nil(t, doc.AssertionMethod.FindByID(id123)) + assert.Nil(t, doc.Authentication.FindByID(id123)) + assert.Nil(t, doc.CapabilityDelegation.FindByID(id123)) + assert.Nil(t, doc.CapabilityInvocation.FindByID(id123)) + assert.Nil(t, doc.KeyAgreement.FindByID(id123)) }) t.Run("not found", func(t *testing.T) { doc := Document{} - doc.RemoveVerificationMethod(*id123) + doc.RemoveVerificationMethod(id123) assert.Len(t, doc.VerificationMethod, 0) }) @@ -462,13 +543,20 @@ func TestVerificationRelationship_UnmarshalJSON(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "did:nuts:123#key-1", actual.ID.String()) }) + t.Run("ok - relative ID", func(t *testing.T) { + input := `{"id": "#key-1"}` + actual := VerificationRelationship{} + err := json.Unmarshal([]byte(input), &actual) + assert.NoError(t, err) + assert.Equal(t, "#key-1", actual.ID.String()) + }) } func TestNewVerificationMethod(t *testing.T) { t.Run("Ed25519VerificationKey2018", func(t *testing.T) { - id, _ := ParseDID("did:example:123") + id := MustParseDIDURL("did:example:123#1") expectedKey, _, _ := ed25519.GenerateKey(rand.Reader) - vm, err := NewVerificationMethod(*id, ssi.ED25519VerificationKey2018, *id, expectedKey) + vm, err := NewVerificationMethod(id, ssi.ED25519VerificationKey2018, id.DID, expectedKey) require.NoError(t, err) assert.Equal(t, ssi.ED25519VerificationKey2018, vm.Type) assert.NotEmpty(t, vm.PublicKeyMultibase) @@ -484,7 +572,7 @@ func TestVerificationMethod_UnmarshalJSON(t *testing.T) { t.Run("both publicKeyJWK and publicKeyMultibase present", func(t *testing.T) { input, _ := json.Marshal(VerificationMethod{ ID: MustParseDIDURL("did:example:123#key-1"), - Controller: MustParseDIDURL("did:example:123"), + Controller: MustParseDID("did:example:123"), PublicKeyJwk: map[string]interface{}{"kty": "EC"}, PublicKeyMultibase: "foobar", }) @@ -495,7 +583,7 @@ func TestVerificationMethod_UnmarshalJSON(t *testing.T) { t.Run("all of publicKeyJWK, publicKeyMultibase and publicKeyBase58 are present", func(t *testing.T) { input, _ := json.Marshal(VerificationMethod{ ID: MustParseDIDURL("did:example:123#key-1"), - Controller: MustParseDIDURL("did:example:123"), + Controller: MustParseDID("did:example:123"), PublicKeyJwk: map[string]interface{}{"kty": "EC"}, PublicKeyMultibase: "foobar", PublicKeyBase58: "foobar", @@ -557,9 +645,9 @@ func TestService_UnmarshalServiceEndpoint(t *testing.T) { } func Test_VerificationMethods(t *testing.T) { - id123, _ := ParseDID("did:example:123") - id456, _ := ParseDID("did:example:456") - unknownID, _ := ParseDID("did:example:abc") + id123, _ := ParseDIDURL("did:example:123") + id456, _ := ParseDIDURL("did:example:456") + unknownID, _ := ParseDIDURL("did:example:abc") t.Run("Remove", func(t *testing.T) { t.Run("ok", func(t *testing.T) { @@ -620,9 +708,9 @@ func Test_VerificationMethods(t *testing.T) { } func TestVerificationRelationships(t *testing.T) { - id123, _ := ParseDID("did:example:123") - id456, _ := ParseDID("did:example:456") - unknownID, _ := ParseDID("did:example:abc") + id123, _ := ParseDIDURL("did:example:123") + id456, _ := ParseDIDURL("did:example:456") + unknownID, _ := ParseDIDURL("did:example:abc") t.Run("Remove", func(t *testing.T) { t.Run("known value", func(t *testing.T) { @@ -783,3 +871,35 @@ func TestDocument_IsController(t *testing.T) { assert.False(t, Document{Controller: []DID{*id456}}.IsController(*id123)) }) } + +func TestVerificationRelationships_FindByID(t *testing.T) { + t.Run("URL with DID", func(t *testing.T) { + keyID := MustParseDIDURL("did:example:123#0") + expected := &VerificationMethod{ + ID: keyID, + } + rel := VerificationRelationships{ + { + VerificationMethod: expected, + }, + } + assert.Same(t, expected, rel.FindByID(keyID)) + }) + t.Run("URL without DID", func(t *testing.T) { + keyID := MustParseDIDURL("#0") + expected := &VerificationMethod{ + ID: keyID, + } + rel := VerificationRelationships{ + { + VerificationMethod: expected, + }, + } + assert.Same(t, expected, rel.FindByID(keyID)) + }) +} + +func mapToDocument(documentAsMap map[string]interface{}) (*Document, error) { + documentAsJSON, _ := json.Marshal(documentAsMap) + return ParseDocument(string(documentAsJSON)) +} diff --git a/did/validator.go b/did/validator.go index eb2ad2e..ce2728c 100644 --- a/did/validator.go +++ b/did/validator.go @@ -111,12 +111,12 @@ func (w baseValidator) Validate(document Document) error { return makeValidationError(ErrInvalidContext) } // Verify `id` - if document.ID.Empty() || document.ID.IsURL() { + if document.ID.Empty() { return makeValidationError(ErrInvalidID) } // Verify `controller` for _, controller := range document.Controller { - if controller.Empty() || controller.IsURL() { + if controller.Empty() { return makeValidationError(ErrInvalidController) } } diff --git a/did/validator_test.go b/did/validator_test.go index 2cc4997..86f7115 100644 --- a/did/validator_test.go +++ b/did/validator_test.go @@ -7,6 +7,7 @@ import ( "errors" ssi "github.com/nuts-foundation/go-did" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "testing" ) @@ -15,10 +16,6 @@ func TestW3CSpecValidator(t *testing.T) { assert.NoError(t, W3CSpecValidator{}.Validate(document())) }) t.Run("base", func(t *testing.T) { - didUrl, err := ParseDIDURL("did:example:123#fragment") - if !assert.NoError(t, err) { - return - } t.Run("context is missing DIDv1", func(t *testing.T) { input := document() input.Context = []interface{}{ @@ -34,28 +31,16 @@ func TestW3CSpecValidator(t *testing.T) { input.ID = DID{} assertIsError(t, ErrInvalidID, W3CSpecValidator{}.Validate(input)) }) - t.Run("invalid ID - is URL", func(t *testing.T) { - input := document() - input.ID = *didUrl - assertIsError(t, ErrInvalidID, W3CSpecValidator{}.Validate(input)) - }) - t.Run("invalid controller - is empty", func(t *testing.T) { input := document() input.Controller = append(input.Controller, DID{}) assertIsError(t, ErrInvalidController, W3CSpecValidator{}.Validate(input)) }) - - t.Run("invalid controller - is URL", func(t *testing.T) { - input := document() - input.Controller = append(input.Controller, *didUrl) - assertIsError(t, ErrInvalidController, W3CSpecValidator{}.Validate(input)) - }) }) t.Run("verificationMethod", func(t *testing.T) { t.Run("invalid ID", func(t *testing.T) { input := document() - input.VerificationMethod[0].ID = DID{} + input.VerificationMethod[0].ID = DIDURL{} assertIsError(t, ErrInvalidVerificationMethod, W3CSpecValidator{}.Validate(input)) }) t.Run("invalid controller", func(t *testing.T) { @@ -68,6 +53,11 @@ func TestW3CSpecValidator(t *testing.T) { input.VerificationMethod[0].Type = " " assertIsError(t, ErrInvalidVerificationMethod, W3CSpecValidator{}.Validate(input)) }) + t.Run("ok - relative ID", func(t *testing.T) { + input := document() + input.VerificationMethod[0].ID = DIDURL{Fragment: "key-1"} + require.NoError(t, W3CSpecValidator{}.Validate(input)) + }) }) t.Run("authentication", func(t *testing.T) { t.Run("invalid ID", func(t *testing.T) { @@ -76,7 +66,7 @@ func TestW3CSpecValidator(t *testing.T) { vm := *input.VerificationMethod[0] input.Authentication[0] = VerificationRelationship{VerificationMethod: &vm} // Then alter - input.Authentication[0].ID = DID{} + input.Authentication[0].ID = DIDURL{} assertIsError(t, ErrInvalidAuthentication, W3CSpecValidator{}.Validate(input)) }) t.Run("invalid controller", func(t *testing.T) { @@ -88,6 +78,11 @@ func TestW3CSpecValidator(t *testing.T) { input.Authentication[0].Controller = DID{} assertIsError(t, ErrInvalidAuthentication, W3CSpecValidator{}.Validate(input)) }) + t.Run("ok - relative ID", func(t *testing.T) { + input := document() + input.Authentication[0].reference = DIDURL{Fragment: "key-1"} + require.NoError(t, W3CSpecValidator{}.Validate(input)) + }) }) t.Run("service", func(t *testing.T) { t.Run("invalid ID", func(t *testing.T) { @@ -115,6 +110,11 @@ func TestW3CSpecValidator(t *testing.T) { input.Service[0].ServiceEndpoint = 5 assertIsError(t, ErrInvalidService, W3CSpecValidator{}.Validate(input)) }) + t.Run("ok - relative ID", func(t *testing.T) { + input := document() + input.Service[0].ServiceEndpoint = "service-1" + require.NoError(t, W3CSpecValidator{}.Validate(input)) + }) t.Run("ok - endpoint is slice", func(t *testing.T) { input := document() input.Service[0].ServiceEndpoint = []interface{}{"a", "b"} @@ -167,11 +167,11 @@ func document() Document { did, _ := ParseDID("did:test:12345") privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - keyID := *did + keyID := DIDURL{DID: *did} keyID.Fragment = "key-1" vm, _ := NewVerificationMethod(keyID, ssi.JsonWebKey2020, *did, privateKey.Public()) - serviceID := *did + serviceID := DIDURL{DID: *did} serviceID.Fragment = "service-1" doc := Document{ Context: []interface{}{