Java
November 7, 2023

Пишем минимальный ActivityPub-сервер с нуля

В последнее время, на фоне покупки Twitter Илоном Маском, люди начали искать ему альтернативы — и многие нашли такую альтернативу в Mastodon.

Mastodon — это децентрализованная социальная сеть, работающая по модели федерации, как email. Протокол федерации называется ActivityPub и является стандартом W3C, а Mastodon — далеко не единственная его реализация, но самая популярная. Различные реализации протокола, как правило, совместимы друг с другом, настолько, насколько им позволяют их совпадения в функциональности. У меня есть и мой собственный проект ActivityPub-сервера — Smithereen, такой зелёный децентрализованный ВК, где я когда-нибудь таки верну стену.

В этой статье мы рассмотрим основы протокола ActivityPub и напишем минимально возможную реализацию сервера, позволяющую отправлять посты в сеть («fediverse»), подписываться на других пользователей и получать от них обновления.

Что вообще такое ActivityPub?

Вообще, согласно спецификации, ActivityPub бывает двух видов: межсерверный и клиент-серверный. Клиент-серверная разновидность странная и не особенно юзабельная на неидеальном интернете, да и вообще её никто толком не реализует, так что мы её рассматривать не будем.

ActivityPub применительно к федерации состоит из следующих основных частей:

  • Акторы — объекты (или субъекты?), которые могут совершать какие-то действия. Например, пользователи или группы. Однозначно глобально идентифицируются адресом (URL), по которому лежит JSON-объект актора.
  • Активити — объекты, представляющие собой эти самые действия, вида «Вася опубликовал пост».
  • Инбокс — эндпоинт на сервере, в который эти активити отправляются. Его адрес указывается в поле inbox у актора.

Все объекты — это просто JSON с определённой схемой. На самом деле это слегка проклятый JSON-LD с неймспейсами, но для наших нужд на них можно забить. Объекты ActivityPub имеют mime-тип application/ld+json; profile="https://www.w3.org/ns/activitystreams" или application/activity+json.

Работает это всё так: актор отправляет активити другому актору в инбокс, а тот её принимает, проверяет, и что-нибудь с ней делает. Например, кладёт новый пост в свою БД, или создаёт подписку и отправляет в ответ активити «подписка принята». Сами активити отправляются post-запросами с подписью ключом актора (у каждого актора есть пара RSA-ключей для аутентификации).

В дополнение к этому необходимо реализовать протокол WebFinger для преобразования человекочитаемых юзернеймов вида @[email protected] в настоящие идентификаторы акторов вида https://example.social/users/vasya. Mastodon отказывается работать с серверами, которые это не реализуют, даже если ему дать прямую ссылку на актора ¯\_(ツ)_/¯

Что должен уметь сервер для участия в федивёрсе?

Для того, чтобы с вашим сервером можно было полноценно взаимодействовать из Mastodon и другого аналогичного ПО, он должен поддерживать следующее:

  1. Отдавать объект актора с минимальным набором полей: ID, inbox, публичный ключ, юзернейм, тип Person.
  2. Отвечать на webfinger-запросы с эндпоинта /.well-known/webfinger. Mastodon без этого откажется видеть вашего актора.
  3. Рассылать свои корректно подписанные активити подписчикам и кому угодно ещё, кому они могут быть актуальны — например, пользователям, упомянутым в посте.
  4. Принимать POST-запросы в inbox и проверять их подписи. Для начала хватит поддержки 4 типов активити: Follow, Undo{Follow}, Accept{Follow} и Create{Note}.
  5. При получении корректной активити Follow, сохранить информацию о новом подписчике куда-нибудь на диск, отправить ему Accept{Follow} и впоследствии отправлять ему все активити о, например, создании новых постов.
  6. А при получении Undo{Follow} — удалить его из этого списка (отправлять ничего не нужно).
  7. Крайне желательно, но не строго обязательно, иметь урлы для уже созданных постов, по которым отдаётся объект Note, чтобы их можно было прогрузить на другом сервере, вставив адрес в поле поиска.

Практическая часть

Теперь давайте рассмотрим каждый пункт в подробностях и с примерами кода. Моя реализация будет на Java, поскольку это мой родной язык программирования. Вы можете либо тыкать в мой пример, либо написать сами по образу и подобию на вашем предпочитаемом стеке. Вам также понадобится домен и HTTPS-сервер/прокси — либо какой-нибудь ngrok, либо ваш собственный.

У моего примера две зависимости: микро-фреймворк Spark для приёма входящих запросов и Gson для работы с JSON. Весь код целиком доступен у меня на гитхабе.

Перед тем, как начинать, сгенерируйте пару RSA-ключей и положите их в папку проекта:

