Owner decision 2026-10-04: fix the account privacy findings.
- Sign-in. Every failure answers "That username and password do not match." after the same work: an unknown login
is hashed against a decoy, and the comparison is constant-time. "Banned" is told only to someone who gave the right
password. This covers /clientapi/user/login, /invitation/login and /oauth/login.
- Recovery.
- Every request answers the same sentence and queues a SendRecovery job, whether or not the account exists or has an
email. The lookup, the code and SMTP move to RecoveryJob, so neither the answer nor its timing says anything.
- Codes are kept only as a SHA-256 hash, for one hour. Migration _011 drops the plaintext ones, which never expired.
- A recovered password ends every session of the root. RootSessions sets CredentialsChangedAt, which JwtEvents
checks against the JWT's issue time, now stamped as nbf, and revokes each persona's OAuth tokens and authorizations.
- Deleting a root (RootRemoval: the admin route, or the restored self-delete at /clientapi/user/delete, which asks for
the password).
- Its sessions end.
- Each persona and each group it owns sends Delete{Actor} to its followers, its members and the accounts it follows.
- The personas' posts are emptied.
- /peasants/{name} answers 410 with a Tombstone (formerType Person or Group), as do its inbox and WebFinger, through
LocalActorService.Gone. The names stay reserved.
- The root keeps only a unique `deleted-{id}` name; the second deletion on an instance used to collide on
"Deleted user".
Also, from phase 2's pasture: GoToSocial files a circle post like a DM and shows it only to accounts it mentions. Each
member's copy, and a member's refetch, now also mentions that member silently. The GoToSocial scenario checks circle
posts in conversations, like DMs, and they pass there now, as on Mastodon.
657 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
269 lines
10 KiB
C#
269 lines
10 KiB
C#
using Microsoft.Extensions.Options;
|
|
|
|
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
namespace PrivaPub.Federation.Actors
|
|
{
|
|
public class LocalActor
|
|
{
|
|
public string Id { get; init; }
|
|
public LocalActorKind Kind { get; init; }
|
|
public string UserName { get; init; }
|
|
public string Name { get; init; }
|
|
public string Summary { get; init; }
|
|
public string PictureURL { get; init; }
|
|
public string ThumbnailURL { get; init; }
|
|
public string PrivateKeyPem { get; init; }
|
|
public string PublicKeyPem { get; init; }
|
|
public bool Discoverable { get; init; } = true;
|
|
public bool ManuallyApprovesFollowers { get; init; }
|
|
public bool IsFederated { get; init; } = true;
|
|
public bool IsCircle { get; init; }
|
|
public bool PostingRestrictedToModerators { get; init; }
|
|
public bool IsBot { get; init; }
|
|
public bool Indexable { get; init; }
|
|
public AvatarSettings Settings { get; init; } = new();
|
|
public DateTime Published { get; init; }
|
|
public string BaseAddress { get; init; }
|
|
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
|
|
|
|
public string Uri => $"{BaseAddress}/peasants/{UserName}";
|
|
public string KeyId => $"{Uri}#main-key";
|
|
public string Inbox => $"{Uri}/mouth";
|
|
public string Outbox => $"{Uri}/anus";
|
|
public string Followers => $"{Uri}/groupies";
|
|
public string Following => $"{Uri}/stalking";
|
|
public string Featured => $"{Uri}/trophies";
|
|
public string FeaturedTags => $"{Uri}/tattoos";
|
|
public string Flock => $"{Uri}/flock";
|
|
public string Wardens => $"{Uri}/wardens";
|
|
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
|
public string Domain => new Uri(BaseAddress).Authority;
|
|
public string Handle => $"{UserName}@{Domain}";
|
|
public string HtmlUrl => $"{BaseAddress}/@{UserName}";
|
|
public string PostUri(string postId) => $"{Uri}/scribbles/{postId}";
|
|
public string PostHtmlUrl(string postId) => $"{HtmlUrl}/{postId}";
|
|
public string ActivityUri(string activityId) => $"{Uri}/grunts/{activityId}";
|
|
public string ConversationUri(string conversationId) => $"{Uri}/whispers/{conversationId}";
|
|
}
|
|
|
|
public interface ILocalActorService
|
|
{
|
|
string BaseAddress { get; }
|
|
Task<LocalActor> FindByUserName(string userName, CancellationToken token);
|
|
Task<GoneActor> Gone(string userName, CancellationToken token);
|
|
Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token);
|
|
Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
|
|
Task<LocalActor> GetInstanceActor(CancellationToken token);
|
|
Task<bool> IsUserNameTaken(string userName, CancellationToken token);
|
|
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
|
|
LocalActor FromAvatar(Avatar avatar);
|
|
LocalActor FromGroup(GroupEntity group);
|
|
}
|
|
|
|
// a persona or group that was deleted: its name stays reserved, and its documents answer 410 with this
|
|
public sealed record GoneActor(string Uri, string FormerType, DateTime DeletedAt);
|
|
|
|
public class LocalActorService : ILocalActorService
|
|
{
|
|
public const string InstanceUserName = "privapub";
|
|
|
|
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
|
|
{
|
|
InstanceUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
|
|
"abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined"
|
|
};
|
|
|
|
readonly DbEntities _dbEntities;
|
|
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
|
|
InstanceActor _instanceActor;
|
|
|
|
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_appConfiguration = appConfiguration;
|
|
}
|
|
|
|
public string BaseAddress => _appConfiguration.CurrentValue.BackendBaseAddress?.TrimEnd('/');
|
|
|
|
public async Task<LocalActor> FindByUserName(string userName, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(userName))
|
|
return default;
|
|
userName = userName.ToLowerInvariant();
|
|
if (userName == InstanceUserName)
|
|
return await GetInstanceActor(token);
|
|
|
|
var avatar = await _dbEntities.Avatars
|
|
.Match(a => a.UserName == userName && !a.DeletionAt.HasValue)
|
|
.ExecuteFirstAsync(token);
|
|
if (avatar != default)
|
|
return FromAvatar(avatar);
|
|
|
|
var group = await _dbEntities.Groups
|
|
.Match(g => g.UserName == userName && !g.DeletionAt.HasValue)
|
|
.ExecuteFirstAsync(token);
|
|
return group == default ? default : FromGroup(group);
|
|
}
|
|
|
|
public async Task<GoneActor> Gone(string userName, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(userName))
|
|
return default;
|
|
userName = userName.ToLowerInvariant();
|
|
var avatar = await _dbEntities.Avatars.Match(a => a.UserName == userName && a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
|
if (avatar != default)
|
|
return new GoneActor($"{BaseAddress}/peasants/{avatar.UserName}", "Person", avatar.DeletionAt.Value);
|
|
var group = await _dbEntities.Groups.Match(g => g.UserName == userName && g.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
|
return group == default ? default : new GoneActor($"{BaseAddress}/peasants/{group.UserName}", "Group", group.DeletionAt.Value);
|
|
}
|
|
|
|
public async Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token)
|
|
{
|
|
switch (kind)
|
|
{
|
|
case LocalActorKind.Person:
|
|
var avatar = await _dbEntities.Avatars.MatchID(id).ExecuteFirstAsync(token);
|
|
return avatar == default ? default : FromAvatar(avatar);
|
|
case LocalActorKind.Group:
|
|
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
|
|
return group == default ? default : FromGroup(group);
|
|
default:
|
|
return await GetInstanceActor(token);
|
|
}
|
|
}
|
|
|
|
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token)
|
|
{
|
|
var prefix = $"{BaseAddress}/peasants/";
|
|
if (string.IsNullOrEmpty(actorUri) || !actorUri.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
|
return Task.FromResult<LocalActor>(default);
|
|
var userName = actorUri[prefix.Length..].Split('/', '#', '?')[0];
|
|
return FindByUserName(userName, token);
|
|
}
|
|
|
|
public async Task<LocalActor> GetInstanceActor(CancellationToken token)
|
|
{
|
|
var instance = _instanceActor ??= await LoadInstanceActor(token);
|
|
|
|
return new LocalActor
|
|
{
|
|
Id = instance.ID,
|
|
Kind = LocalActorKind.Application,
|
|
UserName = InstanceUserName,
|
|
Name = "PrivaPub",
|
|
Summary = "The instance actor of this PrivaPub server; it signs the requests no other actor speaks for.",
|
|
PrivateKeyPem = instance.PrivateKey,
|
|
PublicKeyPem = instance.PublicKey,
|
|
Discoverable = false,
|
|
Published = instance.CreationDate,
|
|
BaseAddress = BaseAddress
|
|
};
|
|
}
|
|
|
|
async Task<InstanceActor> LoadInstanceActor(CancellationToken token)
|
|
{
|
|
var instance = await _dbEntities.InstanceActors.Sort(i => i.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
|
|
if (instance != default)
|
|
return instance;
|
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
|
|
await DB.Default.SaveAsync(instance, token);
|
|
return instance;
|
|
}
|
|
|
|
public async Task<bool> IsUserNameTaken(string userName, CancellationToken token)
|
|
{
|
|
userName = userName?.ToLowerInvariant();
|
|
if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName))
|
|
return true;
|
|
return await DB.Default.Find<ReservedName>().Match(r => r.Name == userName).ExecuteAnyAsync(token);
|
|
}
|
|
|
|
public async Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token)
|
|
{
|
|
userName = userName?.ToLowerInvariant();
|
|
if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName))
|
|
return false;
|
|
try
|
|
{
|
|
await DB.Default.SaveAsync(new ReservedName { Name = userName, OwnerKind = kind, OwnerId = ownerId }, token);
|
|
return true;
|
|
}
|
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public LocalActor FromAvatar(Avatar avatar) => new()
|
|
{
|
|
Id = avatar.ID,
|
|
Kind = LocalActorKind.Person,
|
|
UserName = avatar.UserName,
|
|
Name = string.IsNullOrEmpty(avatar.Name) ? avatar.UserName : avatar.Name,
|
|
Summary = avatar.Biography,
|
|
PictureURL = avatar.PictureURL,
|
|
ThumbnailURL = avatar.ThumbnailURL,
|
|
PrivateKeyPem = avatar.PrivateKey,
|
|
PublicKeyPem = avatar.PublicKey,
|
|
Published = avatar.PublishedOn,
|
|
Fields = avatar.Fields ?? new Dictionary<string, string>(),
|
|
ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true,
|
|
Discoverable = avatar.Settings?.IsDiscoverable != false,
|
|
IsBot = avatar.Settings?.IsBot == true,
|
|
Indexable = avatar.Settings?.IsIndexable == true,
|
|
Settings = avatar.Settings ?? new AvatarSettings(),
|
|
BaseAddress = BaseAddress
|
|
};
|
|
|
|
public LocalActor FromGroup(GroupEntity group) => new()
|
|
{
|
|
Id = group.ID,
|
|
Kind = LocalActorKind.Group,
|
|
UserName = group.UserName,
|
|
Name = string.IsNullOrEmpty(group.Name) ? group.UserName : group.Name,
|
|
Summary = group.Description,
|
|
PictureURL = group.PictureURL,
|
|
ThumbnailURL = group.ThumbnailURL,
|
|
PrivateKeyPem = group.PrivateKey,
|
|
PublicKeyPem = group.PublicKey,
|
|
Discoverable = group.Kind == GroupKind.Community && group.IsDiscoverable,
|
|
ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
|
|
IsCircle = group.Kind == GroupKind.Circle,
|
|
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
|
|
Published = group.PublishedOn,
|
|
BaseAddress = BaseAddress
|
|
};
|
|
}
|
|
|
|
public static class Keys
|
|
{
|
|
public static (string PrivateKeyPem, string PublicKeyPem) NewKeyPair()
|
|
{
|
|
using var rsa = RSA.Create(2048);
|
|
return (rsa.ExportRSAPrivateKeyPem(), rsa.ExportSubjectPublicKeyInfoPem());
|
|
}
|
|
|
|
public static string ToSubjectPublicKeyInfoPem(string publicKeyPem)
|
|
{
|
|
if (string.IsNullOrEmpty(publicKeyPem) || publicKeyPem.Contains("BEGIN PUBLIC KEY"))
|
|
return publicKeyPem;
|
|
using var rsa = RSA.Create();
|
|
rsa.ImportFromPem(publicKeyPem);
|
|
return rsa.ExportSubjectPublicKeyInfoPem();
|
|
}
|
|
}
|
|
}
|