P5 done: a key we cannot fetch for now gets 503, and follow/like/block ids stay private on purpose
Build / Build (push) Successful in 59s
Deploy / privapub.thepra.dev (push) Successful in 1m11s

- When a sender's key cannot be fetched because its server timed out or answered 5xx, the inbox answers 503 with
  Retry-After: 300 instead of 401, so Mastodon 4.7 retries rather than switching to RFC 9421 signatures we do not
  verify yet. The fetcher's failure cache now remembers whether a failure was temporary.
- Follow, Like, Block, Accept, Reject and Undo ids are deliberately not dereferenceable: serving them would publish
  who follows, likes and blocks whom. They are always sent with their object embedded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 18:25:18 +02:00
1 parent b691c6766d
commit 81de470f64
7 files changed
+51 -9

No files matched your search

+5
View File
@@ -132,6 +132,11 @@ Posts with a location (shown to nearby users of this server) never leave the ser
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three - **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
redirects and read at most 1 MB. redirects and read at most 1 MB.
- **HTML.** Received HTML is sanitised to Mastodon's allowlist. - **HTML.** Received HTML is sanitised to Mastodon's allowlist.
- **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered
5xx, the inbox answers 503 with `Retry-After: 300` rather than 401.
- **Activity ids.** `Create` and `Announce` ids dereference. `Follow`, `Like`, `Block` and the `Accept`, `Reject` and
`Undo` that answer them do not, because serving them would reveal who follows, likes and blocks whom; they are always
sent with their object embedded.
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused, - **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
starting at an hour and growing to a week. starting at an hour and growing to a week.
@@ -106,6 +106,21 @@ namespace PrivaPub.Tests.Federation
Assert.NotNull(message.ConversationId); Assert.NotNull(message.ConversationId);
} }
[Fact]
public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing()
{
var (_, alice) = await _harness.Persona("alice");
var offline = new RemoteActor(_harness.Peer, "offline", origin: "http://127.0.0.1:9");
var result = await _harness.Deliver(offline, "/human-centipede", new JsonObject
{
["id"] = NewId(offline, "follows"), ["type"] = "Follow", ["actor"] = offline.Id, ["object"] = alice.Uri
});
Assert.Equal(503, result.StatusCode);
Assert.Equal(300, result.RetryAfterSeconds);
}
[Fact] [Fact]
public async Task A_join_of_a_local_post_is_answered_with_ignore() public async Task A_join_of_a_local_post_is_answered_with_ignore()
{ {
@@ -19,6 +19,7 @@ namespace PrivaPub.Federation.Actors
Task<FetchedJson> FetchObject(string uri, CancellationToken token); Task<FetchedJson> FetchObject(string uri, CancellationToken token);
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token); Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token); Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
bool KeyTemporarilyUnavailable(string keyId) => false;
Task<string> ResolveHandle(string handle, CancellationToken token); Task<string> ResolveHandle(string handle, CancellationToken token);
} }
@@ -120,6 +121,8 @@ namespace PrivaPub.Federation.Actors
return await Upsert(actor, key, token); return await Upsert(actor, key, token);
} }
public bool KeyTemporarilyUnavailable(string keyId) => Origin.Of(keyId) != default && _http.FailedTemporarily(StripFragment(keyId));
public async Task<string> ResolveHandle(string handle, CancellationToken token) public async Task<string> ResolveHandle(string handle, CancellationToken token)
{ {
var parts = handle?.TrimStart('@').Split('@'); var parts = handle?.TrimStart('@').Split('@');
@@ -333,6 +333,8 @@ namespace PrivaPub.Federation.Controllers
{ {
if (result.Error != default) if (result.Error != default)
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error); _logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error);
if (result.RetryAfterSeconds is { } seconds)
Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error); return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
} }
+5 -1
View File
@@ -13,7 +13,7 @@ using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox namespace PrivaPub.Federation.Inbox
{ {
public sealed record InboxResult(int StatusCode, string Error = default); public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = default);
public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default, public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default,
string[] SignedHeaders = default, DateTime? ReceivedAt = default); string[] SignedHeaders = default, DateTime? ReceivedAt = default);
@@ -25,6 +25,8 @@ namespace PrivaPub.Federation.Inbox
public class InboxReceiver : IInboxReceiver public class InboxReceiver : IInboxReceiver
{ {
const int KeyRetrySeconds = 300;
const int MaxBodyBytes = 1024 * 1024; const int MaxBodyBytes = 1024 * 1024;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
@@ -88,6 +90,8 @@ namespace PrivaPub.Federation.Inbox
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
{ {
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token); keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(parameters.KeyId))
return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds);
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
return new(StatusCodes.Status401Unauthorized, "the signature does not verify"); return new(StatusCodes.Status401Unauthorized, "the signature does not verify");
} }
+14 -5
View File
@@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Http
{ {
bool IsAllowed(Uri target); bool IsAllowed(Uri target);
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token); Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
bool FailedTemporarily(string url);
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token); Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token); Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token);
} }
@@ -107,7 +108,8 @@ namespace PrivaPub.Infrastructure.Http
} }
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}"); return Refuse(negativeKey, url, $"status {(int)response.StatusCode}",
transient: (int)response.StatusCode is >= 500 or 429 or 408);
var mediaType = response.Content.Headers.ContentType?.MediaType; var mediaType = response.Content.Headers.ContentType?.MediaType;
if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
@@ -125,14 +127,21 @@ namespace PrivaPub.Infrastructure.Http
} }
catch (OperationCanceledException) when (!token.IsCancellationRequested) catch (OperationCanceledException) when (!token.IsCancellationRequested)
{ {
return Refuse(negativeKey, url, "a timeout"); return Refuse(negativeKey, url, "a timeout", transient: true);
} }
catch (Exception ex) when (ex is HttpRequestException or JsonException or BlockedDestinationException) catch (HttpRequestException ex)
{
return Refuse(negativeKey, url, ex.Message, transient: true);
}
catch (Exception ex) when (ex is JsonException or BlockedDestinationException)
{ {
return Refuse(negativeKey, url, ex.Message); return Refuse(negativeKey, url, ex.Message);
} }
} }
public bool FailedTemporarily(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var target) && _cache.TryGetValue(NegativeKey(target), out bool transient) && transient;
public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token) public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token)
{ {
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target)) if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
@@ -200,9 +209,9 @@ namespace PrivaPub.Infrastructure.Http
static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri; static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri;
FetchedJson Refuse(string negativeKey, string url, string reason) FetchedJson Refuse(string negativeKey, string url, string reason, bool transient = false)
{ {
_cache.Set(negativeKey, true, NegativeCacheLifetime); _cache.Set(negativeKey, transient, NegativeCacheLifetime);
_logger.LogInformation("GET {Url} refused: {Reason}", url, reason); _logger.LogInformation("GET {Url} refused: {Reason}", url, reason);
return default; return default;
} }
+7 -3
View File
@@ -10,7 +10,7 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
- [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet) - [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet)
- [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet) - [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only - [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
- [ ] P5 Lose nothing: wire tolerance, full objects, raw capture (see `docs/INTEROP.md`) - [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8)
- [ ] P6 Emoji, polls, quotes, reactions, cards, players - [ ] P6 Emoji, polls, quotes, reactions, cards, players
- [ ] P7 Threads, communities, moderation, the social graph - [ ] P7 Threads, communities, moderation, the social graph
- [ ] P8 Signatures, discovery, the long tail - [ ] P8 Signatures, discovery, the long tail
@@ -414,7 +414,7 @@ it, raw where it doesn't.
- Persona usernames match Mastodon's and Misskey's pattern (groups already did). - Persona usernames match Mastodon's and Misskey's pattern (groups already did).
- Every term we emit is defined in our JSON-LD context. - Every term we emit is defined in our JSON-LD context.
- `Vary: Accept`. - `Vary: Accept`.
- Still open: every activity id dereferences. - Not done on purpose: see "Deliberately not done" below.
- **Owner decisions that were small** (done in v1.7.0): blocks federate (`Block`, `Undo{Block}`); a persona's and a - **Owner decisions that were small** (done in v1.7.0): blocks federate (`Block`, `Undo{Block}`); a persona's and a
group's `published` is a random day up to two weeks before its creation (migration `_007`). group's `published` is a random day up to two weeks before its creation (migration `_007`).
- **Parsing every shape** (done in v1.8.0, `Federation/Objects/ObjectShapes.cs`): - **Parsing every shape** (done in v1.8.0, `Federation/Objects/ObjectShapes.cs`):
@@ -441,7 +441,11 @@ it, raw where it doesn't.
- A deleted object's id is kept for 90 days (W9), and its `ObjectRecord` is removed with the post. - A deleted object's id is kept for 90 days (W9), and its `ObjectRecord` is removed with the post.
- **Typed details reach clients** (done in v1.9.0) as `Status.privapub`. - **Typed details reach clients** (done in v1.9.0) as `Status.privapub`.
- **Delivery:** a 503 with `Retry-After` is waited out like a 429 and no longer counts against the host (done in v1.9.0). - **Delivery:** a 503 with `Retry-After` is waited out like a 429 and no longer counts against the host (done in v1.9.0).
Still open: answering 503 ourselves when a key fetch fails temporarily, and making every activity id dereference. We answer 503 with `Retry-After` ourselves when a sender's key cannot be fetched for a temporary reason (v1.9.1),
so Mastodon 4.7 retries instead of switching to RFC 9421.
- **Deliberately not done: dereferenceable `Follow`, `Like`, `Block`, `Accept`, `Reject` and `Undo` ids.** Serving them
would publish who follows, likes and blocks whom, which our collections deliberately hide. Every one of them is sent
with its object embedded, which is all Misskey needs. `Create` and `Announce` ids do dereference.
#### P6 What people see: emoji, polls, quotes, reactions, cards, players #### P6 What people see: emoji, polls, quotes, reactions, cards, players
- **Custom emoji** on posts, names, fields and poll options, proxied. - **Custom emoji** on posts, names, fields and poll options, proxied.