Skip to content

Commit f86fa9a

Browse files
committed
verifiers: add support for did:web
1 parent b520568 commit f86fa9a

File tree

3 files changed

+279
-0
lines changed

3 files changed

+279
-0
lines changed

Readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ func main() {
124124
| Method | Controller | Verifier | Description |
125125
|-----------|------------|----------|----------------------------------------------------|
126126
| `did:key` ||| Self-contained DIDs based on public keys |
127+
| `did:web` ||| DID document resolved with HTTP |
127128
| `did:plc` ||| Bluesky's DID with rotation and a public directory |
128129

129130
### Supported Verification Method Types

verifiers/did-web/web.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package did_web
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net"
7+
"net/http"
8+
"net/url"
9+
"regexp"
10+
"strconv"
11+
"strings"
12+
13+
"github.com/MetaMask/go-did-it"
14+
"github.com/MetaMask/go-did-it/document"
15+
)
16+
17+
// Specification: https://w3c-ccg.github.io/did-method-web/
18+
19+
func init() {
20+
did.RegisterMethod("web", Decode)
21+
}
22+
23+
var _ did.DID = DidWeb{}
24+
25+
type DidWeb struct {
26+
msi string // method-specific identifier, i.e. "12345" in "did:web:12345"
27+
parts []string
28+
}
29+
30+
func Decode(identifier string) (did.DID, error) {
31+
const webPrefix = "did:web:"
32+
33+
if !strings.HasPrefix(identifier, webPrefix) {
34+
return nil, fmt.Errorf("%w: must start with 'did:web'", did.ErrInvalidDid)
35+
}
36+
37+
msi := identifier[len(webPrefix):]
38+
if len(msi) == 0 {
39+
return nil, fmt.Errorf("%w: empty did:web identifier", did.ErrInvalidDid)
40+
}
41+
42+
parts := strings.Split(msi, ":")
43+
44+
host, err := url.PathUnescape(parts[0])
45+
if err != nil {
46+
return nil, fmt.Errorf("%w: %w", did.ErrInvalidDid, err)
47+
}
48+
if !isValidHost(host) {
49+
return nil, fmt.Errorf("%w: invalid host", did.ErrInvalidDid)
50+
}
51+
parts[0] = host
52+
53+
for i := 1; i < len(parts); i++ {
54+
parts[i] = url.PathEscape(parts[i])
55+
}
56+
57+
return DidWeb{msi: msi, parts: parts}, nil
58+
}
59+
60+
func (d DidWeb) Method() string {
61+
return "web"
62+
}
63+
64+
func (d DidWeb) Document(opts ...did.ResolutionOption) (did.Document, error) {
65+
params := did.CollectResolutionOpts(opts)
66+
67+
var u string
68+
var err error
69+
70+
if len(d.parts) == 1 {
71+
u, err = url.JoinPath("https://"+d.parts[0], ".well-known/did.json")
72+
} else {
73+
parts := append(d.parts[1:], "did.json")
74+
u, err = url.JoinPath("https://"+d.parts[0], parts...)
75+
}
76+
if err != nil {
77+
return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err)
78+
}
79+
80+
req, err := http.NewRequestWithContext(params.Context(), "GET", u, nil)
81+
if err != nil {
82+
return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err)
83+
}
84+
req.Header.Set("User-Agent", "go-did-it")
85+
86+
res, err := params.HttpClient().Do(req)
87+
if err != nil {
88+
return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err)
89+
}
90+
defer res.Body.Close()
91+
92+
if res.StatusCode != http.StatusOK {
93+
return nil, fmt.Errorf("%w: HTTP %d", did.ErrResolutionFailure, res.StatusCode)
94+
}
95+
96+
// limit at 1MB to avoid abuse
97+
limiter := io.LimitReader(res.Body, 1<<20)
98+
99+
doc, err := document.FromJsonReader(limiter)
100+
if err != nil {
101+
return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err)
102+
}
103+
104+
if doc.ID() != d.String() {
105+
return nil, fmt.Errorf("%w: did:web identifier mismatch", did.ErrResolutionFailure)
106+
}
107+
108+
return doc, nil
109+
}
110+
111+
func (d DidWeb) String() string {
112+
return fmt.Sprintf("did:web:%s", d.msi)
113+
}
114+
115+
func (d DidWeb) ResolutionIsExpensive() bool {
116+
// requires an external HTTP request
117+
return true
118+
}
119+
120+
func (d DidWeb) Equal(d2 did.DID) bool {
121+
if d2, ok := d2.(DidWeb); ok {
122+
return d.msi == d2.msi
123+
}
124+
if d2, ok := d2.(*DidWeb); ok {
125+
return d.msi == d2.msi
126+
}
127+
return false
128+
}
129+
130+
var domainRegexp = regexp.MustCompile(`^(?i)[a-z0-9-]+(\.[a-z0-9-]+)+\.?$`)
131+
132+
func isValidHost(host string) bool {
133+
h, port, err := net.SplitHostPort(host)
134+
if err == nil {
135+
portInt, err := strconv.Atoi(port)
136+
if err != nil {
137+
return false
138+
}
139+
if portInt < 0 || portInt > 65535 {
140+
return false
141+
}
142+
host = h
143+
}
144+
if !domainRegexp.MatchString(host) {
145+
return false
146+
}
147+
if ip := net.ParseIP(host); ip != nil {
148+
// disallow IP addresses
149+
return false
150+
}
151+
return true
152+
}

