はしのした


2023-08-20 ActivityPub 対応(2)

このブログを ActivityPub 対応にするまでに必要だったことをまとめた記事の中編です。 Inbox や Outbox でどんな対応が必要なのかをまとめていきます。

  Outbox への GET に応答

Outbox は各ユーザの投稿一覧を返す場所なのですが……。 後でわかったことですが、実際のところ、この項目をあまりマジメにやる必要はありませんでした。 というのも、Mastodon にしろ Misskey にしろ、負荷対策のために他サーバの投稿を Outbox から引っ張ってくることはなく、ユーザの投稿一覧として自サーバの Inbox に送られてきたものだけを保持・表示するようになっているようです。 とはいえ、作ってしまった(約75行)からには一応説明します。

following や followers と同様に、Outbox でどういうデータを返せば良いかは、リンクで示します。

tDiary では8桁の日付+セクション番号で記事に ID がつけられるので、月ごとにページを分けることにしました。 クエリ文字列に「month=●●」が 含まれていない場合 には、totalItems に全体の投稿数、first と last にそれぞれ最初と最後のページへのリンクを記載した、OrderedCollection オブジェクトを返しています。 含まれている場合 には、指定した月の Note をまとめた配列を orderedItems に、prev と next にそれぞれ前と次のページへのリンクを記載した、OrderedCollectionPage オブジェクトを返しています。

  Inbox への POST を検証

ActivityPub を自力実装しようとすると、この辺りから難易度が急に跳ね上がります。 Inbox にオブジェクトを送る際には、なりすまし・改ざん防止のために電子署名やハッシュが必要になるためです。 当然、自身の Inbox にもこれらが付加されたオブジェクト、より具体的には様々な種類の Activity オブジェクト(以下アクティビティと表記します)が送られてきます。 Inbox での処理の前半部分では、HTTP ヘッダや本文を見て、これらが正しく作られているかを確認することになります。

Mastodon 公式にも Inbox 周辺の解説記事 があるのですが、残念ながら少し情報が古く、この通りに実装するだけだと、うまく行きません。 色々考慮する必要があったため、記述量も相応に多くなりました(約160行)。 具体的にどこに注意が必要かに重点を置きつつ、説明してみます。

リクエストの例

ここでは、動作検証のときに受信した Misskey からのいいね通知(Like アクティビティ)を例にしてみます。 Inbox への POST として、以下の HTTP ヘッダのついたリクエストが送られてきました。

1POST /PLB/ap/inbox.rb
2(関係ない部分は省略)
3Host: arle.plb.local
4Date: Thu, 17 Aug 2023 13:30:15 GMT
5Digest: SHA-256=HWpKdQdcRSdThb2/yrO9Fbp7FQJFe02GGbYd/QULaxg=
6Signature: keyId="https://amitie.plb.local/users/9iikqmda66#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="YJ0EBC(長いので中略)Fz/Q=="

この リクエストの本文 と、送信者の検証鍵(公開鍵) を、別途アップしておきます。 なお、検証環境ではブログが動作するサーバに arle.plb.local、Misskey が動作するサーバに amitie.plb.local と名前をつけています。

ポイントは Digest と Signature の2つのヘッダです。

Digest には、"SHA-256=" の文字列に続いて、リクエストの本文を SHA-256 でハッシュ化し、Base64 でエンコードした文字列が入ります。 自分で同じ処理をして得られた文字列が Digest ヘッダのものと一致しなければ、リクエストの本文に改ざんがあったと判断し、認証失敗となります。

Signature は、カンマ区切りで4つのパートに分かれています。

  • keyId: 送信者の検証鍵(公開鍵)を含む Actor オブジェクトへのリンク
  • algorithm: 電子署名に用いるアルゴリズム(rsa-sha256 固定と考えてよい)
  • headers: 署名対象となるヘッダの組(スペース区切り)
  • signature: 署名を Base64 でエンコードした文字列

algorithm のパートは、Misskey と連携する場合考慮が必要です。 受信時は単に無視しておけば良いのですが、送信時にこのパートをつけていないと Misskey に受け取ってもらえません。

