LoginSignup
5
4

More than 3 years have passed since last update.

個人開発でも気軽に使える Copy Protection のサービスをつくりました

Last updated at Posted at 2020-12-05

この記事は個人開発 Advent Calendar 2020 6日目の記事です。

昨日は @yakipudding さんによる「Reactで作成したポートフォリオサイトをNextに移行した」でした。
改良後のデザインがおっしゃられるとおりとてもスッキリしていて、センスの良さが本当に羨ましかったです。

はじめに

作った理由はこちらに書いたとおりです

3ヶ月ぐらいでできるかと見積もっていたのですが実際はリリースまで6ヶ月もかかってしまい、自分の effort estimation の精度に自信をなくして落ち込んだりもしたのですが、単に私の productivity が普通のエンジニアの半分ぐらいしかなかっただけなのかもしれない1と気をとりなおして今は元気な私はソフトウェア工学屋崩れ2の野良犬です
(U^ω^)

関係ないのですが Software Engineering at Google 読んでて、エンピリカルなソフトウェア工学屋さんの言ういわゆる effort の事を一貫して endeavor って呼んでるのが珍しい言い方3だなとおもいました

こちらでは RPi の話しか書かなかったのですが、RPi4 だけでなく Linux でも Mac でも使える汎用5のコピープロテクションですので、皆様もこちらに書いたみたいな目にあったりされましたら(されないように)使っていただければと思いまして、老爺心ながら使い方をご紹介させていただきます次第です

サービスの紹介

KoshitnoSansi ライブラリと共にアプリケーションのコピープロテクションを提供するクライアント - サーバ型の No license file な application anti-piracy サービスです

コピープロテクションって一般にライセンス定義ファイルとかライセンスキーとかを使ってアプリケーションを unauthorized なコピーから保護するものなのですが、ストレージがむき出しな RPi だとそれが弱点になると考え、それらを使わないで実現するためにクライアント-サーバ型で実装しました

クライアント-サーバ型であることで、ネットワークに接続していないスタンドアロンなアプリでは利用できないというネガティブな副作用があるのですが、逆に、扱いが煩わしく失敗すると事故の原因になりかねないライセンスファイルをつかわないですむのでお気軽お手軽というポジティブな副作用も生じております。世の中捨てる神あれば拾う神あり、人間万事塞翁が馬ということなのでございましょう

koshinto と sansi のコピープロテクションの仕組み

こちらに書いたとおりなのですが、sansi ライブラリはユーザーがダウンロードする時からその内部にアプリケーションの束縛条件を一意に表す ID (以下、Bind_id と呼びます)をバイナリで埋め込んであります

sansi ライブラリの提供する confirm() 関数は、呼ばれると各種の環境情報(デバイスのシリアルナンバー、IP アドレス、Mac アドレス等)を収集して、Bind_id と一緒に Koshinto に送信します

koshinto サーバは sansi から送信された情報と、内部に保存するアプリケーションの束縛条件をチェックして、OK/NG の返答をセキュアに返します

koshinto からの OK/NG の応答を confirm() は戻り値でアプリケーションにします

以上、アプリケーションは起動時に confirm() を呼んで、戻り値が NG だったら exit するように一文を入れるだけで copy protection の実装が完了するわけでございます。これは簡単!

スクリーンショット 2020-11-22 12.35.21.png

この仕組自体の anti-tamper についてはこちらに紹介いたしました

使用例

サンプルアプリケーション

10秒間の秒読みを行う、シチュエーションによってはとても practical なアプリケーションです

main.simple.c
/*
* 10 second countdown, copy guarded by sansi
* 
* @author Dr. Takeyuki UEDA
* @copyright Copyright© Atelier UEDA 2020 - All rights reserved.
*/

#include <stdio.h>  // for printf
#include <unistd.h> // for seep
void tenseccount();

int main(){

  tenseccount();
  return 0;
}

void tenseccount(){
  int i;
  printf("start 10 second countdown\n");
  for (i=10; i>0; i--){
    printf("%d\n",i);
    sleep(1);
  }
  printf("0!\n");
}

Practical なので悪意の第三者による意図せぬコピー、著作権侵害をふせぐために sansi を組み込んで保護しましょう

sansi の組み込み

