When working with the Bluesky Jetstream or the more complicated Firehose, you’re likely to see events that contain Decentralized Identifiers (“DIDs”).
Amongst other things, these DIDs describe the Bluesky user that took the action. In cases where an event impacts multiple users (like a follow), the DID also describes the user that sits on the receiving end.
Of course, DID strings are not human-readable. When we use Bluesky, we tend think in terms of handles — like mine, which is @davepeck.org
.
These notes describe how to write Python code to convert from a handle to a DID and back again.
Converting from a Bluesky handle like @davepeck.org
to a DID is relatively straightforward.
There are currently two supported ways to provide the mapping to the network:
_atproto
DNS TXT record. For the handle @foo.bar
, the record must live at _atproto.foo.bar
.@foo.bar
, the file must live at https://foo.bar/.well-known/atproto-did
.These rules apply whether you use Bluesky to manage your handle or whether you self-host your own PDS.
The dnspython
library makes it easy to query DNS records:
import dns.resolver
def remove_prefix(handle: str) -> str:
"""Returns a raw ATProto handle, without the @ prefix."""
return handle[1:] if handle.startswith("@") else handle
def resolve_handle_dns(handle: str) -> str | None:
"""
Resolves an ATProto handle to a DID using DNS.
Returns None if the handle is not found.
May raise an exception if network requests fail unexpectedly.
"""
try:
handle = remove_prefix(handle)
answers = dns.resolver.resolve(f"_atproto.{handle}", "TXT")
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
return None
for answer in answers:
txt = answer.to_text()
if txt.startswith('"did='):
return txt[len('"did='):-1]
return None
If DNS resolution fails, we can fall back to accessing the well-known text file:
import httpx
def resolve_handle_well_known(handle: str, timeout: float = 5.0) -> str | None:
"""
Resolves an ATProto handle to a DID using a well-known endpoint.
Returns None if the handle is not found.
Raises exceptions if network requests fail unexpectedly.
"""
import httpx
try:
handle = remove_prefix(handle)
response = httpx.get(f"https://{handle}/.well-known/atproto-did", timeout=timeout)
response.raise_for_status()
except httpx.ConnectError:
return None
return response.text.strip()
Putting it all together, we can write a function that tries both methods:
def resolve_handle(handle: str) -> str | None:
"""
Resolves an ATProto handle, like @bsky.app, to a DID.
We resolve as follows:
1. Check the _atproto DNS TXT record for the handle.
2. If not found, query for a .well-known/atproto-did
Returns None if the handle is not found.
Raises exceptions if network requests fail.
"""
maybe_did = resolve_handle_dns(handle)
maybe_did = maybe_did or resolve_handle_well_known(handle)
return maybe_did
You can see this code used in practice to implement the --handle
flag in my jetstream.py
command-line utility.
Working in the other direction, from a DID to a Bluesky handle, isn’t too hard either.
That said, it can be a little unclear how to start. That’s in part because DIDs themselves are complex. Just like URIs can have schemes, DIDs can have “methods” and each requires a different resolution strategy.
As it turns out, ATProto currently blesses exactly two DID methods:
did:web
method, a draft W3C standard in its own right.did:plc
method, a novel DID method developed by the Bluesky team. The did-method-plc
GitHub repository documents the gory details. The vast majority of Bluesky handles today use this DID method.Working with web
DIDs is easy: the ATProto specification mandates that the DID string is a domain name aka a Bluesky handle. In other words, did:web:example.com
is equivalent to the Bluesky handle @example.com
:
def resolve_did_web(did: str) -> str:
"""Resolve a web DID to an unprefixed Bluesky handle."""
if not did.startswith("did:web:"):
raise ValueError("Unexpected web DID format")
return did[len("did:web:"):]
The plc
DID method is a bit more complicated. PLC stands for “Public Ledger of Credentials”, a self-authenticating DID that provides a number of security and usability benefits. Without going into the details, the Bluesky team manages a public resolver that can convert plc
DIDs to Bluesky handles. It’s easy to use:
import httpx
def get_did_plc_data(did: str, timeout: float = 5.0) -> dict | None:
"""
Returns a collection of public data about a PLC DID.
Returns None if the DID is not found.
Raises exceptions if network requests fail.
"""
response = httpx.get(f"https://plc.directory/{did}", timeout=timeout)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
def resolve_did_plc(did: str, timeout: float = 5.0) -> str | None:
"""Resolve a PLC DID to an unprefixed Bluesky handle."""
data = get_did_plc_data(did, timeout=timeout)
if not data:
return None
akas = data.get("alsoKnownAs")
if not akas or not isinstance(akas, list):
raise ValueError("Unexpected PLC DID data")
aka = akas[0]
if not isinstance(aka, str):
raise ValueError("Unexpected PLC DID data")
if not aka.startswith("at://"):
raise ValueError("Unexpected PLC DID data")
return aka[len("at://"):]
There’s a lot of other interesting information contained in the data
dictionary returned by get_did_plc_data()
. For instance, when querying my personal account DID, you can see that I self-host my own Bluesky PDS at bsky.davepeck.dev
:
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": "did:plc:caznr5mgvjft4mu4p2vpttfx",
"alsoKnownAs": [
"at://davepeck.org"
],
"verificationMethod": [
{
"id": "did:plc:caznr5mgvjft4mu4p2vpttfx#atproto",
"type": "Multikey",
"controller": "did:plc:caznr5mgvjft4mu4p2vpttfx",
"publicKeyMultibase": "zQ3shZiY6egGLWnWRGGZBE4uvQk3MewFo7xhyNNo1NMDvuNgn"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://bsky.davepeck.dev"
}
]
}
Putting it all together, we can write a function that tries both methods to resolve a DID:
def resolve_did(did: str, timeout: float = 5.0) -> str | None:
"""
Resolves a DID to an unprefixed Bluesky handle, like `davepeck.org`.
We resolve as follows:
1. Check for a did:web DID.
2. If not found, query the PLC directory for a did:plc DID.
Returns None if the DID cannot be resolved.
Raises exceptions if the DID is invalid or network requests fail.
"""
if did.startswith("did:web:"):
return resolve_did_web(did)
elif did.startswith("did:plc:"):
return resolve_did_plc(did)
else:
raise ValueError("Unsupported DID method")
I’ve written resolve.py
, a tiny command-line utility that provides DID-to-handle and handle-to-DID resolution. It uses the code described above.
To use it, grab the code from GitHub and be sure to have Astral’s UV installed.
Then, simply run:
$ ./resolve.py handle @davepeck.org
did:plc:caznr5mgvjft4mu4p2vpttfx
$ ./resolve.py did did:plc:caznr5mgvjft4mu4p2vpttfx
davepeck.org
Thanks to UV and modern Python packaging magic you don’t need to muck with virtual environments or dependencies.