R9 Web Push, then the honest version: VAPID key, push subscriptions, encrypted pushes, 4.5.0 advertised

The server's VAPID key (PushKeys, made on first need, in Mongo) is the instance API's vapid.public_key and every app's
vapid_key. /api/v1/push/subscription (POST, GET, PUT, DELETE) keeps one subscription per access token, so per persona;
every notification kept (never a filtered one) is queued as a SendPush job for the subscriptions whose alert and policy
admit it, and sent through the `push` client (the safe handler, https only) encrypted as RFC 8291 aes128gcm or the
aesgcm draft with an ES256 VAPID JWT. 404/410 delete the subscription, 429/5xx/timeouts retry, other answers drop the
push. Subscriptions end with their tokens (revocation, sessions ended, suspension, deletion, a restore); backups hold
neither keys nor subscriptions. The walk of every Mastodon 4.3-4.5 client route (docs/MASTODON-API.md) added
conversations/:id/unread, domain_blocks/preview, v2 notifications clear and PUT policy, /oauth/userinfo and stubs, so both
instance APIs now say 4.5.0 (compatible; PrivaPub <release>). The pasture gets a push sink (pushsink.test) and
scenarios/push.sh in batch A.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-18 09:18:26 +02:00
1 parent 87529bca49
commit 9b792042e6
43 files changed
+2079 -49

No files matched your search

