Everything on, phase 3: sign-in and recovery tell nothing, recovered passwords end sessions, deleted roots are gone everywhere

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-04 03:16:59 +02:00
1 parent fcd35f5043
commit 8c2eba6cbb
25 files changed
+665 -248

No files matched your search

+78 -181
View File
@@ -14,9 +14,12 @@ using PrivaPub.ClientModels.User;
using PrivaPub.Models;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.StaticServices;
using System.Globalization;
using System.Text.Json;
#pragma warning disable 8603
#pragma warning disable 8625
@@ -31,8 +34,14 @@ namespace PrivaPub.Services
readonly ILogger<RootUsersService> Logger;
readonly AppConfigurationService AppConfigurationService;
readonly AuthTokenManager AuthTokenManager;
readonly IJobQueue Jobs;
readonly IRootSessions Sessions;
readonly IRootRemoval Removal;
public RootUsersService(
IJobQueue jobs,
IRootSessions sessions,
IRootRemoval removal,
IStringLocalizer<GenericRes> localizer,
ILogger<RootUsersService> logger,
IPasswordHasher passwordHasher,
@@ -40,6 +49,9 @@ namespace PrivaPub.Services
AppConfigurationService appConfigurationService,
AuthTokenManager authTokenManager)
{
Jobs = jobs;
Sessions = sessions;
Removal = removal;
DbEntities = dbEntities;
AuthTokenManager = authTokenManager;
PasswordHasher = passwordHasher;
@@ -48,6 +60,12 @@ namespace PrivaPub.Services
AppConfigurationService = appConfigurationService;
}
public const string NoMatch = "That username and password do not match.";
static string _decoy;
// a hash nobody's password matches, so an unknown login costs the same hashing as a wrong password
string Decoy => _decoy ??= PasswordHasher.Hash(Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)));
public async Task<WebResult> SignUpAsync(LoginForm signUpForm, string invitationCode = default,
bool isPasswordRequired = false)
{
@@ -114,20 +132,16 @@ namespace PrivaPub.Services
var result = new WebResult();
try
{
// One answer, after the same work, whether the login is unknown, deleted or the password wrong: sign-in must not
// tell anyone which logins exist. Only someone who knows the password learns the login is banned.
loginForm.UserName = loginForm.UserName.ToLower();
if (!await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName && u.DeletedAt == null)
.ExecuteAnyAsync())
return result.Invalidate(Localizer["Username '{0}' not found.", loginForm.UserName]);
var user = await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName).ExecuteFirstAsync();
var user = await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName && u.DeletedAt == null).ExecuteFirstAsync();
var (verified, needsUpgrade) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, loginForm.Password);
if (user?.HashedPassword == default || !verified)
return result.Invalidate(Localizer[NoMatch]);
if (user.IsBanned)
return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]);
var (verified, needsUpgrade) = PasswordHasher.Check(user.HashedPassword, loginForm.Password);
if (!verified)
return result.Invalidate(Localizer["Wrong password."]);
if (needsUpgrade)
result.ErrorMessage = Localizer["Needs upgrade!"];
@@ -261,24 +275,12 @@ namespace PrivaPub.Services
var result = new WebResult();
try
{
var users = await DbEntities.RootUsers.Match(u => usersIds.UserIdList.Contains(u.ID) && u.DeletedAt == default).ExecuteAsync();
if (users == null || users.Count == 0)
var removed = 0;
foreach (var id in usersIds.UserIdList.Distinct())
if (await Removal.Remove(id, CancellationToken.None))
removed++;
if (removed == 0)
return result.Invalidate(Localizer["User already deleted."]);
foreach (var user in users)
{
user.Email = Localizer["Deleted user"];
user.HashedPassword = null;
user.Policies.Clear();
user.IsBanned = false;
user.IsEmailValidated = false;
//user.TempSecret = null;
user.UserName = Localizer["Deleted user"];
user.DeletedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(user);
}
return result;
}
catch (Exception ex)
@@ -288,6 +290,26 @@ namespace PrivaPub.Services
}
}
// A root deletes itself only with its password, so a stolen session cannot.
public async Task<WebResult> RemoveSelfAsync(string rootId, string password)
{
var result = new WebResult();
try
{
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty);
if (user?.HashedPassword == default || !verified)
return result.Invalidate(Localizer[NoMatch], StatusCodes.Status403Forbidden);
await Removal.Remove(user.ID, CancellationToken.None);
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveSelfAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> BanUserAsync(UsersIds usersIds)
{
var result = new WebResult();
@@ -354,141 +376,22 @@ namespace PrivaPub.Services
}
}
public const string RecoverySent = "If that account has an email address, a recovery link is on its way. It works for one hour.";
// The same answer, after the same work, whether the account exists, has an email or can be written to: recovery must
// not tell anyone which logins or addresses exist. RecoveryJob does the rest, out of the request.
public async Task<WebResult> SetupAndSendRecoveryEmail(PasswordRecoveryForm passwordRecoveryForm, string host)
{
var result = new WebResult();
try
{
var usernameExists = false;
if (passwordRecoveryForm.IsEmailDisabled)
{
if (!await DbEntities.RootUsers.Match(u => u.UserName == passwordRecoveryForm.UserName && u.DeletedAt == null)
.ExecuteAnyAsync())
return result.Invalidate(Localizer["Username '{0}' not found.", passwordRecoveryForm.UserName],
StatusCodes.Status404NotFound);
usernameExists = true;
}
else
{
if (!await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.DeletedAt == null)
.ExecuteAnyAsync())
return result.Invalidate(Localizer["Username '{0}' not found.", passwordRecoveryForm.UserName],
StatusCodes.Status404NotFound);
}
var user = default(RootUser);
if (usernameExists)
user = await DbEntities.RootUsers.Match(u => u.UserName == passwordRecoveryForm.UserName).ExecuteFirstAsync();
else
user = await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email).ExecuteFirstAsync();
if (string.IsNullOrEmpty(user.Email))
return result.Invalidate(Localizer["This User doesn't have an email, no way to recover."],
StatusCodes.Status423Locked);
var emailRecovery = await DbEntities.EmailRecoveries
.Match(er => er.RootUserId == user.ID).ExecuteFirstAsync();
if (emailRecovery is null)
{
emailRecovery = new()
{
RootUserId = user.ID
};
await DB.Default.SaveAsync(emailRecovery);
}
using var recoveryCodeGenerator = new Password(true, true, true, false, 127);
emailRecovery.RecoveryCode = recoveryCodeGenerator.Next();
await DB.Default.SaveAsync(emailRecovery);
using (var smtpClient = new SmtpClient())
{
try
{
await smtpClient.ConnectAsync(AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer, AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpPort,
AppConfigurationService.AppConfiguration.EmailConfiguration.UseSSL);
if (!smtpClient.IsConnected)
{
Logger.LogError($"Failed to connect to the SMTP server({AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer}).");
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable);
}
}
catch (Exception ex)
{
Logger.LogError(ex, $"Error at connection to the SMTP server({AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer}).");
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable, exception: ex);
}
try
{
await smtpClient.AuthenticateAsync(AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername, AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpPassword);
if (!smtpClient.IsAuthenticated)
{
Logger.LogError($"Failed SMTP authentication of {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
return result.Invalidate(Localizer["Failed to send email."],
StatusCodes.Status503ServiceUnavailable);
}
}
catch (Exception ex)
{
Logger.LogError(ex, $"Failed SMTP authentication of {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
return result.Invalidate(Localizer["Failed to send email."],
StatusCodes.Status503ServiceUnavailable, exception: ex);
}
try
{
var toParsed = await smtpClient.VerifyAsync(user.Email);
if (toParsed == null)
return result.Invalidate(Localizer["Invalid email."], StatusCodes.Status400BadRequest);
}
catch (OperationCanceledException ex)
{
Logger.LogWarning(
"SMTP operation canceled at email verification: {Error}", ex.Message);
}
catch (SmtpCommandException ex)
{
Logger.LogWarning(
"SMTP command exception at email verification: {Error}", ex.Message);
}
catch (SmtpProtocolException ex)
{
Logger.LogWarning(
"SMTP protocol exception at email verification: {Error}", ex.Message);
}
catch (Exception ex)
{
Logger.LogWarning("Exception at email verification: {Error}", ex.Message);
}
var message = new MimeMessage();
message.From.Add(new MailboxAddress("PrivaPub", AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername));
message.To.Add(MailboxAddress.Parse(user.Email));
message.Subject = Localizer["PrivaPub - Password recovery link"];
message.Body = new TextPart("plain")
{
Text = string.Format(Localizer[@"Hey {0},
Eugene from collAnon, following is the password recovery link:
{1}
-- Eugene"], user.UserName, $"{host}/password-recovery?rc={emailRecovery.RecoveryCode}")
};
try
{
await smtpClient.SendAsync(message);
}
catch (Exception ex)
{
Logger.LogError(ex, $"Error at email sending to {user.Email} from {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable, exception: ex);
}
await smtpClient.DisconnectAsync(quit: true);
}
var userName = passwordRecoveryForm.UserName?.ToLowerInvariant();
var user = passwordRecoveryForm.IsEmailDisabled
? await DbEntities.RootUsers.Match(u => u.UserName == userName && u.DeletedAt == null).ExecuteFirstAsync()
: await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.DeletedAt == null).ExecuteFirstAsync();
await Jobs.Enqueue(JobKind.SendRecovery, JsonSerializer.Serialize(new RecoveryPayload(user?.ID, host)), RecoveryJob.Host,
dedupeKey: default, CancellationToken.None);
result.Data = Localizer[RecoverySent].Value;
return result;
}
catch (Exception ex)
@@ -498,16 +401,19 @@ Eugene from collAnon, following is the password recovery link:
}
}
Task<EmailRecovery> LiveRecovery(string recoveryCode)
{
var hash = RecoveryJob.Hash(recoveryCode);
var now = DateTime.UtcNow;
return DbEntities.EmailRecoveries.Match(er => er.CodeHash == hash && er.ExpiresAt > now).ExecuteFirstAsync();
}
public async Task<WebResult> IsValidRecoveryCode(string recoveryCode)
{
var result = new WebResult();
try
{
var isValidRecoveryCode = await DbEntities.EmailRecoveries
.Match(er => er.RecoveryCode == recoveryCode).ExecuteAnyAsync();
result.Data = isValidRecoveryCode;
result.Data = await LiveRecovery(recoveryCode) != default;
return result;
}
catch (Exception ex)
@@ -516,33 +422,24 @@ Eugene from collAnon, following is the password recovery link:
}
}
// A recovered password ends every session the root had: whoever had them may be why it was recovered.
public async Task<WebResult> ChangePassword(NewPasswordForm newPasswordForm)
{
var result = new WebResult();
try
{
var isValidRecoveryCode = await DbEntities.EmailRecoveries
.Match(er => er.RecoveryCode == newPasswordForm.RecoveryCode).ExecuteAnyAsync();
if (!isValidRecoveryCode)
var recovery = await LiveRecovery(newPasswordForm.RecoveryCode);
var user = recovery == default ? default
: await DbEntities.RootUsers.MatchID(recovery.RootUserId).Match(u => u.DeletedAt == null && !u.IsBanned).ExecuteFirstAsync();
if (user == default)
return result.Invalidate(Localizer["Invalid recovery code."], StatusCodes.Status404NotFound);
var emailRecovery = await DbEntities.EmailRecoveries
.Match(er => er.RecoveryCode == newPasswordForm.RecoveryCode)
.Project(er => er.Include(nameof(EmailRecovery.RootUserId)))
.ExecuteFirstAsync();
if (emailRecovery is null || emailRecovery.RootUserId is null)
return result.Invalidate(Localizer["User not found."]);
var newHashedPassword = PasswordHasher.Hash(newPasswordForm.NewPassword);
_ = await DB.Default.Update<RootUser>()
.Match(u => u.ID == emailRecovery.RootUserId && u.DeletedAt == null)
.Modify(u => u.HashedPassword, newHashedPassword)
await DB.Default.Update<RootUser>().MatchID(user.ID)
.Modify(u => u.HashedPassword, PasswordHasher.Hash(newPasswordForm.NewPassword))
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
.ExecuteAsync();
_ = await DB.Default.DeleteAsync<EmailRecovery>(er => er.RecoveryCode == newPasswordForm.RecoveryCode);
await DB.Default.DeleteAsync<EmailRecovery>(er => er.RootUserId == user.ID);
await Sessions.Revoke(user.ID, CancellationToken.None);
return result;
}
catch (Exception ex)