Table of Contents

Resolving and caching handles for DIDs

Events from the firehose and Jetstream, labels, and many other AT Protocol records identify accounts by their DID. To display a handle you need to resolve the DID, and resolving a DID for every event is too slow, and too expensive for the directory and hosts you query, to keep up with a busy stream.

DidHandleCache, in idunno.AtProto, resolves the handle for a DID, verifies it, and caches the result.

using var didHandleCache = new DidHandleCache();

Handle handle = await didHandleCache.ResolveHandleAsync(did, cancellationToken);

Console.WriteLine($"{did} is {handle}");

Verification and handle.invalid

A DID document's alsoKnownAs entry is only a claim; anyone can put any handle in their DID document. Following the AT Protocol identity specification the cache resolves the handle a DID document claims, and only returns it if that handle resolves back to the same DID.

If the DID cannot be resolved, the handle cannot be resolved, the handle resolves to a different DID, or the resolution fails or times out, the cache returns Handle.Invalid, which displays as handle.invalid. It never throws for a failed resolution, so you can check the result with Handle.IsValid:

Handle handle = await didHandleCache.ResolveHandleAsync(did, cancellationToken);

if (handle.IsValid)
{
    Console.WriteLine($"{did} is @{handle}");
}
else
{
    Console.WriteLine($"{did} does not have a verified handle");
}

Failures are logged at the Debug level, see Configuring logging.

Looking up handles

DidHandleCache implements IDidHandleResolver, which has two ways to get a handle:

  • ResolveHandleAsync() returns the cached handle, or resolves, verifies and caches it if it is not cached.
  • TryGetCachedHandle() returns true and the handle only if it is already cached, and never makes a network request.

On a busy stream, such as the full relay firehose, resolving every DID you see will fall behind. A common pattern is to call ResolveHandleAsync() for less frequent events, such as account events, and TryGetCachedHandle() for frequent ones, such as commits, showing just the DID until a handle has been cached:

string Describe(Did did) =>
    didHandleCache.TryGetCachedHandle(did, out Handle? handle) ? $"{did} ({handle})" : did;

Concurrent lookups and cancellation

Concurrent calls to ResolveHandleAsync() for the same DID share a single resolution. At most DidHandleCacheOptions.MaximumConcurrentResolutions resolutions run at once, and other lookups wait for one to finish. A resolution holds its slot until it actually finishes, even after the lookup waiting for it has timed out. To bound memory, at most DidHandleCacheOptions.Size lookups can be pending at once; once that many are pending, a lookup for a DID which is not already being resolved returns handle.invalid immediately, without caching it, so a later lookup tries again.

Cancelling the token passed to ResolveHandleAsync() stops that caller waiting, but does not cancel the shared resolution, which continues for any other callers and is cached when it completes. Each resolution, including any time spent waiting to start, is bounded by DidHandleCacheOptions.ResolutionTimeout. A lookup which times out waiting to start returns handle.invalid without caching it. Disposing the cache cancels any resolutions in progress.

Keeping handles up to date

When an account changes its handle the relay sends an #identity event. Pass the cache to the firehose or Jetstream and it calls Invalidate() for the DID in each identity event it receives, so the next lookup resolves the new handle:

using var didHandleCache = new DidHandleCache();

await using var firehose = new AtProtoFirehose(
    options: new FirehoseOptions
    {
        DidHandleResolver = didHandleCache
    });
using var didHandleCache = new DidHandleCache();

await using var jetstream = new AtProtoJetstream(
    options: new JetstreamOptions
    {
        DidHandleResolver = didHandleCache
    });

If you use AtProtoJetstreamBuilder, call WithDidHandleResolver().

The handle in an identity event is not verified, so it is not cached. If a handle is being resolved when its DID is invalidated, callers already waiting for it receive the result, but it is not cached, so a handle resolved before an identity event is never cached after it.

If you read identity events from another source, call Invalidate() yourself.

Without invalidation, a changed handle is reported until its cache entry expires.

Configuration

Pass a DidHandleCacheOptions to the constructor to configure the cache:

using var didHandleCache = new DidHandleCache(new DidHandleCacheOptions
{
    Size = 10_000,
    Duration = TimeSpan.FromMinutes(30),
    LoggerFactory = loggerFactory
});
Option Default Description
Size 100,000 The maximum number of handles cached. It also bounds the number of lookups which can be pending at once.
MaximumConcurrentResolutions 32 The maximum number of resolutions which can run at once.
Duration 1 hour How long a verified handle is cached.
FailedResolutionDuration 1 minute How long handle.invalid is cached after a failure, so a DID which cannot be resolved is not resolved again for every lookup. It is never cached for longer than Duration.
ResolutionTimeout 30 seconds How long a single resolution, including any wait for one of the MaximumConcurrentResolutions slots, can take before it is abandoned and treated as a failure.
PlcDirectory https://plc.directory The PLC directory used to resolve did:plc DIDs.
HttpClient null The HttpClient used for resolution. If null an SSRF-protected client is created for each resolution. The cache does not dispose a client you supply. See the warning below.
LoggerFactory null The ILoggerFactory used to create loggers.
MeterFactory null The IMeterFactory used to create the cache's meter.
TimeProvider TimeProvider.System The TimeProvider used to expire entries and time out resolutions. Supply a fake time provider in tests.

When the cache is full a new handle is not cached until the cache has been compacted, which happens in the background.

Warning

DID documents and handles name hosts chosen by whoever controls the DID, so resolving them makes requests to hosts an attacker can choose. When HttpClient is null the cache uses a handler which refuses to connect to loopback, private and other non-public addresses. An HttpClient you supply does not get that protection. If you supply one, configure it to block requests to your internal networks, for example by building it with IServiceCollection.AddAtProtoHttpClient().

Untrusted sources of DIDs

The cache helps with well behaved sources of DIDs, but a malicious source can defeat it. A server which sends more distinct DIDs than Size evicts handles before they are reused, and one which sends an identity event before each event for a DID invalidates its handle every time. Either forces a resolution for most lookups, although MaximumConcurrentResolutions and Size limit how many run and wait at once, and lookups beyond those limits return handle.invalid without caching it. Each resolution makes requests to the PLC directory, or for did:web to a host the DID names, as well as to the host the handle names.

If you read from a source you do not control, consider using TryGetCachedHandle() for most events, choosing a Size that fits the number of accounts you expect to see, and watching the cache's metrics.

Dependency injection and testing

Depend on IDidHandleResolver rather than DidHandleCache so you can substitute your own implementation, for example a test double, or a resolver backed by a distributed cache.

services.AddSingleton<IDidHandleResolver>(serviceProvider => new DidHandleCache(new DidHandleCacheOptions
{
    LoggerFactory = serviceProvider.GetService<ILoggerFactory>(),
    MeterFactory = serviceProvider.GetService<IMeterFactory>()
}));

A DidHandleCache is thread safe and is meant to be shared, so register it as a singleton. The container disposes it when the container is disposed. After disposal ResolveHandleAsync() and TryGetCachedHandle() throw ObjectDisposedException, and Invalidate() does nothing.

Metrics

The cache reports hits, misses, shared lookups, rejected lookups, invalid handles and invalidations through the idunno.AtProto.DidHandleCache meter. Register it with AddAtProtoDidHandleCacheMetrics(). See Built-in metrics for the full list.

Samples