Walkthrough

ドメイン所属モデル

Concrntでは、ユーザーは自分のデータを保存・配信するドメインを1つ選びます。ユーザーが引っ越しする可能性を考慮し、ユーザーIDとその現在のドメインとの関連は変更可能です。 各サーバーは、自分が関与する必要があるユーザーの現住所の情報のみを保存し、全ユーザーの情報を把握する必要はありません。

例えば、リモートのサーバーにあるタイムラインを購読している間に遭遇した未知のユーザー名は、そのタイムラインを提供しているサーバーが知っているはずですので、そこに問い合わせることができます。

署名と証明書チェーン

Concrntはユーザーが自身の秘密鍵を用いて自己の同一性を証明します。 Concrntでは秘密鍵にsecp256k1を採用し、アドレスフォーマットはbech32エンコードが利用されています。

署名の検証プロセスは’ecrecover’アルゴリズムを使用して行われます。簡単に説明すると、ECDSA署名は、元のメッセージと署名の値、1bitのリカバリーパラメーターをもとに署名者の公開鍵を復元することができます。 こうして復元した公開鍵からアドレスを計算することで、署名の正しさを検証することができます。このアルゴリズムの利点は、公開鍵を別途共有する必要がないところです。

concurrent.worldのインスペクターパネル(図9)を使うと簡単に送信者の署名の検証結果を表示することができます。

図9 インスペクターパネル

また、Concrntにはサブキーという概念を導入しています。

というのも、使い捨てしても大して問題のない仮想通過のウォレットならともかく、SNSアカウントを1つの秘密鍵で守り切るのはほぼ不可能です。頻繁にいろんな端末で開きたいものですからね。

秘密鍵の扱いを容易にするためには、いつでも新しい鍵を生成でき、そしてその鍵をいつでも無効化できる必要があります。

Concrntでは、自身の秘密鍵を使って別の秘密鍵に対して署名を施すことで、証明書を作ることができます。この証明書をプロフィールのように公開することで、アドレスと直接紐づいた秘密鍵をオフラインに厳重に保管した上で、柔軟にアカウントを運用することができます。 証明書はいつでも無効化できますが、これは一般にwebで使われている証明書と同様に、証明書失効リスト(CRL)を公開することで行われます。なので、サブキーによる署名を検証する際は、証明書の最新の状態についてドメインに問い合わせる必要があります。完全な魚拓を取る際は、投稿の署名とその署名に使われたサブキーの証明書チェーンに加えて、そのユーザーのドメイン加入証明書と、現時点でその証明書チェーンの失効証明書を受け取っていないことのサーバーサイド署名が必要になるでしょう。

リソースモデルとドキュメント

ConcrntはSNSを構成する要素をいくつかのリソースモデルとして分解しています。 ユーザーがプロフィールや投稿などのリソースを作成するためには、「ドキュメント」と呼ばれる署名付きのjsonオブジェクトを作成し、サーバーに対して投稿します。 これが受理されるとサーバーはそのユーザーのコミットログとしてこのドキュメントを内部に保存します。このログをダウンロードし、ほかのサーバーに頭から流し込むことで自分のデータをほかのサーバーに引っ越しすることができます。(バージョン1。バージョン0ではシンプルなAPIのCRUD操作で実装されている。)

ドキュメントの基本の形は次のようになっており、また一部のリソースはこれを拡張して利用しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type DocumentBase[T any] struct {
	ID       string    `json:"id,omitempty"`
	Signer   string    `json:"signer"`
	Type     string    `json:"type"`
	Schema   string    `json:"schema,omitempty"`
	KeyID    string    `json:"keyID,omitempty"`
	Body     T         `json:"body,omitempty"`
	Meta     any       `json:"meta,omitempty"`
	SignedAt time.Time `json:"signedAt"`
}

それでは、各種リソースについて見て見ましょう。

Message

メッセージは投稿などそのままの要素です。 例えばサーバーからこれを取得してみます(IDのフォーマットやレスポンスの内容はVersion0)。

note:
サーバー側から取得する際、メタデータでラップされるためjqでドキュメントの部分だけを取得しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
> 03/31 22:42 ~$ curl -s https://dev.concurrent.world/api/v1/message/570afc5c-5b1d-420b-9934-773fedf80801 | jq '.content.payload | fromjson'
{
  "signer": "CC707E9Aa446961E6e6C33e5d69d827e5420B69E1f",
  "type": "Message",
  "schema": "https://raw.githubusercontent.com/totegamma/concurrent-schemas/master/messages/note/0.0.1.json",
  "body": {
    "body": "帰りの電車で着席に失敗した :sadpolar:",
    "emojis": {
      "sadpolar": {
        "imageURL": "https://fluffy.social/files/d7d34fc2-ac21-496d-b330-0f8531b5b0a7"
      }
    },
    "mentions": []
  },
  "meta": {
    "client": "develop.dqv3bovj4nar0.amplifyapp.com-develop-56f7b95"
  },
  "signedAt": "2024-03-29T14:46:08.176Z",
  "keyID": "CK548AF905296C91B2BE410A95DC79231533d31cFd"
}
> 03/31 22:42 ~$

