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
89 lines
3.4 KiB
C#
89 lines
3.4 KiB
C#
using MailKit.Net.Smtp;
|
|
|
|
using Microsoft.Extensions.Localization;
|
|
|
|
using MimeKit;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
using PrivaPub.Models.Jobs;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Resources;
|
|
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace PrivaPub.Services
|
|
{
|
|
public sealed record RecoveryPayload(string RootUserId, string Host);
|
|
|
|
// Sends a password recovery link. Every request queues one of these, for an account that exists or not, so the answer
|
|
// to the request and its timing say nothing; SMTP's slowness and failures happen here, where nobody waits on them.
|
|
// The code exists only in the email: the database keeps its hash, for an hour.
|
|
public class RecoveryJob : IJobHandler
|
|
{
|
|
public const string Host = "recovery";
|
|
public static readonly TimeSpan CodeLifetime = TimeSpan.FromHours(1);
|
|
|
|
readonly AppConfigurationService _app;
|
|
readonly IStringLocalizer<GenericRes> _localizer;
|
|
readonly ILogger<RecoveryJob> _logger;
|
|
|
|
public RecoveryJob(AppConfigurationService app, IStringLocalizer<GenericRes> localizer, ILogger<RecoveryJob> logger)
|
|
{
|
|
_app = app;
|
|
_localizer = localizer;
|
|
_logger = logger;
|
|
}
|
|
|
|
public JobKind Kind => JobKind.SendRecovery;
|
|
public int Concurrency => 1;
|
|
public int MaxAttempts => 3;
|
|
public int PerHostLimit => 1;
|
|
|
|
public static string Hash(string code) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(code ?? string.Empty)));
|
|
|
|
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
|
{
|
|
var payload = JsonSerializer.Deserialize<RecoveryPayload>(job.Payload);
|
|
if (string.IsNullOrEmpty(payload?.RootUserId))
|
|
return JobOutcome.Done;//asked for an account that does not exist: there is nobody to write to
|
|
var user = await DB.Default.Find<RootUser>().MatchID(payload.RootUserId).ExecuteFirstAsync(token);
|
|
if (user is not { DeletedAt: null, IsBanned: false } || string.IsNullOrEmpty(user.Email))
|
|
return JobOutcome.Done;
|
|
|
|
var code = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
|
|
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == user.ID);
|
|
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = user.ID, CodeHash = Hash(code), ExpiresAt = DateTime.UtcNow + CodeLifetime }, token);
|
|
|
|
var email = _app.AppConfiguration.EmailConfiguration;
|
|
var message = new MimeMessage();
|
|
message.From.Add(new MailboxAddress("PrivaPub", email.SmtpUsername));
|
|
message.To.Add(MailboxAddress.Parse(user.Email));
|
|
message.Subject = _localizer["PrivaPub - Password recovery link"];
|
|
message.Body = new TextPart("plain")
|
|
{
|
|
Text = string.Format(_localizer[
|
|
"Hello,\n\nSomeone asked to reset the password of the PrivaPub login {0}. If it was you, open this link within an hour:\n{1}\n\nIf it was not you, ignore this email: nothing changes."],
|
|
user.UserName, $"{payload.Host}/password-recovery?rc={code}")
|
|
};
|
|
try
|
|
{
|
|
using var smtp = new SmtpClient();
|
|
await smtp.ConnectAsync(email.SmtpServer, email.SmtpPort, email.UseSSL, token);
|
|
await smtp.AuthenticateAsync(email.SmtpUsername, email.SmtpPassword, token);
|
|
await smtp.SendAsync(message, token);
|
|
await smtp.DisconnectAsync(quit: true, token);
|
|
return JobOutcome.Done;
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested)
|
|
{
|
|
_logger.LogWarning(ex, "The recovery email could not be sent");
|
|
return JobOutcome.Retry("smtp");
|
|
}
|
|
}
|
|
}
|
|
}
|