Skip to content

Commit 9cb0d56

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

File tree

3 files changed

+284
-0
lines changed

3 files changed

+284
-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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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], err = url.PathUnescape(parts[i])
55+
if err != nil {
56+
return nil, fmt.Errorf("%w: %w", did.ErrInvalidDid, err)
57+
}
58+
}
59+
60+
return DidWeb{msi: msi, parts: parts}, nil
61+
}
62+
63+
func (d DidWeb) Method() string {
64+
return "web"
65+
}
66+
67+
func (d DidWeb) Document(opts ...did.ResolutionOption) (did.Document, error) {
68+
params := did.CollectResolutionOpts(opts)
69+
70+
var u string
71+
var err error
72+
73+
if len(d.parts) == 1 {
74+
u, err = url.JoinPath("https://"+d.parts[0], ".well-known/did.json")
75+
} else {
76+
parts := append(d.parts[1:], "did.json")
77+
u, err = url.JoinPath("https://"+d.parts[0], parts...)
78+
}
79+
if err != nil {
80+
return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err)
81+
}
82+
83+
req, err := http.NewRequestWithContext(params.Context(), "GET", u, nil)
84+
if err != nil {
85+
return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err)
86+
}
87+
req.Header.Set("User-Agent", "go-did-it")
88+
89+
res, err := params.HttpClient().Do(req)
90+
if err != nil {
91+
return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err)
92+
}
93+
defer res.Body.Close()
94+
95+
if res.StatusCode != http.StatusOK {
96+
return nil, fmt.Errorf("%w: HTTP %d", did.ErrResolutionFailure, res.StatusCode)
97+
}
98+
99+
// limit at 1MB to avoid abuse
100+
limiter := io.LimitReader(res.Body, 1<<20)
101+
102+
doc, err := document.FromJsonReader(limiter)
103+
if err != nil {
104+
return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err)
105+
}
106+
107+
if doc.ID() != d.String() {
108+
return nil, fmt.Errorf("%w: did:web identifier mismatch", did.ErrResolutionFailure)
109+
}
110+
111+
return doc, nil
112+
}
113+
114+
func (d DidWeb) String() string {
115+
return fmt.Sprintf("did:web:%s", d.msi)
116+
}
117+
118+
func (d DidWeb) ResolutionIsExpensive() bool {
119+
// requires an external HTTP request
120+
return true
121+
}
122+
123+
func (d DidWeb) Equal(d2 did.DID) bool {
124+
if d2, ok := d2.(DidWeb); ok {
125+
return d.msi == d2.msi
126+
}
127+
if d2, ok := d2.(*DidWeb); ok {
128+
return d.msi == d2.msi
129+
}
130+
return false
131+
}
132+
133+
var domainRegexp = regexp.MustCompile(`^(?i)[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\.?$`)
134+
135+
func isValidHost(host string) bool {
136+
h, port, err := net.SplitHostPort(host)
137+
if err == nil {
138+
portInt, err := strconv.Atoi(port)
139+
if err != nil {
140+
return false
141+
}
142+
if portInt < 0 || portInt > 65535 {
143+
return false
144+
}
145+
host = h
146+
}
147+
if !domainRegexp.MatchString(host) {
148+
return false
149+
}
150+
if ip := net.ParseIP(host); ip != nil {
151+
// disallow IP addresses
152+
return false
153+
}
154+
return true
155+
}

verifiers/did-web/web_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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+
{"-example.com", false},
53+
{"example.com-", false},
54+
}
55+
for _, tc := range testcases {
56+
t.Run(tc.host, func(t *testing.T) {
57+
if isValidHost(tc.host) != tc.valid {
58+
t.Errorf("isValidHost(%q) = %v, want %v", tc.host, isValidHost(tc.host), tc.valid)
59+
}
60+
})
61+
}
62+
}
63+
64+
func TestResolution(t *testing.T) {
65+
client := &MockHTTPClient{
66+
url: "https://example.com/.well-known/did.json",
67+
resp: `{
68+
"@context": [
69+
"https://www.w3.org/ns/did/v1",
70+
"https://w3id.org/security/suites/ed25519-2020/v1",
71+
"https://w3id.org/security/suites/x25519-2020/v1"
72+
],
73+
"id": "did:web:example.com",
74+
"verificationMethod": [{
75+
"id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
76+
"type": "Ed25519VerificationKey2020",
77+
"controller": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
78+
"publicKeyMultibase": "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
79+
}],
80+
"authentication": [
81+
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
82+
],
83+
"assertionMethod": [
84+
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
85+
],
86+
"capabilityDelegation": [
87+
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
88+
],
89+
"capabilityInvocation": [
90+
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
91+
],
92+
"keyAgreement": [{
93+
"id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6LSj72tK8brWgZja8NLRwPigth2T9QRiG1uH9oKZuKjdh9p",
94+
"type": "X25519KeyAgreementKey2020",
95+
"controller": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
96+
"publicKeyMultibase": "z6LSj72tK8brWgZja8NLRwPigth2T9QRiG1uH9oKZuKjdh9p"
97+
}]
98+
}`,
99+
}
100+
101+
d, err := Decode("did:web:example.com")
102+
require.NoError(t, err)
103+
104+
doc, err := d.Document(did.WithHttpClient(client))
105+
require.NoError(t, err)
106+
107+
require.Equal(t, "did:web:example.com", doc.ID())
108+
require.Len(t, doc.VerificationMethods(), 1)
109+
require.Len(t, doc.Authentication(), 1)
110+
require.Len(t, doc.Assertion(), 1)
111+
require.Len(t, doc.KeyAgreement(), 1)
112+
}
113+
114+
type MockHTTPClient struct {
115+
url string
116+
resp string
117+
}
118+
119+
func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
120+
if req.URL.String() != m.url {
121+
return nil, fmt.Errorf("unexpected url: %s", req.URL.String())
122+
}
123+
124+
return &http.Response{
125+
StatusCode: http.StatusOK,
126+
Body: io.NopCloser(strings.NewReader(m.resp)),
127+
}, nil
128+
}

0 commit comments

Comments
 (0)