[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 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 =
[
"image/png",

View file

@ -30,16 +30,26 @@ public class UserResolver(
* The full web finger algorithm:
*
* 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.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
* 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
)
{
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 fingerRes = await webFingerSvc.ResolveAsync(query);
@ -56,6 +66,12 @@ public class UserResolver(
logger.LogDebug("Actor ID differs from query, retrying...");
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)
{
@ -68,7 +84,10 @@ public class UserResolver(
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)
throw new GracefulException($"WebFinger response for '{query}' didn't contain a candidate link");
var subjectUri = GetAcctUri(fingerRes) ??
@ -80,13 +99,40 @@ public class UserResolver(
if (subjectHost == apUriHost && subjectHost == queryHost)
return (subjectUri, apUri);
// We need to skip this step when performing reverse discovery & the uris match
if (apUri != actorUri)
{
if (actorUri != null) throw new Exception("Reverse discovery failed: uri mismatch");
fingerRes = responses.GetValueOrDefault(apUri);
if (fingerRes == null)
{
logger.LogDebug("AP uri didn't match query, re-running WebFinger for '{apUri}'", apUri);
fingerRes = await webFingerSvc.ResolveAsync(apUri) ?? throw new Exception($"Failed to WebFinger '{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) ??
throw new Exception($"WebFinger response for '{apUri}' didn't contain any acct uris");

View file

@ -128,7 +128,7 @@ public class ASActor : ASObject
[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");

View file

@ -116,7 +116,7 @@ public class UserService(
var actor = await fetchSvc.FetchActorAsync(uri);
logger.LogDebug("Got actor: {url}", actor.Url);
actor.Normalize(uri, acct);
actor.Normalize(uri);
if (actor.Id != uri)
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);
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 ??= new UserProfile { User = user };