main.simple.c
/*
* 10 second countdown, copy guarded by sansi
* 
* @author Dr. Takeyuki UEDA
* @copyright Copyright© Atelier UEDA 2020 - All rights reserved.
*/

#include "sansi.h"  // for sansi libraries

#include <stdio.h>  // for printf
#include <unistd.h> // for seep
void tenseccount();

int main(){

  if (ok_confirmed == confirm(NULL, NULL, NULL)){
    printf("OK\n");
  } else {
    printf("NG\n");
    return -1;
  }

  tenseccount();
  return 0;
}

void tenseccount(){
  int i;
  printf("start 10 second countdown\n");
  for (i=10; i>0; i--){
    printf("%d\n",i);
    sleep(1);
  }
  printf("0!\n");
}

追加したのは以下の2点で
- 8行目: sansi.h のインクルード
- 16-21行目: アプリの最初に confirm() を呼び、戻り値が ok_confirmed でなければ異常終了

サンプルプロジェクトの clone

完成品はこちらにございますので、git clone して

MacBook-Air:tmp takeyuki$ git clone https://github.com/UedaTakeyuki/sansi_examples.git

sansi_examples/c フォルダに移動してください

MacBook-Air:tmp takeyuki$ cd sansi_examples/c

ls してみるといろいろなファイルがあります

MacBook-Air:c takeyuki$ ls
README.md   compile.sh  main.c      main.simple.c   sansi.h

左からそれぞれ README、 compile スクリプト、別のサンプルの main.c (本稿ではつかいません)、先程紹介した main.simple.c、そして sansi のヘッダーファイルです

ここでは紹介しませんが、C アプリの他に C++, go, python, bash の組込例も用意させていただきました
go は cgo で sansi をリンクする例をご紹介させていただきました。vlang も同様にできるように思っているのですけどまだ試せていません。どなたか pull req いただければ感謝の言葉もございません
python は nuitka でコンパイルして strip すること、bash は shc とかでバイナリ化する前提です

sansi のダウンロード

Koshinto にログインして Home から [Binds]-[All] を選択してください

スクリーンショット 2020-11-30 18.29.08.png

アカウントを作った直後だと、一つだけですが Bind がすでにあるのでこれを選択して

スクリーンショット 2020-11-30 18.28.51.png

メニューから Sansi library Download を開き
スクリーンショット 2020-11-30 18.29.24.png

アプリケーションのターゲットに合わせてライブラリをダウンロードしてください

スクリーンショット 2020-11-30 18.29.37.png

ダウンロードしたライブラリを先程 git clone したプロジェクトにコピーしてください

MacBook-Air:c takeyuki$ cp /Users/takeyuki/Downloads/libsansi_ZbPdGoGyrNkQ_mac_v1.1.o .
MacBook-Air:c takeyuki$ ls
README.md               main.c
compile.sh              main.simple.c
libsansi_ZbPdGoGyrNkQ_mac_v1.1.o    sansi.h

アプリケーションのコンパイル

コンパイルスクリプトの使い方は

MacBook-Air:c takeyuki$ ./compile.sh -h
Usage: ./compile.sh [-h][-c][-g][-m][-o obj] [source] [libsansi]
  [source]:   compiling source file, default is 'main.sample.c' 
  [libsansi]: path for linking 'libsansi….o', default is found it in cwd automatically 
  [-h]: show this usage and exit
  [-c]: compile for linux by clang
  [-g]: compile for linux by gcc, this is default
  [-m]: compile for mac by clang
  [-o obj] set compiled object file name, default is a.out
MacBook-Air:c takeyuki$ ./compile.sh -m

デフォルトで main.sample.c をコンパイルして、デフォルトで a.out をつくります。デフォルトで sansi ライブラリをよしなにさがしてくれるので便利です

Mac 用のコンパイルの場合

Mac 用のコンパイルであることを指示する -m を指定します
bash:
MacBook-Air:c takeyuki$ ./compile.sh -m
source = main.simple.c
libsansi = libsansi_ZbPdGoGyrNkQ_mac_v1.1.o
compiler = clang
obj = a.out
compiling…

尚、sansi は OpenSSL に依存するのですが、Mac だと OpenSSL がインストールされていないかもしれません。その旨のエラーがでた場合はインストールしてください

Linux 用のコンパイルの場合

Linux の場合はなにも指定する必要がありません

