[backend/federation] Support WebFinger reverse discovery (ISH-364)

This resolves federation issues with old versions of pixelfed, as well as various AP implementations that are not widely deployed.
This commit is contained in:
Laura Hausmann 2024-08-13 23:41:38 +02:00
parent 2506e42733
commit acbedd3bae
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
4 changed files with 62 additions and 13 deletions

View file

@ -20,6 +20,9 @@ public static class Constants
public const string MisskeyNs = "https://misskey-hub.net/ns"; public const string MisskeyNs = "https://misskey-hub.net/ns";
public static readonly string[] SystemUsers = ["instance.actor", "relay.actor"]; public static readonly string[] SystemUsers = ["instance.actor", "relay.actor"];
public const string ASMime = "application/activity+json";
public const string ASMimeAlt = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
public static readonly string[] BrowserSafeMimeTypes = public static readonly string[] BrowserSafeMimeTypes =
[ [
"image/png", "image/png",

View file

@ -30,16 +30,26 @@ public class UserResolver(
* The full web finger algorithm: * The full web finger algorithm:
* *
* 1. WebFinger(input_uri), find the rel=self type=application/activity+json entry, that's ap_uri * 1. WebFinger(input_uri), find the rel=self type=application/activity+json entry, that's ap_uri
* 1.1 Failing this, fetch the actor
* 1.1.1 If the actor uri differs from the query, recurse (once!) with the new uri
* 1.1.2 Otherwise, Perform reverse discovery for the actor uri
* 2. WebFinger(ap_uri), find the first acct: URI in [subject] + aliases, that's candidate_acct_uri * 2. WebFinger(ap_uri), find the first acct: URI in [subject] + aliases, that's candidate_acct_uri
* 2.1 Failing this, perform reverse discovery for ap_uri
* 3. WebFinger(candidate_acct_uri), validate it also points to ap_uri. If so, you have acct_uri * 3. WebFinger(candidate_acct_uri), validate it also points to ap_uri. If so, you have acct_uri
* 4. Failing this, acct_uri = "acct:" + preferredUsername from AP actor + "@" + hostname from ap_uri * 3.1 Failing this, acct_uri = "acct:" + preferredUsername from AP actor + "@" + hostname from ap_uri
* *
* Avoid repeating WebFinger's with same URI for performance, optimize away validation checks when the domain matches * Avoid repeating WebFinger's with same URI for performance, optimize away validation checks when the domain matches.
* Skip step 2 when performing reverse discovery & ap_uri matches the actor uri.
*/ */
private async Task<(string Acct, string Uri)> WebFingerAsync(string query, bool recurse = true) private async Task<(string Acct, string Uri)> WebFingerAsync(
string query, bool recurse = true, string? actorUri = null
)
{ {
logger.LogDebug("Running WebFinger for query '{query}'", query); if (actorUri == null)
logger.LogDebug("Running WebFinger for query '{query}'", query);
else
logger.LogDebug("Running WebFinger reverse discovery for query '{query}' and uri '{uri}'", query, actorUri);
var responses = new Dictionary<string, WebFingerResponse>(); var responses = new Dictionary<string, WebFingerResponse>();
var fingerRes = await webFingerSvc.ResolveAsync(query); var fingerRes = await webFingerSvc.ResolveAsync(query);
@ -56,6 +66,12 @@ public class UserResolver(
logger.LogDebug("Actor ID differs from query, retrying..."); logger.LogDebug("Actor ID differs from query, retrying...");
return await WebFingerAsync(actor.Id, false); return await WebFingerAsync(actor.Id, false);
} }
logger.LogDebug("Actor ID matches query, performing reverse discovery...");
actor.Normalize(query);
var domain = new Uri(actor.Id).Host;
var username = actor.Username!;
return await WebFingerAsync($"acct:{username}@{domain}", false, actor.Id);
} }
catch (Exception e) catch (Exception e)
{ {
@ -68,7 +84,10 @@ public class UserResolver(
responses.Add(query, fingerRes); responses.Add(query, fingerRes);
var apUri = fingerRes.Links.FirstOrDefault(p => p is { Rel: "self", Type: "application/activity+json" })?.Href; var apUri = fingerRes.Links
.FirstOrDefault(p => p is { Rel: "self", Type: Constants.ASMime or Constants.ASMimeAlt })
?.Href;
if (apUri == null) if (apUri == null)
throw new GracefulException($"WebFinger response for '{query}' didn't contain a candidate link"); throw new GracefulException($"WebFinger response for '{query}' didn't contain a candidate link");
var subjectUri = GetAcctUri(fingerRes) ?? var subjectUri = GetAcctUri(fingerRes) ??
@ -80,12 +99,39 @@ public class UserResolver(
if (subjectHost == apUriHost && subjectHost == queryHost) if (subjectHost == apUriHost && subjectHost == queryHost)
return (subjectUri, apUri); return (subjectUri, apUri);
fingerRes = responses.GetValueOrDefault(apUri); // We need to skip this step when performing reverse discovery & the uris match
if (fingerRes == null) if (apUri != actorUri)
{ {
logger.LogDebug("AP uri didn't match query, re-running WebFinger for '{apUri}'", apUri); if (actorUri != null) throw new Exception("Reverse discovery failed: uri mismatch");
fingerRes = await webFingerSvc.ResolveAsync(apUri) ?? throw new Exception($"Failed to WebFinger '{apUri}'"); fingerRes = responses.GetValueOrDefault(apUri);
responses.Add(apUri, fingerRes); if (fingerRes == null)
{
logger.LogDebug("AP uri didn't match query, re-running WebFinger for '{apUri}'", apUri);
fingerRes = await webFingerSvc.ResolveAsync(apUri);
if (fingerRes == null)
{
logger.LogDebug("Failed to validate apUri, falling back to reverse discovery");
try
{
var actor = await fetchSvc.FetchActorAsync(apUri);
if (apUri != actor.Id)
throw new Exception("Reverse discovery fallback failed: uri mismatch");
logger.LogDebug("Actor ID matches apUri, performing reverse discovery...");
actor.Normalize(apUri);
var domain = new Uri(actor.Id).Host;
var username = new Uri(actor.Username!).Host;
return await WebFingerAsync($"acct:{username}@{domain}", false, apUri);
}
catch (Exception e)
{
logger.LogDebug("Failed to fetch actor {uri}: {e}", query, e.Message);
throw new GracefulException($"Failed to WebFinger '{query}'");
}
}
responses.Add(apUri, fingerRes);
}
} }
var acctUri = GetAcctUri(fingerRes) ?? var acctUri = GetAcctUri(fingerRes) ??

View file

@ -128,7 +128,7 @@ public class ASActor : ASObject
[JI] public bool IsBot => Type == $"{Constants.ActivityStreamsNs}#Service"; [JI] public bool IsBot => Type == $"{Constants.ActivityStreamsNs}#Service";
public void Normalize(string uri, string acct) public void Normalize(string uri)
{ {
if (Type == null || !ActorTypes.Contains(Type)) throw new Exception("Actor is of invalid type"); if (Type == null || !ActorTypes.Contains(Type)) throw new Exception("Actor is of invalid type");

View file

@ -116,7 +116,7 @@ public class UserService(
var actor = await fetchSvc.FetchActorAsync(uri); var actor = await fetchSvc.FetchActorAsync(uri);
logger.LogDebug("Got actor: {url}", actor.Url); logger.LogDebug("Got actor: {url}", actor.Url);
actor.Normalize(uri, acct); actor.Normalize(uri);
if (actor.Id != uri) if (actor.Id != uri)
throw GracefulException.UnprocessableEntity("Uri doesn't match id of fetched actor"); throw GracefulException.UnprocessableEntity("Uri doesn't match id of fetched actor");
@ -257,7 +257,7 @@ public class UserService(
logger.LogDebug("Updating user with uri {uri}", uri); logger.LogDebug("Updating user with uri {uri}", uri);
actor ??= await fetchSvc.FetchActorAsync(user.Uri); actor ??= await fetchSvc.FetchActorAsync(user.Uri);
actor.Normalize(uri, user.AcctWithPrefix); actor.Normalize(uri);
user.UserProfile ??= await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user); user.UserProfile ??= await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user);
user.UserProfile ??= new UserProfile { User = user }; user.UserProfile ??= new UserProfile { User = user };