WordPress's ActivityPub plugin (and Ghost and Fedify) sign with RFC 9421 first and fall back to draft-cavage only after a refusal, so each first delivery cost two requests and a 401 in our statistics. Now a request carrying Signature-Input is verified as an HTTP message signature: its covered components (the method and our own public target, the body's Content-Digest), its created and expires, with the actor's RSA key under PKCS#1 v1.5 or PSS. Deliveries and signed fetches both take it; the ledger names the scheme (rfc9421:rsa-v1_5-sha256). What PrivaPub sends stays draft-cavage, which every server reads. Ed25519 waits for FEP-521a keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
97 lines
4.4 KiB
C#
97 lines
4.4 KiB
C#
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Federation.Signing
|
|
{
|
|
public interface ISignedFetchAuthorizer
|
|
{
|
|
Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token);
|
|
Task<bool> MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token);
|
|
}
|
|
|
|
// Who may refetch a post that is not public (owner decision 2026-10-04). A server that received a followers-only post,
|
|
// a DM or a circle post refetches it, signed by the account it was for or by its instance actor, and Mastodon deletes its
|
|
// copy when the refetch answers 404. So the post is served to a signed request from someone it was for, or from the
|
|
// instance actor of a server where someone it was for lives; everyone else still gets 404.
|
|
public class SignedFetchAuthorizer : ISignedFetchAuthorizer
|
|
{
|
|
readonly IRemoteActorService _remoteActors;
|
|
readonly DbEntities _dbEntities;
|
|
|
|
readonly ILocalActorService _localActors;
|
|
|
|
public SignedFetchAuthorizer(IRemoteActorService remoteActors, DbEntities dbEntities, ILocalActorService localActors = default)
|
|
{
|
|
_remoteActors = remoteActors;
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
}
|
|
|
|
// the actor whose signature (draft-cavage, or RFC 9421 when there is a base address to check its target against)
|
|
// verifies on the request
|
|
public async Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token)
|
|
{
|
|
var signature = RequestSignature.Of(request);
|
|
if (signature == default || signature.Problem(request, body: default) != default)
|
|
return default;
|
|
var signed = signature.Signed(request, _localActors?.BaseAddress ?? $"{request.Scheme}://{request.Host}");
|
|
var actor = await _remoteActors.GetActorByKeyId(signature.KeyId, refresh: false, token);
|
|
if (actor != default && signature.VerifiedBy(actor.PublicKey, signed))
|
|
return actor;
|
|
actor = await _remoteActors.GetActorByKeyId(signature.KeyId, refresh: true, token);
|
|
return actor != default && signature.VerifiedBy(actor.PublicKey, signed) ? actor : default;
|
|
}
|
|
|
|
public async Task<bool> MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token)
|
|
{
|
|
if (post == default || requester == default)
|
|
return false;
|
|
switch (post.Visibility)
|
|
{
|
|
case PostVisibility.Public or PostVisibility.Unlisted:
|
|
return true;
|
|
case PostVisibility.Circle:
|
|
var circle = string.IsNullOrEmpty(post.GroupId) ? default : await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token);
|
|
return circle != default && MayReadCircle(circle, requester);
|
|
case PostVisibility.Direct:
|
|
return Addressed(post).Any(uri => Is(requester, uri));
|
|
case PostVisibility.FollowersOnly:
|
|
if (Addressed(post).Any(uri => Is(requester, uri)))
|
|
return true;
|
|
var followers = await _dbEntities.Followers
|
|
.Match(f => f.LocalActorId == post.GroupUserId && f.LocalActorKind == LocalActorKind.Person && f.IsAccepted)
|
|
.ExecuteAsync(token);
|
|
return followers.Any(f => Is(requester, f.ActorURI));
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static IEnumerable<string> Addressed(PostEntity post) =>
|
|
post.To.Concat(post.Cc).Concat(post.Mentions.Select(m => m.ActorURI)).Where(uri => !string.IsNullOrEmpty(uri));
|
|
|
|
// the account itself, or the instance actor of the server it lives on
|
|
static bool Is(ForeignAvatar requester, string actorUri) =>
|
|
actorUri == requester.ActorURI || requester.AvatarType == AvatarType.Application && Origin.Same(actorUri, requester.ActorURI);
|
|
|
|
public static bool MayReadCircle(GroupEntity circle, ForeignAvatar requester) =>
|
|
requester != default && circle.Members.Any(m => m.IsForeign && Is(requester, m.AvatarId));
|
|
|
|
public static bool MayReadConversation(Models.Group.DmGroup conversation, ForeignAvatar requester) =>
|
|
requester != default && conversation.Members.Any(m => m.IsForeign && Is(requester, m.AvatarId));
|
|
|
|
// the members a refetch names in cc: the requesting member, or the members on an instance actor's server
|
|
public static IReadOnlyList<string> CircleReaders(GroupEntity circle, ForeignAvatar requester) =>
|
|
requester == default
|
|
? Array.Empty<string>()
|
|
: circle.Members.Where(m => m.IsForeign && Is(requester, m.AvatarId)).Select(m => m.AvatarId).ToList();
|
|
}
|
|
}
|