From 1a62e8a56752941df95963b2c068441f17012c2f Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 11:04:20 +0200 Subject: [PATCH] CLAUDE.md describes the code after P0 The repo map gains Infrastructure/ and Objects/, the federation invariants are the ones the code now enforces instead of a list of known holes, and Commands, Data, Testing and Deploy cover the test project, the startup order (warm maps, migrate, index), the admin CLI and the deploy checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- CLAUDE.md | 98 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 67 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 94af610..4a86513 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,11 +73,17 @@ PrivaPub/ ASP.NET Core Web API, net10.0 Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS) Controllers/ClientToServer/ /clientapi/*: RootUser (signup/login/invitations/recovery), PrivateAvatar, Group, Post (posts + DMs), Admin, Data + Infrastructure/ + Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out + Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs + Cli/ AdminCommands (`PrivaPub admin promote|demote `) + RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies Federation/ Controllers/ PeasantsController (actor, outbox, followers, following, posts, inboxes), WellKnownController (webfinger, nodeinfo), UsersController (redirect) - Actors/ LocalActorService (LocalActor, Keys), RemoteActorService (signed fetch, - WebFinger, ForeignAvatar cache) + Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService + (authoritative fetch, key verification, WebFinger), ActorDocument (parser) + Objects/ Origin (same-origin rules), ContentSanitizer (HtmlSanitizer, Mastodon allowlist) Signing/ HttpSignatures (draft-cavage sign/verify) Inbox/ InboxService (Follow/Undo/Create/Delete/Update) Outbox/ DeliveryService + DeliveryWorker @@ -87,6 +93,7 @@ PrivaPub/ ASP.NET Core Web API, net10.0 StaticServices/ DbEntities (Find accessors), AuthTokenManager (JWT), PasswordHasher Data/InitDb.cs first-run seeding (languages) PrivaPub.ClientModels/ DTOs + validation resources shared with clients +PrivaPub.Tests/ xUnit v3; Support/ has a fake two-origin peer and a throwaway-database fixture deploy/ nginx vhost, systemd units (privapub, privapub-mongod), max/setup.sh .gitea/workflows/ build.yml (push) · deploy.yml (tag v*) docs/ROADMAP.md decisions + phased plan @@ -99,6 +106,9 @@ one pure-move commit at a time. ```bash dotnet build PrivaPub.sln -c Release +dotnet test PrivaPub.sln # unit tests; integration tests skip +PRIVAPUB_TEST_MONGOD=1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017 + # (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after) cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \ Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \ AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \ @@ -106,42 +116,58 @@ cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \ ``` Development config (`appsettings.Development.json`) binds HTTPS 7195 with HTTP/2 only; the overrides above make it -curl-able. Outbound fetches only go to https DNS hosts, so local federation needs a real peer (see Testing). +curl-able. Outbound fetches only go to https DNS names resolving to public addresses; a test network (Pasture) sets +`Federation__AllowPrivateNetworks=true` and `Federation__AllowPlainHttp=true`, which startup refuses in Production. + +Promoting an admin on the box (signing up as "admin" grants nothing): + +```bash +cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote +``` ## Federation invariants -1. **Every outbound request is signed** with draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on - bodies). GoToSocial and authorized-fetch Mastodon require signed GETs. -2. **Inbound inboxes verify everything before acting:** - - the Digest matches the body; - - the Date is inside the window; - - the signature verifies against the key owner's key; - - the activity's `actor` equals the key owner. -3. **Status codes:** +1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only + to public addresses; redirects are followed by hand (three at most, each re-checked); bodies are capped at 1 MB; + only JSON media types are read; a refused URL is not asked again for five minutes. Never create another + `HttpClient` for federation. +2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting + avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies). +3. **A remote document is believed only from its own address.** `RemoteActorService.FetchObject` requires the + document's `id` to be the URL it was served from (a same-origin alias is followed once). A key is accepted only if + the actor lists it, its `owner` is the actor and it shares the actor's origin. +4. **Inbound inboxes verify everything before acting:** + - the signature covers `(request-target)`, `host`, `digest` and `date` or `(created)`; + - the Digest matches the body and the date is at most an hour old and fifteen minutes ahead; + - the signature verifies against the key owner's key, and the activity's `actor` is the key owner; + - the activity's `id`, and any object it creates, updates or deletes, is on the actor's origin; a cross-origin + object is refetched from its own origin. +5. **Status codes:** - bad or missing signature: **401**; - - malformed body: **400**; + - malformed or forged body: **400**; - accepted: **202**; + - over the rate limit: **429**; - **never 500** from `/peasants` or an inbox. Peers retry or "double-knock" based on these codes. -4. **Actor documents:** +6. **Actor documents:** - served as `application/activity+json`; - `publicKey` is an SPKI PEM at `{actor}#main-key`, with `owner` equal to the actor's id; - the shared inbox is advertised in `endpoints.sharedInbox`. -5. **Delete and Update apply only to objects the sender authored.** A group re-announces only members' posts. -6. **Durable delivery:** activities go out through the delivery queue, never inline in a request, and are signed by - the acting avatar or group. -7. **Known weaknesses being fixed in roadmap phase P0:** - - an actor's document isn't yet checked against its fetch URL (key-cache poisoning); - - the SSRF guard is string-only; - - remote HTML is stored unsanitised; - - object origins aren't checked. - - Don't build on these behaviours. +7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown, + `ContentFormat` says what `Text` holds. Remote names are plain text. +8. **Circles never federate.** A circle's actor, collections, WebFinger and inbox answer 404, and its posts are + `IsLocalOnly`. Only communities are Group actors. +9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote + `context` decides nothing. +10. **Durable delivery:** activities go out through the delivery queue, never inline in a request. ## Privacy invariants -- **No root id in federation output, NodeInfo or logs, and no IP next to an identity in logs.** Some log lines in - `RootUserController` still do this; P0 removes them. -- **Never derive a public name from the root username.** Invitation sign-up still does; P0 fixes it. +- **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a + client** ("Something went wrong." instead). +- **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one + equal to the login. +- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before + they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one. - **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars. - **Location-ranged posts never federate.** @@ -153,6 +179,10 @@ curl-able. Outbound fetches only go to https DNS hosts, so local federation need - **Collection name = class name, so never rename an entity class.** - `Entity.ID` is a 24-character lowercase hex string; `GenerateNewID()` returns `object`, so cast it. - New fields must be additive: a deploy rollback restores the binary, not the database. +- **Startup order:** `EntityMaps.Warm()` (every entity's class map, one at a time; two mapped at once throw "An item + with the same key has already been added" and stay broken), then `MigrateAsync` (`Infrastructure/Data/Migrations`, + `_NNN_` order, each runs once), then `Indexes.Create()`. A new entity needs nothing; a new unique index needs a + dedupe migration before it. - Production runs its **own mongod** on 127.0.0.1:27022 (unit `privapub-mongod`, no auth, data in `/var/lib/privapub/mongo`). The box's shared mongod needs credentials nobody here has. @@ -173,9 +203,14 @@ curl-able. Outbound fetches only go to https DNS hosts, so local federation need ## Testing -There is no test project yet; roadmap P0 adds `PrivaPub.Tests` (xUnit v3) with fixture JSON captured per peer. +`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip +without `PRIVAPUB_TEST_MONGOD=1`. CI runs the unit tests only (the box's mongods are production). -Until then, verify by building, running locally, and exercising: +- `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost`), so origin rules + can be tested; `Support/RemoteActor` signs real deliveries with its own key. +- Inbox scenarios go through `InboxService.Receive` with a signed request, not through the private handlers. + +Beyond the tests, verify by building, running locally, and exercising: - the client API (sign up, create an avatar, a group, a post); - the ActivityPub endpoints with curl and `Accept: application/activity+json`. @@ -184,9 +219,10 @@ the owner's GoToSocial at social.arasaka.software. **Ask before acting from the ## Deploy -- **CI/CD:** push to `master` runs `build.yml` on the instance-wide `build` runner. A `v*` tag runs `deploy.yml`: - self-contained linux-x64 publish, snapshot to `/var/backups/privapub.thepra.dev`, stop → rsync → start, a - `127.0.0.1:6970/build.json` health loop with rollback, then public checks. +- **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs + `deploy.yml`: tests, self-contained linux-x64 publish, snapshot and `mongodump` to `/var/backups/privapub.thepra.dev`, + stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo, + Swagger 404, inbox junk 400, unsigned 401). - **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with `ASPNETCORE_ENVIRONMENT=Production`. - **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`.