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:
1 parent
fcd35f5043
commit
8c2eba6cbb
25 files changed
+665
-248
No files matched your search
@@ -6,6 +6,8 @@ using MongoDB.Entities;
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
@@ -136,9 +138,11 @@ namespace PrivaPub.Tests.Http
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
Assert.Equal(root.Id, (await ok.JsonBody())["userId"]!.GetValue<string>());
|
||||
// a wrong password and an unknown login get the same answer, so sign-in tells nobody which logins exist
|
||||
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
|
||||
Assert.Equal("Wrong password.", await Message(wrong));
|
||||
Assert.Equal(RootUsersService.NoMatch, await Message(wrong));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
|
||||
Assert.Equal(await Message(wrong), await Message(unknown));
|
||||
Assert.Equal(HttpStatusCode.OK, logout.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymousLogout.StatusCode);
|
||||
}
|
||||
@@ -290,42 +294,59 @@ namespace PrivaPub.Tests.Http
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recovery_without_an_email_is_refused()
|
||||
public async Task Recovery_answers_the_same_whoever_asks_and_queues_the_work()
|
||||
{
|
||||
var root = await _host.SignUp("noemail");
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||
var withEmail = await _host.SignUp("withemail");
|
||||
var withoutEmail = await _host.SignUp("noemail");
|
||||
var email = $"{Guid.NewGuid():N}@example.test";
|
||||
using (var signedIn = _host.As(withEmail.Jwt))
|
||||
Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode);
|
||||
using var client = _host.Client();
|
||||
|
||||
var response = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
|
||||
var unknown = await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") });
|
||||
var neither = await client.PostJson("/clientapi/user/recover/password", new { });
|
||||
var answers = new[]
|
||||
{
|
||||
await client.PostJson("/clientapi/user/recover/password", new { userName = withEmail.UserName }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { email }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { userName = withoutEmail.UserName }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { email = $"{Guid.NewGuid():N}@example.test" })
|
||||
};
|
||||
var bodies = new List<string>();
|
||||
foreach (var answer in answers)
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
|
||||
bodies.Add(await answer.Content.ReadAsStringAsync(token));
|
||||
}
|
||||
|
||||
Assert.Equal(HttpStatusCode.Locked, response.StatusCode);
|
||||
Assert.Contains("doesn't have an email", await Message(response));
|
||||
Assert.False(await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, neither.StatusCode);
|
||||
Assert.Single(bodies.Distinct());
|
||||
Assert.Contains("recovery link is on its way", bodies[0]);
|
||||
var queued = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.SendRecovery && j.CreatedAt >= since).ExecuteAsync(token);
|
||||
Assert.True(queued.Count >= answers.Length);
|
||||
Assert.Contains(queued, j => j.Payload.Contains(withEmail.Id));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostJson("/clientapi/user/recover/password", new { })).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recovery_through_an_unreachable_mail_server_says_so_and_nothing_more()
|
||||
public async Task The_recovery_job_keeps_only_a_hash_and_an_unreachable_mail_server_says_nothing_to_anyone()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var root = await _host.SignUp("smtp");
|
||||
var email = $"{Guid.NewGuid():N}@example.test";
|
||||
using (var signedIn = _host.As(root.Jwt))
|
||||
Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode);
|
||||
using var client = _host.Client();
|
||||
|
||||
var byName = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
|
||||
var byEmail = await client.PostJson("/clientapi/user/recover/password", new { email });
|
||||
var answer = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
|
||||
await _host.Run(j => j.Kind == JobKind.SendRecovery && j.Payload.Contains(root.Id), token);
|
||||
|
||||
foreach (var response in new[] { byName, byEmail })
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Equal("Failed to send email.", JsonNode.Parse(body)!["errorMessage"]!.GetValue<string>());
|
||||
foreach (var leak in new[] { "xception", "efused", "ocket", "127.0.0.1", "smtp", "SMTP", " at " })
|
||||
Assert.DoesNotContain(leak, body);
|
||||
}
|
||||
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
|
||||
var recovery = await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteFirstAsync(token);
|
||||
Assert.NotNull(recovery);
|
||||
Assert.Null(recovery.RecoveryCode);
|
||||
Assert.Equal(64, recovery.CodeHash.Length);
|
||||
Assert.InRange(recovery.ExpiresAt, DateTime.UtcNow.AddMinutes(50), DateTime.UtcNow.AddMinutes(61));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -348,7 +369,10 @@ namespace PrivaPub.Tests.Http
|
||||
{
|
||||
var root = await _host.SignUp("recover");
|
||||
var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
|
||||
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, RecoveryCode = code }, TestContext.Current.CancellationToken);
|
||||
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddHours(1) },
|
||||
TestContext.Current.CancellationToken);
|
||||
var persona = await _host.Persona(root, "recovered");
|
||||
var apiToken = await _host.MastodonToken(persona);
|
||||
using var client = _host.Client();
|
||||
|
||||
var valid = await client.PostAsync("/clientapi/user/recover/valid", new StringContent($"\"{code}\"", Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken);
|
||||
@@ -361,6 +385,29 @@ namespace PrivaPub.Tests.Http
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await LogIn(root.UserName, root.Password)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, NewPassword)).StatusCode);
|
||||
Assert.False(await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
||||
// whoever held the old sessions may be why the password was recovered: they end
|
||||
using (var oldSession = _host.As(root.Jwt))
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await oldSession.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken)).StatusCode);
|
||||
using (var oldApp = _host.Client())
|
||||
{
|
||||
oldApp.DefaultRequestHeaders.Authorization = new("Bearer", apiToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await oldApp.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_expired_recovery_code_changes_nothing()
|
||||
{
|
||||
var root = await _host.SignUp("expired");
|
||||
var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
|
||||
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddMinutes(-1) },
|
||||
TestContext.Current.CancellationToken);
|
||||
using var client = _host.Client();
|
||||
|
||||
var change = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = NewPassword, repeatedPassword = NewPassword, recoveryCode = code });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, change.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, root.Password)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Tests.Http
|
||||
{
|
||||
// Deleting a root deletes its personas and their groups everywhere (owner decision 2026-10-04).
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class RootRemovalTests : IAsyncLifetime
|
||||
{
|
||||
PrivaPubHost _host;
|
||||
HttpClient _client;
|
||||
Peer _peer;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_host = await PrivaPubHost.Shared();
|
||||
_client = _host.Client();
|
||||
_peer = await Peer.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_client?.Dispose();
|
||||
if (_peer != default)
|
||||
await _peer.DisposeAsync();
|
||||
}
|
||||
|
||||
async Task<HttpResponseMessage> AdminRemoves(params string[] rootIds)
|
||||
{
|
||||
var admin = await _host.Admin();
|
||||
using var client = _host.As(admin.Jwt);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, "/clientapi/admin/remove/users") { Content = JsonContent.Create(new { userIdList = rootIds }) };
|
||||
return await client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_removed_roots_personas_and_groups_are_deleted_everywhere_and_say_so()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var root = await _host.SignUp("leaving");
|
||||
var persona = await _host.Persona(root, "leaving");
|
||||
var follower = new RemoteActor(_peer, "fan");
|
||||
var member = new RemoteActor(_peer, "member");
|
||||
await DB.Default.SaveAsync(new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = follower.Id, InboxURL = follower.Id + "/inbox" }, token);
|
||||
var group = await _host.Group(persona, community: true);
|
||||
var groupId = group["id"]!.GetValue<string>();
|
||||
await DB.Default.SaveAsync(new Follower { LocalActorId = groupId, LocalActorKind = LocalActorKind.Group, ActorURI = member.Id, InboxURL = member.Id + "/inbox" }, token);
|
||||
var post = await _host.Publish(persona, "goodbye soon");
|
||||
var apiToken = await _host.MastodonToken(persona);
|
||||
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(root.Id)).StatusCode);
|
||||
|
||||
var toFollower = Assert.Single(await Jobs.Deliveries(follower.Id + "/inbox", since, token), d => d["type"]!.GetValue<string>() == "Delete");
|
||||
Assert.Equal(persona.ActorUri(), toFollower["object"]!.GetValue<string>());
|
||||
Assert.Equal(persona.ActorUri(), toFollower["actor"]!.GetValue<string>());
|
||||
var groupName = group["userName"]!.GetValue<string>();
|
||||
Assert.Contains(await Jobs.Deliveries(member.Id + "/inbox", since, token),
|
||||
d => d["type"]!.GetValue<string>() == "Delete" && d["object"]!.GetValue<string>() == $"{PrivaPubHost.Base}/peasants/{groupName}");
|
||||
var gone = await _client.Fetch($"/peasants/{persona.UserName}");
|
||||
Assert.Equal(HttpStatusCode.Gone, gone.Status);
|
||||
Assert.Equal("Person", gone.Json["formerType"]!.GetValue<string>());
|
||||
Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch($"/peasants/{groupName}")).Status);
|
||||
Assert.Equal(HttpStatusCode.Gone, (await _client.GetAsync($"/.well-known/webfinger?resource=acct:{persona.UserName}@{PrivaPubHost.Host}", token)).StatusCode);
|
||||
var stored = await DB.Default.Find<PostEntity>().MatchID(post.ID).ExecuteFirstAsync(token);
|
||||
Assert.NotNull(stored.DeletedAt);
|
||||
Assert.Null(stored.ContentHtml);
|
||||
Assert.NotNull((await DB.Default.Find<GroupEntity>().MatchID(groupId).ExecuteFirstAsync(token)).DeletionAt);
|
||||
var removed = await DB.Default.Find<RootUser>().MatchID(root.Id).ExecuteFirstAsync(token);
|
||||
Assert.Equal($"deleted-{root.Id}", removed.UserName);
|
||||
Assert.Null(removed.HashedPassword);
|
||||
using var app = _host.Client();
|
||||
app.DefaultRequestHeaders.Authorization = new("Bearer", apiToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await app.GetAsync("/api/v1/accounts/verify_credentials", token)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_second_removal_does_not_collide_with_the_first()
|
||||
{
|
||||
var first = await _host.SignUp("first");
|
||||
var second = await _host.SignUp("second");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(first.Id)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(second.Id)).StatusCode);
|
||||
|
||||
Assert.NotNull((await DB.Default.Find<RootUser>().MatchID(second.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).DeletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_root_deletes_itself_only_with_its_password()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var root = await _host.SignUp("self");
|
||||
var persona = await _host.Persona(root, "self");
|
||||
using var client = _host.As(root.Jwt);
|
||||
|
||||
var refused = await client.PostJson("/clientapi/user/delete", new { password = "Not-The-Password-1" });
|
||||
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
|
||||
Assert.Null((await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt);
|
||||
|
||||
var accepted = await client.PostJson("/clientapi/user/delete", new { password = root.Password });
|
||||
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
||||
Assert.NotNull((await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/clientapi/user/sniff/again", token)).StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user