openssl genrsa 2048 | openssl pkcs8 -topk8 -nocrypt -out private.pem
openssl rsa -in private.pem -outform PEM -pubout -out public.pem

Отдаём объект актора

По любому удобному вам адресу отдаёте JSON-объект такого вида:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://w3id.org/security/v1"
  ],
  "type": "Person",
  "id": "https://example.social/users/vasya",
  "preferredUsername": "vasya",
  "inbox": "https://example.social/inbox",
  "publicKey": {
    "id": "https://example.social/users/vasya#main-key",
    "owner": "https://example.social/users/vasya",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n----END PUBLIC KEY----"
  }
}

Отдавать нужно с заголовком Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams". Назначение полей:

  • @context — контекст JSON-LD. Не обращайте внимание на него, просто помните, что он должен быть. Но если любопытно узнать про него побольше, то вот и вот.
  • type — тип объекта. Person — личный профиль человека. Бывает ещё Group, Organization, Application и Service.
  • id — глобальный идентификатор объекта, а также адрес, по которому его можно получить (ссылка на самого себя, ага).
  • preferredUsername — юзернейм пользователя, который выводится в интерфейсе и используется для поиска и упоминаний.
  • inbox — адрес того самого инбокса, эндпоинта, принимающего входящие активити.
  • publicKey — публичный RSA-ключ, с помощью которго проверяется подпись активити от этого актора:
    • id — идентификатор ключа. В теории их может быть несколько, но на практике у всех один. Просто добавляете #main-key после ID актора.
    • owner — идентификатор владельца ключа, просто ID вашего актора.
    • publicKeyPem — сам ключ в формате PEM.

Дополнительные необязательные поля, которые вы можете захотеть добавить:

  • followers и following — адреса коллекций подписчиков и подписок. Можно возвращать оттуда 403 или 404, но некоторым серверам важно, чтобы эти поля просто присутствовали в объекте.
  • outbox — inbox в обратную сторону, коллекция некоторых активити, отправленных этим пользователем. Обычно там только Create{Note} и Announce{Note}.
  • url — ссылка на профиль в веб-интерфейсе сервера.
  • name — отображаемое имя, например, Вася Пупкин.
  • icon и image — аватарка и обложка соответственно. Объекты типа Image, с полями type, mediaType и url. Аватарки обычно квадратные.
  • summary — поле «о себе». В нём обычно HTML.

Чтобы посмотреть на объект актора (или поста, или ещё чего-нибудь) с другого сервера, отправьте GET-запрос с заголовком Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams" на тот же адрес, по которому вы видите профиль в браузере:

$ curl -H 'Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"' https://mastodon.social/@Gargron
{"@context":["https://www.w3.org/ns/activitystreams", ...

Код для получения актора

private static final String AP_CONTENT_TYPE = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";

/**
 * Получить объект актора с другого сервера
 * @param id идентификатор актора
 * @throws IOException в случае ошибки сети
 */
private static JsonObject fetchRemoteActor(URI id) throws IOException {
  try {
    HttpRequest req = HttpRequest.newBuilder()
        .GET()
        .uri(id)
        .header("Accept", AP_CONTENT_TYPE)
        .build();
    HttpResponse<String> resp = HTTP_CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
    return JsonParser.parseString(resp.body()).getAsJsonObject();
  } catch(InterruptedException x) {
    throw new RuntimeException(x);
  }
}

Код для отдачи актора

private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create();

get("/actor", (req, res) -> {
  Map<String, Object> actorObj = Map.of(
      "@context", List.of("https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"),
      "type", "Person",
      "id", ACTOR_ID,
      "preferredUsername", USERNAME,
      "inbox", "https://" + LOCAL_DOMAIN + "/inbox",
      "publicKey", Map.of(
          "id", ACTOR_ID + "#main-key",
          "owner", ACTOR_ID,
          "publicKeyPem", publicKey
      )
  );
  res.type(AP_CONTENT_TYPE);
  return GSON.toJson(actorObj);
});

Отвечаем на webfinger-запросы

Запрос и (урезанный до минимально необходимого) ответ выглядят вот так:

$ curl -v https://mastodon.social/.well-known/webfinger?resource=acct:[email protected]
...
< HTTP/2 200 
< content-type: application/jrd+json; charset=utf-8
...
{
  "subject":"acct:[email protected]",
  "links":[
    {
      "rel":"self",
      "type":"application/activity+json",
      "href":"https://mastodon.social/users/Gargron"
    }
  ]
}

Это всего лишь способ сказать «ID актора, соответствующий юзернейму gargron на сервере mastodon.social — https://mastodon.social/users/Gargron».

Этих двух эндпоинтов уже достаточно, чтобы ваш актор был виден на других серверах — попробуйте ввести URL объекта актора в поле поиска в Mastodon, чтобы увидеть его профиль.

Рассылаем активити

Активити отправляются POST-запросами в инбоксы. Для аутентификации используются HTTP-подписи — это такой заголовок с подписью других заголовков с помощью ключа актора. Выглядит он вот так:

Signature: keyId="https://example.social/actor#main-key",headers="(request-target) host date digest",signature="..."

Где keyId — идентификатор ключа из объекта актора, headers — заголовки, которые мы подписали, а signature — сама подпись в base64. В подписанные заголовки обязательно входит Host, Date и «псевдо-заголовок» (request-target) — это метод и путь (например, post /inbox). Время в Date должно отличаться от времени принимающего сервера не более чем на 30 секунд — это нужно для предотвращения replay-атак. Современные версии Mastodon также требуют заголовок Digest, это SHA-256 в base64 от тела запроса:

Digest: SHA-256=n4hdMVUMmFN+frCs+jJiRUSB7nVuJ75uVZbr0O0THvc=

Строка, которую нужно подписать — это названия и значения заголовков в том же порядке, в котором они перечислены в поле headers. Названия пишутся строчными буквами и отделяются от значений двоеточием и пробелом. После каждого заголовка, кроме последнего, ставится перевод строки (\n):

(request-target): post /users/1/inbox
host: friends.grishka.me
date: Sun, 05 Nov 2023 01:23:45 GMT
digest: SHA-256=n4hdMVUMmFN+frCs+jJiRUSB7nVuJ75uVZbr0O0THvc=

Код для отправки активити

/**
 * Отправить активити в чей-нибудь инбокс
 * @param activityJson JSON самой активити
 * @param inbox адрес инбокса
 * @param key приватный ключ для подписи
 * @throws IOException в случае ошибки сети
 */
private static void deliverOneActivity(String activityJson, URI inbox, PrivateKey key) throws IOException {
  try {
    byte[] body = activityJson.getBytes(StandardCharsets.UTF_8);
    String date = DateTimeFormatter.RFC_1123_DATE_TIME.format(Instant.now().atZone(ZoneId.of("GMT")));
    String digest = "SHA-256="+Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(body));
    String toSign = "(request-target): post " + inbox.getRawPath() + "\nhost: " + inbox.getHost() + "\ndate: " + date + "\ndigest: " + digest;

    Signature sig = Signature.getInstance("SHA256withRSA");
    sig.initSign(key);
    sig.update(toSign.getBytes(StandardCharsets.UTF_8));
    byte[] signature = sig.sign();

    HttpRequest req = HttpRequest.newBuilder()
        .POST(HttpRequest.BodyPublishers.ofByteArray(body))
        .uri(inbox)
        .header("Date", date)
        .header("Digest", digest)
        .header("Signature", "keyId=\""+ACTOR_ID+"#main-key\",headers=\"(request-target) host date digest\",signature=\""+Base64.getEncoder().encodeToString(signature)+"\",algorithm=\"rsa-sha256\"")
        .header("Content-Type", AP_CONTENT_TYPE)
        .build();
    HttpResponse<String> resp = HTTP_CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
    System.out.println(resp);
  } catch(InterruptedException | NoSuchAlgorithmException | InvalidKeyException | SignatureException x) {
    throw new RuntimeException(x);
  }
}

У вас всё готово для того, чтобы отправить свою первую активити! Попробуйте оставить комментарий под моим постом об этой статье — отправьте вот это в мой инбокс, https://friends.grishka.me/users/1/inbox, заменив example.social на домен, на котором запущен ваш сервер:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://example.social/createHelloWorldPost",
  "type": "Create",
  "actor": "https://example.social/actor",
  "to": "https://www.w3.org/ns/activitystreams#Public",
  "object": {
    "id": "https://example.social/helloWorldPost",
    "type": "Note",
    "published": "2023-11-05T12:00:00Z",
    "attributedTo": "https://example.social/actor",
    "to": "https://www.w3.org/ns/activitystreams#Public",
    "inReplyTo": "https://friends.grishka.me/posts/884435",
    "content": "<p>Привет, федивёрс</p>"
  }
}

Если вы всё сделали правильно, под постом появится ваш комментарий.

Здесь: Create — тип активити, мы что-то создали. actor — кто создал, object — что он создал, to — кому эта активити адресована (всему миру). А создали мы «заметку» (так в ActivityPub называются посты) с текстом «Привет, федивёрс», которая является ответом на мой пост. В тексте постов поддерживается базовый набор HTML-тегов для форматирования, но конкретный список того, что поддерживается, зависит от конкретного сервера.

Принимаем активити и проверяем подписи