pi@raspberrypi:~/sansi_examples/c $ ./compile.sh 
source   = main.simple.c
libsansi = libsansi_ZbPdGoGyrNkQ_arm_v1.1.o
compiler = gcc
obj      = a.out
compiling…

-c を指定すると gcc の代わりに clang でコンパイルします

pi@raspberrypi:~/sansi_examples/c $ ./compile.sh -c
source   = main.simple.c
libsansi = libsansi_ZbPdGoGyrNkQ_arm_v1.1.o
compiler = clang
obj      = a.out
compiling…

例1. アプリケーションを Mac のシリアル番号に束縛する

Koshinto に戻って Bind のステータスを確認すると Not Active になっているはずです

スクリーンショット 2020-11-30 17.23.33.png

アプリケーションを Bind する方法は以下の3通りがあります
- 直接、値を入力して Bind
- sansi が取得してきた値を確認して Bind
- sansi が取得してきた値に自動的に Bind

正常な Bind のステータスは5通りあって、上記の3つの方法によってステータスの遷移がかわります。詳細については
こちらを参照ください

1. 事前に取得した key の値に直接束縛する場合

Koshinto に戻って Bind の [Keys] を開きます

スクリーンショット 2020-11-30 19.45.28.png

調べておいた Mac のシリアルナンバーを Platform Serial Number に入力して lock を✓し、右下の UPDATE をクリックします
スクリーンショット 2020-11-30 19.53.15.png

Status を開き、Binding を選択して右下の UPDATE をクリックします
スクリーンショット 2020-12-02 18.56.05.png

Mac に戻って a.out がブロックされずに実行できているのが確認できます

MacBook-Air:c takeyuki$ ./a.out
OK
start 10 second countdown
10
9
8
7
6
5
4
3
2
1
0!

2. sansi が取得して送信してきた値から選択して束縛する場合

現在の status は Not Active なので
スクリーンショット 2020-11-30 17.23.24.png

Bind Waiting に変更して
スクリーンショット 2020-11-30 17.32.06.png

右下の UPDATE をクリックします
スクリーンショット 2020-11-30 17.32.15.png

Mac に戻り、先程コンパイルした a.out を実行します
結果は NG になりますが、sansi が取得したMac の環境の各種値が Koshinto に送信されて Bind に反映されているはずです

MacBook-Air:c takeyuki$ ./a.out
NG

現在の Bind の値を Koshinto からブラウザに反映させるために、Bind の右下のフローティング・アクション・ボタン1(グルグルみたいな奴)を一度クリックします

スクリーンショット 2020-11-30 17.24.15.png

keys を開くと値が送信されてきています

スクリーンショット 2020-11-30 17.34.59.png

Platform Serial Number の lock を✓して右下の UPDATE をクリックします

スクリーンショット 2020-11-30 17.35.08.png

Status は Bind Waiting から Bind Requesting に遷移しています

スクリーンショット 2020-11-30 17.33.26.png

Status を Binding に変更します

スクリーンショット 2020-11-30 17.33.43.png

変更したら右下の UPDATE をクリックして変更を反映させます

スクリーンショット 2020-11-30 17.35.33.png

Mac に戻って a.out を実行してみると、今度は正常に起動して10秒の秒読みが実行されます

MacBook-Air:c takeyuki$ ./a.out
OK
start 10 second countdown
10
9
8
7
6
5
4
3
2
1
0!

3. 何を key にするか決めておいて、sansi が送信してきた値に自動的に束縛する場合

アプリケーションが最初に起動された Mac のシリアルナンバーに自動的に束縛することにします
keys を開いて Platform Serial Number の lock を✓し、右下の UPDATE をクリックして変更を反映させます

スクリーンショット 2020-11-30 17.22.36.png

現在の Status は Not Active なので

スクリーンショット 2020-11-30 17.23.33.png

Auto Bind Waiting に変更します

スクリーンショット 2020-11-30 17.23.47.png

スクリーンショット 2020-11-30 17.24.00.png

Mac に戻って a.out を実行してみると、初回から正常に起動して10秒の秒読みが実行されます

MacBook-Air:c takeyuki$ ./a.out
OK
start 10 second countdown
10
9
8
7
6
5
4
3
2
1
0!

