Skip to content

Commit a922d17

Browse files
TheTechmagedbluhm
authored andcommitted
feat: Add did:web resolver support
Signed-off-by: Colton Wolkins (Indicio work address) <[email protected]>
1 parent ccc9b77 commit a922d17

File tree

3 files changed

+247
-0
lines changed

3 files changed

+247
-0
lines changed

didcomm_messaging/resolver/web.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""did:web resolver.
2+
3+
Resolve did:web style dids to a did document. did:web spec:
4+
https://w3c-ccg.github.io/did-method-web/
5+
"""
6+
7+
from . import DIDResolver, DIDNotFound, DIDResolutionError
8+
from pydid import DID
9+
from urllib.parse import urlparse
10+
from datetime import datetime, timedelta
11+
import urllib.request as url_request
12+
import re
13+
import json
14+
import urllib
15+
16+
domain_regex = (
17+
r"((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}"
18+
r"\.(xn--)?([a-z0-9\._-]{1,61}|[a-z0-9-]{1,30})"
19+
r"(%3[aA]\d+)?" # Port
20+
r"(:[a-zA-Z]+)*" # Path
21+
)
22+
did_web_pattern = re.compile(rf"^did:web:{domain_regex}$")
23+
cache = {}
24+
TIME_TO_CACHE = 1800 # 30 minutes
25+
26+
27+
class DIDWeb(DIDResolver):
28+
"""Utility functions for building and interacting with did:web."""
29+
30+
async def resolve(self, did: str) -> dict:
31+
"""Resolve a did:web to a did document via http request."""
32+
33+
# Check to see if we've seen the did recently
34+
if did in cache:
35+
if cache[did]["timestamp"] > datetime.now() + timedelta(
36+
seconds=-TIME_TO_CACHE
37+
):
38+
return cache[did]["doc"]
39+
else:
40+
del cache[did]
41+
42+
uri = DIDWeb._did_to_uri(did)
43+
headers = {
44+
"User-Agent": "DIDCommRelay/1.0",
45+
}
46+
request = url_request.Request(url=uri, method="GET", headers=headers)
47+
try:
48+
with url_request.urlopen(request) as response:
49+
doc = json.loads(response.read().decode())
50+
cache[did] = {
51+
"timestamp": datetime.now(),
52+
"doc": doc,
53+
}
54+
return doc
55+
except urllib.error.HTTPError as e:
56+
if e.code == 404:
57+
raise DIDNotFound(
58+
f"The did:web {did} returned a 404 not found while resolving"
59+
)
60+
else:
61+
raise DIDResolutionError(
62+
f"Unknown server error ({e.code}) while resolving did:web: {did}"
63+
)
64+
except json.decoder.JSONDecodeError as e:
65+
msg = str(e)
66+
raise DIDNotFound(f"The did:web {did} returned invalid JSON {msg}")
67+
except Exception as e:
68+
raise DIDResolutionError("Failed to fetch did document") from e
69+
70+
@staticmethod
71+
def _did_to_uri(did: str) -> str:
72+
# Split the did by it's segments
73+
did_segments = did.split(":")
74+
75+
# Get the hostname & port
76+
hostname = did_segments[2].lower()
77+
hostname = hostname.replace("%3a", ":")
78+
79+
# Resolve the path portion of the DID, if there is no path, default to
80+
# a .well-known address
81+
path = ".well-known"
82+
if len(did_segments) > 3:
83+
path = "/".join(did_segments[3:])
84+
85+
# Assemble the URI
86+
did_uri = f"https://{hostname}/{path}/did.json"
87+
88+
return did_uri
89+
90+
async def is_resolvable(self, did: str) -> bool:
91+
"""Determine if the did is a valid did:web did that can be resolved."""
92+
if DID.is_valid(did) and did_web_pattern.match(did):
93+
return True
94+
return False
95+
96+
@staticmethod
97+
def did_from_url(url: str) -> DID:
98+
"""Convert a URL into a did:web did."""
99+
100+
# Make sure that the URL starts with a scheme
101+
if not url.startswith("http"):
102+
url = f"https://{url}"
103+
104+
# Parse it out to we can grab pieces
105+
parsed_url = urlparse(url)
106+
107+
# Assemble the domain portion of the DID
108+
did = "did:web:%s" % parsed_url.netloc.replace(":", "%3A")
109+
110+
# Cleanup the path
111+
path = parsed_url.path.replace(".well-known/did.json", "")
112+
path = path.replace("/did.json", "")
113+
114+
# Add the path portion of the did
115+
if len(path) > 1:
116+
did += path.replace("/", ":")
117+
return did

tests/conftest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import pytest
2+
3+
4+
def pytest_addoption(parser):
5+
parser.addoption(
6+
"--runexternal",
7+
action="store_true",
8+
default=False,
9+
help="run tests that make external requests",
10+
)
11+
12+
13+
def pytest_configure(config):
14+
config.addinivalue_line("markers", "external_fetch: mark test as slow to run")
15+
16+
17+
def pytest_collection_modifyitems(config, items):
18+
if config.getoption("--runexternal"):
19+
# --runslow given in cli: do not skip slow tests
20+
return
21+
skip_external = pytest.mark.skip(reason="need --runexternal option to run")
22+
for item in items:
23+
if "external_fetch" in item.keywords:
24+
item.add_marker(skip_external)

tests/test_didweb.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import pytest
2+
3+
4+
from didcomm_messaging.resolver.web import DIDWeb
5+
6+
DIDWEB = "did:web:example.com"
7+
DIDWEB_URI = "https://example.com/.well-known/did.json"
8+
DIDWEB_COMPLEX = "did:web:example.com%3A4443:DIDs:alice:relay"
9+
DIDWEB_COMPLEX_URI = "https://example.com:4443/DIDs/alice/relay/did.json"
10+
11+
12+
@pytest.mark.asyncio
13+
async def test_didweb_from_didurl_domain():
14+
did = DIDWeb.did_from_url("example.com")
15+
assert did
16+
assert did == DIDWEB
17+
18+
19+
@pytest.mark.asyncio
20+
async def test_didweb_from_didurl_schema_and_domain():
21+
did = DIDWeb.did_from_url("https://example.com")
22+
assert did
23+
assert did == DIDWEB
24+
25+
26+
@pytest.mark.asyncio
27+
async def test_didweb_from_didurl_schema_and_domain_slash():
28+
did = DIDWeb.did_from_url("https://example.com/")
29+
assert did
30+
assert did == DIDWEB
31+
32+
33+
@pytest.mark.asyncio
34+
async def test_didweb_from_didurl_schema_and_domain_path():
35+
did = DIDWeb.did_from_url("https://example.com/did.json")
36+
assert did
37+
assert did == DIDWEB
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_didweb_from_didurl_schema_and_domain_wellknown():
42+
did = DIDWeb.did_from_url("https://example.com/.well-known/did.json")
43+
assert did
44+
assert did == DIDWEB
45+
46+
47+
@pytest.mark.asyncio
48+
async def test_didweb_from_didurl_schema_and_domain_port_wellknown():
49+
did = DIDWeb.did_from_url("https://example.com:443/.well-known/did.json")
50+
assert did
51+
assert did == DIDWEB + "%3A443"
52+
53+
54+
@pytest.mark.asyncio
55+
async def test_didweb_from_didurl_schema_and_complex_domain_path():
56+
did = DIDWeb.did_from_url("https://example.com:4443/DIDs/alice/relay/did.json")
57+
assert did
58+
assert did == DIDWEB_COMPLEX
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_didweb_to_url():
63+
uri = DIDWeb._did_to_uri(DIDWEB)
64+
assert uri
65+
assert uri == DIDWEB_URI
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_didweb_to_url_complex():
70+
uri = DIDWeb._did_to_uri(DIDWEB_COMPLEX)
71+
assert uri
72+
assert uri == DIDWEB_COMPLEX_URI
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_didweb_is_resolvable():
77+
resolver = DIDWeb()
78+
resolvable = await resolver.is_resolvable(DIDWEB)
79+
assert resolvable
80+
resolvable_complex = await resolver.is_resolvable(DIDWEB_COMPLEX)
81+
assert resolvable_complex
82+
83+
84+
@pytest.mark.external_fetch
85+
@pytest.mark.asyncio
86+
async def test_didweb_fetch():
87+
did_web = "did:web:colton.wolkins.net"
88+
resolver = DIDWeb()
89+
uri = await resolver.resolve(did_web)
90+
print(uri)
91+
assert uri
92+
assert isinstance(uri, dict)
93+
94+
95+
@pytest.mark.external_fetch
96+
@pytest.mark.asyncio
97+
async def test_didweb_double_fetch():
98+
did_web = "did:web:colton.wolkins.net"
99+
resolver = DIDWeb()
100+
uri = await resolver.resolve(did_web)
101+
print(uri)
102+
assert uri
103+
assert isinstance(uri, dict)
104+
uri = await resolver.resolve(did_web)
105+
assert uri
106+
assert isinstance(uri, dict)

0 commit comments

Comments
 (0)