diff --git a/FEDERATION.md b/FEDERATION.md index 99ee095b..c703d2bf 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -59,7 +59,7 @@ There are separate fields for first and last names, birth date, and gender, all Smithereen uses non-square profile pictures on the profile page. In order to retain compatibility with everything else, `icon` in the actor still points to a square picture. It's extended with the `image` field that contains the full rectangular one, and `sm:cropRegion` with the coordinates of the square version within the rectangular one. The coordinates are in the order `[x1, y1, x2, y2]`, where (x1, y1) are the top-left corner of the square, and (x2, y2) are the bottom-right. The top-left corner of the rectangle is (0, 0), and the bottom-right one is (1, 1). Example: -```json +```json lines ... "icon": { "type": "Image", @@ -85,7 +85,7 @@ Example: Groups are like users, except they can't follow anything. Groups have walls that work the same way as user walls. Both `Join` and `Follow` activities work for joining groups, as well as `Leave` and `Undo{Follow}` for leaving. Outgoing activities are `Follow` and `Undo{Follow}` in order to maximize the compatibility. Groups have administrators that are listed in the `attributedTo` field: -```json +```json lines "attributedTo": [ { "type": "Person", @@ -97,3 +97,130 @@ Groups have administrators that are listed in the `attributedTo` field: These links must point to a `Person` object and will be ignored otherwise. Any actions of the group administrators are federated as if the group actor itself performed them. + +A group has one of three access types, specified in `sm:accessType` field: + +* `open`: all content is public. Anyone can join and/or participate (unless blocked, of course). Joining the group is **not** required to post in it or interact with its content. This also is the default if no `sm:accessType` field is present. +* `closed`: the profile and the member list are public, but the content is private and visible to members only. You become a member after either sending a join request that is then manually reviewed and accepted by the group staff (the usual `Join`/`Accept{Join}` flow) or being invited by an existing group member (see below for the group invitations). +* `private`: nothing is public, including the profile. The only way to join is to be invited. Also, only group staff can send invitations. + +#### Access control in non-public groups +To control access to the content in closed and private groups, Smithereen employs two mechanisms: signed GET requests and so-called "actor tokens". + +To fetch an object from **the server that hosts the group** (including the `Group` actor itself for private groups), you need to sign your GET request with an HTTP signature using the key of **any** actor from a server that has members in the group. Smithereen itself always uses the service actor for this purpose, `/activitypub/serviceActor`. The rationale for this is that most ActivityPub servers only fetch and store a single copy of each object for all users to whom it may concern, and are responsible themselves for enforcing the visibility rules, if any, either way. + +The process of fetching an object from **other** server involves an **actor token**. An actor token is a cryptographically signed temporary proof of membership in a group. Since it would be impractical to provide a revocation mechanism, an actor token has a limited validity time in order to account for cases when someone has left a group or was removed from it. It is a JSON object with the following fields: + +* `issuer`: ID of the actor that generated this token +* `actor`: ID of the actor that the token is issued to (and must be presented with a valid HTTP signature of) +* `issuedAt`: timestamp when the token was generated, ISO-8601 instant (same format as ActivityPub timestamps) +* `validUntil`: timestamp when the token expires, ISO-8601 instant +* `signatures`: array of signature objects, currently with only one possible, and required, element defined: + * `algorithm`: must be the string `rsa-sha256` + * `keyId`: key ID, same as in HTTP signatures (e.g. `https://example.com/groups/1#main-key`) + * `signature`: the RSA-SHA256 signature itself encoded as base64, see below for details + +To obtain an actor token, make a signed GET request to the endpoint specified in `sm:actorToken` under `endpoints` in the actor object. + +To use an actor token when fetching an object, pass it as `Authorization: ActivityPubActorToken {...}` HTTP header. + +To generate a source string for signature: + +1. Iterate over the keys in the actor token JSON object, skipping `signature`, and transform them into the format `key: value`. Add these strings to an array. +2. Sort the resulting array lexicographically. +3. Join the strings with newline character (`\n`, U+000A). +4. Convert the resulting string to a UTF-8 byte array. + +To generate an actor token: + +1. Verify that the requesting actor, as per HTTP signature, has access to the group (there are members with the same domain). If it does not, return a 403 error and stop. +2. Create a JSON object with the fields above (except `signature`). It is recommended that the validity period is 30 minutes, and it must not exceed 2 hours. +3. Generate a signature source string as above, sign it, and wrap it into an object with `signature`, `algorithm`, and `keyId` fields. +4. Add the object as a single element in the `signatures` array. + +To verify an actor token: + +1. Check that the HTTP signature is valid, and that `actor` in the token object matches the actor ID from `keyId` in the HTTP signature. +2. In the `signatures` array, find an object that has `algorithm` set to `rsa-sha256` to get the `signature` value. If there isn't any, return a 403 and stop. +3. Check the validity time: `issuedAt` must be in the past, `validUntil` must be in the future, and the difference between them must not exceed 2 hours. It is recommended to apply some margin to these checks to account for imprecisely set clocks. Smithereen uses 5 minutes. +4. Generate the signature source string as above and verify the signature. +5. Check that the object the requester is accessing is, in fact, part of a collection owned by `issuer`. + +#### Events & tentative membership +An event is an extension of group. An event is identified as such by having an `Event` object in its `attachments`: +```json lines +{ + "type": "Group", + "id": "https://friends.grishka.me/groups/70", + "attachment": [ + { + "type": "Event", + "startTime": "2022-07-15T09:00:00Z" + } + ], + "name": "Встреча с Гришкой в Макдональдсе", + /* ... more fields ... */ +} +``` +The `Event` object must have `startTime` and may have `endTime`. There is currently no provisions for specifying the location of the event, but this is likely to change in the future. + +Events can only have either `open` or `private` access type. It is possible to join an event tentatively ("I'm not sure I will attend"). Tentative membership adds the following: + +* `sm:tentativeMembers` collection in the actor. Contains tentative members. +* `sm:TentativeJoin` activity type: + * For non-members, joins them to the event tentatively and accepts invitation, if any. + * For members, changes their decision by moving them from `followers` to `sm:tentativeMemebers`. (The reverse is done with regular `Join`/`Follow`.) +* `sm:tentativeMembership` element in `litepub:capabilities` to indicate support for this feature. + +#### Invitations +It is possible to invite a friend (i.e. mutual follow) to a group by sending an `Invite{Group}` activity both to the group and to the user (there will be a privacy setting for this in the future). +* Anyone can invite to a `public` group. +* Only members can invite to a `closed` group. +* Only staff (listed under `attributedTo`) can invite to a `private` group. + +It is important to send a copy of the `Invite` activity to the group itself so the group knows to expect that person to join. This is especially important for non-public groups because they would not accept that join otherwise. + +Group staff can cancel a pending invitation by sending `Undo{Invite{Group}}` to the invitee from the group actor. To accept the invitation, the invitee simply joins the group (`Join`/`Follow`/`sm:TentativeJoin`). To decline the invitation, the invitee sends a `Reject{Invite{Group}}` to the group actor. + +### The collection query endpoint +All Smithereen actors have `sm:collectionSimpleQuery` endpoint under `endpoints`. This is useful for when, for example, you've received a wall post, but you don't know whether the owner of the wall accepted that post. It supports these collections: + +* `sm:wall` +* `sm:friends` (for user actors) +* `sm:groups` (for user actors) +* `sm:members` (for group actors) +* `sm:tentativeMembers` (for event actors) + +The collection query endpoint accepts POST requests with form-data fields: `collection` for the collection ID (like `https://friends.grishka.me/users/1/wall`) and one or more `item` with the object IDs that you wish to test for presence in the collection. The result is a `sm:CollectionQueryResult` (which extends `CollectionPage`) containing only the object IDs that are actually present in the collection. + +
+Request and response example + +``` +POST /users/1/collectionQuery HTTP/1.1 +Content-Type: application/x-www-form-urlencoded; charset=utf-8 +Host: smithereen.local:8080 +Connection: close +User-Agent: Paw/3.3.6 (Macintosh; OS X/12.5.0) GCDHTTPRequest +Content-Length: 177 + +collection=http%3A%2F%2Fsmithereen.local%3A8080%2Fusers%2F1%2Ffriends&item=http%3A%2F%2Fsmithereen.local%3A8080%2Fusers%2F2&item=https%3A%2F%2Ffriends.grishka.me%2Fposts%2F85372 +``` + +```json +{ + "type": "CollectionQueryResult", + "items": [ + "http://smithereen.local:8080/users/2" + ], + "partOf": "http://smithereen.local:8080/users/1/friends", + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "sm": "http://smithereen.software/ns#", + "CollectionQueryResult": "sm:CollectionQueryResult" + } + ] +} +``` +
\ No newline at end of file