また、この情報は図9のインスペクターパネルから簡単に確認することもできます。 メッセージの内容を見てみると、schemaとして謎のURLが指定されていますね。このschemaのURLの内容はこのようになっています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
> 03/31 22:48 ~$ curl https://raw.githubusercontent.com/totegamma/concurrent-schemas/master/messages/note/0.0.1.json
{
    "$id": "https://raw.githubusercontent.com/totegamma/concurrent-schemas/master/messages/note/0.0.1.json",
    "$schema": "http://json-schema.org/draft-07/schema#",
    "type": "object",
    "properties": {
        "body": {
            "type": "string"
        },
        "emojis": {
            "type": "object",
            "additionalProperties": {
                "type": "object",
                "properties": {
                    "imageURL": {
                        "type": "string"
                    },
                    "animURL": {
                        "type": "string"
                    }
                },
                "additionalProperties": false
            }

        },
        ...
        // 一部省略
        ...
    },
    "additionalProperties": false,
    "required": ["body"]
}
> 03/31 22:48 ~$

このように、Concrntではメッセージと一言に言っても、その内容をjsonSchemaで分類・定義できるようなモデルを取っています。

concurrent.worldはこの仕組みで通常の投稿・リプライ・リルートが分類されています。

Association

associationは、日本語で言うところの「関連付け」ですが、これはあるConcrntオブジェクトに対して第三者が情報を付与するのに使うことができます。 分かりやすい例では、ふぁぼやリアクションがこれを用いることで実装可能です。より面白いのは、これがリプライやリルートのメッセージにも使えるということです。これらは「元のメッージに対するリプライ一覧の参照」のように、メッセージ間の関連を示すのに役立ちます。

また、associationの所有者はその付与先のユーザーになります。これにより、付与されたユーザーは自由に受け取ったふぁぼやリアクションを消去することができます。 これはリプライとの関連付けにおいて非常によく機能します。というのも、自分のメッセージに対して不快なリプライが付けられた際、そのリプライを自分のメッセージを見る人の視界から消すことができます。 一方で、リプライした人のメッセージ自体は消去されないしできないので、その人の発言権までは侵害されず、適切なバランスが保たれます。

Timeline

timelineはmessageやassociationを時系列に並べたものです。concurrent.worldでは、ユーザータイムラインやコミュニティタイムラインがこれを用いて実装されています。 タイムラインはユーザー所有のものとドメイン所有のものがそれぞれ選択できます(Version1)。これは、ユーザーのホームタイムラインはユーザーが引っ越した場合に一緒に引っ越されるべきですが、コミュニティタイムラインなど共用性の高いタイムラインはその件数も多くなることが想定され、ユーザーの引っ越しに伴って移動が必須になるのは大変だからです。

リアルタイムセッション

Concrntはwebsocketを使ってリアルタイムイベントを受信できます。 また、受信したいタイムラインが複数サーバーにまたぐ場合でも、自分の所属するサーバーだけにソケットを張っておけば、そのサーバーが間接的に外部サーバーのイベントをリレーしてくれます(図10)。

図11 Concrntのリアルタイム購読フロー

タイムラインの購読

Concrntはのタイムラインの購読はオンデマンドです。このデザインは、愚直な実装ではレスポンスが遅くなってしまうものの、キャッシュによって効率的に行うように工夫されています。 Concrntはタイムラインを10分間隔のチャンクに分割しており、これによりデータの管理が容易になります。

また、タイムラインのチャンクは基本的にスパースになりがちです。例えば、深夜帯はメッセージの件数が減りますよね。ですので、タイムラインのデータそのものを保有するチャンクに加えて、最寄りのデータチャンクへの参照を保持するイテレーターチャンクを中間に挟んでいます(図12)。 こうすることで、より効率よくタイムラインのデータを保持・解決することができます。

図12 イテレーターチャンクとデータチャンクの関係

タイムラインを購読する際には、これらのチャンクがキャッシュから一括で取得され、キャッシュミスがあった場合はDBないしはネットワークから追加の取得が行われます。その後、必要な範囲のデータのみがソートされ、ユーザーに返却されます。

この方法では、一見すると過去10分に満たない最新のチャンクはキャッシュできません。というのも、内容が未確定なのでいつ更新されるかが分からないからです。 ですが、前節で述べた通り、Concrntにはリアルタイムのセッションの仕組みがあります。これを用いて、10分のウィンドウの間にユーザーの要求によって作成されてしまった未確定のチャンクのキャッシュに関しては チャンクの内容が確定する時刻が来るまでリアルタイムでキャッシュを更新し続けています。

Concrntはこのような仕組みにより、ローカルだけでなく複数のリモートを織り交ぜた、様々なタイムラインからなるタイムラインをユーザーに迅速に提供しています。