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()returnstrueand 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
- Samples.Firehose and
Samples.Jetstream use a
DidHandleCacheto show handles alongside DIDs.