Мы только что отправили активити, теперь нам нужно научиться их принимать. Повторяться смысла нет, всё то же самое. Для проверки подписи нужно:

  • Распарсить заголовок Date. Если время отличается от текущего больше, чем на 30 секунд, отклонить запрос (можно вернуть код 400, например).
  • Распарсить тело запроса. Получить объект актора по урлу в actor.
  • Проверить, что keyId в заголовке Signature совпадает с идентификатором ключа актора.
  • Распарсить публичный ключ, составить строку для подписи (см. выше) и проверить подпись.

Код для получения активити с проверкой подписи

post("/inbox", (req, res) -> {
  // Время в заголовке Date должно быть в пределах 30 секунд от текущего
  long timestamp = DateTimeFormatter.RFC_1123_DATE_TIME.parse(req.headers("Date"), Instant::from).getEpochSecond();
  if (Math.abs(timestamp - Instant.now().getEpochSecond()) > 30) {
    res.status(400);
    return "";
  }

  // Вытаскиваем актора
  JsonObject activity = JsonParser.parseString(req.body()).getAsJsonObject();
  URI actorID = new URI(activity.get("actor").getAsString());
  JsonObject actor = fetchRemoteActor(actorID);

  // Парсим заголовок и проверяем подпись
  Map<String, String> signatureHeader = Arrays.stream(req.headers("Signature").split(","))
      .map(part->part.split("=", 2))
      .collect(Collectors.toMap(keyValue->keyValue[0], keyValue->keyValue[1].replaceAll("\"", "")));
  if (!Objects.equals(actor.getAsJsonObject("publicKey").get("id").getAsString(), signatureHeader.get("keyId"))) {
    // ID ключа, которым подписан запрос, не совпадает с ключом актора
    res.status(400);
    return "";
  }
  List<String> signedHeaders = List.of(signatureHeader.get("headers").split(" "));
  if (!new HashSet<>(signedHeaders).containsAll(Set.of("(request-target)", "host", "date"))) {
    // Один или несколько обязательных для подписи заголовков не содержатся в подписи
    res.status(400);
    return "";
  }
  String toSign = signedHeaders.stream()
      .map(header -> {
        String value;
        if ("(request-target)".equals(header)) {
          value="post /inbox";
        } else {
          value=req.headers(header);
        }
        return header+": "+value;
      })
      .collect(Collectors.joining("\n"));
  PublicKey actorKey = Utils.decodePublicKey(actor.getAsJsonObject("publicKey").get("publicKeyPem").getAsString());
  Signature sig = Signature.getInstance("SHA256withRSA");
  sig.initVerify(actorKey);
  sig.update(toSign.getBytes(StandardCharsets.UTF_8));
  if (!sig.verify(Base64.getDecoder().decode(signatureHeader.get("signature")))) {
    // Подпись не проверилась
    res.status(400);
    return "";
  }

  // Всё получилось - запоминаем активити, чтобы потом показать её пользователю
  receivedActivities.addFirst(activity);

  return ""; // Достаточно просто ответа с кодом 200
});

Подписываемся на людей

Теперь у вас есть все компоненты, необходимые для того, чтобы подписаться на другого пользователя. Попробуйте подписаться на свой аккаунт в Mastodon, или, например, на mastodon.social/@Mastodon. Отправьте такую активити нужному актору (не забудьте заменить example.social на свой домен и object на id желаемого актора):

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://example.social/oh-wow-i-followed-someone",
  "type": "Follow",
  "actor": "https://example.social/actor",
  "object": "https://mastodon.social/users/Mastodon"
}

Вскоре после этого вам должна придти активити Accept, с вашей Follow внутри в качестве object (я типы таких вложенных активити пишу в формате Accept{Follow}). Это означает, что другой сервер принял вашу подписку, и будет впредь присылать вам, например, Create, Announce и Delete про посты, которые этот актор будет создавать, репостить и удалять. Чтобы отписаться, отправьте Undo{Follow}.

Что дальше?

Если вы дочитали досюда и всё сделали по инструкции, поздравляю — у вас есть работающий ActivityPub-сервер! Можете попробовать очевидные улучшения:

  • Добавить возможность подписываться на вашего актора
  • Не просто складывать активити в массив, а обрабатывать их в зависимости от типа
  • Да и в интерфейсе можно сделать не большое поле для JSON, а нормальные кнопки для конкретных действий
  • Подключить базу данных для хранения пользователей, постов и подписок
  • … и поддержать больше одного пользователя на сервере
  • Сделать полноценный веб-интерфейс и/или API для клиентских приложений
  • Добавить какую-нибудь аутентификацию, в конце концов
  • Кэшировать объекты с других серверов локально, чтобы не делать слишком много одинаковых запросов

Полезные ссылки

Больше ActivityPub-серверов, хороших и разных:

Источник