現在の Bind の値を Koshinto からブラウザに反映させるために、Bind の右下のフローティング・アクション・ボタン(グルグルみたいな奴)を一度クリックします

スクリーンショット 2020-11-30 17.24.15.png

keys の値は sansi が収集して送信してきた値に更新されていて、Platform Serial Number も更新されています

スクリーンショット 2020-11-30 17.25.17.png

Status は Binding に遷移しています

スクリーンショット 2020-11-30 17.25.04.png

例2. アプリケーションを RaspberryPi の SDカードのシリアルIDに束縛する

Koshinto に戻って Bind のステータスを確認すると Not Active になっているはずです

スクリーンショット 2020-11-30 17.23.33.png

アプリケーションを Bind する方法は以下の3通りがあります
- 直接、値を入力して Bind
- sansi が取得してきた値を確認して Bind
- sansi が取得してきた値に自動的に Bind

正常な Bind のステータスは5通りあって、上記の3つの方法によってステータスの遷移がかわります。詳細については
こちらを参照ください

1. 事前に取得した key の値に直接束縛する場合

Koshinto に戻って Bind の [Keys] を開きます

スクリーンショット 2020-11-30 19.45.28.png

調べておいた SD カードのシリアルナンバーを Platform Serial Number に入力して lock を✓し、右下の UPDATE をクリックします

スクリーンショット 2020-12-02 18.49.24.png

Status を開き、Binding を選択して右下の UPDATE をクリックします
スクリーンショット 2020-12-02 18.56.05.png

Raspberry Pi に戻って a.out がブロックされずに実行できているのが確認できます

pi@raspberrypi:~/sansi_examples/c $ ./a.out
OK
start 10 second countdown
10
9
8
7
6
5
4
3
2
1
0!

2. sansi が取得して送信してきた値から選択して束縛する場合

現在の status は Not Active なので
スクリーンショット 2020-11-30 17.23.24.png

Bind Waiting に変更して
スクリーンショット 2020-11-30 17.32.06.png

右下の UPDATE をクリックします
スクリーンショット 2020-11-30 17.32.15.png

Raspberry Pi に戻り、先程コンパイルした a.out を実行します
結果は NG になりますが、sansi が取得したMac の環境の各種値が Koshinto に送信されて Bind に反映されているはずです

pi@raspberrypi:~/sansi_examples/c $ ./a.out
NG

現在の Bind の値を Koshinto からブラウザに反映させるために、Bind の右下のフローティング・アクション・ボタン1(グルグルみたいな奴)を一度クリックします

スクリーンショット 2020-11-30 17.24.15.png

keys を開くと値が送信されてきています

スクリーンショット 2020-12-02 19.04.58.png

SD Card Serial ID の lock を✓して右下の UPDATE をクリックします

スクリーンショット 2020-12-02 19.09.19.png

Status は Bind Waiting から Bind Requesting に遷移しています

スクリーンショット 2020-11-30 17.33.26.png

Status を Binding に変更します

スクリーンショット 2020-11-30 17.33.43.png

変更したら右下の UPDATE をクリックして変更を反映させます

スクリーンショット 2020-11-30 17.35.33.png

Raspberry Pi に戻って a.out を実行してみると、今度は正常に起動して10秒の秒読みが実行されます

pi@raspberrypi:~/sansi_examples/c $ ./a.out
OK
start 10 second countdown
10
9
8
7
6
5
4
3
2
1
0!

3. 何を key にするか決めておいて、sansi が送信してきた値に自動的に束縛する場合

アプリケーションが最初に起動された Raspberry Pi に装着されている SD カードのシリアルIDに自動的に束縛することにします
keys を開いて Platform Serial Number の lock を✓し、右下の UPDATE をクリックして変更を反映させます

スクリーンショット 2020-12-02 19.12.31.png

現在の Status は Not Active なので

スクリーンショット 2020-11-30 17.23.33.png

Auto Bind Waiting に変更します

スクリーンショット 2020-11-30 17.23.47.png

スクリーンショット 2020-11-30 17.24.00.png

Raspberry Pi に戻って a.out を実行してみると、初回から正常に起動して10秒の秒読みが実行されます

MacBook-Air:c takeyuki$ ./a.out
OK
start 10 second countdown
10
9
8
7
6
5
4
3
2
1
0!

