Merge pull request #18 from MetaMask/didweb
verifiers: add support for did:web
This commit is contained in:
@@ -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
|
||||
|
||||
155
verifiers/did-web/web.go
Normal file
155
verifiers/did-web/web.go
Normal file
@@ -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
|
||||
}
|
||||
128
verifiers/did-web/web_test.go
Normal file
128
verifiers/did-web/web_test.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user