verifiers/did-web/web_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package did_web
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/MetaMask/go-did-it"
13+
)
14+
15+
func TestDecode(t *testing.T) {
16+
testcases := []struct {
17+
did string
18+
valid bool
19+
}{
20+
{"did:web:w3c-ccg.github.io", true},
21+
{"did:web:w3c-ccg.github.io:user:alice", true},
22+
{"did:web:example.com%3A3000", true},
23+
}
24+
25+
for _, tc := range testcases {
26+
t.Run(tc.did, func(t *testing.T) {
27+
_, err := Decode(tc.did)
28+
if tc.valid && err != nil {
29+
t.Errorf("Decode(%q) = %v, want nil", tc.did, err)
30+
} else if !tc.valid && err == nil {
31+
t.Errorf("Decode(%q) = nil, want error", tc.did)
32+
}
33+
})
34+
}
35+
}
36+
37+
func TestIsValidHost(t *testing.T) {
38+
testcases := []struct {
39+
host string
40+
valid bool
41+
}{
42+
{"example.com", true},
43+
{"sub.example.com", true},
44+
{"example.com:8080", true},
45+
{"w3c-ccg.github.io", true},
46+
{"192.168.1.1", false},
47+
{"invalid..com", false},
48+
{".example.com", false},
49+
{"example.com.", true},
50+
{"", false},
51+
{"just_invalid", false},
52+
}
53+
for _, tc := range testcases {
54+
t.Run(tc.host, func(t *testing.T) {
55+
if isValidHost(tc.host) != tc.valid {
56+
t.Errorf("isValidHost(%q) = %v, want %v", tc.host, isValidHost(tc.host), tc.valid)
57+
}
58+
})
59+
}
60+
}
61+
62+
func TestResolution(t *testing.T) {
63+
client := &MockHTTPClient{
64+
url: "https://example.com/.well-known/did.json",
65+
resp: `{
66+
"@context": [
67+
"https://www.w3.org/ns/did/v1",
68+
"https://w3id.org/security/suites/ed25519-2020/v1",
69+
"https://w3id.org/security/suites/x25519-2020/v1"
70+
],
71+
"id": "did:web:example.com",
72+
"verificationMethod": [{
73+
"id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
74+
"type": "Ed25519VerificationKey2020",
75+
"controller": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
76+
"publicKeyMultibase": "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
77+
}],
78+
"authentication": [
79+
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
80+
],
81+
"assertionMethod": [
82+
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
83+
],
84+
"capabilityDelegation": [
85+
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
86+
],
87+
"capabilityInvocation": [
88+
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
89+
],
90+
"keyAgreement": [{
91+
"id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6LSj72tK8brWgZja8NLRwPigth2T9QRiG1uH9oKZuKjdh9p",
92+
"type": "X25519KeyAgreementKey2020",
93+
"controller": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
94+
"publicKeyMultibase": "z6LSj72tK8brWgZja8NLRwPigth2T9QRiG1uH9oKZuKjdh9p"
95+
}]
96+
}`,
97+
}
98+
99+
d, err := Decode("did:web:example.com")
100+
require.NoError(t, err)
101+
102+
doc, err := d.Document(did.WithHttpClient(client))
103+
require.NoError(t, err)
104+
105+
require.Equal(t, "did:web:example.com", doc.ID())
106+
require.Len(t, doc.VerificationMethods(), 1)
107+
require.Len(t, doc.Authentication(), 1)
108+
require.Len(t, doc.Assertion(), 1)
109+
require.Len(t, doc.KeyAgreement(), 1)
110+
}
111+
112+
type MockHTTPClient struct {
113+
url string
114+
resp string
115+
}
116+
117+
func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
118+
if req.URL.String() != m.url {
119+
return nil, fmt.Errorf("unexpected url: %s", req.URL.String())
120+
}
121+
122+
return &http.Response{
123+
StatusCode: http.StatusOK,
124+
Body: io.NopCloser(strings.NewReader(m.resp)),
125+
}, nil
126+
}

0 commit comments

Comments
 (0)