Webex Teams API Clientを使って、Cisco Webex TeamsのBotを作ってみる(準備編)

# 1. Webex Teams API Client

Cisco Webex Teamsのメッセージング関連のAPIを簡単に呼び出せるように、
非公式のWebex Teams API Clientを実装してみました。

Cisco Webex TeamsのAPI自体は、シンプルなものですが、
利用時にそれなりの注意点はあるので、その辺も考慮して実装しています。

今後の記事では、.NET利用者ではない方が、
Cisco Webex TeamsのAPIを利用することも考慮して、その辺りの注意点にも触れていこうと思います。

.NET利用可能な方は、NuGet経由で入手できます。
ソースコードは、GitHubで公開しています(MIT License)。

[![nuget](https://img.shields.io/nuget/v/Thrzn41.WebexTeams.svg?style=plastic)](https://www.nuget.org/packages/Thrzn41.WebexTeams)  [nuget上のパッケージ](https://www.nuget.org/packages/Thrzn41.WebexTeams)
[![GitHub](https://img.shields.io/github/license/Thrzn41/WebexTeamsAPIClient.svg?style=social&label=GitHub)](https://github.com/thrzn41/WebexTeamsAPIClient)  [GitHub上のリポジトリ](https://github.com/thrzn41/WebexTeamsAPIClient)
[GitHub上の日本語Readme](https://github.com/thrzn41/WebexTeamsAPIClient/blob/master/README.ja-JP.md)

# 2. 前提となる知識、準備

以下の、２つの記事が前提条件の知識と準備になります。

* [Cisco Webex Teamsの無償アカウント作成とクライアントアプリの入手](https://qiita.com/thrzn41/items/2454506a405f9606530c)
* [Cisco Webex TeamsのBot用のアカウントの作成](https://qiita.com/thrzn41/items/672870bb8a6ab2ca44b2)

# 3. Cisco Webex Teams API Clientのインスタンスを作成するまで

## 3-1. 必要なパッケージ

NuGetのパッケージマネージャなどを利用して、`Thrzn41.WebexTeams`を入手後に、
`Thrzn41.WebexTeams`, `Thrzn41.WebexTeams.Version1`, `Thrzn41.Util`が利用可能になるはずです。

usingするなら、以下でほぼすべての機能が利用できます。

``` csharp
using Thrzn41.WebexTeams;
using Thrzn41.WebexTeams.Version1;
using Thrzn41.Util;
```

## 3-1. Botのトークンを暗号化して保存する

認証関連の処理で利用するトークンを保護することは重要です。
ユーザの環境変数に保存する例を見かけることがありますが、個人的には快く思っていません。

トークンは、暗号化して保存した方がよいでしょう。
Webex Teams API Clientで利用可能な、`Thrzn41.Util.LocalProtectedString`を使ってトークンを暗号化できます。

大まかには、以下のような感じです。

``` csharp
char[] tokens = GetBotTokenFromBotOwner();

var protectedToken = LocalProtectedString.FromChars(tokens);
LocalProtectedString.ClearChars(tokens);

Save("token.dat",   protectedToken.EncryptedData);
Save("entropy.dat", protectedToken.Entropy);
```

暗号化するにあたり、いったんは、生のトークン文字列を入手する必要があります。
1番最初にアプリとしてどうやって生のトークン文字列を入力してもらって受け取るかを、
様々な環境で動作するように、かつ、本記事で記載できるほど簡潔に実装するのは難しいですが、
本記事最後に、リーズナブルな方法での実装にトライしてみようと思います。

## 3-2. LocalProtectedStringの特性を知る

暗号化できるからと言って、安全であるとは言い切れないので、
何ができて、何ができないのかを知ることによって、より適切な利用方法がとれると思います。

* プロセスの仮想メモリ内では安全ではない。

`LocalProtectedString`は、仮想メモリ内では、簡単に復号できる状態なので、
特に安全ではありません。
`LocalProtectedString`を使って暗号化したトークン文字列を出力したデータ(ファイルなど)自体は、
`LocalProtectedString`が提供している暗号化の保護の範囲で安全といえます。

`LocalProtectedString`は、内部的には、`System.Security.Cryptography.ProtectedData`を利用しています。

* 暗号化したデータは、別のマシンでは復号できない。

暗号化や復号の際のキーの生成に、ローカルユーザ、または、ローカルマシン固有の情報を利用しているため、
一般的な方法では、別のマシンでは、復号できません。
ローカルマシンを再構成した場合も復号できなくなります。
この場合は、トークンを再入手して、再度、暗号化する必要があります。

ローカルユーザ単位で暗号化するか、ローカルマシン単位にするかは、
`LocalProtectedString`のインスタンス作成時のオプションで指定可能です。

暗号化と復号の処理の場所の話であって、保存先は、マシンをまたぐことができます。

* 暗号化したデータと、エントロピーデータは別次元で管理する。

`LocalProtectedString.Entropy`は、暗号化をより複雑にするために利用されるデータです。
暗号化されたトークンのデータ`LocalProtectedString.EncryptedData`とは、別々に管理した方が
安全性はちょっと高まると思います。
例えば、暗号化したデータとエントロピーを、別々のデータベースサーバに保存すると、
２つのデータベースサーバから情報が流出しないと、
復号は、より難しくなるので、安全性は高まります。

## 3-3. Botのトークンを復号して、Cisco Webex Teams API Clientのインスタンスを作成する

保存した暗号化済みのトークンを読み込んで、
Webex Teams API Clientのインスタンスを作成する手順は、主には以下のような感じになります。

このプロセス内では、
**Webex Teams API Clientのインスタンスを作成後に、トークンは復号された状態**になっています。

``` csharp
byte[] encryptedData = Load("token.dat");
byte[] entropy       = Load("entropy.dat");

var protectedToken = LocalProtectedString.FromEncryptedData(encryptedData, entropy);

TeamsAPIClient teams = TeamsAPI.CreateVersion1Client(protectedToken);
```

## 3-4. 実際にBotのトークンの暗号化と復号の実装を考えてみる

実際に実装する際は、それなりにいろいろやらないと行けないです。
本記事で書き切る程度、かつ、いろんな環境で動作させる、お手頃な方法はなかなか難しいですが、
以下の実装辺りがぎりぎりの妥協点かもしれません。

環境が決まっているなら、それに合わせて、ちゃんと作りこんだ方がよいです。

### 暗号化して保存

1. アプリと同じディレクトリに、`BotToken.txt`ファイルを置いて、そこの1行目に生のBotトークンを入れて保存する。
1. アプリを実行すると、`BotToken.txt`ファイルの1行目を読み込んで暗号化する。
1. 暗号化したトークンは、ユーザディレクトリに出力、エントロピーはアプリと同じディレクトリに出力する。
1. `BotToken.txt`ファイルの中身は削除する。

> `BotToken.txt`ファイルの中身は何度かランダムデータを書き込んでから削除していますが、
> 気休め程度の方法です。
> 最近のディスクのファーム、ドライバ等の実装によっては、この方法は効果的ではありません。

``` csharp
// 実行ファイルのパスを取得
var executingPath = new FileInfo(Assembly.GetExecutingAssembly().Location);

// 生のBotトークンが保存されたファイル
string botTokenFilePath = String.Format("{0}{1}BotToken.txt", executingPath.DirectoryName, Path.DirectorySeparatorChar);

// エントロピーの保存先
string entropyFilePath = String.Format("{0}{1}entropy.dat", executingPath.DirectoryName, Path.DirectorySeparatorChar);


// 暗号化されたBotトークンの保存先として、ユーザプロファイルディレクトリ配下に、'.ciscoteamsapiclient'ディレクトを作成
var userProfilePath  = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
var encryptedFileDir = userProfilePath.CreateSubdirectory(".ciscoteamsapiclient");

// Botトークンの保存先
string encryptedFilePath = String.Format("{0}{1}bottoken.dat", encryptedFileDir.FullName, Path.DirectorySeparatorChar);


// Botトークンを読み込んで暗号化します(1行目にトークンがあると前提)。
LocalProtectedString protectedToken;

using (var fs     = new FileStream(botTokenFilePath, FileMode.Open, FileAccess.Read, FileShare.None))
using (var reader = new StreamReader(fs, true))
{
  protectedToken = LocalProtectedString.FromString(reader.ReadLine());
}

// 暗号化したBotトークンとエントロピーをファイルに保存します。
using (var encryptedFs = new FileStream(encryptedFilePath, FileMode.Create, FileAccess.Write, FileShare.None))
using (var entropyFs   = new FileStream(entropyFilePath,   FileMode.Create, FileAccess.Write, FileShare.None))
{
  encryptedFs.Write(protectedToken.EncryptedData, 0, protectedToken.EncryptedData.Length);
  entropyFs.Write(protectedToken.Entropy,         0, protectedToken.Entropy.Length);
}


// 生のBotトークンが保存されたファイルにランダムな値を書き込んでから消去します。
// この方法は完全ではなく、ディスクのファーム、ドライバ実装によってはあまり意味がない方法。
using (var fs = new FileStream(botTokenFilePath, FileMode.Open, FileAccess.Write, FileShare.None))
{
    CryptoRandom rand = new CryptoRandom();

    var data = new byte[fs.Length];

    for(int i = 0; i < 16; i++)
    {
        rand.FillBytes(data);

        fs.Write(data, 0, data.Length);

        fs.Position = 0;
    }

    fs.SetLength(0);
}

Console.WriteLine("Botのトークンを暗号化して保存しました。");
Console.WriteLine("生のトークン情報は削除しました。");
Console.WriteLine("暗号化したBotのトークン保存先: {0}", encryptedFilePath);
Console.WriteLine("エントロピーの保存先: {0}", entropyFilePath);

Console.WriteLine("何かキーを押すと終了します");
Console.ReadKey(true);
```

### 暗号化したデータを読み込んで、Cisco Webex Teams API Clientのインスタンス作成

こちらは暗号化よりは、わかりやすいと思います。
暗号化の際に出力された、エントロピーのファイルを、このアプリと同じディレクトリにコピーしておく必要があります。

``` csharp
// 実行ファイルのパスを取得
var executingPath = new FileInfo(Assembly.GetExecutingAssembly().Location);

// エントロピーの保存先
string entropyFilePath = String.Format("{0}{1}entropy.dat", executingPath.DirectoryName, Path.DirectorySeparatorChar);


// 暗号化されたBotトークンの保存先として、ユーザプロファイルディレクトリ配下に、'.ciscoteamsapiclient'ディレクトを取得
var userProfilePath  = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
var encryptedFileDir = userProfilePath.CreateSubdirectory(".ciscoteamsapiclient");

// Botトークンの保存先
string encryptedFilePath = String.Format("{0}{1}bottoken.dat", encryptedFileDir.FullName, Path.DirectorySeparatorChar);


// 暗号化されたBotトークンを読み込んで、復号します。
LocalProtectedString protectedToken;

using (var encryptedFs   = new FileStream(encryptedFilePath, FileMode.Open, FileAccess.Read, FileShare.None))
using (var entropyFs     = new FileStream(entropyFilePath,   FileMode.Open, FileAccess.Read, FileShare.None))
using (var encryptedData = new MemoryStream((int)encryptedFs.Length))
using (var entropyData   = new MemoryStream((int)entropyFs.Length)))
{
  encryptedFs.CopyTo(encryptedData);
  entropyFs.CopyTo(  entropyData);

  protectedToken = LocalProtectedString.FromEncryptedData(encryptedData.ToArray(), entropyData.ToArray());
}

// Webex Teams API Clientのインスタンスを作成します。
TeamsAPIClient teams = TeamsAPI.CreateVersion1Client(protectedToken);
```