また、Date ヘッダについても、現在時刻から極端に離れていないか確認しておくことが望ましいようです。 仮に第三者がリクエストを傍受できたとすると(その時点で色々まずいですが)、そのリクエストを後で再送する、いわゆるリプレイ攻撃が成立しうるからです。 Date や Digest ヘッダの値を署名対象に含めることで、これらの値の改ざんを防いでいます。

リクエストの検証の例

では、上記のリクエストが正しく作られたものかどうかを検証してみましょう。 コードの例を以下に示します。

 1require 'net/http'
 2require 'openssl'
 3require 'base64'
 4
 5pem = File.open("public_key_sample.pem"){|f| f.read }
 6json = File.open("like_activity_sample.json"){|f| f.read }
 7str_to_sign = [
 8  "(request-target): post /PLB/ap/inbox.rb",
 9  "date: Thu, 17 Aug 2023 13:30:15 GMT",
10  "host: arle.plb.local",
11  "digest: SHA-256=HWpKdQdcRSdThb2/yrO9Fbp7FQJFe02GGbYd/QULaxg="
12].join("\n")
13signature_base64 = [
14  "YJ0EBCo+Cxu4LHlJaMMNQPkKRm7HBHEmMeLX2AZmkEfkm3zz8GvY7fLdR7YGKnxg5a4FGA",
15  "LfQI3ZHEtp6UzYnSB885VwSVYVkJ5sTqiWxFizKuuYOvfRAVgVVzfRT8DlMt1EvBLfIkij",
16  "DByOTui+wmwgUIUSfjWic9zQwrIis8Qaj1sOxwJf1b20yOWUyJMCXUMzZTFZXMKv1OSD7t",
17  "oB0A2X0OwX31cMi9p22DZdUypxbiv7jm9IQUzawMWURNqRzPzC8D5Gkbm1PQekGDVXNkbD",
18  "lblfq0cpb1E2rOdS72Jb30o8ZyW56iI6IOA1N+MXUcr3bg8777DWHqcvM+Fz/Q=="
19].join("")
20
21digest = "SHA-256=" + Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(json))
22puts "digest = #{digest}"
23
24pubkey = OpenSSL::PKey::RSA.new(pem)
25signature = Base64.decode64(signature_base64)
26result = pubkey.verify("sha256", signature, str_to_sign)
27puts "verification = #{(result) ? "PASS" : "FAIL"}"

str_to_sign が今回の署名対象の文字列です. 今回、Signature ヘッダの headers には (request-target) date host digest が含まれています。 (request-target) にはリクエストの方法(post)と対象を記載し、残りは受け取った HTTP ヘッダの値をそのまま入れます。 なお、Mastodon は content-type も署名対象にする 点に注意が必要です。手抜きをするとハマります。 今回 str_to_sign は定数として与えていますが、実際には受け取ったリクエストから都度作成しましょう。

あとは、リクエスト本文を SHA-256 にかけてハッシュ値が一致するか確認し、作成した署名対象の文字列を Actor オブジェクトから得られた検証鍵(公開鍵)で検証します。 検証に失敗した場合には、相手先には 401 Authorization Required を返しておきましょう。成功した場合は 200 OK なり 202 Accepted を返します。

  Activity に応じた処理

Inbox での処理の後半部分は、届いたアクティビティの種類(type)を見て,それに応じた処理をすることです。 今回の目的は、少なくとも Mastodon と Misskey からフォロー可能にすることと、届いたいいね、ブースト/リノート、返信を記録・表示することでしたので、処理は大きく分けて以下の4種類としました。カッコ内はアクティビティの種類です。

  • フォロー申請(Follow)
  • 返信の投稿(Create)および修正(Update)
  • いいね(Like)およびブースト/リノート(Announce)
  • フォロー・いいね・ブースト/リノートの取り消し(Undo)および返信の削除(Delete)

これだけ処理の種類があると、流石に記述量も多くなりました(約220行)。 以下、それぞれで行った処理を説明します。

Follow アクティビティ

ActivityPub におけるフォローは、フォローする側が Follow アクティビティを送り、フォローされる側がフォローする側に Accept アクティビティを返送することで、成立となります。 動作検証のときに受信した Misskey からの Follow アクティビティを例として示します。@context は省略しています。

1{
2  "id": "https://amitie.plb.local/follows/9iim49du5s",
3  "type": "Follow",
4  "actor": "https://amitie.plb.local/users/9iikqmda66",
5  "object": "https://arle.plb.local/PLB/ap/"
6}