現在の Bind の値を Koshinto からブラウザに反映させるために、Bind の右下のフローティング・アクション・ボタン(グルグルみたいな奴)を一度クリックします

スクリーンショット 2020-11-30 17.24.15.png

keys の値は sansi が収集して送信してきた値に更新されていて、SD Card Serial ID も更新されています

スクリーンショット 2020-12-02 19.16.54.png

Status は Binding に遷移しています

スクリーンショット 2020-11-30 17.25.04.png

例3. アプリケーションを Linux Server の Global IP address に束縛する

Koshinto に戻って Bind のステータスを確認すると Not Active になっているはずです

スクリーンショット 2020-11-30 17.23.33.png

アプリケーションを Bind する方法は以下の3通りがあります
- 直接、値を入力して Bind
- sansi が取得してきた値を確認して Bind
- sansi が取得してきた値に自動的に Bind

正常な Bind のステータスは5通りあって、上記の3つの方法によってステータスの遷移がかわります。詳細については
こちらを参照ください

1. 事前に取得した key の値に直接束縛する場合

Koshinto に戻って Bind の [Keys] を開きます

スクリーンショット 2020-11-30 19.45.28.png

調べておいた Global IP addressスクリーンショット 2020-12-03 22.54.14.png
を Global IP に入力して lock を✓し、右下の UPDATE をクリックします

スクリーンショット 2020-12-02 20.37.02.png

Status を開き、Binding を選択して右下の UPDATE をクリックします
スクリーンショット 2020-12-02 18.56.05.png

Linux に戻って a.out がブロックされずに実行できているのが確認できます

ueda@amfortas:~/sansi_examples/c$ ./a.out
OK
start 10 second countdown
10
9
8
7
6
5
4
3
2
1
0!

2. sansi が取得して送信してきた値から選択して束縛する場合

現在の status は Not Active なので
スクリーンショット 2020-11-30 17.23.24.png

Bind Waiting に変更して
スクリーンショット 2020-11-30 17.32.06.png

右下の UPDATE をクリックします
スクリーンショット 2020-11-30 17.32.15.png

Linux に戻り、先程コンパイルした a.out を実行します
結果は NG になりますが、sansi が取得したMac の環境の各種値が Koshinto に送信されて Bind に反映されているはずです

ueda@amfortas:~/sansi_examples/c$ ./a.out
NG

現在の Bind の値を Koshinto からブラウザに反映させるために、Bind の右下のフローティング・アクション・ボタン1(グルグルみたいな奴)を一度クリックします

スクリーンショット 2020-11-30 17.24.15.png

keys を開くと値が送信されてきています

スクリーンショット 2020-12-02 19.04.58.png

Global IP の lock を✓して右下の UPDATE をクリックします

スクリーンショット 2020-12-03 22.56.17.png

Status は Bind Waiting から Bind Requesting に遷移しています

スクリーンショット 2020-11-30 17.33.26.png

Status を Binding に変更します

スクリーンショット 2020-11-30 17.33.43.png

変更したら右下の UPDATE をクリックして変更を反映させます

スクリーンショット 2020-11-30 17.35.33.png

Linux に戻って a.out を実行してみると、今度は正常に起動して10秒の秒読みが実行されます

ueda@amfortas:~/sansi_examples/c$ ./a.out
OK
start 10 second countdown
10
9
8
7
6
5
4
3
2
1
0!

3. 何を key にするか決めておいて、sansi が送信してきた値に自動的に束縛する場合

アプリケーションが最初に起動された Raspberry Pi に装着されている SD カードのシリアルIDに自動的に束縛することにします
keys を開いて Platform Serial Number の lock を✓し、右下の UPDATE をクリックして変更を反映させます

スクリーンショット 2020-12-03 22.59.42.png

現在の Status は Not Active なので

スクリーンショット 2020-11-30 17.23.33.png

Auto Bind Waiting に変更します

スクリーンショット 2020-11-30 17.23.47.png

スクリーンショット 2020-11-30 17.24.00.png

Linux に戻って a.out を実行してみると、初回から正常に起動して10秒の秒読みが実行されます

ueda@amfortas:~/sansi_examples/c$ ./a.out
OK
start 10 second countdown
10
9
8
7
6
5
4
3
2
1
0!

