From 9cb0d5647fe0b370cd1aa6fa536b5bfdcae78521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Mon, 4 Aug 2025 18:41:06 +0200 Subject: [PATCH] verifiers: add support for did:web --- Readme.md | 1 + verifiers/did-web/web.go | 155 ++++++++++++++++++++++++++++++++++ verifiers/did-web/web_test.go | 128 ++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 verifiers/did-web/web.go create mode 100644 verifiers/did-web/web_test.go diff --git a/Readme.md b/Readme.md index 1824913..7c54c96 100644 --- a/Readme.md +++ b/Readme.md @@ -124,6 +124,7 @@ func main() { | Method | Controller | Verifier | Description | |-----------|------------|----------|----------------------------------------------------| | `did:key` | ✅ | ✅ | Self-contained DIDs based on public keys | +| `did:web` | ❌ | ✅ | DID document resolved with HTTP | | `did:plc` | ❌ | ✅ | Bluesky's DID with rotation and a public directory | ### Supported Verification Method Types diff --git a/verifiers/did-web/web.go b/verifiers/did-web/web.go new file mode 100644 index 0000000..560acab --- /dev/null +++ b/verifiers/did-web/web.go @@ -0,0 +1,155 @@ +package did_web + +import ( + "fmt" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/MetaMask/go-did-it" + "github.com/MetaMask/go-did-it/document" +) + +// Specification: https://w3c-ccg.github.io/did-method-web/ + +func init() { + did.RegisterMethod("web", Decode) +} + +var _ did.DID = DidWeb{} + +type DidWeb struct { + msi string // method-specific identifier, i.e. "12345" in "did:web:12345" + parts []string +} + +func Decode(identifier string) (did.DID, error) { + const webPrefix = "did:web:" + + if !strings.HasPrefix(identifier, webPrefix) { + return nil, fmt.Errorf("%w: must start with 'did:web'", did.ErrInvalidDid) + } + + msi := identifier[len(webPrefix):] + if len(msi) == 0 { + return nil, fmt.Errorf("%w: empty did:web identifier", did.ErrInvalidDid) + } + + parts := strings.Split(msi, ":") + + host, err := url.PathUnescape(parts[0]) + if err != nil { + return nil, fmt.Errorf("%w: %w", did.ErrInvalidDid, err) + } + if !isValidHost(host) { + return nil, fmt.Errorf("%w: invalid host", did.ErrInvalidDid) + } + parts[0] = host + + for i := 1; i < len(parts); i++ { + parts[i], err = url.PathUnescape(parts[i]) + if err != nil { + return nil, fmt.Errorf("%w: %w", did.ErrInvalidDid, err) + } + } + + return DidWeb{msi: msi, parts: parts}, nil +} + +func (d DidWeb) Method() string { + return "web" +} + +func (d DidWeb) Document(opts ...did.ResolutionOption) (did.Document, error) { + params := did.CollectResolutionOpts(opts) + + var u string + var err error + + if len(d.parts) == 1 { + u, err = url.JoinPath("https://"+d.parts[0], ".well-known/did.json") + } else { + parts := append(d.parts[1:], "did.json") + u, err = url.JoinPath("https://"+d.parts[0], parts...) + } + if err != nil { + return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err) + } + + req, err := http.NewRequestWithContext(params.Context(), "GET", u, nil) + if err != nil { + return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err) + } + req.Header.Set("User-Agent", "go-did-it") + + res, err := params.HttpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: HTTP %d", did.ErrResolutionFailure, res.StatusCode) + } + + // limit at 1MB to avoid abuse + limiter := io.LimitReader(res.Body, 1<<20) + + doc, err := document.FromJsonReader(limiter) + if err != nil { + return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err) + } + + if doc.ID() != d.String() { + return nil, fmt.Errorf("%w: did:web identifier mismatch", did.ErrResolutionFailure) + } + + return doc, nil +} + +func (d DidWeb) String() string { + return fmt.Sprintf("did:web:%s", d.msi) +} + +func (d DidWeb) ResolutionIsExpensive() bool { + // requires an external HTTP request + return true +} + +func (d DidWeb) Equal(d2 did.DID) bool { + if d2, ok := d2.(DidWeb); ok { + return d.msi == d2.msi + } + if d2, ok := d2.(*DidWeb); ok { + return d.msi == d2.msi + } + return false +} + +var domainRegexp = regexp.MustCompile(`^(?i)[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\.?$`) + +func isValidHost(host string) bool { + h, port, err := net.SplitHostPort(host) + if err == nil { + portInt, err := strconv.Atoi(port) + if err != nil { + return false + } + if portInt < 0 || portInt > 65535 { + return false + } + host = h + } + if !domainRegexp.MatchString(host) { + return false + } + if ip := net.ParseIP(host); ip != nil { + // disallow IP addresses + return false + } + return true +} diff --git a/verifiers/did-web/web_test.go b/verifiers/did-web/web_test.go new file mode 100644 index 0000000..9a8bd36 --- /dev/null +++ b/verifiers/did-web/web_test.go @@ -0,0 +1,128 @@ +package did_web + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MetaMask/go-did-it" +) + +func TestDecode(t *testing.T) { + testcases := []struct { + did string + valid bool + }{ + {"did:web:w3c-ccg.github.io", true}, + {"did:web:w3c-ccg.github.io:user:alice", true}, + {"did:web:example.com%3A3000", true}, + } + + for _, tc := range testcases { + t.Run(tc.did, func(t *testing.T) { + _, err := Decode(tc.did) + if tc.valid && err != nil { + t.Errorf("Decode(%q) = %v, want nil", tc.did, err) + } else if !tc.valid && err == nil { + t.Errorf("Decode(%q) = nil, want error", tc.did) + } + }) + } +} + +func TestIsValidHost(t *testing.T) { + testcases := []struct { + host string + valid bool + }{ + {"example.com", true}, + {"sub.example.com", true}, + {"example.com:8080", true}, + {"w3c-ccg.github.io", true}, + {"192.168.1.1", false}, + {"invalid..com", false}, + {".example.com", false}, + {"example.com.", true}, + {"", false}, + {"just_invalid", false}, + {"-example.com", false}, + {"example.com-", false}, + } + for _, tc := range testcases { + t.Run(tc.host, func(t *testing.T) { + if isValidHost(tc.host) != tc.valid { + t.Errorf("isValidHost(%q) = %v, want %v", tc.host, isValidHost(tc.host), tc.valid) + } + }) + } +} + +func TestResolution(t *testing.T) { + client := &MockHTTPClient{ + url: "https://example.com/.well-known/did.json", + resp: `{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/ed25519-2020/v1", + "https://w3id.org/security/suites/x25519-2020/v1" + ], + "id": "did:web:example.com", + "verificationMethod": [{ + "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "type": "Ed25519VerificationKey2020", + "controller": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "publicKeyMultibase": "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + }], + "authentication": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "assertionMethod": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "capabilityDelegation": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "capabilityInvocation": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "keyAgreement": [{ + "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6LSj72tK8brWgZja8NLRwPigth2T9QRiG1uH9oKZuKjdh9p", + "type": "X25519KeyAgreementKey2020", + "controller": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "publicKeyMultibase": "z6LSj72tK8brWgZja8NLRwPigth2T9QRiG1uH9oKZuKjdh9p" + }] +}`, + } + + d, err := Decode("did:web:example.com") + require.NoError(t, err) + + doc, err := d.Document(did.WithHttpClient(client)) + require.NoError(t, err) + + require.Equal(t, "did:web:example.com", doc.ID()) + require.Len(t, doc.VerificationMethods(), 1) + require.Len(t, doc.Authentication(), 1) + require.Len(t, doc.Assertion(), 1) + require.Len(t, doc.KeyAgreement(), 1) +} + +type MockHTTPClient struct { + url string + resp string +} + +func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) { + if req.URL.String() != m.url { + return nil, fmt.Errorf("unexpected url: %s", req.URL.String()) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(m.resp)), + }, nil +}