From 7fabdda104c82a55c3bfabbb3a2937b27ee9cf03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Wed, 11 Jun 2025 17:18:54 +0200 Subject: [PATCH] did: add and complete DID and DID-URL syntax check --- did.go | 89 +++++++++++++++---- did_test.go | 91 ++++++++++++++++++++ verifications/ed25519/VerificationKey2020.go | 2 +- verifications/x25519/KeyAgreementKey2020.go | 2 +- 4 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 did_test.go diff --git a/did.go b/did.go index b0400b6..5f8e314 100644 --- a/did.go +++ b/did.go @@ -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{} diff --git a/did_test.go b/did_test.go new file mode 100644 index 0000000..3dc01c0 --- /dev/null +++ b/did_test.go @@ -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") + } +} diff --git a/verifications/ed25519/VerificationKey2020.go b/verifications/ed25519/VerificationKey2020.go index dab39ae..f9bce75 100644 --- a/verifications/ed25519/VerificationKey2020.go +++ b/verifications/ed25519/VerificationKey2020.go @@ -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 diff --git a/verifications/x25519/KeyAgreementKey2020.go b/verifications/x25519/KeyAgreementKey2020.go index 256a60c..59bedb5 100644 --- a/verifications/x25519/KeyAgreementKey2020.go +++ b/verifications/x25519/KeyAgreementKey2020.go @@ -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