フォローする側とされる側の Actor へのリンクがそれぞれ actor と object に入っています。当然ですが、object は自分を指しています。

Follow アクティビティを受け取ったら、まずは相手をフォロワーの一覧に登録します。 この際、後々 Follow に対する Undo(つまりフォロー解除)に対応できるように、このアクティビティの ID をフォロワーの一覧に登録したことを、Undo 用のデータファイルに保存しておくと良いでしょう。

そして、actor を自分にし、object に受け取った Follow アクティビティそのものを格納した、Accept アクティビティを作成します。 後で説明しますが、今回は Activity を相手先の Inbox に配送するためのスクリプトを別に用意しています。 ですので、実際はこの Accept アクティビティを作成・配信するよう配送キューに登録するにとどめています。 最後に配送スクリプトを起動して(後述)、フォローへの対応はおしまいです。

Create/Update アクティビティ

投稿やその更新は、対象の Note オブジェクトを object とした、Create や Update アクティビティとして送られてきます。 また、投稿が別の投稿への返信である場合は、返信先へのリンクがその Note オブジェクトの inReplyTo に登録されています。

ですので、これらのアクティビティへの対応は Create でも Update でも変わらず、以下の通りとなります。

  • object の inReplyTo を見て、日記のどのセクションへの返信なのかを確認する
  • actor のリンク先から、返信した Actor の情報(名前など)を取りに行く
  • object の content を見て、適宜タグを削除するなどの下処理をする
  • 各セクションのコメント一覧ファイルに、コメントを登録する
  • Undo 用のデータファイルに、当該セクションにコメントを登録したことを保存する

Like/Announce アクティビティ

いいねは Like アクティビティとして、ブースト/リノートは Announce アクティビティとして、それぞれ送られます。 どちらの場合でも対象の投稿は object に登録されています。

そのため、やるべきことはどちらでも一緒で、かつ Create/Update のときとほとんど同じです。 異なるのは、object の inReplyTo のかわりに object そのものを確認することと、下処理の内容だけです。

Misskey には絵文字でリアクションする機能がありますが、これらも Like アクティビティの拡張として送られてきます。 具体的には、絵文字が Unicode の絵文字(😁のような)である場合には content にその絵文字が入っています。 また、カスタム絵文字の場合には、content には :hoge: のような Misskey の記法で書かれた絵文字の名前が入ります。

カスタム絵文字を含む Like アクティビティの例を以下に示します。

 1{
 2  "type": "Like",
 3  "id": "https://amitie.plb.local/likes/9iirjmcctc",
 4  "actor": "https://amitie.plb.local/users/9iikqmda66",
 5  "object": "https://arle.plb.local/PLB/ap/status.rb?id=20230817p02",
 6  "content": ":rgb:",
 7  "_misskey_reaction": ":rgb:",
 8  "tag": [
 9    {
10      "id": "https://amitie.plb.local/emojis/rgb",
11      "type": "Emoji",
12      "name": ":rgb:",
13      "updated": "2023-08-17T13:17:37.587Z",
14      "icon": {
15        "type": "Image",
16        "mediaType": "image/png",
17        "url": "https://amitie.plb.local/files/ba81ebb2-db95-43bb-982d-d842dd7063f7"
18      }
19    }
20  ]
21}

tag 以下に書かれている画像の URL を拾ってくれば、Misskey のカスタム絵文字を表示することも可能です。

Undo/Delete アクティビティ

Follow, Like, Announce の取消は Undo アクティビティとして、Create の取消は Delete アクティビティとして、それぞれ送られてきます。 Undo アクティビティの object は取消対象のアクティビティ(ないしその URL)、Delete アクティビティの object は削除対象の Note オブジェクト(ないしその URL)です。

やるべきことは、Undo 用のデータファイルから対象がどこに登録されているかを確認した上で、登録した情報の削除を行う、ということになります。 ただここで気をつけないといけないのは、Misskey では Follow アクティビティの ID と、対応する Undo アクティビティの object が一致しない 場合があるということです。 理由は不明ですが、ともかく Follow に対する Undo で ID が一致するものが見当たらない場合には、actor でマッチングを試みる必要があります。