現在の Bind の値を Koshinto からブラウザに反映させるために、Bind の右下のフローティング・アクション・ボタン(グルグルみたいな奴)を一度クリックします

スクリーンショット 2020-11-30 17.24.15.png

keys の値は sansi が収集して送信してきた値に更新されていて、Global IP も更新されています

スクリーンショット 2020-12-03 23.03.39.png

Status は Binding に遷移しています

スクリーンショット 2020-11-30 17.25.04.png

詳説

1. Bind の5種類の状態と、アプリケーションを Bind する3通りの方法

正常な Bind のステータスは以下の5つのどれかになります。各ステータスで confirm() の通知を受けた際の戻り値と、koshinto 内の bind の key と status の変化を表にまとめました

status return koshinto の bind の key koshinto 内の bind の status
Not Active 常に ng_comfirmed 変化なし 変化なし
Bind Waiting 常に ng_comfirmed confirm からの値で更新 Bind Requestingに遷移
Bind Requesting 常に ng_comfirmed 変化なし 変化なし
Auto Bind Waiting 常に ok_comfirmed confirm からの値で更新 Binding
Binding confirm からの値によって ng_comfirmed またはok_comfirmed 変化なし 変化なし

Not Active が、Bind が使われていない状態です
Binding が、アプリケーションが束縛されている状態です

アプリケーションを Bind する方法は3種類あって

  1. キーの値を設定してロックし、状態を Binding に変更
  2. 状態を [Bind Waitingに変更]して sansi からの confirm()を待つ。bind の key が更新され、状態が [Bind Requesting] に変わるので、適切な key を選んでロックし、状態を [Binding]に変更
  3. 適切な key を選んでロックしてから状態を [Auto Bind Waitingに変更]して sansi からの confirm()を待つ。key の値が更新されて自動的に [Bind]状態になる

束縛したい key の値(今の場合は Mac のシリアルナンバー)を事前に知っていて(「このマックについて」で確認済で)、その値を使って束縛する場合に 1. の手順になります。調べるのってめんどくさかったり typo があったりすると思うので後述の 2. や 3. の手順をお薦めいたしますが、Bind の状態の推移はシンプルに次のようになります

[Not Active] -> [Bind]

束縛したい key の値を知らない、もしくは自分で調べるのがめんどくさいので sansi に任せたい場合、sansi が送ってくる値を確認して問題がなければその値に束縛する場合が 2. の手順になります。遷移は

[Not Active] -> [Bind Waiting] -> [Bind Requesting] ->[Bind]

確認せずに自動的にその環境に束縛するのが 3. の手順になります。遷移は

[Not Active] -> [Auto Bind Waiting] -> [Binding]

related works

こちらを参照いただければ幸いです

名前の由来

Koshinto と Sansi の名前の由来はこちらを参照賜われれば至福の至に存じます次第です

結び

長々と書いてしまい本当にどうもすみません、今は謹んで反省しております次第です、無駄に長生きした年寄りの話が無駄に長いのは相応というものでございますれば、エンジニアの情けで許してくだされ/(^o^)\

references

明日は

明日は@azukisiromochiさんによる「アプリ名『焼き鳥』事件 😱😱😱」です!私は前日なのでメンションもらって限定公開の頃から読ませて頂いてたのですが、正直、ものすごくおもしろい、ためになるお話なんですよ!お楽しみに!


  1. もともと RPi だけの、それも SD CARD と Board の ID だけのつもりだったのに、つい NIC を追加したら Mac や Linux 一般にも欲がでて、という途中からの仕様追加を個人開発なのにやりまくったのも悪かったかもしれません 

  2. 無駄に工学博士持ってます。せっかく博士なんだから、何時の日か自分の戦隊から「博士を守るんだ!」とかやってもらうのが幼少の折より今に至る夢です。戦隊コスとかの人、本物の工学博士を守ってみませんか?ちなみに「博士、お薬の時間ですよ」とかは経験済です 

  3. タイトルに反してそういう人達へのネガティブな感情が前提にあるのかと読んでて勘ぐりました 

  4. Raspberry Pi 以外の各種 IoT ボードも Linux ベースであれば IP address や MAC IDに束縛することで利用可能です。ボード固有のIDのへの束縛も対応していきたく、リファレンスボードの donation を募集してます 

  5. Windows は持ってないので、開発に使える PC の donation を募集しています 

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4