+1
View File
@@ -407,6 +407,7 @@ PrivaPub/media-store-incoming/
PrivaPub/media-store-backups/
tools/pasture/.publish/
tools/pasture/.flood/
tools/pasture/.pushsink/
.claude/worktrees/
tools/pasture/.ca/
tools/pasture/.state/
+134 -11
View File
@@ -94,6 +94,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
Push/ Web Push (R9): Vapid (the server's key, ES256 JWTs), WebPushCrypto (aes128gcm, aesgcm),
WebPushJob (SendPush, through the `push` client)
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
Cli/ AdminCommands (`PrivaPub admin promote|demote|role|create-root|move-persona|delete-root|reset-totp`,
@@ -130,6 +132,7 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Moderation/ ModerationLog (the staff's log, AdminAction), AccountModeration (silence, suspend, posts
removed), Restrictions (who is held back, in memory), ModerationFlags (posts' flags again)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements,
WebPush (who is pushed what; WebPushDispatcher on Notifications.Told),
FeaturedTags (a persona's featured hashtags, sent and received), Suggestions (who to follow)
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
@@ -146,8 +149,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch
Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, lists, filters,
media, polls, reactions, reports, scheduled statuses, streaming, tags, trends, cdns,
provenance, custom emojis, featured tags, suggestions; the stubs are `StubsController`
in `SearchController.cs`
provenance, custom emojis, featured tags, suggestions, Web Push (`PushController`), OAuth
userinfo; the stubs are `StubsController` in `SearchController.cs`
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent)
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, RootRemoval,
@@ -429,6 +432,87 @@ client's role route never does.
and `ProtectiveMerge` deletes those of a persona deleted since. Mastodon's account archive has no featured tags, notes or
endorsements, so the persona archive leaves them out.
## Web Push (completion program R9)
- **The key** (`Models/PushKeys.cs`, `Infrastructure/Push/Vapid.cs`): one P-256 VAPID key pair (RFC 8292), made the first
time something needs it and kept in Mongo (one document, id `vapid`; two processes at once keep the first one saved).
Its public key, the uncompressed point in URL-safe base64 with its padding (as Mastodon writes `VAPID_PUBLIC_KEY`), is
v2's `configuration.vapid.public_key`, an app's `vapid_key` (`POST /api/v1/apps`, `apps/verify_credentials`) and every
subscription's `server_key`.
- **Subscriptions** (`Models/Social/PushSubscription.cs`, a new collection, unique on `TokenId`): one per Mastodon access
token, as on Mastodon, so one persona's (the token's subject) and never its login's. Each holds the endpoint, the
client's `p256dh` and `auth` as it sent them, `standard`, the alerts turned on, the policy, and the token itself, which
the payload carries back. The alerts are Mastodon 4.5's names (`WebPush.Alerts`: `mention`, `quote`, `status`,
`reblog`, `follow`, `follow_request`, `favourite`, `poll`, `update`, `quoted_update`, `severed_relationships`,
`moderation_warning`, `annual_report`, `admin.sign_up`, `admin.report`) and Pleroma's `pleroma:emoji_reaction`; those
PrivaPub never notifies (the admin ones, `quoted_update`, `severed_relationships`, `annual_report`) are kept and never fire.
- **Routes** (`Api/Mastodon/Controllers/PushController.cs`, the `push` scope, a persona's token; Mastodon's parameters as
a form or JSON):
- `POST /api/v1/push/subscription`: `subscription[endpoint]` (an https URL; plain http only where
`Federation:AllowPlainHttp`, which production refuses), `subscription[keys][p256dh]` (a point on P-256),
`subscription[keys][auth]` (16 bytes), `subscription[standard]`, `data[alerts][<alert>]`, `data[policy]` (`all` by
default, `followed`, `follower`, `none`). It replaces the token's subscription and answers it; 400 without an
endpoint, 422 for an endpoint that is no https URL, keys that are no Web Push keys, or another policy.
- `GET`: the token's subscription, or 404 `Record not found`.
- `PUT`: `data[alerts]` and `data[policy]` replace what it had, as Mastodon's update does (an alert left out is off, a
policy left out is `all`); 404 without one.
- `DELETE`: `{}`, whether there was one or not.
- The answer is Mastodon's `WebPushSubscription`: `id` (a string), `endpoint`, `standard`, `alerts` (every alert, true
or false), `server_key`, `policy`.
- **What is pushed** (`Domain/Social/WebPush.cs`): `Notifications.Add` and `Notifications.Warn` tell `Notifications.Told`
of each notification they keep, never one the notification policy filtered or dropped nor one blocks or mutes hid.
`WebPushDispatcher` (a hosted service: the command line pushes nothing) hangs there and queues one `SendPush` job
(`JobKind.SendPush`, the last member; dedupe `push|<subscription>|<notification>`, its host the endpoint's) for each
subscription of that persona whose alert for the type is on and whose policy admits the sender: `followed`, an account
the persona follows; `follower`, one that follows it; the persona itself (its poll ended, the staff's warning) under
every policy but `none`. Nothing for a persona `Restrictions` holds back (suspended, its login banned).
- **Sending** (`Infrastructure/Push/WebPushJob.cs`). Everything is read again when the job runs: the subscription, its
persona (not held back, not deleted), its token (valid, unexpired, its subject the subscription's persona, or the
subscription goes), the notification (still the persona's, still pushable, still shown to it by
`MastodonMapper.Notifications`). The payload is Mastodon's JSON, at most 3993 bytes (`WebPushCrypto.MaxPayload`):
- `access_token`, the subscription's own token, never another's; `preferred_locale`, the persona's default posting
language, else the server's first; `notification_id`, PrivaPub's id (a string); `notification_type`, its Mastodon
name;
- `icon`, the sender's avatar through the media proxy; `title`, Mastodon's English subjects ("You were mentioned by
…", "… favorited your post", …); `body`, the post's warning, else its words, else the sender's bio, without tags, 140
characters at most.
- **Encryption** (`WebPushCrypto`, System.Security.Cryptography only: ECDiffieHellman on P-256, HKDF-SHA-256, AesGcm),
a fresh key pair and salt per message. A `standard` subscription gets RFC 8291's `aes128gcm` (RFC 8188's header:
salt, record size 4096, our public key as the key id; one record, delimiter 0x02) with `Authorization: vapid
t=<jwt>, k=<key>`. Any other gets the draft Mastodon still sends by default, `aesgcm` (two bytes of padding length
first) with `Encryption: salt=…`, `Crypto-Key: dh=<our key>;p256ecdsa=<the VAPID key>` and `Authorization: WebPush
<jwt>`. Both carry `Content-Type: application/octet-stream`, `TTL: 172800` and `Urgency: normal`, as Mastodon's do.
- **The JWT** (`VapidKey.Token`): ES256, `aud` the endpoint's origin, `exp` twelve hours ahead (RFC 8292 allows a
day), `sub` `mailto:` the contact email the administrator gave, else the server's address (`mailto:privapub@<host>`
for a test server on plain http). Never a persona.
- **Answers:** 2xx is done; 404 and 410 delete the subscription; 408, 429, a 5xx, a timeout or a broken connection retry
on the jobs' backoff (six attempts, about forty minutes); any other answer, a redirect, or an endpoint that does not
resolve to a public address drops the push, logged by the push service's host alone. Four run at a time, four per host.
- **decePub:** the first-party client's exchanged persona tokens may hold `push` too (`PersonaExchange.FirstPartyScopes`),
so it can subscribe like any other client.
- **Lifecycle:** a subscription goes with its token: `/oauth/revoke` (an OpenIddict handler after the revocation),
`RootSessions.Revoke` (a password changed or recovered, two-factor turned on, signing out everywhere, a ban, a deleted
login), `PersonaRemoval`, a suspension (`AccountModeration`), `admin move-persona`, and any token found unusable when a
push is sent. A new subscription made with the same token replaces it.
- **Backups:** `PushSubscription` and `PushKeys` are never in a backup (`ServerBackup.Excluded`). A restore leaves the
live key as it is, so the key the push services know never changes, and deletes every subscription with the tokens
(`ProtectiveMerge`); clients subscribe again once signed in.
- **The rest of the honest version** (`docs/MASTODON-API.md`, the walk of every Mastodon 4.3, 4.4 and 4.5 client route),
built with it:
- `POST /api/v1/conversations/:id/unread` (`ConversationState.MarkedUnread`, until it is read);
- `GET /api/v1/domain_blocks/preview` (`following_count` and `followers_count` the block would take, counted as
`BlockDomain` takes them);
- `POST /api/v2/notifications/clear` and `PUT /api/v2/notifications/policy`;
- `GET`/`POST /oauth/userinfo` (the `profile` scope: `iss`, `sub` the actor, `name`, `preferred_username`, `profile`,
`picture`; `userinfo_endpoint` in the server's metadata) and `verify_credentials` for a `profile` token
(`Scope("read:accounts|profile")`: a scope written `a|b` is either);
- stubs: `GET /api/v1/annual_reports/:year` (none) and `POST …/:year/read` (`{}`), `POST /api/v1/accounts` (403, as
Mastodon with registrations closed: they are cut), `POST /api/v1/emails/confirmations` (403) and
`GET /api/v1/emails/check_confirmation` (`true`), `GET /api/v1/peers/search` (`[]`), and 4.4's
`GET /api/v1_alpha/async_refreshes/:id` (404: PrivaPub never sends `Mastodon-Async-Refresh`).
Both instance APIs then say `4.5.0 (compatible; PrivaPub <release>)` (Mastodon invariant 6).
## Custom emoji (completion program R7)
- **The server's own emoji** (`Models/Post/LocalEmoji.cs`, a collection of its own): a shortcode (`[a-zA-Z0-9_]{2,64}`,
@@ -487,8 +571,12 @@ client's role route never does.
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. The only other outbound traffic is SMTP and `GeoUpdater`'s monthly DB-IP Lite download
(its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in).
`HttpClient` for federation. The only other outbound traffic is SMTP, `GeoUpdater`'s monthly DB-IP Lite download
(its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in), and Web Push (R9):
the named `push` client, on `SafeHttpHandlerFactory`'s handler (it resolves the name itself and connects only to
public addresses, with federation's test-network exceptions; no redirects, cookies or proxy), https only (plain http
only where `Federation:AllowPlainHttp`), 5 s to connect and 15 s a request. It POSTs encrypted payloads to the
endpoints clients subscribed with, and reads nothing back but the status.
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). Inbound,
an RFC 9421 signature (`Signature-Input`) is verified too, its target against our public address. A persona's own
@@ -605,9 +693,14 @@ client's role route never does.
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. Any `/api`
error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from
`UseMastodonErrorBodies`.
6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays,
because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped
notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy.
6. **Advertise `4.5.0 (compatible; PrivaPub <release>)`** in both instance APIs (`InstanceController.Version`, since R9;
the release is the build's tag without its `v`, else its ref or commit), with `api_versions.mastodon = 7`, 4.5's own.
The number is the walk's (`docs/MASTODON-API.md`): every client route of Mastodon 4.3, 4.4 and 4.5 is served or
answers as a named stub, and none a client relies on is missing. A route Mastodon adds, or one PrivaPub stops serving,
goes there first, and the version follows the walk, never a wish; `tools/smoke/mastodon-api.sh` checks it on every
deploy. Clients branch on both numbers, so everything a 4.3+ client calls must answer: grouped notifications
(`/api/v2/notifications`, its unread count, groups, accounts, dismiss and clear), quotes, Web Push, streaming and the
notification policy.
The policy is real (`Domain/Social/NotificationPolicies.cs`, applied in `Notifications.Add`): accepted by default,
it filters (into a `NotificationRequest` per account, out of every list and count unless `include_filtered`) or drops
what strangers, accounts that do not follow the persona (or only for three days), accounts newer than 30 days,
@@ -624,6 +717,8 @@ client's role route never does.
private notes (shown to it alone, never federated), dismissed suggestions. Suggestions never offer the persona itself
or a sibling persona of its login, and `familiar_followers` answers no one for every account, since it would tell who
follows whom here.
9. **A Web Push subscription is its token's** (R9): one per access token, read, changed and deleted only with that token,
pushed only its persona's notifications, and its payload carries only that token. It ends when the token does.
## Media invariants
@@ -766,6 +861,12 @@ client's role route never does.
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server
something about an avatar gets the same treatment: ask, then record it there.
- **Location-ranged posts never federate.**
- **Web Push tells a push service when, never what** (R9). Every payload is encrypted to the subscribing client's own
keys (RFC 8291, or the aesgcm draft), so the push service (Google's, Mozilla's, Apple's, a UnifiedPush distributor)
learns only that this server has something for that endpoint, when, and how big: never the words, the sender or the
persona. The VAPID JWT names the push service's origin and the server's contact (the contact email, or the server's
address), never a persona. Subscriptions are per token, so per persona: none receives a sibling's notifications, and
nothing stored pairs two personas of one login.
- **Statistics name servers, never people** (owner decisions on statistics, 2026-10-03). Every interaction is recorded
through `IInteractionLedger` (`Infrastructure/Statistics`) as an `InteractionEvent` (90 days), but an event never
holds a root, persona or group id, an activity id, an inbox URL, a remote actor URI or a sender IP:
@@ -868,7 +969,8 @@ client's role route never does.
**Never in a backup** (`ServerBackup.Excluded`): `InteractionSalt` (owner rule), `Job` and `Delivery` (work in flight),
`EmailRecovery` and `EmailVerification`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore
never opens setup again), and `AppConfiguration` (its SMTP password; it is made again from appsettings at boot). One
never opens setup again), `AppConfiguration` (its SMTP password; it is made again from appsettings at boot),
`PushSubscription` (sessions' too, R9) and `PushKeys` (the VAPID key: a restore keeps the live one). One
backup or restore runs at a time
(`MaintenanceLock`, a heartbeat document; a holder silent for 2 minutes is taken over), and the media janitor purges
nothing meanwhile. `BackupScheduler` backs up at `Backups:NightlyAt` (03:30 UTC), or at once when it missed the night;
@@ -895,7 +997,8 @@ client's role route never does.
nothing holds any more goes there too); the staff's log (`AdminAction`) is the union, so a restore never loses an
entry, its own request included, and the server's settings and custom emoji (`LocalEmoji`, R7) are P's: the live
settings and emoji outlive a restore, and an emoji deleted since stays deleted;
5. every session ends (a new `SessionStamp` for each root, persona tokens deleted), circuits close, and a
5. every session ends (a new `SessionStamp` for each root, persona tokens deleted, and every Web Push subscription with
them, R9: clients subscribe again, to the same VAPID key), circuits close, and a
`RestoreRecord` (never in a backup) tells what happened (`admin restore --status`).
Each attempt redoes everything. Refused before anything changed, it is abandoned and recorded, and the server boots as
@@ -1154,6 +1257,25 @@ tools/pasture/run.sh down # removes e
DM; her endorsement of him and her note on him are hers alone (feat_bob, a sibling persona of the same login, sees
neither, and `GET /api/v1/accounts/:id/endorsements` answers nobody else), and feat_bob is never suggested to her; the
stubs (translate 422, annual reports, the admin API 403) answer.
- **Web Push (`scenarios/push.sh`, needs mastodon and pushsink; after featured in batch A):** push_alice subscribes with
the sink's keys; mastouser's mention reaches the sink as aes128gcm, its VAPID JWT by the instance API's key for the
sink's origin with TTL and Urgency, and decrypts to a `mention` with her own access token and her notification's id;
the mention alert turned off stops it while his favourite is pushed; `policy=followed` stops him until she follows him;
`standard=false` is aesgcm (Encryption, Crypto-Key, `WebPush`) and decrypts too, and the new subscription replaces the
old; push_bob, a sibling persona of the same login, never gets hers nor she his; DELETE stops the pushes; the sink
answering 410 removes the subscription; a token revoked at `/oauth/revoke` is pushed nothing; no subscription is left.
Both instance APIs say 4.5.0 and give the key an app registration gives.
- **pushsink (`peers/pushsink.sh`, `pushsink/pushsink.cs`, as `pushsink.test`):** a Web Push service of the pasture's
own, a .NET file-based app published once into `.pushsink` like flood. It makes each subscription's keys itself, checks
what a push service checks (the VAPID JWT: ES256 by the server's key, the instance API's or the one the scenario gave
it; `aud` its own origin; `exp` ahead and at most a day; a `mailto:` or `https:` `sub`; TTL), decrypts `aes128gcm` and
`aesgcm` with its own code, not PrivaPub's, and keeps what it took. Its API, behind Caddy: `POST /subscriptions`
(`{"server_key"}` optional) answers `{id, endpoint, keys: {p256dh, auth}}`; `GET /subscriptions/{id}`;
`POST /subscriptions/{id}/answer` (`{"status": 410, "times": 1}`, for the next pushes); `GET` and `DELETE
/subscriptions/{id}/received` (each push: when, the status answered, `ok` and the errors, headers, the JWT's claims, the
decrypted payload); `POST /push/{id}` is the push service (201, 403 for a VAPID failure, 400 for the rest, 404 for no
such subscription); `/health`. PrivaPub reaches it through Caddy as every peer, since its `push` client honours
`appsettings.Pasture.json`'s Federation exceptions; nothing new is configured. In memory only.
- **Mailpit (1.31.4, `peers/mailpit.sh`, as `mailpit.test`):** the pasture's mail server. `appsettings.Pasture.json`
sends PrivaPub's mail to `pasture-mailpit:1025` (plain SMTP, any login accepted) and its recovery links to
`https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the
@@ -1318,8 +1440,9 @@ the owner's GoToSocial account.**
the server's own pre-deploy backup (`PrivaPub admin backup --kind pre-deploy --db-only`, run from the new build before
its migrations, verified, and checked to hold no statistics salt; no deploy while `restore.json` waits),
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) and `tools/smoke/mastodon-api.sh` (app registration, client credentials,
discovery, instance, public timeline). Then it checks that what production should be running is running:
Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration with its `vapid_key`,
client credentials, discovery, both instance APIs with the advertised version and the VAPID key, public timeline).
Then it checks that what production should be running is running:
- NodeInfo and the instance API agree on registrations (closed, invitations only);
- `/stargazing` says the crawler is on;
- nothing signs in (owner decision 2026-10-17): the deploy's own login, `deploy-smoke`, gave its @thepra to the
+8
View File
@@ -456,6 +456,14 @@ PrivaPub keeps statistics about servers, never about accounts (see `/stargazing`
- **Opting out:** in robots.txt, the crawler obeys the group `PrivaPub-Stargazer`, then `PrivaPub`, then `*`. A
robots.txt that answers with a server error or times out also keeps it out, as do this server's domain blocks.
## Web Push
Not federation, but traffic an operator may see: when a client signed in as a persona subscribes to Web Push (Mastodon's
`/api/v1/push/subscription`), PrivaPub POSTs its notifications to the push service the client named, encrypted to the
client (RFC 8291 `aes128gcm`, or the older `aesgcm` draft Mastodon still sends) and carrying a VAPID JWT (RFC 8292) signed
by the server's own P-256 key, the one `/api/v2/instance` gives as `configuration.vapid.public_key`. No HTTP signature
and no ActivityPub; https endpoints on public addresses only, no redirects. The push service learns when, never what.
## Local-only posts
Posts with a location (shown to nearby users of this server) never leave the server, in any form.
+17
View File
@@ -72,6 +72,23 @@ namespace PrivaPub.Api.Mastodon.Auth
options.AddEventHandler<ApplyAuthorizationResponseContext>(builder => builder.UseInlineHandler(OutOfBandCode)
.SetOrder(OpenIddictServerAspNetCoreHandlers.Authentication.ProcessFormPostResponse.Descriptor.Order - 1_000)
.SetType(OpenIddictServerHandlerType.Custom));
// a revoked token takes its Web Push subscription with it (R9), once OpenIddict has revoked it
options.AddEventHandler<HandleRevocationRequestContext>(builder => builder.UseInlineHandler(async context =>
{
if (context.GenericTokenPrincipal?.GetTokenId() is { Length: > 0 } tokenId)
await PrivaPub.Domain.Social.WebPush.ForgetToken(tokenId);
})
.SetOrder(OpenIddictServerHandlers.Revocation.RevokeToken.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.Custom));
// Mastodon 4.4's userinfo (Controllers/UserinfoController), named in the authorization server's metadata
options.AddEventHandler<HandleConfigurationRequestContext>(builder => builder.UseInlineHandler(context =>
{
if (context.Issuer != default)
context.UserInfoEndpoint = new Uri(context.Issuer, Controllers.UserinfoController.Path.TrimStart('/'));
return default;
})
.SetOrder(OpenIddictServerHandlers.Discovery.AttachEndpoints.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.Custom));
options.AddEventHandler<ApplyTokenResponseContext>(builder => builder.UseInlineHandler(context =>
{
if (context.Response.AccessToken != default)
@@ -27,6 +27,9 @@ namespace PrivaPub.Api.Mastodon.Auth
public static string FirstPartyClientId(IConfiguration configuration) =>
configuration["ClientApi:FirstPartyClientId"] is { Length: > 0 } clientId ? clientId : "decepub";
// what the first-party client's persona tokens may hold: push since R9, so decePub can subscribe to Web Push
public static readonly string[] FirstPartyScopes = { "read", "write", "follow", "push" };
//the first-party application: public (a browser keeps no secret), token exchange only, no redirect
public static async Task EnsureFirstPartyClient(IServiceProvider services, CancellationToken token)
{
@@ -46,7 +49,7 @@ namespace PrivaPub.Api.Mastodon.Auth
Permissions.GrantTypes.TokenExchange
}
};
foreach (var scopeName in new[] { "read", "write", "follow" })
foreach (var scopeName in FirstPartyScopes)
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scopeName);
var existing = await applications.FindByClientIdAsync(clientId, token);
@@ -70,7 +70,7 @@ namespace PrivaPub.Api.Mastodon.Auth
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(Claims.Subject, avatar.ID);
identity.SetClaim(Claims.Name, avatar.UserName);
identity.SetScopes(MastodonScopes.Parse(request.Scope).Intersect(new[] { "read", "write", "follow" }));
identity.SetScopes(MastodonScopes.Parse(request.Scope).Intersect(PersonaExchange.FirstPartyScopes));
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
@@ -47,7 +47,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
_outbox = outbox;
}
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")]
// Mastodon 4.4: a token with the profile scope alone may read it too
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts|profile")]
public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts"), EnableRateLimiting(RateLimiting.Uploads), RequestSizeLimit(20 * 1024 * 1024),
@@ -398,6 +399,28 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> DomainBlocks(CancellationToken token) =>
Json((await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)).Select(b => b.Domain).ToList());
// Mastodon's preview of a server block: how many of the accounts the persona follows, and of those following it, the
// block would take away, counted as BlockDomain takes them (an account on the server or a subdomain, or whose handle
// is)
[HttpGet("/api/v1/domain_blocks/preview"), Scope("write:blocks")]
public async Task<IActionResult> PreviewDomainBlock(CancellationToken token)
{
var domain = Params.Get("domain")?.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(domain))
return Error(StatusCodes.Status400BadRequest, "Param is missing or the value is empty: domain");
if (Uri.CheckHostName(domain) != UriHostNameType.Dns)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Domain is invalid");
var followings = await _dbEntities.Followings.Match(f => f.AvatarId == MyId && !f.TargetIsLocal && f.State == FollowState.Accepted)
.ExecuteAsync(token);
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.IsAccepted).ExecuteAsync(token);
var handles = await Hidden.HandleDomains(followings.Select(f => f.TargetActorURI).Concat(followers.Select(f => f.ActorURI)).ToList(), token);
return Json(new
{
following_count = followings.Count(f => Hidden.Blocks(domain, f.TargetActorURI, handles.GetValueOrDefault(f.TargetActorURI))),
followers_count = followers.Count(f => Hidden.Blocks(domain, f.ActorURI, handles.GetValueOrDefault(f.ActorURI)))
});
}
[HttpPost("/api/v1/domain_blocks"), Scope("write:blocks")]
public async Task<IActionResult> BlockDomain(CancellationToken token)
{
@@ -16,10 +16,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
public class AppsController : MastodonController
{
readonly IOpenIddictApplicationManager _applications;
readonly PrivaPub.Infrastructure.Push.IVapid _vapid;
public AppsController(IOpenIddictApplicationManager applications)
public AppsController(IOpenIddictApplicationManager applications, PrivaPub.Infrastructure.Push.IVapid vapid)
{
_applications = applications;
_vapid = vapid;
}
[HttpPost("/api/v1/apps"), AllowAnonymous]
@@ -69,7 +71,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
RedirectUris = redirects,
RedirectUri = string.Join("\n", redirects),
ClientId = descriptor.ClientId,
ClientSecret = descriptor.ClientSecret
ClientSecret = descriptor.ClientSecret,
VapidKey = (await _vapid.Key(token)).ForApi//Web Push's server key, as Mastodon still gives it here
});
}
@@ -88,7 +91,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
website = properties.TryGetValue("website", out var website) ? website.GetString() : default,
scopes = User.GetScopes().ToList(),
redirect_uris = (await _applications.GetRedirectUrisAsync(application, token)).ToList(),
vapid_key = string.Empty
vapid_key = (await _vapid.Key(token)).ForApi
});
}
@@ -28,7 +28,19 @@ namespace PrivaPub.Api.Mastodon.Controllers
// are enforced.
public class InstanceController : MastodonController
{
public const string Version = "4.2.0 (compatible; PrivaPub)";
// The Mastodon version this API answers as, then PrivaPub's own release (its tag, or the commit a build came from).
// 4.5.0 since R9 (2026-10-18): every client route of Mastodon 4.3, 4.4 and 4.5 is served or answers as a documented
// stub (docs/MASTODON-API.md), streaming and Web Push included. api_versions.mastodon is 4.5's own, 7.
public const string MastodonVersion = "4.5.0";
public static readonly string Version = Advertised(string.IsNullOrEmpty(BuildInfo.Ref) ? BuildInfo.Commit : BuildInfo.Ref);
static string Advertised(string release)
{
var own = release?.Trim();
if (own is { Length: > 1 } && own[0] == 'v' && char.IsDigit(own[1]))
own = own[1..];//a tag v1.27.0 is release 1.27.0
return string.IsNullOrEmpty(own) ? $"{MastodonVersion} (compatible; PrivaPub)" : $"{MastodonVersion} (compatible; PrivaPub {own})";
}
const int ActivityWeeks = 12;
static readonly TimeSpan ActivityLifetime = TimeSpan.FromHours(1);
static volatile CachedActivity _activity;
@@ -39,14 +51,16 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly IOptionsMonitor<RegistrationOptions> _registrations;
readonly IInstanceSettings _settings;
readonly MastodonMapper _mapper;
readonly PrivaPub.Infrastructure.Push.IVapid _vapid;
public InstanceController(ILocalActorService localActors, IOptionsMonitor<RegistrationOptions> registrations, IInstanceSettings settings,
MastodonMapper mapper)
MastodonMapper mapper, PrivaPub.Infrastructure.Push.IVapid vapid)
{
_localActors = localActors;
_registrations = registrations;
_settings = settings;
_mapper = mapper;
_vapid = vapid;
}
bool Open => _registrations.CurrentValue.IsOpen;
@@ -165,7 +179,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
statuses = Statuses,
media_attachments = MediaAttachments,
polls = Polls,
translation = new { enabled = false }
translation = new { enabled = false },
vapid = new { public_key = (await _vapid.Key(token)).ForApi }//Web Push (R9): the key every push is signed with
},
registrations = new { enabled = Open, approval_required = false, message = Open ? default : RegistrationOptions.ClosedMessage },
contact = new { email = Settings.ContactEmail ?? string.Empty, account = await ContactAccount(Settings, token) },
@@ -69,7 +69,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")]
public async Task<IActionResult> Policy(CancellationToken token) => Json(await V2(await NotificationPolicies.Of(MyId, token), token));
[HttpPatch("/api/v2/notifications/policy"), Scope("write:notifications")]
[HttpPatch("/api/v2/notifications/policy"), HttpPut("/api/v2/notifications/policy"), Scope("write:notifications")]
public async Task<IActionResult> SetPolicy(CancellationToken token)
{
var policy = await NotificationPolicies.Of(MyId, token);
@@ -0,0 +1,169 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using MongoDB.Entities;
using OpenIddict.Abstractions;
using OpenIddict.Validation.AspNetCore;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Social;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Push;
using PrivaPub.Models.Social;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Mastodon's Web Push API (completion program R9): one subscription per access token, so per persona, made, read, changed
// and deleted with the token itself (the `push` scope). Its parameters as Mastodon takes them, as a form or JSON:
// subscription[endpoint], subscription[keys][p256dh], subscription[keys][auth], subscription[standard] (4.4: RFC 8291
// and RFC 8292, else the aesgcm draft), data[alerts][<type>] and data[policy] (all, followed, follower, none). Its answer is
// Mastodon's WebPushSubscription. What is pushed, and how, is WebPushDispatcher's and WebPushJob's.
public class PushController : MastodonController
{
const int MaxEndpointLength = 2048;
readonly IVapid _vapid;
readonly IOptionsMonitor<FederationOptions> _federation;
public PushController(IVapid vapid, IOptionsMonitor<FederationOptions> federation)
{
_vapid = vapid;
_federation = federation;
}
string TokenId => User.GetTokenId();
// a new subscription replaces the one this token had, as on Mastodon
[HttpPost("/api/v1/push/subscription"), Scope("push")]
public async Task<IActionResult> Create(CancellationToken token)
{
var endpoint = Params.Get("subscription[endpoint]")?.Trim();
if (string.IsNullOrEmpty(endpoint))
return Error(StatusCodes.Status400BadRequest, "Param is missing or the value is empty: subscription");
if (endpoint.Length > MaxEndpointLength || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || !WebPushJob.IsAllowed(uri, _federation.CurrentValue))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Endpoint is not a valid URL");
var p256dh = Params.Get("subscription[keys][p256dh]")?.Trim();
var auth = Params.Get("subscription[keys][auth]")?.Trim();
if (string.IsNullOrEmpty(p256dh) || string.IsNullOrEmpty(auth))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Key p256dh can't be blank, Key auth can't be blank");
if (!WebPushCrypto.AreUsable(WebPushCrypto.FromBase64Url(p256dh), WebPushCrypto.FromBase64Url(auth)))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Keys are not valid Web Push keys");
var (alerts, policy, invalid) = Data();
if (invalid != default)
return invalid;
var tokenId = TokenId;
var accessToken = await AccessToken();
if (string.IsNullOrEmpty(tokenId) || string.IsNullOrEmpty(accessToken))
return Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
var subscription = new PushSubscription
{
AvatarId = MyId,
TokenId = tokenId,
AccessToken = accessToken,
Endpoint = endpoint,
P256dh = p256dh,
Auth = auth,
Standard = Params.Bool("subscription[standard]") == true,
Alerts = alerts,
Policy = policy
};
// (two at once with one token: the unique index on the token keeps one, and the later replaces it)
for (var attempt = 0; ; attempt++)
{
await WebPush.ForgetToken(tokenId);
try
{
subscription.ID = default;
await DB.Default.SaveAsync(subscription, token);
break;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey && attempt < 2)
{
}
}
return Json(await View(subscription, token));
}
[HttpGet("/api/v1/push/subscription"), Scope("push")]
public async Task<IActionResult> Show(CancellationToken token) =>
await Mine(token) is { } subscription ? Json(await View(subscription, token)) : NotFoundError();
// Mastodon's update: the data given replaces what was there (alerts left out are off, the policy left out is all)
[HttpPut("/api/v1/push/subscription"), Scope("push")]
public async Task<IActionResult> Update(CancellationToken token)
{
var subscription = await Mine(token);
if (subscription == default)
return NotFoundError();
var (alerts, policy, invalid) = Data();
if (invalid != default)
return invalid;
subscription.Alerts = alerts;
subscription.Policy = policy;
subscription.UpdatedAt = DateTime.UtcNow;
await DB.Default.Update<PushSubscription>().MatchID(subscription.ID)
.Modify(s => s.Alerts, alerts)
.Modify(s => s.Policy, policy)
.Modify(s => s.UpdatedAt, subscription.UpdatedAt)
.ExecuteAsync(token);
return Json(await View(subscription, token));
}
[HttpDelete("/api/v1/push/subscription"), Scope("push")]
public async Task<IActionResult> Delete(CancellationToken token)
{
await WebPush.ForgetToken(TokenId);
return Json(new { });
}
async Task<PushSubscription> Mine(CancellationToken token)
{
var tokenId = TokenId;
var avatarId = MyId;
return string.IsNullOrEmpty(tokenId) ? default
: await DB.Default.Find<PushSubscription>().Match(s => s.TokenId == tokenId && s.AvatarId == avatarId).ExecuteFirstAsync(token);
}
// data[alerts][<type>] (each true or false; unknown types are ignored, as Mastodon permits only its own) and
// data[policy]
(List<string> Alerts, PushPolicy Policy, IActionResult Invalid) Data()
{
var alerts = WebPush.Alerts.Where(alert => Params.Bool($"data[alerts][{alert}]") == true).ToList();
var given = Params.Get("data[policy]");
if (string.IsNullOrWhiteSpace(given))
return (alerts, PushPolicy.All, default);
return WebPush.ParsePolicy(given) is { } policy
? (alerts, policy, default)
: (alerts, PushPolicy.All, Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Policy is not included in the list"));
}
// the bearer token of this request, wherever the client put it (OpenIddict takes the header, a form field or the
// query): the payload carries it back, as Mastodon's does
async Task<string> AccessToken()
{
var stored = await HttpContext.GetTokenAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme,
OpenIddictValidationAspNetCoreConstants.Tokens.AccessToken);
if (!string.IsNullOrEmpty(stored))
return stored;
var header = Request.Headers.Authorization.ToString();
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return header["Bearer ".Length..].Trim();
return Params.Get("access_token");
}
// Mastodon's WebPushSubscription
async Task<object> View(PushSubscription subscription, CancellationToken token) => new
{
id = subscription.ID,
endpoint = subscription.Endpoint,
standard = subscription.Standard,
alerts = WebPush.Alerts.ToDictionary(alert => alert, alert => subscription.Alerts.Contains(alert)),
server_key = (await _vapid.Key(token)).ForApi,
policy = WebPush.Name(subscription.Policy)
};
}
}
@@ -140,9 +140,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
public class StubsController : MastodonController
{
[HttpGet("/api/v1/push/subscription"), Scope("push")]
public IActionResult PushSubscription() => Error(StatusCodes.Status404NotFound, "Record not found");
// Routes Mastodon answers that we have nothing behind: an empty or default answer lets clients degrade, where a
// 404 breaks some of them (Mastodon invariant 5)
[HttpGet("/api/v1/timelines/link"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
@@ -163,9 +160,35 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpPost("/api/v1/statuses/{id}/translate"), Scope("read:statuses")]
public IActionResult Translate(string id) => Error(StatusCodes.Status422UnprocessableEntity, "Translation is not available on this server");
// Mastodon 4.3's yearly reports (Wrapstodon): none are made here, so there are never any to show
[HttpGet("/api/v1/annual_reports"), Scope("read:accounts")]
public IActionResult AnnualReports() => Json(new { annual_reports = Array.Empty<object>(), accounts = Array.Empty<object>(), statuses = Array.Empty<object>() });
// Mastodon 4.3's yearly reports (Wrapstodon): none are made here, so there are never any to show, for any year (4.4's
// show), and marking one read changes nothing
[HttpGet("/api/v1/annual_reports"), HttpGet("/api/v1/annual_reports/{year}"), Scope("read:accounts")]
public IActionResult AnnualReports(string year) => Json(new { annual_reports = Array.Empty<object>(), accounts = Array.Empty<object>(), statuses = Array.Empty<object>() });
[HttpPost("/api/v1/annual_reports/{year}/read"), Scope("write:accounts")]
public IActionResult ReadAnnualReport(string year) => Json(new { });
// Registration through the API is cut (owner decision 2026-10-17): a login is made by setup or an invitation, on
// /clientapi. Mastodon answers this when its registrations are closed; its e-mail confirmation routes follow, since no
// account here waits for one
[HttpPost("/api/v1/accounts"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Register() => Error(StatusCodes.Status403Forbidden, "This action is not allowed");
[HttpPost("/api/v1/emails/confirmations"), Scope("write:accounts")]
public IActionResult ResendConfirmation() =>
Error(StatusCodes.Status403Forbidden, "This method is only available while the e-mail is awaiting confirmation");
[HttpGet("/api/v1/emails/check_confirmation"), Scope("read:accounts")]
public IActionResult CheckConfirmation() => Json(true);
// the peers' names are not published (/instance/peers stays empty), so a search of them finds none
[HttpGet("/api/v1/peers/search"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult SearchPeers() => Json(Array.Empty<string>());
// Mastodon 4.4's asynchronous refreshes are announced by a Mastodon-Async-Refresh header, which PrivaPub never sends
// (a thread's replies are read again in the background and simply show up): there is never one to ask about
[HttpGet("/api/v1_alpha/async_refreshes/{id}"), Scope("read")]
public IActionResult AsyncRefresh(string id) => NotFoundError();
// the Mastodon admin API is cut: moderation stays on /clientapi (ModeratorController, AdminController), so every
// admin route answers as Mastodon answers an account that may not use it, whoever asks
@@ -177,6 +177,18 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(conversation ?? new Conversation { Id = group.ID });
}
// Mastodon's unread (R9): shown unread again until it is read
[HttpPost("/api/v1/conversations/{id}/unread"), Scope("write:conversations")]
public async Task<IActionResult> UnreadConversation(string id, CancellationToken token)
{
var group = await MyConversation(id, token);
if (group == default)
return NotFoundError();
await ConversationStates.Unread(MyId, group.ID, token);
var conversation = (await ConversationsOf(new List<DmGroup> { group }, token)).FirstOrDefault();
return Json(conversation ?? new Conversation { Id = group.ID, Unread = true });
}
// off the persona's list until a newer message; the messages themselves stay
[HttpDelete("/api/v1/conversations/{id}"), Scope("write:conversations")]
public async Task<IActionResult> RemoveConversation(string id, CancellationToken token)
@@ -410,7 +422,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
return mapped == default ? NotFoundError() : Json(mapped);
}
[HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")]
[HttpPost("/api/v1/notifications/clear"), HttpPost("/api/v2/notifications/clear"), Scope("write:notifications")]
public async Task<IActionResult> Clear(CancellationToken token)
{
await DB.Default.DeleteAsync<NotificationEntity>(n => n.AvatarId == MyId);
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Federation.Actors;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Mastodon 4.4's OAuth userinfo (OpenID Connect's shape, GET or POST, the `profile` scope): who the token's persona is,
// for a site that signs in with it. Only what its public account says already; never its login. The authorization
// server's metadata names it (OAuthSetup).
public class UserinfoController : MastodonController
{
public const string Path = "/oauth/userinfo";
readonly MastodonMapper _mapper;
readonly ILocalActorService _localActors;
public UserinfoController(MastodonMapper mapper, ILocalActorService localActors)
{
_mapper = mapper;
_localActors = localActors;
}
[HttpGet(Path), HttpPost(Path), Scope("profile")]
public async Task<IActionResult> Show(CancellationToken token)
{
var account = await _mapper.Local(Me, false, token);
return Json(new
{
iss = _localActors.BaseAddress + "/",
sub = Me.Uri,
name = account.DisplayName,
preferred_username = account.Username,
profile = account.Url,
picture = account.Avatar
});
}
}
}
@@ -48,7 +48,8 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
var scope = context.ActionDescriptor.EndpointMetadata.OfType<ScopeAttribute>().LastOrDefault();
if (User.Identity?.IsAuthenticated == true)
{
if (scope != default && !MastodonScopes.Grants(User.GetScopes(), scope.Scope))
// (a scope written a|b is either)
if (scope != default && !scope.Scope.Split('|').Any(one => MastodonScopes.Grants(User.GetScopes(), one)))
{
context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes");
return;
@@ -154,6 +154,7 @@ namespace PrivaPub.Domain.Moderation
// nothing acts as it from now on: its Mastodon tokens end and its open streams close
await _tokens.RevokeBySubjectAsync(avatar.ID, CancellationToken.None);
await _authorizations.RevokeBySubjectAsync(avatar.ID, CancellationToken.None);
await Social.WebPush.Forget(new[] { avatar.ID });//and its Web Push subscriptions with them (R9): it is pushed nothing
Streams.CloseFor(avatar.ID);
}
if (action is ModerationActions.Silence or ModerationActions.Suspend)
+21
View File
@@ -7,6 +7,25 @@ namespace PrivaPub.Domain.Social
{
public static class Notifications
{
// told of every notification once it is kept for the persona's lists and streams: never one the notification policy
// filtered or dropped, nor one its blocks and mutes hid. Web Push hangs here (R9, WebPushDispatcher, set while the
// host runs; the command line sets nothing). Whatever it does never fails the notification.
public static Func<Notification, CancellationToken, Task> Told { get; set; }
static async Task Tell(Notification notification, CancellationToken token)
{
if (Told is not { } told)
return;
try
{
await told(notification, token);
}
catch (Exception)
{
//the hook logs its own failures
}
}
public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
CancellationToken token, string emoji = default, string emojiUrl = default, string text = default)
{
@@ -42,6 +61,7 @@ namespace PrivaPub.Domain.Social
var told = new Timelines.Streams.Event("notification", notification.ID);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told);
await Tell(notification, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
@@ -73,6 +93,7 @@ namespace PrivaPub.Domain.Social
var told = new Timelines.Streams.Event("notification", notification.ID);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told);
await Tell(notification, CancellationToken.None);
}
}
}
+156
View File
@@ -0,0 +1,156 @@
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Controllers;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Social;
using System.Text.Json;
namespace PrivaPub.Domain.Social
{
public sealed record PushPayload(string SubscriptionId, string NotificationId);
// What Web Push sends, and to whom (completion program R9). A notification is pushed to each subscription of its persona
// whose alert for its type is on and whose policy admits its sender, as Mastodon's Web::PushSubscription#pushable? says;
// a filtered one never is, nor anything for a persona held back (suspended, or its login banned).
public static class WebPush
{
// Mastodon 4.5's alerts (its Notification::TYPES, which data[alerts] takes), then Pleroma's for emoji reactions.
// admin.sign_up and admin.report are kept but never fire: moderation is on /clientapi, and it notifies nobody.
public static readonly IReadOnlyList<string> Alerts = new[]
{
"mention", "quote", "status", "reblog", "follow", "follow_request", "favourite", "poll", "update", "quoted_update",
"severed_relationships", "moderation_warning", "annual_report", "admin.sign_up", "admin.report", "pleroma:emoji_reaction"
};
/// <summary>The alert a notification's type answers to: its Mastodon name.</summary>
public static string AlertOf(NotificationType type) => NotificationsController.Names.TryGetValue(type, out var name) ? name : default;
public static string Name(PushPolicy policy) => policy switch
{
PushPolicy.Followed => "followed",
PushPolicy.Follower => "follower",
PushPolicy.None => "none",
_ => "all"
};
public static PushPolicy? ParsePolicy(string value) => value?.Trim().ToLowerInvariant() switch
{
"all" => PushPolicy.All,
"followed" => PushPolicy.Followed,
"follower" => PushPolicy.Follower,
"none" => PushPolicy.None,
_ => (PushPolicy?)null
};
public static async Task<bool> Pushable(PushSubscription subscription, Notification notification, CancellationToken token)
{
if (notification.Filtered || notification.AvatarId != subscription.AvatarId)
return false;
var alert = AlertOf(notification.Type);
if (alert == default || !subscription.Alerts.Contains(alert))
return false;
return await Admits(subscription, notification, token);
}
// Mastodon's policy_allows_notification?: everyone, nobody, the accounts the persona follows, those that follow it. What
// the persona tells itself (its own poll ended, a warning from the staff, which Mastodon sends from the account itself)
// passes every policy but none.
static async Task<bool> Admits(PushSubscription subscription, Notification notification, CancellationToken token)
{
if (subscription.Policy == PushPolicy.None)
return false;
if (subscription.Policy == PushPolicy.All || notification.FromAccountId == subscription.AvatarId)
return true;
var avatarId = subscription.AvatarId;
var fromId = notification.FromAccountId;
var fromUri = notification.FromActorURI;
if (subscription.Policy == PushPolicy.Followed)
{
if (!string.IsNullOrEmpty(fromId) && await DB.Default.Find<Following>()
.Match(f => f.AvatarId == avatarId && f.FollowerKind == null && f.State == FollowState.Accepted && f.TargetAccountId == fromId).ExecuteAnyAsync(token))
return true;
return !string.IsNullOrEmpty(fromUri) && await DB.Default.Find<Following>()
.Match(f => f.AvatarId == avatarId && f.FollowerKind == null && f.State == FollowState.Accepted && f.TargetActorURI == fromUri).ExecuteAnyAsync(token);
}
if (!string.IsNullOrEmpty(fromUri) && await DB.Default.Find<Follower>()
.Match(f => f.LocalActorId == avatarId && f.LocalActorKind == LocalActorKind.Person && f.IsAccepted && f.ActorURI == fromUri).ExecuteAnyAsync(token))
return true;
return !string.IsNullOrEmpty(fromId) && await DB.Default.Find<Following>()
.Match(f => f.AvatarId == fromId && f.FollowerKind == null && f.State == FollowState.Accepted && f.TargetAccountId == avatarId).ExecuteAnyAsync(token);
}
/// <summary>Every subscription of these personas goes (their tokens ended, or they did).</summary>
public static async Task Forget(IReadOnlyCollection<string> avatarIds)
{
var ids = avatarIds?.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList() ?? new List<string>();
if (ids.Count > 0)
await DB.Default.DeleteAsync<PushSubscription>(s => ids.Contains(s.AvatarId));//a delete takes no token outside a transaction
}
/// <summary>The subscription made with this token goes (the token was revoked).</summary>
public static async Task ForgetToken(string tokenId)
{
if (!string.IsNullOrEmpty(tokenId))
await DB.Default.DeleteAsync<PushSubscription>(s => s.TokenId == tokenId);
}
}
// Hangs on Notifications.Told while the host runs and queues one SendPush job per subscription the notification goes to
// (WebPushJob sends it, encrypted). Queueing is a few indexed reads and inserts; nothing leaves the server here.
public class WebPushDispatcher : IHostedService
{
readonly IJobQueue _jobs;
readonly ILogger<WebPushDispatcher> _logger;
public WebPushDispatcher(IJobQueue jobs, ILogger<WebPushDispatcher> logger)
{
_jobs = jobs;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
Notifications.Told = Dispatch;
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
Notifications.Told = default;
return Task.CompletedTask;
}
public async Task Dispatch(Notification notification, CancellationToken token)
{
try
{
if (notification == default || notification.Filtered || Moderation.Restrictions.IsSuspended(notification.AvatarId))
return;
var alert = WebPush.AlertOf(notification.Type);
if (alert == default)
return;
var avatarId = notification.AvatarId;
var subscriptions = await DB.Default.Find<PushSubscription>().Match(s => s.AvatarId == avatarId && s.Alerts.Contains(alert)).ExecuteAsync(token);
var jobs = new List<Job>();
foreach (var subscription in subscriptions)
if (await WebPush.Pushable(subscription, notification, token))
jobs.Add(new Job
{
Kind = JobKind.SendPush,
Payload = JsonSerializer.Serialize(new PushPayload(subscription.ID, notification.ID)),
Host = Uri.TryCreate(subscription.Endpoint, UriKind.Absolute, out var endpoint) ? endpoint.Host : default,
DedupeKey = $"push|{subscription.ID}|{notification.ID}"
});
if (jobs.Count > 0)
await _jobs.EnqueueMany(jobs, token);
}
catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested)
{
_logger.LogWarning(ex, "{Service} could not queue the pushes of a notification", nameof(WebPushDispatcher));
}
}
}
}
+29 -3
View File
@@ -21,14 +21,40 @@ namespace PrivaPub.Domain.Statuses
await Read(authorAvatarId, conversationId, postId, token);
}
public static Task Read(string avatarId, string conversationId, string postId, CancellationToken token) =>
Mark(avatarId, conversationId, s => s.LastReadPostId, postId, token);
public static async Task Read(string avatarId, string conversationId, string postId, CancellationToken token)
{
await Mark(avatarId, conversationId, s => s.LastReadPostId, postId, token);
await DB.Default.Update<ConversationState>()
.Match(s => s.AvatarId == avatarId && s.ConversationId == conversationId && s.MarkedUnread)
.Modify(s => s.MarkedUnread, false)
.ExecuteAsync(token);
}
// Mastodon's unread: the conversation shows unread, whoever wrote last, until the persona reads it again (R9)
public static async Task Unread(string avatarId, string conversationId, CancellationToken token)
{
Task Write() => DB.Default.Update<ConversationState>()
.Match(s => s.AvatarId == avatarId && s.ConversationId == conversationId)
.Modify(s => s.MarkedUnread, true)
.Modify(s => s.UpdatedAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
try
{
await Write();
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
await Write();
}
}
public static Task Hide(string avatarId, string conversationId, string postId, CancellationToken token) =>
Mark(avatarId, conversationId, s => s.HiddenUpTo, postId, token);
public static bool IsUnread(ConversationState state, string lastPostId, string lastAuthorId, string avatarId) =>
lastPostId != default && lastAuthorId != avatarId && string.CompareOrdinal(lastPostId, state?.LastReadPostId) > 0;
state?.MarkedUnread == true
|| lastPostId != default && lastAuthorId != avatarId && string.CompareOrdinal(lastPostId, state?.LastReadPostId) > 0;
public static bool IsHidden(ConversationState state, string lastPostId) =>
state?.HiddenUpTo != default && string.CompareOrdinal(lastPostId, state.HiddenUpTo) <= 0;
@@ -168,6 +168,9 @@ namespace PrivaPub.Infrastructure.Backup
.Set("CredentialsChangedAt", now), cancellationToken: token);
foreach (var collection in new[] { "openiddict.tokens", "openiddict.authorizations" })
report.SessionsEnded += (int)(await database.GetCollection<BsonDocument>(collection).DeleteManyAsync(FilterDefinition<BsonDocument>.Empty, token)).DeletedCount;
// and the Web Push subscriptions made with those tokens (R9; never in a backup): clients subscribe again once signed
// in. The VAPID key (PushKeys, never in a backup either) stays the live one.
await database.GetCollection<BsonDocument>("PushSubscription").DeleteManyAsync(FilterDefinition<BsonDocument>.Empty, token);
// what the backup remembered of servers being down is long past
await database.GetCollection<BsonDocument>("RemoteInstance").UpdateManyAsync(FilterDefinition<BsonDocument>.Empty,
@@ -42,7 +42,9 @@ namespace PrivaPub.Infrastructure.Backup
["AppConfiguration"] = "the configuration's copy holds the SMTP password, and is made again from appsettings at boot",
[nameof(RestoreRecord)] = "what restores did outlives what they restore",
["PersonaArchive"] = "personas' archives being made or imported belong to the moment",
["ServerSetup"] = "the server is set up once: a restore never opens its setup again"
["ServerSetup"] = "the server is set up once: a restore never opens its setup again",
["PushSubscription"] = "Web Push subscriptions belong to sessions: a restore ends every one, and clients subscribe again",
["PushKeys"] = "the VAPID key every push subscription was made with: a restore keeps the live one"
};
static readonly JsonWriterSettings Json = new() { OutputMode = JsonOutputMode.CanonicalExtendedJson, Indent = false };
@@ -290,6 +290,7 @@ namespace PrivaPub.Infrastructure.Cli
await DB.Default.Update<RootToAvatar>().MatchID(link.ID).Modify(ra => ra.RootId, root.ID).ExecuteAsync();
await services.GetRequiredService<IOpenIddictTokenManager>().RevokeBySubjectAsync(persona.ID);
await services.GetRequiredService<IOpenIddictAuthorizationManager>().RevokeBySubjectAsync(persona.ID);
await Domain.Social.WebPush.Forget(new[] { persona.ID });//its Web Push subscriptions go with its tokens (R9)
await Log(services, AdminActions.PersonaMove, AdminTargets.Persona, persona.ID, "@" + personaName, "moved to another login");
output.WriteLine($"@{personaName}: moved to {login}");
return 0;
+3
View File
@@ -73,6 +73,9 @@ namespace PrivaPub.Infrastructure.Data
await Plain<PersonaNote>(token, n => n.TargetAccountId);
await DB.Default.Index<SuggestionDismissal>().Key(d => d.AvatarId, KeyType.Ascending).Key(d => d.AccountId, KeyType.Ascending)
.Option(o => o.Unique = true).CreateAsync(token);
// Web Push (a new collection, R9): one subscription per access token, a persona's read when it is notified
await Unique<PushSubscription>(s => s.TokenId, Builders<PushSubscription>.Filter.Gt(s => s.TokenId, ""), token);
await Plain<PushSubscription>(token, s => s.AvatarId);
// the staff's log, read newest first
await DB.Default.Index<Models.AdminAction>().Key(a => a.At, KeyType.Descending).CreateAsync(token);
@@ -11,7 +11,7 @@ namespace PrivaPub.Infrastructure.Http
public static class SafeHttpHandlerFactory
{
public static SocketsHttpHandler Create(FederationOptions options, IConnectedAddresses connected = default) => new()
public static SocketsHttpHandler Create(FederationOptions options, IConnectedAddresses connected = default, TimeSpan? connectTimeout = default) => new()
{
SslOptions = options.AcceptAnyCertificate
? new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, _, _, _) => true }
@@ -20,7 +20,7 @@ namespace PrivaPub.Infrastructure.Http
UseProxy = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.All,
ConnectTimeout = TimeSpan.FromSeconds(10),
ConnectTimeout = connectTimeout ?? TimeSpan.FromSeconds(10),
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
MaxResponseHeadersLength = 64,
ConnectCallback = (context, token) => Connect(context.DnsEndPoint, options.AllowPrivateNetworks, token, connected)
+116
View File
@@ -0,0 +1,116 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace PrivaPub.Infrastructure.Push
{
// The server's VAPID key (RFC 8292, completion program R9) and the JWT every push carries. The key is made the first time
// it is needed and kept in Mongo (PushKeys, one document); every process reads that one.
public interface IVapid
{
Task<VapidKey> Key(CancellationToken token);
}
public sealed class VapidKey
{
// RFC 8292 2: no more than 24 hours ahead; half of it leaves room for a push service's clock
public static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(12);
readonly ECParameters _parameters;
public VapidKey(byte[] publicPoint, byte[] d)
{
PublicPoint = publicPoint;
_parameters = new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
D = d,
Q = new ECPoint { X = publicPoint[1..33], Y = publicPoint[33..] }
};
}
public byte[] PublicPoint { get; }
/// <summary>As the APIs give it (the instance's vapid.public_key, an app's vapid_key, a subscription's server_key):
/// URL-safe base64 with its padding, as Mastodon's VAPID_PUBLIC_KEY is written.</summary>
public string ForApi => Convert.ToBase64String(PublicPoint).Replace('+', '-').Replace('/', '_');
/// <summary>As headers take it (RFC 8292's k, the draft's Crypto-Key p256ecdsa): base64url without padding.</summary>
public string ForHeader => WebPushCrypto.Base64Url(PublicPoint);
/// <summary>The ES256 JWT for one push service (aud: its origin), signed by this key.</summary>
public string Token(string audience, string subject, DateTime now)
{
var header = WebPushCrypto.Base64Url("{\"typ\":\"JWT\",\"alg\":\"ES256\"}"u8);
var claims = WebPushCrypto.Base64Url(JsonSerializer.SerializeToUtf8Bytes(new Dictionary<string, object>
{
["aud"] = audience,
["exp"] = new DateTimeOffset(now + TokenLifetime).ToUnixTimeSeconds(),
["sub"] = subject
}));
var signed = header + "." + claims;
using var key = ECDsa.Create(_parameters);
var signature = key.SignData(Encoding.ASCII.GetBytes(signed), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
return signed + "." + WebPushCrypto.Base64Url(signature);
}
/// <summary>A push service's origin, the JWT's aud: scheme, host and a port that is not the scheme's own.</summary>
public static string Audience(Uri endpoint) => endpoint.GetLeftPart(UriPartial.Authority).ToLower(CultureInfo.InvariantCulture);
}
public class Vapid : IVapid
{
readonly SemaphoreSlim _gate = new(1, 1);
volatile VapidKey _key;
public async Task<VapidKey> Key(CancellationToken token)
{
if (_key is { } known)
return known;
await _gate.WaitAsync(token);
try
{
if (_key is { } meanwhile)
return meanwhile;
var stored = await Stored(token) ?? await Make(token);
_key = new VapidKey(WebPushCrypto.FromBase64Url(stored.PublicKey), WebPushCrypto.FromBase64Url(stored.PrivateKey));
return _key;
}
finally
{
_gate.Release();
}
}
static Task<PushKeys> Stored(CancellationToken token) =>
DB.Default.Find<PushKeys>().Match(k => k.ID == PushKeys.Name).ExecuteFirstAsync(token);
// the first need makes it; two processes at once keep the first one saved
static async Task<PushKeys> Make(CancellationToken token)
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = key.ExportParameters(true);
var keys = new PushKeys
{
ID = PushKeys.Name,
PublicKey = WebPushCrypto.Base64Url([0x04, .. parameters.Q.X, .. parameters.Q.Y]),
PrivateKey = WebPushCrypto.Base64Url(parameters.D)
};
try
{
await DB.Default.InsertAsync(keys, token);
return keys;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return await Stored(token);
}
}
}
}
@@ -0,0 +1,169 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace PrivaPub.Infrastructure.Push
{
// Web Push message encryption, on System.Security.Cryptography alone (completion program R9). Two encodings, as Mastodon
// sends them:
// - RFC 8291 (`aes128gcm`, in RFC 8188's record), for a subscription made with `standard` (Mastodon 4.4);
// - the draft before it (`aesgcm`: draft-ietf-webpush-encryption-04 over draft-ietf-httpbis-encryption-encoding-03), for
// every other one, with its salt in the Encryption header and our public key in Crypto-Key.
// Both: a fresh P-256 key pair and a fresh 16-byte salt per message, ECDH with the client's p256dh, HKDF-SHA-256 keyed by
// its auth secret, AES-128-GCM, one record. Only the client can read the message; the push service carries it blind.
public static class WebPushCrypto
{
public const int RecordSize = 4096;
// the largest payload a 4096-byte aes128gcm body holds: 86 bytes of header, the delimiter and the tag
public const int MaxPayload = RecordSize - 86 - 1 - 16;
const int SaltLength = 16;
const int TagLength = 16;
public sealed record Message(byte[] Body, string ContentEncoding, byte[] Salt, byte[] ServerPublicKey);
public static Message Aes128Gcm(ReadOnlySpan<byte> plaintext, byte[] clientPublicKey, byte[] authSecret)
{
using var server = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
return Aes128Gcm(plaintext, clientPublicKey, authSecret, server, RandomNumberGenerator.GetBytes(SaltLength));
}
public static Message Aes128Gcm(ReadOnlySpan<byte> plaintext, byte[] clientPublicKey, byte[] authSecret, ECDiffieHellman server, byte[] salt)
{
if (plaintext.Length > MaxPayload)
throw new ArgumentException($"a push payload holds {MaxPayload} bytes at most", nameof(plaintext));
var serverPublic = PublicPoint(server);
var shared = SharedSecret(server, clientPublicKey);
// RFC 8291 3.3 and 3.4: PRK_key = HMAC-SHA-256(auth, ecdh); IKM = HKDF-Expand(PRK_key, "WebPush: info" 0x00
// ua_public as_public, 32)
var keyInfo = Concat(Encoding.ASCII.GetBytes("WebPush: info\0"), clientPublicKey, serverPublic);
var ikm = HKDF.Expand(HashAlgorithmName.SHA256, HKDF.Extract(HashAlgorithmName.SHA256, shared, authSecret), 32, keyInfo);
// RFC 8188 2.2 and 2.3: the content encryption key and the nonce, from the salt
var prk = HKDF.Extract(HashAlgorithmName.SHA256, ikm, salt);
var cek = HKDF.Expand(HashAlgorithmName.SHA256, prk, 16, Encoding.ASCII.GetBytes("Content-Encoding: aes128gcm\0"));
var nonce = HKDF.Expand(HashAlgorithmName.SHA256, prk, 12, Encoding.ASCII.GetBytes("Content-Encoding: nonce\0"));
// one record, the last: the payload, its delimiter 0x02, no padding
var record = new byte[plaintext.Length + 1];
plaintext.CopyTo(record);
record[^1] = 0x02;
var encrypted = Seal(cek, nonce, record);
// RFC 8188 2.1's header: the salt, the record size, the key id's length and the key id (RFC 8291: our public key)
var body = new byte[SaltLength + 4 + 1 + serverPublic.Length + encrypted.Length];
salt.CopyTo(body, 0);
BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(SaltLength), RecordSize);
body[SaltLength + 4] = (byte)serverPublic.Length;
serverPublic.CopyTo(body, SaltLength + 5);
encrypted.CopyTo(body, SaltLength + 5 + serverPublic.Length);
return new Message(body, "aes128gcm", salt, serverPublic);
}
public static Message AesGcm(ReadOnlySpan<byte> plaintext, byte[] clientPublicKey, byte[] authSecret)
{
using var server = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
return AesGcm(plaintext, clientPublicKey, authSecret, server, RandomNumberGenerator.GetBytes(SaltLength));
}
public static Message AesGcm(ReadOnlySpan<byte> plaintext, byte[] clientPublicKey, byte[] authSecret, ECDiffieHellman server, byte[] salt)
{
if (plaintext.Length > MaxPayload)
throw new ArgumentException($"a push payload holds {MaxPayload} bytes at most", nameof(plaintext));
var serverPublic = PublicPoint(server);
var shared = SharedSecret(server, clientPublicKey);
// the draft's: IKM = HKDF(salt: auth, ecdh, "Content-Encoding: auth" 0x00, 32), then the key and nonce from the salt
// over a context of both public keys, each after its length
var ikm = HKDF.Expand(HashAlgorithmName.SHA256, HKDF.Extract(HashAlgorithmName.SHA256, shared, authSecret), 32,
Encoding.ASCII.GetBytes("Content-Encoding: auth\0"));
var context = Concat(Encoding.ASCII.GetBytes("P-256\0"), Length16(clientPublicKey), clientPublicKey, Length16(serverPublic), serverPublic);
var prk = HKDF.Extract(HashAlgorithmName.SHA256, ikm, salt);
var cek = HKDF.Expand(HashAlgorithmName.SHA256, prk, 16, Concat(Encoding.ASCII.GetBytes("Content-Encoding: aesgcm\0"), context));
var nonce = HKDF.Expand(HashAlgorithmName.SHA256, prk, 12, Concat(Encoding.ASCII.GetBytes("Content-Encoding: nonce\0"), context));
// two bytes saying how much padding follows (none), then the payload
var padded = new byte[2 + plaintext.Length];
plaintext.CopyTo(padded.AsSpan(2));
return new Message(Seal(cek, nonce, padded), "aesgcm", salt, serverPublic);
}
/// <summary>A client's public key: an uncompressed point on P-256, or a CryptographicException.</summary>
public static ECDiffieHellman ImportPublic(byte[] point)
{
if (point is not { Length: 65 } || point[0] != 0x04)
throw new CryptographicException("not an uncompressed P-256 point");
var key = ECDiffieHellman.Create(new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint { X = point[1..33], Y = point[33..] }
});
return key;
}
/// <summary>Whether a subscription's keys can be encrypted to: a point on P-256 and a 16-byte secret.</summary>
public static bool AreUsable(byte[] p256dh, byte[] auth)
{
if (auth is not { Length: 16 })
return false;
try
{
using var key = ImportPublic(p256dh);
using var probe = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
probe.DeriveRawSecretAgreement(key.PublicKey);
return true;
}
catch (CryptographicException)
{
return false;
}
}
public static byte[] PublicPoint(ECAlgorithm key)
{
var parameters = key.ExportParameters(false);
return [0x04, .. parameters.Q.X, .. parameters.Q.Y];
}
public static string Base64Url(ReadOnlySpan<byte> bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
/// <summary>base64url or base64, padded or not, as clients send keys; null when it is neither.</summary>
public static byte[] FromBase64Url(string text)
{
if (string.IsNullOrWhiteSpace(text))
return default;
var normal = text.Trim().TrimEnd('=').Replace('-', '+').Replace('_', '/');
normal = normal.PadRight(normal.Length + (4 - normal.Length % 4) % 4, '=');
var bytes = new byte[normal.Length * 3 / 4];
return Convert.TryFromBase64String(normal, bytes, out var written) ? bytes[..written] : default;
}
static byte[] SharedSecret(ECDiffieHellman server, byte[] clientPublicKey)
{
using var client = ImportPublic(clientPublicKey);
return server.DeriveRawSecretAgreement(client.PublicKey);
}
static byte[] Seal(byte[] key, byte[] nonce, byte[] plaintext)
{
var output = new byte[plaintext.Length + TagLength];
using var aes = new AesGcm(key, TagLength);
aes.Encrypt(nonce, plaintext, output.AsSpan(0, plaintext.Length), output.AsSpan(plaintext.Length));
return output;
}
static byte[] Length16(byte[] value)
{
var length = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(length, (ushort)value.Length);
return length;
}
static byte[] Concat(params byte[][] parts)
{
var all = new byte[parts.Sum(p => p.Length)];
var at = 0;
foreach (var part in parts)
{
part.CopyTo(all, at);
at += part.Length;
}
return all;
}
}
}
+264
View File
@@ -0,0 +1,264 @@
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using MongoDB.Entities;
using OpenIddict.Abstractions;
using PrivaPub.Api.Mastodon.Controllers;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Instance;
using PrivaPub.Domain.Moderation;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.RegularExpressions;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace PrivaPub.Infrastructure.Push
{
// Sends one Web Push notification to one subscription (JobKind.SendPush, queued by WebPushDispatcher; completion
// program R9), as Mastodon's Web::PushNotificationWorker does:
// - the payload is Mastodon's (access_token, preferred_locale, notification_id, notification_type, icon, title, body), built
// when it is sent, as the persona sees the notification then (one it can no longer see, or dismissed meanwhile, is not
// sent), its access_token the subscription's own: the token is checked to be valid and the persona's first;
// - encrypted for the client alone (WebPushCrypto): RFC 8291 aes128gcm with RFC 8292's `vapid t=, k=` for a standard
// subscription, otherwise the aesgcm draft with Encryption and Crypto-Key and `WebPush <jwt>`; the JWT (Vapid) names the
// push service's origin (aud), lasts twelve hours (exp) and the server's contact (sub: mailto: the contact email, or
// the server's address; never a persona);
// - TTL two days and Urgency normal, as Mastodon's;
// - through the `push` client: public addresses only, https only, no redirects, small timeouts (the federation client's
// safe handler, with the pasture's exceptions where Federation says so).
// A 404 or 410 means the subscription is gone: it is deleted. 408, 429, a 5xx, a timeout or a broken connection retry on
// the jobs' backoff; any other answer drops the push (logged by the push service's host alone).
public class WebPushJob : IJobHandler
{
public const string ClientName = "push";
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(15);
public static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
// Mastodon's Web::PushNotificationWorker::TTL and URGENCY
const int TimeToLive = 48 * 3600;
const string Urgency = "normal";
const int BodyLength = 140;
readonly IHttpClientFactory _http;
readonly IVapid _vapid;
readonly IServiceScopeFactory _scopes;
readonly IInstanceSettings _settings;
readonly ILocalActorService _localActors;
readonly IOptionsMonitor<FederationOptions> _federation;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<WebPushJob> _logger;
public WebPushJob(IHttpClientFactory http, IVapid vapid, IServiceScopeFactory scopes, IInstanceSettings settings, ILocalActorService localActors,
IOptionsMonitor<FederationOptions> federation, IStringLocalizer<GenericRes> localizer, ILogger<WebPushJob> logger)
{
_http = http;
_vapid = vapid;
_scopes = scopes;
_settings = settings;
_localActors = localActors;
_federation = federation;
_localizer = localizer;
_logger = logger;
}
public JobKind Kind => JobKind.SendPush;
public int Concurrency => 4;
public int MaxAttempts => 6;
public int PerHostLimit => 4;
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var payload = JsonSerializer.Deserialize<PushPayload>(job.Payload ?? "null");
if (string.IsNullOrEmpty(payload?.SubscriptionId) || string.IsNullOrEmpty(payload.NotificationId))
return JobOutcome.Done;
var subscriptionId = payload.SubscriptionId;
var subscription = await DB.Default.Find<PushSubscription>().Match(s => s.ID == subscriptionId).ExecuteFirstAsync(token);
if (subscription == default)
return JobOutcome.Done;
// a persona held back (suspended, its login banned) gets no push
if (Restrictions.IsSuspended(subscription.AvatarId))
return JobOutcome.Done;
var notificationId = payload.NotificationId;
var avatarId = subscription.AvatarId;
var notification = await DB.Default.Find<Notification>().Match(n => n.ID == notificationId && n.AvatarId == avatarId).ExecuteFirstAsync(token);
if (notification == default || !await WebPush.Pushable(subscription, notification, token))
return JobOutcome.Done;
if (!Uri.TryCreate(subscription.Endpoint, UriKind.Absolute, out var endpoint) || !IsAllowed(endpoint, _federation.CurrentValue))
return JobOutcome.Dead("endpoint");
byte[] json;
using (var scope = _scopes.CreateScope())
{
// the token the subscription was made with must still be valid, and the persona's: it goes into the payload
if (!await TokenHolds(scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>(), subscription, token))
{
await WebPush.ForgetToken(subscription.TokenId);
return JobOutcome.Done;
}
var avatar = await DB.Default.Find<Avatar>().Match(a => a.ID == avatarId && a.DeletionAt == null).ExecuteFirstAsync(token);
if (avatar == default)
{
await WebPush.Forget(new[] { avatarId });
return JobOutcome.Done;
}
var mapped = (await scope.ServiceProvider.GetRequiredService<MastodonMapper>().Notifications(new List<Notification> { notification }, avatarId, token))
.FirstOrDefault();
if (mapped == default)
return JobOutcome.Done;
json = JsonSerializer.SerializeToUtf8Bytes(new Dictionary<string, object>
{
["access_token"] = subscription.AccessToken,
["preferred_locale"] = Locale(avatar, scope.ServiceProvider),
["notification_id"] = notification.ID,
["notification_type"] = WebPush.AlertOf(notification.Type),
["icon"] = mapped.Account?.AvatarStatic ?? mapped.Account?.Avatar,
["title"] = Title(notification, mapped),
["body"] = Body(mapped)
});
}
var key = await _vapid.Key(token);
var p256dh = WebPushCrypto.FromBase64Url(subscription.P256dh);
var auth = WebPushCrypto.FromBase64Url(subscription.Auth);
if (!WebPushCrypto.AreUsable(p256dh, auth))
return JobOutcome.Dead("keys");
var message = subscription.Standard ? WebPushCrypto.Aes128Gcm(json, p256dh, auth) : WebPushCrypto.AesGcm(json, p256dh, auth);
var jwt = key.Token(VapidKey.Audience(endpoint), Subject(), DateTime.UtcNow);
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) { Content = new ByteArrayContent(message.Body) };
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
request.Content.Headers.ContentEncoding.Add(message.ContentEncoding);
request.Headers.TryAddWithoutValidation("TTL", TimeToLive.ToString(System.Globalization.CultureInfo.InvariantCulture));
request.Headers.TryAddWithoutValidation("Urgency", Urgency);
if (subscription.Standard)
request.Headers.TryAddWithoutValidation("Authorization", $"vapid t={jwt}, k={key.ForHeader}");
else
{
request.Headers.TryAddWithoutValidation("Authorization", $"WebPush {jwt}");
request.Headers.TryAddWithoutValidation("Encryption", $"salt={WebPushCrypto.Base64Url(message.Salt)}");
request.Headers.TryAddWithoutValidation("Crypto-Key", $"dh={WebPushCrypto.Base64Url(message.ServerPublicKey)};p256ecdsa={key.ForHeader}");
}
try
{
using var response = await _http.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
var status = (int)response.StatusCode;
if (status is >= 200 and < 300)
return JobOutcome.Done;
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
{
await DB.Default.DeleteAsync<PushSubscription>(subscription.ID);
_logger.LogInformation("{Kind}: {Host} answered {Status}, the subscription is gone", Kind, endpoint.Host, status);
return JobOutcome.Done;
}
if (response.StatusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || status >= 500)
return JobOutcome.Retry($"status {status}");
_logger.LogWarning("{Kind}: {Host} refused a push with {Status}; it is dropped", Kind, endpoint.Host, status);
return JobOutcome.Dead($"status {status}");
}
catch (BlockedDestinationException)
{
_logger.LogWarning("{Kind}: {Host} does not resolve to a public address; the push is dropped", Kind, endpoint.Host);
return JobOutcome.Dead("blocked destination");
}
catch (TaskCanceledException) when (!token.IsCancellationRequested)
{
return JobOutcome.Retry("timeout");
}
catch (HttpRequestException ex) when (ex.InnerException is BlockedDestinationException)
{
_logger.LogWarning("{Kind}: {Host} does not resolve to a public address; the push is dropped", Kind, endpoint.Host);
return JobOutcome.Dead("blocked destination");
}
catch (Exception ex) when (ex is HttpRequestException or IOException or SocketException)
{
return JobOutcome.Retry(ex.GetType().Name);
}
}
/// <summary>https, a host, no user information; plain http only on a test network (Federation:AllowPlainHttp, which
/// production refuses at start).</summary>
public static bool IsAllowed(Uri endpoint, FederationOptions options) =>
endpoint != default && endpoint.IsAbsoluteUri && !string.IsNullOrEmpty(endpoint.Host) && string.IsNullOrEmpty(endpoint.UserInfo)
&& (endpoint.Scheme == Uri.UriSchemeHttps || options?.AllowPlainHttp == true && endpoint.Scheme == Uri.UriSchemeHttp);
static async Task<bool> TokenHolds(IOpenIddictTokenManager tokens, PushSubscription subscription, CancellationToken token)
{
if (string.IsNullOrEmpty(subscription.TokenId) || string.IsNullOrEmpty(subscription.AccessToken))
return false;
var stored = await tokens.FindByIdAsync(subscription.TokenId, token);
if (stored == default || !await tokens.HasStatusAsync(stored, Statuses.Valid, token))
return false;
if (await tokens.GetExpirationDateAsync(stored, token) is { } expires && expires < DateTimeOffset.UtcNow)
return false;
return await tokens.GetSubjectAsync(stored, token) == subscription.AvatarId;
}
// RFC 8292's sub: how the push service reaches the server's operator. The contact email the administrator gave, or
// the server's own address; never a persona. (A server on plain http, a test network, has no https: address to give,
// so it names a mailbox at its host: RFC 8292 takes only mailto: and https:.)
string Subject()
{
var email = _settings.Current.ContactEmail?.Trim();
if (!string.IsNullOrEmpty(email) && !email.Any(char.IsWhiteSpace) && email.Contains('@'))
return "mailto:" + email;
var address = _localActors.BaseAddress;
return Uri.TryCreate(address, UriKind.Absolute, out var base_) && base_.Scheme != Uri.UriSchemeHttps
? "mailto:privapub@" + base_.Host
: address;
}
// the persona's language (its default for posts), else the server's first
static string Locale(Avatar avatar, IServiceProvider services)
{
var language = avatar.Settings?.DefaultLanguage;
return string.IsNullOrWhiteSpace(language) ? InstanceController.LanguageCodes(services)[0] : language.Trim();
}
// Mastodon's notification_mailer subjects
string Title(Notification notification, Api.Mastodon.Entities.Notification mapped)
{
var name = string.IsNullOrWhiteSpace(mapped.Account?.DisplayName) ? mapped.Account?.Username ?? string.Empty : mapped.Account.DisplayName;
return notification.Type switch
{
NotificationType.Mention => string.Format(_localizer["You were mentioned by {0}"], name),
NotificationType.Status => string.Format(_localizer["{0} just posted"], name),
NotificationType.Reblog => string.Format(_localizer["{0} boosted your post"], name),
NotificationType.Follow => string.Format(_localizer["{0} is now following you"], name),
NotificationType.FollowRequest => string.Format(_localizer["Pending follower: {0}"], name),
NotificationType.Favourite => string.Format(_localizer["{0} favorited your post"], name),
NotificationType.Poll => string.Format(_localizer["A poll by {0} has ended"], name),
NotificationType.Update => string.Format(_localizer["{0} edited a post"], name),
NotificationType.Quote => string.Format(_localizer["{0} quoted your post"], name),
NotificationType.Reaction => string.Format(_localizer["{0} reacted to your post with {1}"], name, notification.Emoji),
NotificationType.ModerationWarning => _localizer["You have received a moderation warning"],
_ => name
};
}
// Mastodon's: the post's warning, else its words, else the account's bio, without tags, 140 characters at most
static string Body(Api.Mastodon.Entities.Notification mapped)
{
var source = !string.IsNullOrWhiteSpace(mapped.Status?.SpoilerText) ? mapped.Status.SpoilerText
: mapped.Status?.Content ?? mapped.ModerationWarning?.Text ?? mapped.Account?.Note ?? string.Empty;
var text = WebUtility.HtmlDecode(Tags.Replace(Breaks.Replace(source, "\n"), string.Empty)).Trim();
return text.Length <= BodyLength ? text : text[..(BodyLength - 3)] + "...";
}
static readonly Regex Breaks = new(@"<br\s*/?>|</p>\s*<p[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex Tags = new("<[^>]*>", RegexOptions.Compiled);
}
}
@@ -57,7 +57,21 @@ namespace PrivaPub.Middleware
.ConfigurePrimaryHttpMessageHandler(provider =>
SafeHttpHandlerFactory.Create(provider.GetRequiredService<IOptions<FederationOptions>>().Value, provider.GetRequiredService<IConnectedAddresses>()))
.AddHttpMessageHandler(provider => new EdgeHintsHandler(provider.GetRequiredService<IEdgeHints>()));
// not federation: Web Push (R9), to the push services clients subscribed with. The same safe handler (public
// addresses only, the pasture's exceptions where Federation says so, no redirects), https only (WebPushJob),
// small timeouts, and no statistics of where it connects
service.AddHttpClient(Infrastructure.Push.WebPushJob.ClientName, (provider, client) =>
{
var baseAddress = provider.GetRequiredService<IOptionsMonitor<AppConfiguration>>().CurrentValue.BackendBaseAddress?.TrimEnd('/');
client.Timeout = Infrastructure.Push.WebPushJob.RequestTimeout;
client.DefaultRequestHeaders.UserAgent.ParseAdd($"PrivaPub/{BuildInfo.Ref} (+{baseAddress}/)");
})
.ConfigurePrimaryHttpMessageHandler(provider =>
SafeHttpHandlerFactory.Create(provider.GetRequiredService<IOptions<FederationOptions>>().Value, connectTimeout: Infrastructure.Push.WebPushJob.ConnectTimeout));
return service
.AddSingleton<Infrastructure.Push.IVapid, Infrastructure.Push.Vapid>()
.AddSingleton<IJobHandler, Infrastructure.Push.WebPushJob>()
.AddHostedService<Domain.Social.WebPushDispatcher>()
.AddSingleton<IConnectedAddresses, ConnectedAddresses>()
.AddSingleton<IEdgeHints, EdgeHints>()
.AddSingleton<IFederationHttp, FederationHttp>()
+2 -1
View File
@@ -40,7 +40,8 @@ namespace PrivaPub.Models.Jobs
ExportArchive,
ImportArchive,
SyncFeeds,
SendEmailVerification
SendEmailVerification,
SendPush//a Web Push notification to one subscription (R9, WebPushJob)
}
public enum JobState
+18
View File
@@ -0,0 +1,18 @@
using MongoDB.Entities;
namespace PrivaPub.Models
{
// The server's VAPID key pair (RFC 8292, completion program R9): one P-256 key, made the first time something needs it
// (Infrastructure/Push/Vapid) and kept here, one document with the fixed id `vapid`. Every push carries a JWT it signs,
// and the push services tie each subscription to its public key, which the instance API and app registrations
// advertise. Never in a backup and never dropped by a restore (ServerBackup.Excluded): a restored key would break every
// subscription a client made since.
public class PushKeys : Entity
{
public const string Name = "vapid";
public string PublicKey { get; set; }//the uncompressed point (0x04 || X || Y), base64url without padding
public string PrivateKey { get; set; }//the private scalar D, base64url without padding
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
@@ -10,6 +10,7 @@ namespace PrivaPub.Models.Social
public string ConversationId { get; set; }
public string LastReadPostId { get; set; }
public string HiddenUpTo { get; set; }
public bool MarkedUnread { get; set; }//Mastodon's POST /api/v1/conversations/:id/unread, until it is read again (R9)
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
}
@@ -0,0 +1,34 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
// A Web Push subscription (completion program R9). One per Mastodon access token, as on Mastodon (a new one made with the
// same token replaces it), and so one persona's, the token's subject: never its login's, so nothing pairs sibling
// personas. It holds the token it was made with, which the encrypted payload carries back to the client as Mastodon's
// does (`access_token`), so it goes the moment the token does: revoked (/oauth/revoke, its login's sessions ended), its
// persona suspended, moved or deleted, a restore. Never in a backup (ServerBackup.Excluded).
public class PushSubscription : Entity
{
public string AvatarId { get; set; }
public string TokenId { get; set; }//the OpenIddict token's id; unique
public string AccessToken { get; set; }//the bearer token as the client sent it when it subscribed
public string Endpoint { get; set; }//the push service's URL for this client
public string P256dh { get; set; }//the client's ECDH public key: an uncompressed P-256 point, base64url as it sent it
public string Auth { get; set; }//its 16-byte authentication secret, base64url as it sent it
public bool Standard { get; set; }//RFC 8291 aes128gcm and RFC 8292 `vapid` when true (Mastodon 4.4), else the aesgcm draft
public List<string> Alerts { get; set; } = new();//the alerts turned on, by Mastodon's names (WebPush.Alerts)
public PushPolicy Policy { get; set; } = PushPolicy.All;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
// whose notifications are pushed (Mastodon's data[policy]): everyone's, the accounts the persona follows, the accounts
// that follow it, nobody's
public enum PushPolicy
{
All,
Followed,
Follower,
None
}
}
+1
View File
@@ -95,6 +95,7 @@ namespace PrivaPub.Services
// nothing acts as it while it goes
await _tokens.RevokeBySubjectAsync(avatar.ID, token);
await _authorizations.RevokeBySubjectAsync(avatar.ID, token);
await Domain.Social.WebPush.Forget(new[] { avatar.ID });//its Web Push subscriptions with its tokens (R9)
Streams.CloseFor(avatar.ID);
foreach (var group in await _dbEntities.Groups.Match(g => g.OwnerAvatarId == avatar.ID && !g.DeletionAt.HasValue).ExecuteAsync(token))
+5 -2
View File
@@ -15,7 +15,7 @@ namespace PrivaPub.Services
}
// Ends everything a root is signed in with: its /clientapi tokens, through a new SessionStamp (checked by JwtEvents),
// and the Mastodon API tokens and authorizations of each of its personas. Used when its password is changed or
// and the Mastodon API tokens and authorizations of each of its personas, with their Web Push subscriptions. Used when its password is changed or
// recovered, when it signs out everywhere and when it is deleted. End signs out one session only: the device that
// asked (its Mastodon tokens it revokes itself).
public class RootSessions : IRootSessions
@@ -40,11 +40,14 @@ namespace PrivaPub.Services
.Modify(u => u.SessionStamp, Guid.NewGuid().ToString("N"))
.Modify(u => u.EndedSessions, new List<EndedSession>())//the new stamp ends those too
.ExecuteAsync(token);
foreach (var link in await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId).ExecuteAsync(token))
var links = await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId).ExecuteAsync(token);
foreach (var link in links)
{
await _tokens.RevokeBySubjectAsync(link.AvatarId, token);
await _authorizations.RevokeBySubjectAsync(link.AvatarId, token);
}
// their Web Push subscriptions go with their tokens (R9)
await Domain.Social.WebPush.Forget(links.Select(l => l.AvatarId).ToList());
}
// remembered as long as one of its tokens could still be valid; the ones that have expired are forgotten meanwhile
+70
View File
@@ -0,0 +1,70 @@
# The Mastodon client API: what PrivaPub answers
The walk behind the version PrivaPub advertises (completion program R9, 2026-10-18). Every client route of Mastodon 4.3.0,
4.4.0 and 4.5.0, as their `config/routes/api.rb` and `config/routes.rb` define them, set against PrivaPub's controllers
(`PrivaPub/Api/Mastodon/Controllers`). Since nothing a client relies on is missing, both instance APIs say
`4.5.0 (compatible; PrivaPub <release>)` (`InstanceController.Version`; Mastodon invariant 6 in CLAUDE.md), and
`api_versions.mastodon` is 7, 4.5's own.
- **Served:** the route does what Mastodon's does, within PrivaPub's rules (a token is one persona, per-persona state,
located posts outside the Mastodon API).
- **Stub:** the route answers as Mastodon answers when the feature is off or empty here (an empty list, `{}`, 403, 404 or
422 with Mastodon's message), so clients degrade instead of breaking (Mastodon invariant 5). Each is a decision, named.
- **Missing:** no route. None of these is the client API.
When a route is added to Mastodon, add it here, and keep the advertised version honest.
## Served
| Area | Routes |
|---|---|
| Statuses | `GET /api/v1/statuses` (`id[]`), `POST`, `GET`/`PUT`/`DELETE /api/v1/statuses/:id`, `context`, `history`, `source`, `reblogged_by`, `favourited_by`, `reblog`/`unreblog`, `favourite`/`unfavourite`, `bookmark`/`unbookmark`, `mute`/`unmute`, `pin`/`unpin`; 4.5's `GET …/quotes`, `POST …/quotes/:id/revoke` and `PUT …/interaction_policy` |
| Timelines | `home`, `public`, `tag/:hashtag`, `list/:id` |
| Streaming | `/api/v1/streaming` (WebSocket) and `/api/v1/streaming/*` (server-sent events), `health`: `user`, `user:notification`, `public`, `public:local`, `public:remote`, `hashtag`, `hashtag:local`, `list`, `direct` |
| Accounts | `verify_credentials` (4.4: the `profile` scope too), `update_credentials`, `search`, `lookup`, `relationships`, `familiar_followers` (empty for every account by design: R8), `GET /api/v1/accounts` (`id[]`), `:id`, `:id/statuses`, `followers`, `following`, `lists`, `featured_tags`, `endorsements` (4.4; to the persona itself), `follow`, `unfollow`, `remove_from_followers`, `block`, `unblock`, `mute`, `unmute`, `pin`/`unpin` and 4.4's `endorse`/`unendorse`, `note` |
| Relationships | `blocks`, `mutes`, `domain_blocks` (`GET`, `POST`, `DELETE`), `domain_blocks/preview` (R9: how many follows and followers the block would take), `follow_requests` with `authorize` and `reject`, `endorsements` |
| Notifications | v1 `notifications` (index, `:id`, `clear`, `unread_count`, `:id/dismiss`), `notifications/requests` (index, `:id`, `accept`, `dismiss`, the bulk `accept` and `dismiss`, `merged`), `notifications/policy` (`GET`, `PUT`, `PATCH`); v2 grouped `notifications` (index, `:group_key`, `:group_key/accounts`, `:group_key/dismiss`, `unread_count`, `clear` since R9), `notifications/policy` (`GET`, `PATCH`, `PUT` since R9) |
| Web Push | `POST`, `GET`, `PUT`, `DELETE /api/v1/push/subscription` (R9; CLAUDE.md, Web Push) |
| Conversations | `conversations` (index), `DELETE :id`, `:id/read`, `:id/unread` (R9) |
| Media and polls | `POST /api/v1/media`, `/api/v2/media`, `GET`/`PUT`/`DELETE /api/v1/media/:id`; `polls/:id`, `polls/:id/votes` |
| Lists, filters, tags | `lists` (CRUD, `:id/accounts` `GET`/`POST`/`DELETE`); v1 `filters` and v2 `filters` with `keywords` and `statuses` (both nested and by id); `tags/:name` with `follow`, `unfollow` and 4.4's `feature`, `unfeature`; `followed_tags`; `featured_tags` (index, create, destroy) and `featured_tags/suggestions` |
| Discovery | `/api/v2/search`, `trends` and `trends/tags`, `trends/statuses`, `trends/links`, `directory`, `suggestions` (v1 index and destroy, v2 index) |
| The persona's own | `bookmarks`, `favourites`, `markers` (`GET`, `POST`), `preferences`, `scheduled_statuses` (index, show, update, destroy), `profile/avatar` and `profile/header` (`DELETE`), `reports` |
| The server | v1 and v2 `instance`, `instance/peers` (empty by design: which servers this one talks to is not published), `rules`, `domain_blocks` (the published ones), `extended_description`, `privacy_policy`, 4.4's `terms_of_service` and `terms_of_service/:date`, `languages`, `translation_languages` (none: translation is cut), `activity` (empty unless the administrator publishes it), `custom_emojis`, `announcements` with `dismiss` and `reactions` |
| Apps and OAuth | `POST /api/v1/apps`, `apps/verify_credentials`, `/oauth/authorize`, `/oauth/token`, `/oauth/revoke`, `/.well-known/oauth-authorization-server`, 4.4's `/oauth/userinfo` (`GET` and `POST`, the `profile` scope; R9) |
## Stubs
| Route | Answer | Why |
|---|---|---|
| `POST /api/v1/statuses/:id/translate` | 422 `Translation is not available on this server` | translation is cut (owner decision 2026-10-17) |
| `GET /api/v1/timelines/link` | `[]` | 4.3's timeline of a trending link: not built (trending links themselves are served) |
| `GET /api/v1/annual_reports`, 4.4's `GET /api/v1/annual_reports/:year` | `{"annual_reports": [], "accounts": [], "statuses": []}` | no yearly reports are made |
| 4.4's `POST /api/v1/annual_reports/:year/read` | `{}` | there is none to read |
| `POST /api/v1/accounts` | 403 `This action is not allowed` (Mastodon's answer with registrations closed) | registration through the API is cut: logins are made by setup or invitation on `/clientapi` |
| `POST /api/v1/emails/confirmations` | 403 `This method is only available while the e-mail is awaiting confirmation` | no account here waits for a confirmation |
| `GET /api/v1/emails/check_confirmation` | `true` | the same |
| `GET /api/v1/peers/search` | `[]` | peers are not published, as `instance/peers` |
| 4.4's `GET /api/v1_alpha/async_refreshes/:id` | 404 `Record not found` | PrivaPub never sends `Mastodon-Async-Refresh`: a thread's replies are read in the background and show up |
| `GET /api/v1/accounts/:id/identity_proofs` | `[]` | gone from Mastodon long ago; still asked by old clients |
| every verb under `/api/v1/admin/**`, `GET`/`POST` under `/api/v2/admin/**` | 403 `This action is not allowed`, signed in or not | the Mastodon admin API is cut: moderation is on `/clientapi` |
## Missing, and not the client API
| Route | Why it is not served |
|---|---|
| `GET /api/oembed` | embedding a post in another website; PrivaPub's public pages have no embed view |
| `/api/web/*` (`settings`, `embeds`, `push_subscriptions`) | Mastodon's own web interface, signed in by its session cookie; PrivaPub's client is decePub on `/clientapi` |
| `/oauth/authorized_applications` | Doorkeeper's HTML page of a user's applications, in Mastodon's web interface |
| `/invite/:code` (JSON) | Mastodon's app shows an invitation before registering through the API, which is cut; PrivaPub's invitations are `/clientapi`'s |
| `/.well-known/host-meta` | OStatus-era discovery; every client and server here uses WebFinger directly |
| `POST /api/v1/polls` | in 4.3's route list with no action behind it; gone in 4.4 |
Beyond routes: the `public:media` streams (and their `:local` and `:remote` forms) are not offered; the media-only public
timelines (`only_media`) are, so a client polls them.
## PrivaPub's own, on the same API
`/api/v1/pleroma/statuses/:id/reactions` (Pleroma's emoji reactions), `/api/v1/statuses/:id/downvote` and `undownvote`,
`/api/v1/feeds` and `/api/v1/timelines/feed/:id` (PieFed's and Lemmy's feeds), and `/api/privapub/v1/*` (provenance,
servers, CDNs, events' join and leave): Mastodon invariant 7.
+22 -2
View File
@@ -154,6 +154,24 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
settings form; a `Move` carries the persona's endorsement and note to the new account; featured tags, notes and
endorsements are left out of the persona archive (Mastodon's layout has none), its `following_accounts.csv` now
carries notify and languages.
- R9 Web Push, then the honest version (built 2026-10-18, not yet deployed; `tools/pasture/scenarios/push.sh` with the
pasture's own push service, `tools/pasture/pushsink`): the server's VAPID key (`PushKeys`, made on first need, in
Mongo, never in a backup), Mastodon's `/api/v1/push/subscription` (create, show, update, delete; one subscription
per access token, so per persona), every notification kept (never a filtered one) pushed to the subscriptions whose
alert and policy admit it, as `SendPush` jobs through the `push` client, encrypted as RFC 8291 `aes128gcm` or the
`aesgcm` draft, signed with an ES256 VAPID JWT; subscriptions end with their tokens (revocation, sessions ended,
suspension, deletion, a restore). Then the walk of every Mastodon 4.3, 4.4 and 4.5 client route
(`docs/MASTODON-API.md`), with what it found missing built (`conversations/:id/unread`, `domain_blocks/preview`, v2
`notifications/clear` and `PUT` policy, `/oauth/userinfo` and the `profile` scope on `verify_credentials`) or answered
as stubs (annual reports by year, API registration 403, e-mail confirmation, peer search, async refreshes), and both
instance APIs advertising `4.5.0 (compatible; PrivaPub <release>)`. Chosen while building it, for the owner to
confirm: the payload carries the subscription's own access token, as Mastodon's does (encrypted to that client
alone); what a persona tells itself (its poll ended, the staff's warning) passes the `followed` and `follower` policies
(Mastodon would hold it back); a push service's 4xx other than 404, 410, 408 and 429 drops that push and keeps the
subscription (Mastodon deletes it); the JWT lasts twelve hours (Mastodon's a day); titles are Mastodon's English
subjects and `preferred_locale` is the persona's posting language (no persona has an interface language); six
attempts on the jobs' backoff (Mastodon retries five times); `alerts` answers every alert true or false (Mastodon
echoes those sent); the key lives in Mongo, not in the configuration; `/oauth/userinfo` is served (Mastodon 4.4's).
## Intent
@@ -600,7 +618,8 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol
- `Link` paging on `max_id`, `since_id` and `min_id`; CORS exposes `Link`.
- `Idempotency-Key` honoured on posting statuses.
- **Endpoints:**
- **Instance:** v1 and v2, advertising version `4.2.0 (compatible; PrivaPub)`.
- **Instance:** v1 and v2, advertising version `4.2.0 (compatible; PrivaPub)`. *`4.5.0 (compatible; PrivaPub
<release>)` since R9 (2026-10-18), after the walk in `docs/MASTODON-API.md`.*
- **Accounts:** `verify_credentials`, `update_credentials`, `:id`, `:id/statuses`, `lookup`, `relationships`,
`search`, followers and following, follow and unfollow, `follow_requests`.
- **Statuses:** CRUD, edit, `context`, `history`, `source`, favourite and reblog plus their undos (outbound Like and
@@ -829,7 +848,8 @@ it, raw where it doesn't.
- **Mastodon API:** ~~streaming WebSocket~~ (done 2026-10-05: `/api/v1/streaming` as a WebSocket and as server-sent
events, user, notification, public, hashtag and list streams, each event mapped for its persona; a deletion reaches
only the streams that showed the post), Web Push (gated: outbound traffic to push services; approved 2026-10-17, C R9), grouped
notifications. Then advertise an honest version.
notifications. Then advertise an honest version. *Done in R9 (2026-10-18): Web Push, then `4.5.0 (compatible; PrivaPub
<release>)` once every 4.3, 4.4 and 4.5 client route was served or a named stub (`docs/MASTODON-API.md`).*
- **Backfill:** an author's outbox after following them: **done 2026-10-05** (`OutboxBackfill`, the first page's
public posts, twenty at most, once a day; checked live against Mastodon).
- **Long tail:**
+5
View File
@@ -207,6 +207,11 @@ lemmy19.test {
reverse_proxy pasture-lemmy19:8536
}
pushsink.test {
tls internal
reverse_proxy pasture-pushsink:8080
}
flood1.test, flood2.test, flood3.test, flood4.test, flood5.test, flood6.test, flood7.test, flood8.test, flood9.test, flood10.test, flood11.test, flood12.test, flood13.test, flood14.test, flood15.test, flood16.test, flood17.test, flood18.test, flood19.test, flood20.test {
tls internal
reverse_proxy pasture-flood:8080
+4 -4
View File
@@ -42,14 +42,14 @@ privapub_root() {
echo "$root" | j "print(d['token'])"
}
# privapub_token <persona>: creates the persona under the pasture root if needed and returns a Mastodon token for it,
# through the real OAuth flow (tools/smoke/oauth.sh).
# privapub_token <persona> [scopes]: creates the persona under the pasture root if needed and returns a Mastodon token for
# it, through the real OAuth flow (tools/smoke/oauth.sh); "read write follow" unless other scopes are asked for
privapub_token() {
local persona=$1 jwt
local persona=$1 scopes=${2:-read write follow} jwt
jwt=$(privapub_root)
curl -s -o /dev/null -X POST $P/clientapi/avatar/private/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"userName\":\"$persona\",\"name\":\"$persona of PrivaPub\",\"biography\":\"testing federation\"}"
"$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" "$persona" "read write follow" | cut -d' ' -f1
"$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" "$persona" "$scopes" | cut -d' ' -f1
}
# p_report <authorization header> <account id> <reason> <status id...>: a persona reports an account's posts and asks for
+22
View File
@@ -0,0 +1,22 @@
# pushsink: a Web Push service as pushsink.test, for scenarios/push.sh (tools/pasture/pushsink/pushsink.cs, a .NET
# file-based app published once into .pushsink and run on the runtime image PrivaPub uses). It makes each subscription's
# keys itself, takes PrivaPub's pushes as a push service would (the VAPID JWT checked against the instance API's key, the
# aes128gcm or aesgcm body decrypted) and keeps what it took for the scenario to read: https://pushsink.test:6443/
# subscriptions/<id>/received. It can be told to answer the next push otherwise (/subscriptions/<id>/answer, a 410).
# PrivaPub reaches it through Caddy as it reaches every peer: its push client honours appsettings.Pasture.json's
# Federation exceptions (AcceptAnyCertificate for Caddy's CA, AllowPrivateNetworks), which production refuses at start.
pushsink_up() {
local dotnet=${DOTNET:-$(command -v dotnet || echo ~/.dotnet/dotnet)}
if [ ! -x "$here/.pushsink/pushsink" ] || [ "$here/pushsink/pushsink.cs" -nt "$here/.pushsink/pushsink" ]; then
"$dotnet" publish "$here/pushsink/pushsink.cs" -r linux-x64 --self-contained true -o "$here/.pushsink" -v quiet
fi
podman run -d --replace --name pasture-pushsink --network $net --label pasture=1 -w /app -v "$here/.pushsink:/app:Z,ro" \
-v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" -e SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
-e PUSHSINK_BASE=https://pushsink.test -e PUSHSINK_INSTANCE=https://privapub.test \
mcr.microsoft.com/dotnet/runtime-deps:10.0 /app/pushsink >/dev/null
for _ in $(seq 1 60); do
site pushsink.test -s -o /dev/null -w '%{http_code}' https://pushsink.test:6443/health 2>/dev/null | grep -q 200 && break
sleep 1
done
echo "pushsink: https://pushsink.test:6443"
}
+465
View File
@@ -0,0 +1,465 @@
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
#:property InvariantGlobalization=true
#:property Nullable=disable
// pushsink: a Web Push service for the pasture (tools/pasture/scenarios/push.sh), behind Caddy as pushsink.test, which
// takes PrivaPub's pushes as a browser's push service would and keeps what it took for the scenario to read. It makes the
// keys of each subscription itself, so it can read what PrivaPub encrypted to them, and it checks what a push service
// checks: the VAPID JWT (ES256 by the server's key, the push service's origin as aud, exp in the future and at most a day
// ahead, a mailto: or https: sub) and the encryption (RFC 8291 aes128gcm, or the aesgcm draft with Encryption and
// Crypto-Key). Its own code, not PrivaPub's, so a round trip proves the two agree.
// POST /subscriptions {"server_key": "<the instance's vapid.public_key>"} (optional; without it the
// key is read from $PUSHSINK_INSTANCE/api/v2/instance): a new subscription, its
// keys made here: {"id", "endpoint", "keys": {"p256dh", "auth"}}
// GET /subscriptions/{id} the subscription, with what it answers next
// POST /subscriptions/{id}/answer {"status": 410, "times": 1}: the next push(es) are answered so, after being read
// GET /subscriptions/{id}/received every push it took or refused, oldest first: when, the status it answered,
// whether every check passed (ok) and the errors, the headers, the JWT's claims,
// the decrypted payload (parsed JSON)
// DELETE /subscriptions/{id}/received forgets them
// POST /push/{id} the push service: 201 when every check passes, 403 for the VAPID's failures, 400
// for the rest, 404 for no such subscription, or the status asked for
// GET /health
// Everything is in memory: a new container starts empty.
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
var publicBase = (Environment.GetEnvironmentVariable("PUSHSINK_BASE") ?? "https://pushsink.test").TrimEnd('/');
var instance = (Environment.GetEnvironmentVariable("PUSHSINK_INSTANCE") ?? "https://privapub.test").TrimEnd('/');
var listen = Environment.GetEnvironmentVariable("PUSHSINK_LISTEN") ?? "http://0.0.0.0:8080";
var subscriptions = new ConcurrentDictionary<string, Subscription>();
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var builder = WebApplication.CreateBuilder();
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.WebHost.UseUrls(listen);
builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = 64 * 1024);
var app = builder.Build();
app.MapGet("/health", () => Results.Json(new { ok = true, subscriptions = subscriptions.Count }));
app.MapPost("/subscriptions", async (HttpContext context) =>
{
var request = context.Request.ContentLength > 0 ? await JsonNode.ParseAsync(context.Request.Body) : null;
var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
var subscription = new Subscription(Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(12)), key, RandomNumberGenerator.GetBytes(16),
request?["server_key"]?.GetValue<string>());
subscriptions[subscription.Id] = subscription;
return Results.Json(subscription.View(publicBase));
});
app.MapGet("/subscriptions/{id}", (string id) =>
subscriptions.TryGetValue(id, out var subscription) ? Results.Json(subscription.View(publicBase)) : Results.NotFound());
app.MapPost("/subscriptions/{id}/answer", async (HttpContext context, string id) =>
{
if (!subscriptions.TryGetValue(id, out var subscription))
return Results.NotFound();
var request = await JsonNode.ParseAsync(context.Request.Body);
var status = request?["status"]?.GetValue<int>() ?? 0;
if (status is < 200 or > 599)
return Results.BadRequest(new { error = "status: 200 to 599" });
subscription.Answer(status, request?["times"]?.GetValue<int>() ?? 1);
return Results.Json(subscription.View(publicBase));
});
app.MapGet("/subscriptions/{id}/received", (string id) =>
subscriptions.TryGetValue(id, out var subscription) ? Results.Text(subscription.Received().ToJsonString(), "application/json") : Results.NotFound());
app.MapDelete("/subscriptions/{id}/received", (string id) =>
{
if (!subscriptions.TryGetValue(id, out var subscription))
return Results.NotFound();
subscription.Forget();
return Results.Json(new { });
});
app.MapPost("/push/{id}", async (HttpContext context, string id) =>
{
if (!subscriptions.TryGetValue(id, out var subscription))
return Results.NotFound();
using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer);
var check = new Check(context.Request, buffer.ToArray(), subscription, publicBase);
var serverKey = subscription.ServerKey ?? await InstanceKey();
check.Run(serverKey);
var forced = subscription.NextAnswer();
var status = forced ?? (check.Errors.Count == 0 ? 201 : check.VapidFailed ? 403 : 400);
subscription.Keep(check.Record(status));
if (status == 201)
context.Response.Headers.Location = $"{publicBase}/messages/{Guid.NewGuid():N}";
return Results.StatusCode(status);
});
app.Run();
return 0;
// the server's key as its instance API gives it (configuration.vapid.public_key), read once a minute at most
async Task<string> InstanceKey()
{
if (InstanceCache.Key is { } known && DateTime.UtcNow - InstanceCache.At < TimeSpan.FromMinutes(1))
return known;
try
{
var document = JsonNode.Parse(await http.GetStringAsync(instance + "/api/v2/instance"));
InstanceCache.Key = document?["configuration"]?["vapid"]?["public_key"]?.GetValue<string>();
InstanceCache.At = DateTime.UtcNow;
}
catch (Exception)
{
InstanceCache.Key = null;
}
return InstanceCache.Key;
}
static class InstanceCache
{
public static string Key;
public static DateTime At;
}
sealed class Subscription(string id, ECDiffieHellman key, byte[] auth, string serverKey)
{
readonly Lock _gate = new();
readonly List<JsonObject> _received = new();
int _answer;
int _answerTimes;
public string Id => id;
public ECDiffieHellman Key => key;
public byte[] Auth => auth;
public string ServerKey => serverKey;
public byte[] PublicPoint
{
get
{
var parameters = key.ExportParameters(false);
return [0x04, .. parameters.Q.X, .. parameters.Q.Y];
}
}
public JsonObject View(string publicBase)
{
lock (_gate)
return new JsonObject
{
["id"] = id,
["endpoint"] = $"{publicBase}/push/{id}",
["keys"] = new JsonObject { ["p256dh"] = B64.Url(PublicPoint), ["auth"] = B64.Url(auth) },
["server_key"] = serverKey,
["next_answer"] = _answerTimes > 0 ? _answer : null,
["next_answer_times"] = _answerTimes,
["received"] = _received.Count
};
}
public void Answer(int status, int times)
{
lock (_gate)
{
_answer = status;
_answerTimes = Math.Max(1, times);
}
}
public int? NextAnswer()
{
lock (_gate)
{
if (_answerTimes <= 0)
return null;
_answerTimes--;
return _answer;
}
}
public void Keep(JsonObject record)
{
lock (_gate)
_received.Add(record);
}
public JsonArray Received()
{
lock (_gate)
return new JsonArray(_received.Select(r => (JsonNode)r.DeepClone()).ToArray());
}
public void Forget()
{
lock (_gate)
_received.Clear();
}
}
// what a push service checks of one push, and what the sink keeps of it
sealed class Check(HttpRequest request, byte[] body, Subscription subscription, string publicBase)
{
public List<string> Errors { get; } = new();
public bool VapidFailed { get; private set; }
JsonObject _claims;
string _scheme;
string _k;
JsonNode _payload;
string _encoding;
public void Run(string expectedServerKey)
{
_encoding = request.Headers.ContentEncoding.ToString().Trim().ToLowerInvariant();
if (_encoding is not ("aes128gcm" or "aesgcm"))
Errors.Add($"Content-Encoding is '{_encoding}', neither aes128gcm nor aesgcm");
if (!int.TryParse(request.Headers["TTL"].ToString(), out var ttl) || ttl < 0)
Errors.Add("no TTL header (RFC 8030 5.2)");
Vapid(expectedServerKey);
try
{
var plaintext = _encoding switch
{
"aes128gcm" => Decrypt.Aes128Gcm(body, subscription.Key, subscription.PublicPoint, subscription.Auth),
"aesgcm" => Decrypt.AesGcm(body, Param(request.Headers["Encryption"].ToString(), "salt"), Param(request.Headers["Crypto-Key"].ToString(), "dh"),
subscription.Key, subscription.PublicPoint, subscription.Auth),
_ => null
};
if (plaintext != null)
_payload = JsonNode.Parse(plaintext);
}
catch (Exception ex) when (ex is CryptographicException or FormatException or JsonException or ArgumentException or IndexOutOfRangeException)
{
Errors.Add("the body could not be decrypted: " + ex.Message);
}
}
// RFC 8292 3: Authorization: vapid t=<jwt>, k=<key>; the draft's: Authorization: WebPush <jwt> and Crypto-Key's p256ecdsa
void Vapid(string expectedServerKey)
{
var authorization = request.Headers.Authorization.ToString().Trim();
var space = authorization.IndexOf(' ');
_scheme = space > 0 ? authorization[..space] : authorization;
var rest = space > 0 ? authorization[(space + 1)..].Trim() : string.Empty;
string jwt;
if (_scheme.Equals("vapid", StringComparison.OrdinalIgnoreCase))
{
jwt = Param(rest, "t");
_k = Param(rest, "k");
}
else if (_scheme.Equals("WebPush", StringComparison.OrdinalIgnoreCase))
{
jwt = rest;
_k = Param(request.Headers["Crypto-Key"].ToString(), "p256ecdsa");
}
else
{
Fail($"Authorization's scheme is '{_scheme}', neither vapid nor WebPush");
return;
}
if (_encoding == "aes128gcm" && !_scheme.Equals("vapid", StringComparison.OrdinalIgnoreCase))
Fail("an aes128gcm push carries RFC 8292's vapid authorization");
var key = B64.Decode(_k);
if (key is not { Length: 65 } || key[0] != 0x04)
{
Fail("the VAPID key (k or p256ecdsa) is no uncompressed P-256 point");
return;
}
if (string.IsNullOrEmpty(expectedServerKey))
Fail("the server's key is unknown (no server_key given, and the instance API did not say)");
else if (!B64.Decode(expectedServerKey).AsSpan().SequenceEqual(key))
Fail("the VAPID key is not the server's (the instance API's vapid.public_key)");
var parts = jwt?.Split('.') ?? [];
if (parts.Length != 3)
{
Fail("the VAPID token is no JWT");
return;
}
JsonNode header;
try
{
header = JsonNode.Parse(B64.Decode(parts[0]));
_claims = JsonNode.Parse(B64.Decode(parts[1])) as JsonObject;
}
catch (Exception)
{
Fail("the JWT's header or claims are not JSON");
return;
}
if (header?["alg"]?.GetValue<string>() != "ES256")
Fail($"the JWT's alg is '{header?["alg"]}', not ES256");
using (var verifier = ECDsa.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = key[1..33], Y = key[33..] } }))
{
var signature = B64.Decode(parts[2]);
if (signature is not { Length: 64 } || !verifier.VerifyData(Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]), signature, HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation))
Fail("the JWT's ES256 signature does not verify with the VAPID key");
}
var audience = new Uri($"{publicBase}/push/{subscription.Id}").GetLeftPart(UriPartial.Authority);
if (_claims?["aud"]?.GetValue<string>() != audience)
Fail($"the JWT's aud is '{_claims?["aud"]}', not the push service's origin {audience}");
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var exp = _claims?["exp"]?.GetValue<long>() ?? 0;
if (exp <= now)
Fail("the JWT has expired (exp)");
else if (exp > now + 24 * 3600 + 60)
Fail("the JWT's exp is more than 24 hours ahead (RFC 8292 2)");
var sub = _claims?["sub"]?.GetValue<string>() ?? string.Empty;
if (!sub.StartsWith("mailto:", StringComparison.Ordinal) && !sub.StartsWith("https:", StringComparison.Ordinal))
Fail($"the JWT's sub is '{sub}', neither mailto: nor https:");
}
void Fail(string error)
{
VapidFailed = true;
Errors.Add(error);
}
// a parameter of a header written a=b, c=d or a=b;c=d (Crypto-Key may hold several)
static string Param(string header, string name)
{
foreach (var part in (header ?? string.Empty).Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var equals = part.IndexOf('=');
if (equals > 0 && part[..equals].Trim().Equals(name, StringComparison.OrdinalIgnoreCase))
return part[(equals + 1)..].Trim().Trim('"');
}
return null;
}
public JsonObject Record(int status) => new()
{
["at"] = DateTime.UtcNow.ToString("O"),
["status"] = status,
["ok"] = Errors.Count == 0,
["errors"] = new JsonArray(Errors.Select(e => (JsonNode)e).ToArray()),
["encoding"] = _encoding,
["headers"] = new JsonObject
{
["ttl"] = request.Headers["TTL"].ToString(),
["urgency"] = request.Headers["Urgency"].ToString(),
["content_type"] = request.ContentType,
["content_encoding"] = request.Headers.ContentEncoding.ToString(),
["authorization_scheme"] = _scheme,
["encryption"] = request.Headers["Encryption"].ToString(),
["crypto_key"] = request.Headers["Crypto-Key"].ToString(),
["user_agent"] = request.Headers.UserAgent.ToString()
},
["vapid"] = new JsonObject
{
["k"] = _k,
["aud"] = _claims?["aud"]?.DeepClone(),
["exp"] = _claims?["exp"]?.DeepClone(),
["sub"] = _claims?["sub"]?.DeepClone()
},
["bytes"] = body.Length,
["payload"] = _payload?.DeepClone()
};
}
// the client's side of RFC 8291 and of the aesgcm draft
static class Decrypt
{
public static byte[] Aes128Gcm(byte[] body, ECDiffieHellman client, byte[] clientPublic, byte[] auth)
{
// RFC 8188 2.1: salt (16), record size (4), key id length (1), key id: the sender's public key
var salt = body[..16];
var recordSize = (int)BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(16, 4));
var idLength = body[20];
var serverPublic = body[21..(21 + idLength)];
if (recordSize < 18)
throw new CryptographicException($"record size {recordSize} is below 18");
var shared = Agree(client, serverPublic);
var keyInfo = Concat(Encoding.ASCII.GetBytes("WebPush: info\0"), clientPublic, serverPublic);
var ikm = HKDF.Expand(HashAlgorithmName.SHA256, HKDF.Extract(HashAlgorithmName.SHA256, shared, auth), 32, keyInfo);
var prk = HKDF.Extract(HashAlgorithmName.SHA256, ikm, salt);
var cek = HKDF.Expand(HashAlgorithmName.SHA256, prk, 16, Encoding.ASCII.GetBytes("Content-Encoding: aes128gcm\0"));
var nonce = HKDF.Expand(HashAlgorithmName.SHA256, prk, 12, Encoding.ASCII.GetBytes("Content-Encoding: nonce\0"));
var content = body[(21 + idLength)..];
var plaintext = new List<byte>();
var records = (content.Length + recordSize - 1) / recordSize;
for (var i = 0; i < records; i++)
{
var record = content[(i * recordSize)..Math.Min(content.Length, (i + 1) * recordSize)];
var recordNonce = (byte[])nonce.Clone();
for (var b = 0; b < 8; b++)//the record's sequence number, XORed into the nonce's last bytes
recordNonce[11 - b] ^= (byte)((long)i >> (8 * b));
var opened = Open(cek, recordNonce, record);
var end = opened.Length - 1;
while (end >= 0 && opened[end] == 0)
end--;
var last = i == records - 1;
if (end < 0 || opened[end] != (last ? 0x02 : 0x01))
throw new CryptographicException("a record's delimiter is wrong");
plaintext.AddRange(opened[..end]);
}
return plaintext.ToArray();
}
public static byte[] AesGcm(byte[] body, string salt, string serverKey, ECDiffieHellman client, byte[] clientPublic, byte[] auth)
{
var saltBytes = B64.Decode(salt) ?? throw new FormatException("no salt in the Encryption header");
var serverPublic = B64.Decode(serverKey) ?? throw new FormatException("no dh in the Crypto-Key header");
var shared = Agree(client, serverPublic);
var ikm = HKDF.Expand(HashAlgorithmName.SHA256, HKDF.Extract(HashAlgorithmName.SHA256, shared, auth), 32, Encoding.ASCII.GetBytes("Content-Encoding: auth\0"));
var context = Concat(Encoding.ASCII.GetBytes("P-256\0"), Length16(clientPublic), clientPublic, Length16(serverPublic), serverPublic);
var prk = HKDF.Extract(HashAlgorithmName.SHA256, ikm, saltBytes);
var cek = HKDF.Expand(HashAlgorithmName.SHA256, prk, 16, Concat(Encoding.ASCII.GetBytes("Content-Encoding: aesgcm\0"), context));
var nonce = HKDF.Expand(HashAlgorithmName.SHA256, prk, 12, Concat(Encoding.ASCII.GetBytes("Content-Encoding: nonce\0"), context));
var opened = Open(cek, nonce, body);
var padding = BinaryPrimitives.ReadUInt16BigEndian(opened);
return opened[(2 + padding)..];
}
static byte[] Agree(ECDiffieHellman client, byte[] serverPublic)
{
if (serverPublic is not { Length: 65 } || serverPublic[0] != 0x04)
throw new CryptographicException("the sender's key is no uncompressed P-256 point");
using var server = ECDiffieHellman.Create(new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint { X = serverPublic[1..33], Y = serverPublic[33..] }
});
return client.DeriveRawSecretAgreement(server.PublicKey);
}
static byte[] Open(byte[] key, byte[] nonce, byte[] sealedRecord)
{
if (sealedRecord.Length < 16)
throw new CryptographicException("a record shorter than its tag");
var plaintext = new byte[sealedRecord.Length - 16];
using var aes = new AesGcm(key, 16);
aes.Decrypt(nonce, sealedRecord.AsSpan(0, plaintext.Length), sealedRecord.AsSpan(plaintext.Length), plaintext);
return plaintext;
}
static byte[] Length16(byte[] value)
{
var length = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(length, (ushort)value.Length);
return length;
}
static byte[] Concat(params byte[][] parts) => parts.SelectMany(p => p).ToArray();
}
static class B64
{
public static string Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
public static byte[] Decode(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var normal = text.Trim().TrimEnd('=').Replace('-', '+').Replace('_', '/');
normal = normal.PadRight(normal.Length + (4 - normal.Length % 4) % 4, '=');
try
{
return Convert.FromBase64String(normal);
}
catch (FormatException)
{
return null;
}
}
}
+168
View File
@@ -0,0 +1,168 @@
# Web Push (completion program R9), with Mastodon and the pushsink. push_alice subscribes with keys the sink made for
# her: a mention from mastouser reaches the sink encrypted (aes128gcm), signed by the server's VAPID key for the sink's
# origin, and decrypts to Mastodon's payload with notification_type mention and her own access token. Turning the mention
# alert off stops it while a favourite still comes; policy=followed stops mastouser's mention until she follows him;
# standard=false is the aesgcm draft and decrypts too, and a new subscription replaces the old; push_bob, a sibling persona
# of the same login, never gets hers, nor she his; DELETE stops the pushes; the sink answering 410 removes the
# subscription; a revoked token takes its subscription with it. Needs the mastodon and pushsink peers (batch A); run
# after featured.
M=https://mastodon.test:6443
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
PS=https://pushsink.test:6443
scurl() { curl -sk --resolve pushsink.test:6443:127.0.0.1 "$@"; }
. "$here/peers/mastodon.sh"
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
# sink_new: a subscription of the sink's own, its keys made there, told the server's key to check the VAPID JWT with
sink_new() { scurl -X POST "$PS/subscriptions" -H 'Content-Type: application/json' -d "{\"server_key\":\"$server_key\"}"; }
sink_count() { scurl "$PS/subscriptions/$1" | j "print(d['received'])"; }
# sink_last <id> <python over d, the pushes it took, oldest first>
sink_last() { scurl "$PS/subscriptions/$1/received" | j "$2"; }
sink_waits() { local sid=$1 want=$2; until_true 30 '[ "$(sink_count "$sid")" -ge "$want" ]'; }
# subscribe <authorization header> <sink subscription json> <standard> <alert...> [policy=<policy>]: Mastodon's form
subscribe() {
local auth=$1 sink=$2 standard=$3 args=() a
shift 3
for a in "$@"; do
case $a in policy=*) args+=(-d "data[policy]=${a#policy=}") ;; *) args+=(-d "data[alerts][$a]=true") ;; esac
done
curl -s -X POST -H "$auth" "$P/api/v1/push/subscription" \
--data-urlencode "subscription[endpoint]=$(echo "$sink" | j "print(d['endpoint'])")" \
--data-urlencode "subscription[keys][p256dh]=$(echo "$sink" | j "print(d['keys']['p256dh'])")" \
--data-urlencode "subscription[keys][auth]=$(echo "$sink" | j "print(d['keys']['auth'])")" \
-d "subscription[standard]=$standard" "${args[@]}"
}
# m_mention <handle> <words>: mastouser's public post naming a persona here
m_mention() { mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses" --data-urlencode "status=@$1@privapub.test $2" -d visibility=public; }
# notified <authorization header> <words>: the persona holds a mention with these words (it arrived, pushed or not)
notified() { curl -s -H "$1" "$P/api/v1/notifications?types[]=mention&limit=30" | j "print(any('$2' in (n.get('status') or {}).get('content', '') for n in d))"; }
# arrives_unpushed <authorization header> <words> <sink id> <count before>: the mention arrived and the sink got nothing
arrives_unpushed() {
local auth=$1 words=$2 sid=$3 had=$4
until_true 30 '[ "$(notified "$auth" "$words")" = True ]' || return 1
sleep 6
[ "$(sink_count "$sid")" = "$had" ]
}
echo "push"
run=$(date +%s)
AT=$(privapub_token push_alice "read write follow push"); AH="Authorization: Bearer $AT"
BT=$(privapub_token push_bob "read write follow push"); BH="Authorization: Bearer $BT"
MT=$(mastodon_token); MH="Authorization: Bearer $MT"
[ -n "$AT" ] && [ -n "$BT" ] && [ -n "$MT" ] && ok "tokens for push_alice, push_bob (her sibling, the same login) and mastouser" \
|| { ko "a token is missing"; return 1; }
echo " the server's key"
server_key=$(curl -s "$P/api/v2/instance" | j "print(d['configuration']['vapid']['public_key'])")
[ "$(python3 -c "import base64,sys; k=base64.urlsafe_b64decode(sys.argv[1]); print(len(k) == 65 and k[0] == 4)" "$server_key" 2>/dev/null)" = True ] \
&& ok "the instance API gives the VAPID key (an uncompressed P-256 point)" || ko "configuration.vapid: $server_key"
[ "$(curl -s -X POST "$P/api/v1/apps" -d client_name=push-check -d redirect_uris=urn:ietf:wg:oauth:2.0:oob -d scopes=push | j "print(d['vapid_key'])")" = "$server_key" ] \
&& ok "an app's vapid_key is the same key" || ko "the app's vapid_key differs"
[ "$(curl -s "$P/api/v1/instance" | j "print(d['version'].startswith('4.5.0 (compatible; PrivaPub'))")" = True ] \
&& [ "$(curl -s "$P/api/v2/instance" | j "print(d['version'].startswith('4.5.0 (compatible; PrivaPub') and d['api_versions']['mastodon'] == 7)")" = True ] \
&& ok "both instance APIs say 4.5.0 (compatible; PrivaPub ...)" || ko "the version: $(curl -s "$P/api/v2/instance" | j "print(d['version'])")"
echo " a mention, pushed"
m_on_p=$(curl -s -H "$AH" "$P/api/v2/search?q=mastouser@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
# (a run before may have left the follow the policy check needs gone)
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/unfollow"
for persona in push_alice push_bob; do
mcurl -o /dev/null -H "$MH" "$M/api/v2/search?q=@$persona@privapub.test&resolve=true&type=accounts"
done
sa=$(sink_new); sa_id=$(echo "$sa" | j "print(d['id'])")
made=$(subscribe "$AH" "$sa" true mention favourite policy=all)
[ "$(echo "$made" | j "print(d['standard'] is True and d['alerts']['mention'] is True and d['alerts']['reblog'] is False and d['policy'] == 'all' and d['server_key'] == '$server_key' and d['endpoint'].startswith('https://pushsink.test/push/'))")" = True ] \
&& ok "push_alice subscribes with the sink's keys (standard)" || ko "the subscription: $(echo "$made" | head -c 400)"
[ "$(curl -s -H "$AH" "$P/api/v1/push/subscription" | j "print(d['id'])")" = "$(echo "$made" | j "print(d['id'])")" ] \
&& ok "GET answers it" || ko "GET: $(curl -s -H "$AH" "$P/api/v1/push/subscription" | head -c 300)"
[ "$(code -H "$BH" "$P/api/v1/push/subscription")" = 404 ] && ok "push_bob's token has none" || ko "push_bob's token sees a subscription"
m_mention push_alice "first push $run"
sink_waits "$sa_id" 1 \
&& [ "$(sink_last "$sa_id" "r = d[-1]; p = r['payload'] or {}; print(r['ok'] and r['status'] == 201 and r['encoding'] == 'aes128gcm' and p.get('notification_type') == 'mention' and 'first push $run' in p.get('body', ''))")" = True ] \
&& ok "mastouser's mention reaches the sink, aes128gcm, and decrypts to a mention" || ko "the push: $(scurl "$PS/subscriptions/$sa_id/received" | head -c 800)"
[ "$(sink_last "$sa_id" "print(d[-1]['payload']['access_token'] == '$AT')")" = True ] && ok "it carries push_alice's own access token" \
|| ko "the push's access_token is not hers"
[ "$(sink_last "$sa_id" "r = d[-1]; v = r['vapid']; print(v['aud'] == 'https://pushsink.test' and v['sub'].startswith(('mailto:', 'https:')) and 'push_alice' not in v['sub'] and r['headers']['authorization_scheme'] == 'vapid' and int(r['headers']['ttl']) > 0 and r['headers']['urgency'] == 'normal')")" = True ] \
&& ok "signed for the sink's origin by the server, TTL and Urgency set, no persona in the sub" || ko "the VAPID claims: $(sink_last "$sa_id" "print(d[-1]['vapid'], d[-1]['headers'])")"
pushed_id=$(sink_last "$sa_id" "print(d[-1]['payload']['notification_id'])")
[ "$(curl -s -H "$AH" "$P/api/v1/notifications/$pushed_id" | j "print(d['type'] == 'mention' and 'first push $run' in d['status']['content'])")" = True ] \
&& ok "its notification_id is her notification" || ko "notification $pushed_id: $(curl -s -H "$AH" "$P/api/v1/notifications/$pushed_id" | head -c 200)"
[ "$(sink_last "$sa_id" "p = d[-1]['payload']; print(p['icon'].startswith('https://privapub.test/') and 'mastouser' in p['title'] and p['preferred_locale'] != '')")" = True ] \
&& ok "its icon goes through PrivaPub, its title names mastouser" || ko "icon and title: $(sink_last "$sa_id" "print(d[-1]['payload'])")"
echo " alerts and policy"
changed=$(curl -s -X PUT -H "$AH" "$P/api/v1/push/subscription" -d "data[alerts][favourite]=true")
[ "$(echo "$changed" | j "print(d['alerts']['mention'] is False and d['alerts']['favourite'] is True and d['policy'] == 'all')")" = True ] \
&& ok "PUT turns the mention alert off" || ko "PUT: $(echo "$changed" | head -c 300)"
before=$(sink_count "$sa_id")
m_mention push_alice "unwanted $run"
arrives_unpushed "$AH" "unwanted $run" "$sa_id" "$before" && ok "with the alert off, the mention arrives unpushed" || ko "a mention was pushed with its alert off"
alice_post=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" --data-urlencode "status=push_alice posts $run" -d visibility=public | j "print(d['uri'])")
m_post=$(mcurl -H "$MH" "$M/api/v2/search?q=$alice_post&resolve=true&type=statuses" | j "print(d['statuses'][0]['id'])")
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses/$m_post/favourite"
sink_waits "$sa_id" $((before + 1)) && [ "$(sink_last "$sa_id" "print(d[-1]['ok'] and d[-1]['payload']['notification_type'])")" = favourite ] \
&& ok "his favourite of her post is pushed" || ko "no favourite push: $(sink_last "$sa_id" "print(d[-1])")"
curl -s -o /dev/null -X PUT -H "$AH" "$P/api/v1/push/subscription" -d "data[alerts][mention]=true" -d "data[policy]=followed"
before=$(sink_count "$sa_id")
m_mention push_alice "a stranger $run"
arrives_unpushed "$AH" "a stranger $run" "$sa_id" "$before" && ok "policy=followed: mastouser, whom she does not follow, is not pushed" \
|| ko "a non-followed sender was pushed"
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/follow"
until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/accounts/relationships?id[]=$m_on_p" | j "print(d[0][\"following\"])")" = True ]' \
&& ok "push_alice follows mastouser" || ko "her follow was not accepted"
m_mention push_alice "a friend now $run"
sink_waits "$sa_id" $((before + 1)) && [ "$(sink_last "$sa_id" "print('a friend now $run' in d[-1]['payload']['body'])")" = True ] \
&& ok "once followed, his mention is pushed" || ko "a followed sender was not pushed"
echo " the aesgcm draft"
sl=$(sink_new); sl_id=$(echo "$sl" | j "print(d['id'])")
[ "$(subscribe "$AH" "$sl" false mention | j "print(d['standard'])")" = False ] && ok "push_alice subscribes again, standard=false" || ko "the legacy subscription"
before=$(sink_count "$sa_id")
m_mention push_alice "the draft $run"
sink_waits "$sl_id" 1 \
&& [ "$(sink_last "$sl_id" "r = d[-1]; print(r['ok'] and r['encoding'] == 'aesgcm' and r['headers']['authorization_scheme'] == 'WebPush' and 'p256ecdsa=' in r['headers']['crypto_key'] and r['headers']['encryption'].startswith('salt=') and r['payload']['notification_type'] == 'mention' and r['payload']['access_token'] == '$AT')")" = True ] \
&& ok "aesgcm, with Encryption and Crypto-Key, decrypts to her mention" || ko "the aesgcm push: $(scurl "$PS/subscriptions/$sl_id/received" | head -c 800)"
sleep 3
[ "$(sink_count "$sa_id")" = "$before" ] && ok "the subscription it replaced gets nothing" || ko "the replaced subscription is still pushed"
echo " siblings"
sb=$(sink_new); sb_id=$(echo "$sb" | j "print(d['id'])")
subscribe "$BH" "$sb" true mention policy=all >/dev/null
alice_before=$(sink_count "$sl_id")
m_mention push_alice "for alice only $run"
sink_waits "$sl_id" $((alice_before + 1)); sleep 4
[ "$(sink_count "$sb_id")" = 0 ] && ok "push_bob's subscription never gets push_alice's mention" || ko "a sibling's subscription got her push"
m_mention push_bob "for bob only $run"
sink_waits "$sb_id" 1 && [ "$(sink_last "$sb_id" "p = d[-1]['payload']; print(p['access_token'] == '$BT' and 'for bob only $run' in p['body'])")" = True ] \
&& ok "push_bob's own mention, with his own token" || ko "push_bob's push: $(scurl "$PS/subscriptions/$sb_id/received" | head -c 400)"
sleep 3
[ "$(sink_count "$sl_id")" = "$((alice_before + 1))" ] && [ "$(sink_last "$sl_id" "print(all(r['payload']['access_token'] == '$AT' for r in d))")" = True ] \
&& ok "push_alice gets nothing of his, and only her token" || ko "push_alice's subscription got push_bob's"
[ "$(curl -s -X DELETE -H "$BH" "$P/api/v1/push/subscription")" = "{}" ] && ok "push_bob unsubscribes" || ko "push_bob's DELETE"
echo " DELETE, 410, revocation"
[ "$(curl -s -X DELETE -H "$AH" "$P/api/v1/push/subscription")" = "{}" ] && [ "$(code -H "$AH" "$P/api/v1/push/subscription")" = 404 ] \
&& ok "DELETE: the subscription is gone" || ko "DELETE left it"
before=$(sink_count "$sl_id")
m_mention push_alice "after delete $run"
arrives_unpushed "$AH" "after delete $run" "$sl_id" "$before" && ok "nothing is pushed after DELETE" || ko "pushed after DELETE"
sg=$(sink_new); sg_id=$(echo "$sg" | j "print(d['id'])")
subscribe "$AH" "$sg" true mention >/dev/null
scurl -o /dev/null -X POST "$PS/subscriptions/$sg_id/answer" -H 'Content-Type: application/json' -d '{"status": 410}'
m_mention push_alice "gone $run"
sink_waits "$sg_id" 1 && until_true 15 '[ "$(code -H "$AH" "$P/api/v1/push/subscription")" = 404 ]' \
&& ok "the sink answering 410 removes the subscription" || ko "after a 410: $(curl -s -H "$AH" "$P/api/v1/push/subscription" | head -c 200)"
before=$(sink_count "$sg_id")
m_mention push_alice "still gone $run"
arrives_unpushed "$AH" "still gone $run" "$sg_id" "$before" && ok "and nothing more goes there" || ko "pushed after the 410"
oauth=$("$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" push_alice "read write follow push")
RT=$(echo "$oauth" | cut -d' ' -f1); rcid=$(echo "$oauth" | cut -d' ' -f2); rcs=$(echo "$oauth" | cut -d' ' -f3)
sr=$(sink_new); sr_id=$(echo "$sr" | j "print(d['id'])")
subscribe "Authorization: Bearer $RT" "$sr" true mention >/dev/null
curl -s -o /dev/null -X POST "$P/oauth/revoke" -d "token=$RT" -d "client_id=$rcid" -d "client_secret=$rcs"
[ "$(code -H "Authorization: Bearer $RT" "$P/api/v1/push/subscription")" = 401 ] && ok "the revoked token is refused" || ko "the revoked token still works"
m_mention push_alice "revoked $run"
arrives_unpushed "$AH" "revoked $run" "$sr_id" 0 && ok "a revoked token's subscription is pushed nothing" || ko "pushed to a revoked token's subscription"
[ "$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'db.PushSubscription.countDocuments()' 2>/dev/null | tail -1)" = 0 ] \
&& ok "no subscription is left" || ko "subscriptions left: $(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'db.PushSubscription.countDocuments()' 2>/dev/null | tail -1)"
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/unfollow"
+5 -4
View File
@@ -6,11 +6,12 @@
# PrivaPub runs with SecureMode on, as in production, unless PRIVAPUB_ENV says otherwise; DOTNET and PASTURE_PORT reach
# run.sh as they are. Each batch's output is kept in out/sweep-<batch>.log.
# usage: tools/pasture/sweep.sh [A|B|C|D ...] (default: every batch, in that order)
# A gts mastodon misskey sharkey akkoma, with relay, aoderelay and mailpit; scenarios: those five, threads followsync
# A gts mastodon misskey sharkey akkoma, with relay, aoderelay, mailpit and pushsink; scenarios: those five, threads followsync
# pins moves relay (relay's covers both relays) accounts (mailpit's) instance (the server's own settings: it changes
# them all and puts them back) moderation (gts and mastodon; after instance, whose log check expects only the
# administrator's acts) emoji (gts, mastodon, misskey and akkoma; after moderation) featured (mastodon: featured
# tags, notify, the direct stream, endorsements and notes per persona; after emoji)
# tags, notify, the direct stream, endorsements and notes per persona; after emoji) push (mastodon and pushsink: Web
# Push, encrypted both ways, alerts, policies, siblings, DELETE, 410 and revocation; after featured)
# B lemmy lemmy19 piefed mbin nodebb
# C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale
# D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla
@@ -22,7 +23,7 @@ export PRIVAPUB_ENV=${PRIVAPUB_ENV:-Federation__SecureMode=true}
batch_peers() {
case "$1" in
A) echo "gts mastodon misskey sharkey akkoma relay aoderelay mailpit" ;;
A) echo "gts mastodon misskey sharkey akkoma relay aoderelay mailpit pushsink" ;;
B) echo "lemmy lemmy19 piefed mbin nodebb" ;;
C) echo "peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale" ;;
D) echo "friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla" ;;
@@ -30,7 +31,7 @@ batch_peers() {
}
batch_scenarios() {
case "$1" in
A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts instance moderation emoji featured" ;;
A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts instance moderation emoji featured push" ;;
*) batch_peers "$1" ;;
esac
}
+8 -1
View File
@@ -6,12 +6,19 @@ BASE="${1:?base url}"; TOKEN="${2:-}"
fail() { echo "::error::$*"; exit 1; }
json() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
# the version clients branch on (Mastodon invariant 6): Mastodon 4.5's, then PrivaPub's release
version=$(curl -fsS "$BASE/api/v1/instance" | json "print(d['version'])") || fail "instance v1"
curl -fsS "$BASE/api/v2/instance" | json "assert d['configuration']['statuses']['max_characters'] > 0" || fail "instance v2"
case "$version" in "4.5.0 (compatible; PrivaPub"*) ;; *) fail "instance v1 says version '$version', not 4.5.0 (compatible; PrivaPub ...)" ;; esac
v2=$(curl -fsS "$BASE/api/v2/instance") || fail "instance v2"
echo "$v2" | json "assert d['configuration']['statuses']['max_characters'] > 0" || fail "instance v2"
echo "$v2" | json "assert d['version'] == '$version' and d['api_versions']['mastodon'] == 7" || fail "instance v2's version or api_versions"
vapid=$(echo "$v2" | json "import base64; k = d['configuration']['vapid']['public_key']; assert len(base64.urlsafe_b64decode(k)) == 65; print(k)") \
|| fail "instance v2 gives no VAPID key (configuration.vapid.public_key)"
curl -fsS "$BASE/.well-known/oauth-authorization-server" | json "assert d['token_endpoint'].endswith('/oauth/token')" || fail "oauth discovery"
app=$(curl -fsS -X POST "$BASE/api/v1/apps" -d 'client_name=privapub-smoke&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read') || fail "app registration"
id=$(echo "$app" | json "print(d['client_id'])"); secret=$(echo "$app" | json "print(d['client_secret'])")
[ "$(echo "$app" | json "print(d['vapid_key'])")" = "$vapid" ] || fail "the app's vapid_key is not the instance's VAPID key"
app_token=$(curl -fsS -X POST "$BASE/oauth/token" -d "grant_type=client_credentials&client_id=$id&client_secret=$secret&scope=read" | json "print(d['access_token'])") || fail "client credentials"
curl -fsS -H "Authorization: Bearer $app_token" "$BASE/api/v1/apps/verify_credentials" | json "assert d['name'] == 'privapub-smoke'" || fail "app verify_credentials"
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $app_token" "$BASE/api/v1/accounts/verify_credentials")