did: add and complete DID and DID-URL syntax check

This commit is contained in:
Michael Muré
2025-06-11 17:18:54 +02:00
parent be5313d5fc
commit 7fabdda104
4 changed files with 164 additions and 20 deletions

89
did.go
View File

@@ -2,6 +2,7 @@ package did
import (
"fmt"
"net/url"
"strings"
"sync"
)
@@ -9,7 +10,7 @@ import (
const JsonLdContext = "https://www.w3.org/ns/did/v1"
// Decoder is a function decoding a DID string representation ("did:example:foo") into a DID.
type Decoder func(identifier string) (DID, error)
type Decoder func(didStr string) (DID, error)
// RegisterMethod registers a DID decoder for a given DID method.
// Method must be the DID method (for example, "key" in did:key).
@@ -26,20 +27,20 @@ func RegisterMethod(method string, decoder Decoder) {
}
// Parse attempts to decode a DID from its string representation.
func Parse(identifier string) (DID, error) {
func Parse(didStr string) (DID, error) {
decodersMu.RLock()
defer decodersMu.RUnlock()
if !strings.HasPrefix(identifier, "did:") {
if !strings.HasPrefix(didStr, "did:") {
return nil, fmt.Errorf("%w: must start with \"did:\"", ErrInvalidDid)
}
method, suffix, ok := strings.Cut(identifier[len("did:"):], ":")
method, suffix, ok := strings.Cut(didStr[len("did:"):], ":")
if !ok {
return nil, fmt.Errorf("%w: must have a method and an identifier", ErrInvalidDid)
}
if !checkSuffix(suffix) {
if !checkMethodSpecificId(suffix) {
return nil, fmt.Errorf("%w: invalid identifier characters", ErrInvalidDid)
}
@@ -48,29 +49,29 @@ func Parse(identifier string) (DID, error) {
return nil, fmt.Errorf("%w: %s", ErrMethodNotSupported, method)
}
return decoder(identifier)
return decoder(didStr)
}
// MustParse is like Parse but panics instead of returning an error.
func MustParse(identifier string) DID {
did, err := Parse(identifier)
func MustParse(didStr string) DID {
did, err := Parse(didStr)
if err != nil {
panic(err)
}
return did
}
// HasValidSyntax tells if the given string representation conforms to DID syntax.
// HasValidDIDSyntax tells if the given string representation conforms to DID syntax.
// This does NOT verify that the method is supported by this library.
func HasValidSyntax(identifier string) bool {
if !strings.HasPrefix(identifier, "did:") {
func HasValidDIDSyntax(didStr string) bool {
if !strings.HasPrefix(didStr, "did:") {
return false
}
method, suffix, ok := strings.Cut(identifier[len("did:"):], ":")
method, suffix, ok := strings.Cut(didStr[len("did:"):], ":")
if !ok {
return false
}
return checkMethod(method) && checkSuffix(suffix)
return checkMethod(method) && checkMethodSpecificId(suffix)
}
func checkMethod(method string) bool {
@@ -87,18 +88,70 @@ func checkMethod(method string) bool {
return true
}
func checkSuffix(suffix string) bool {
func checkMethodSpecificId(suffix string) bool {
if len(suffix) == 0 {
return false
}
// TODO
// for _, char := range suffix {
//
// }
segments := strings.Split(suffix, ":")
for i, segment := range segments {
if i == len(segments)-1 && len(segment) == 0 {
// last segment can't be empty
return false
}
var percentExpected int
for _, char := range segment {
if percentExpected > 0 {
switch {
case char >= '0' && char <= '9':
percentExpected--
case char >= 'a' && char <= 'f':
percentExpected--
case char >= 'A' && char <= 'F':
percentExpected--
default:
return false
}
}
switch {
case char >= 'a' && char <= 'z': // nothing to do
case char >= 'A' && char <= 'Z': // nothing to do
case char >= '0' && char <= '9': // nothing to do
case char == '.': // nothing to do
case char == '-': // nothing to do
case char == '_': // nothing to do
case char == '%':
percentExpected = 2
default:
return false
}
}
if percentExpected > 0 {
// unfinished percent encoding
return false
}
}
return true
}
// HasValidDidUrlSyntax tells if the given string representation conforms to DID URL syntax.
// This does NOT verify that the method is supported by this library.
func HasValidDidUrlSyntax(didUrlStr string) bool {
cutPos := strings.IndexAny(didUrlStr, "/#?")
if cutPos == -1 {
return HasValidDIDSyntax(didUrlStr)
}
base, rest := didUrlStr[:cutPos], didUrlStr[cutPos+1:]
if HasValidDIDSyntax(base) == false {
return false
}
_, err := url.Parse("example.com" + rest)
return err == nil
}
var (
decodersMu sync.RWMutex
decoders = map[string]Decoder{}

91
did_test.go Normal file
View File

@@ -0,0 +1,91 @@
package did
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestHasValidDIDSyntax(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
// Valid Test Cases
{"Shortest valid DID", "did:a:1", true},
{"Simple valid DID", "did:example:123456789abcdefghi", true},
{"Valid DID with special characters in method-specific-id", "did:example:abc.def-ghi_jkl", true},
{"Valid DID with multiple colon-separated segments", "did:example:abc:def:ghi:jkl", true},
{"Valid DID with percent-encoded characters", "did:example:abc%20def%3Aghi", true},
{"Valid DID with numeric method-specific-id", "did:example:123:456:789", true},
{"Valid DID with custom method name", "did:methodname:abc:def%20ghi:jkl", true},
{"Valid DID with mixed characters in method-specific-id", "did:abc123:xyz-789_abc.def", true},
{"Valid DID with multiple percent-encoded segments", "did:example:abc:def%3Aghi:jkl%20mno", true},
{"Valid DID with complex method-specific-id", "did:example:abc:def:ghi:jkl%20mno%3Apqr", true},
{"Valid DID with empty segment in method-specific-id", "did:example:abc:def::ghi", true},
{"Valid DID with deeply nested segments", "did:example:abc:def:ghi:jkl%20mno%3Apqr%3Astuv", true},
// Invalid Test Cases
{"Missing method-specific-id", "did:example", false},
{"Missing method-name", "did::123456789abcdefghi", false},
{"Invalid characters in method-name", "did:Example:123456789abcdefghi", false},
{"Empty method-specific-id", "did:example:", false},
{"Trailing colon in method-specific-id", "did:example:abc:def:ghi:jkl:", false},
{"Invalid percent-encoding", "did:example:abc:def:ghi:jkl%ZZ", false},
{"Incomplete percent-encoding", "did:example:abc:def:ghi:jkl%2", false},
{"Trailing '%' in pct-encoded", "did:example:abc:def:ghi:jkl%20mno%3Apqr%", false},
{"Incomplete pct-encoded at the end", "did:example:abc:def:ghi:jkl%20mno%3Apqr%3", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
require.Equal(t, tt.expected, HasValidDIDSyntax(tt.input))
})
}
}
func BenchmarkHasValidDIDSyntax(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
HasValidDIDSyntax("did:example:abc:def:ghi:jkl%20mno%3Apqr%3Astuv")
}
}
func TestHasValidDidUrlSyntax(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
// Valid Test Cases
{"Base DID only", "did:example:123456789abcdefghi", true},
{"Base DID with path", "did:example:123456789abcdefghi/path/to/resource", true},
{"Base DID with query", "did:example:123456789abcdefghi?key=value", true},
{"Base DID with fragment", "did:example:123456789abcdefghi#section1", true},
{"Base DID with path, query, and fragment", "did:example:123456789abcdefghi/path/to/resource?key=value#section1", true},
{"Base DID with empty path", "did:example:123456789abcdefghi/", true},
{"Base DID with percent-encoded path", "did:example:123456789abcdefghi/path%20to%20resource", true},
{"Base DID with percent-encoded query", "did:example:123456789abcdefghi?key=value%20with%20spaces", true},
{"Base DID with percent-encoded fragment", "did:example:123456789abcdefghi#section%201", true},
// Invalid Test Cases
{"Invalid DID", "did:example", false}, // Base DID is invalid
{"Invalid DID with path", "did:example:/path/to/resource", false}, // Base DID is invalid
{"Invalid DID with query", "did:example:?key=value", false}, // Base DID is invalid
{"Invalid DID with fragment", "did:example:#section1", false}, // Base DID is invalid
{"Invalid percent-encoding in path", "did:example:123456789abcdefghi/path%ZZto%20resource", false}, // Invalid percent-encoding
{"Invalid percent-encoding in query", "did:example:123456789abcdefghi?key=value%ZZ", false}, // Invalid percent-encoding
{"Invalid percent-encoding in fragment", "did:example:123456789abcdefghi#section%ZZ", false}, // Invalid percent-encoding
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.expected, HasValidDidUrlSyntax(tt.input))
})
}
}
func BenchmarkHasValidDidUrlSyntax(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
HasValidDidUrlSyntax("did:example:123456789abcdefghi/path/to/resource?key=value#section1")
}
}

View File

@@ -74,7 +74,7 @@ func (v *VerificationKey2020) UnmarshalJSON(bytes []byte) error {
return fmt.Errorf("invalid publicKeyMultibase: %w", err)
}
v.controller = aux.Controller
if !did.HasValidSyntax(v.controller) {
if !did.HasValidDIDSyntax(v.controller) {
return errors.New("invalid controller")
}
return nil

View File

@@ -74,7 +74,7 @@ func (k *KeyAgreementKey2020) UnmarshalJSON(bytes []byte) error {
return fmt.Errorf("invalid publicKeyMultibase: %w", err)
}
k.controller = aux.Controller
if !did.HasValidSyntax(k.controller) {
if !did.HasValidDIDSyntax(k.controller) {
return errors.New("invalid controller")
}
return nil