Converting between DIDs and Bluesky handles with Python

Last updated: November 21, 2024

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.

From a Bluesky handle to a DID

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:

  1. Via an _atproto DNS TXT record. For the handle @foo.bar, the record must live at _atproto.foo.bar.
  2. Via a well-known text file at the handle’s domain. For the handle @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.

From a DID to a Bluesky handle

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:

  1. The did:web method, a draft W3C standard in its own right.
  2. The 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")

A command-line utility for working with handles and DIDs

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.