FileMakerでセキュアーにパスワード認証をする（2）

昨年のアドベントカレンダーに書いた、[FileMakerでセキュアーにパスワードを認証をする](https://qiita.com/HAZI/items/e7d4c1e10ed43be29c8f) の続きです。

## 振り返り

前回の内容を簡単にまとめると

- パスワードは復号可能な状態で保存することはアンチパターンとして有名で、`CryptEncrypt` で暗号化して保存、 `CryptDecrypt` で復号して認証ということを行うのはやってはいけません。
  - DBの管理者がパスワードを知ることができるのは、利用者にとって大きなリスクです。
  - 利用者に他で使っているパスワードを使わないように完璧に強要するのは無理ですし、パスワードをどこかにメモする危険性があります。
- なので、パスワードを復号できない状態に暗号化して認証しましょう。
- それにはハッシュ化、ストレッチング、ソルトという方法がありますよ。

という内容で、作成したハッシュ関数を公開しました（その他詳細は[前回の記事](https://qiita.com/HAZI/items/e7d4c1e10ed43be29c8f)をご覧ください）。

が、正直使い勝手悪いし信頼性に欠ける部分もあり、記事の内容が専門的な話ばかりで使い方が良くわからないと思われた方も多いのではないでしょうか。

今回はそのあたりを見直し完全に新しい関数を作ってきました。

## 目次

- 旧関数（RawPasswordToHash）の問題点
- 使い方
- 実装説明
- 使い方（応用編）
- 実装プロセス
- 終わりに

## 旧関数（RawPasswordToHash）の問題点

前回開発した関数 `RawPasswordToHash(rawPassword; salt; stretching)` は大きく2つの問題がありました。

**1ユーザーにつき、3つの値をバラバラにDBに保存する必要があること**

他言語のパスワード暗号化の関数を調べてみると、3つも値を保存するなものは存在せず基本的には1つの値で完結しています。3つも値があるとそれだけフィールドは増えるし、各値の意味を理解しないといけないし面倒ですよね。

本来、1つで済むはずなのでそうするべきではないか。ということに前回の記事を書いて数日して気がつきました。

**完全な独自実装であること**

`RawPasswordToHash` は完全な独自実装で他の方のチェックが一切入ってません。
一応気をつけて実装はしているものの、専門家じゃない一人の人間が作ったものというのは信頼性に欠けます。

実際に今回作り直すことで、前回の関数の「ストレッチング処理が単純すぎる」という問題が発覚しました。

## 使い方

今回は2つの問題点を見直し、新しい関数を作成しました。

実装情報はGitHubにすべておいてあります。
[hazi/FileMaker\-PasswordHash](https://github.com/hazi/FileMaker-PasswordHash)

インストール方法などはリンク先を参考にしてください。

関数名はPHPを参考にして `PasswordHash ( password ; salt ; rounds )`, `PasswordVerify ( password ; hash )` とシンプルな名前にしました。

ハッシュ値は1つの文字列で一般的なcrypt系の実装に合わせました。

```java
PasswordHash("password"; ""; "")
// => "$6f$nsH8UZ47TpbN3DBH$hJ1MGTJxfdKk3Do1EO72wQM00NeOTEOrZYNRV.TXk5h0Pi5gS461KGE9JBEMjt0b72mZJWFR2qoDW6uF62mbHw"
```

引数の `salt` は空欄の場合自動で `PasswordSaltGenerate` 関数を使って擬似乱数を生成します。空欄推奨です。
 `rounds` は指定しない場合デフォルト値として `5000` が採用されます。

戻り値は基本的に `$` で区切られて3〜4つのパラメーターを持っています。

形式は `$<id>$<option>$<salt>$<encrypted>` となっており、`<option>` がない時は `$` が1つ減ります（上記の例では `<option>` がありません）。

`<option>` はストレッチング回数のみで使用されます。デフォルト値以外のストレッチングを指定すると下記のような感じになります。

```java
PasswordHash("password"; ""; 10000)
// => "$6f$rounds=10000$J877Fdeln3DUTuU.$xZOQhrSvdsyzL18nXj2YecocfxUhf6h7L5QwWIlmkQK3NttG9m5uYzopHSPHqRvshvJ.cFSg0lxXhEtVP6UleA"
```

認証時には `PasswordVerify ( password ; hash )` 関数を使って認証します。

```java
PasswordVerify("password"; "$6f$rounds=10000$J877Fdeln3DUTuU.$xZOQhrSvdsyzL18nXj2YecocfxUhf6h7L5QwWIlmkQK3NttG9m5uYzopHSPHqRvshvJ.cFSg0lxXhEtVP6UleA")
// => 1
```

`True(1)` が返った時には、引数のパスワードを元に作成されたハッシュであることが確認されています。

`PasswordVerify` の戻り値は、他に `False(0)` と、 `"?"` があります。

なので、実際に使用する際には下記のように `Exact` を使って`True(1)`と完全一致しているか確認してください。
`If` などに `"?"` を渡すと `True(1)` を渡した時と同様の挙動になり危険です。

```java
If(Exact(PasswordVerify($password; User::hash); True); "PASS"; "FAIL")
```

ちなみに、`"?"` はエラーを意味しているので、エラーを検知したい場合には下記のようにしましょう。

```java
Let(
  ~result = PasswordVerify($password; $userHash);
  Case(
    Exact(~result; True); "PASS";
    Exact(~result; False); "FAIL";
    "ERROR"
  )
)
```

`PasswordHash ( password ; salt ; rounds )` も同様に `"?"` を返すことがあります。
`"?"` に続いてエラー内容を返すこともあるので、実際に利用する際には下記のようにエラーが発生していないかチェックしてください。

```java
Let(
  ~hash = PasswordHash($password; ""; "");
  Case(
    Left(~hash; 1) = "?";
    "ERROR: " & ~hash;
    "OK"
  )
)
```

実際に使用する際は、`CryptEncrypt`、`CryptDecrypt` を使って認証している場合と同じように行えば大丈夫です。

### 制限

利用している関数の関係で、実行環境は制限されます。

- FileMaker Pro 16 以降
- ランタイムソリューションでの利用不可

テストは主にFileMaker Pro 16, 17, 18で行っています。
FileMaker Goなどではテストしていませんが問題なく動作すると思います。
もし、テストしていただけたらコメントなどで報告いただけると幸いです。

引数にもいくつか制限がります。

**パスワード文字数：8文字以上、64文字以下**
文字数は65文字以上にも対応しようと思えばできるのですが、実装の中で対応する文字数に応じてどうしてもコードを長くしないといけない部分があり制限を持たせる必要がありました。

8文字以上としたのは、そもそもセキュアーな実装を行ったとしても8文字未満の場合はリスクを伴うため、実装としてそのように制限を設けました。

**利用可能文字列：ASCII（不可視文字はスペースのみ）**
password, saltは半角の小文字大文字の英数と記号のみ利用可能です。
不可視文字（スペース、TABやDELなど）はスペースのみ利用可能です。
具体的にはUnicodeの`0020`〜`007E`までに制限しています。

`AsciiFilter(textToFilter)` という関数を使ってチェックしていますので、パスワードに利用できない文字がないか事前にチェックしたい場合には、この関数を使ってください。

```java

If(
  Exact($password; AsciiFilter($password));
  "OK";
  "利用できない文字が混入しています"
)
```

**salt文字数：16文字以下**
これは参考にした実装の制限をそのまま採用しています。
17文字以上指定されている場合は、17文字目以降を削除して利用します。

**ストレッチング回数：1,000〜49,000**
1000以下を指定すると、勝手に1000回になります。これも参考にした実装をそのまま採用しています。
49,000回を超えるストレッチングは可能なのですが、FileMakerの制限で関数の再帰実行が50,000回に制限されているため通常は余裕を見て49,000を上限に運用することをオススメします。

ストレッチング回数は多ければ多いほど安全ですが、多すぎると計算時間がどんどん増えてFileMakerがフリーズしたような状況になるので気をつけてください。

49,000を超えるストレッチングを行う場合にはFileMaker 18以降であれば `SetRecursion` 関数を使うことで対応可能です。

```java
SetRecursion(PasswordHash("Password"; ""; 60000); 60100)
SetRecursion(PasswordVerify($password; User::hash); 60100)
```

再帰実行の限界を超えた場合は、FileMakerの仕様で `"?"` が返ってきます。

## 実装説明

今回暗号化のアルゴリズムの信用性を上げるために、UNIX crypt(3) の SHA-512 実装を元に開発を行いました。完全一致を目指したのですが、残念ながらそれはできませんでした。

### cryptとの違い

認証情報のバージョンを表す最初の `<ID>` は `6f` と独自のIDになっています。
`6` はSHA-512を利用した場合のcryptのIDです。`f` は "FileMaker" の "F" を取りました。

具体的な違いは `<encrypted>` を生成する際の最後の処理でビット演算などを使ってBase64っぽいエンコード処理を行う部分があるのですが、それをFileMakerで再現する方法を思いつかず断念してしまった形です。
代わりにSHA-512でハッシュ化を行なった上でBase64Encodeを行う形をとっています。

_もし、実装方法のヒントだけでもわかる方がいればぜひコメントなどでご連絡ください！_

それ以外の部分はcrypt SHA-512と完全に一致しており、信頼性もそれなりに担保できていると思っています。

### 実装のチェック

実装方法を真似たとしても実装が本当に意図した形になっているのか、バグがないのか。という点に関しては担保されません。

なので、今回rubyの [mogest/unix\-crypt](https://github.com/mogest/unix-crypt) を元に、rubyの実装を先に作成しました。
GitHubのリポジトリ内の [crypt6fm_ruby](https://github.com/hazi/FileMaker-PasswordHash/tree/master/crypt6fm_ruby) に置いてあります。

このruby実装の結果と一致するようにテストしながら FileMaker で実装を行いました。

テストはGitHubのリポジトリ内の[spec ディレクトリ](https://github.com/hazi/FileMaker-PasswordHash/tree/master/spec)にあるFileMakerファイルで行っています。

ruby実装の実行結果・引数をFileMakerファイルに保存し、`PasswordHash ( password ; salt ; rounds )` を実行した際に一致するハッシュが返ってくるか、`PasswordVerify ( password ; hash )` でちゃんと `True` が返ってくるかをチェックしています。

### Ruby実装ある意味

Ruby実装があることで、今回のハッシュがFileMakerの外でも使えるようになっています。

FileMakerとWeb技術を組み合わせたサービスの場合両方でこの認証を使うことができますし、FileMakerをやめてRuby+MySQLで作成したサービスに移行する時も認証情報を改めて1から作り直す手間や、シームレスな新しい認証方法への移行が実現できます。

## 使い方（応用編）

> 追記: 2021-03-23
> 応用編の実装に不用意な接続IDの使用があったので大幅に書き直しております。
> 不用意な接続IDの使用があまりよくない理由は [Get ( 持続 ID ) 関数の罠](https://www.slideshare.net/famlog/getpersistentid) をご覧ください。

パスワード認証用の関数ですが認証記録をデバイスに保存するのにも使用できます。
具体的には、乱数(UUID)、認証用のパスワード、日付をパスワードとしてハッシュを生成し、これをデバイスに保存することでファイルの管理者認証をスキップしパスワード入力の手間を省くようにしています。

ただし、注意点がいくつかあります。

* ブラウザのログイン記憶と似たような実装です。共有のデバイスで使用する場合などには使えませんのでご注意ください。
* 生成した認証情報を盗まれた場合、期限までその認証情報は悪用される可能性があります。



```java
Let([
  ~deviceIdentifier = $deviceIdentifier;
  ~keyword = "Vsa.YmpdEF💖書き換えてね💖*JosjgdkqK6TH";
  ~date = Left(Get(ホストのタイムスタンプ); 7);

  ~list = List(
    Base64EncodeRFC(4648; CryptDigest(~deviceIdentifier; "MD5"));
    Base64EncodeRFC(4648; CryptDigest(~keyword; "MD5"));
    ~date;
  );
  ~password = Substitute(~list; [¶;""]; ["="; ""]);
_=0];
  PasswordHash(~password; ""; "")
)
```

* `$deviceIdentifier` は `Get(UUID)` を利用します。このUUIDはデータベースではなく端末にのみ保存します。
* `~keyword` は単純にスクリプトのみに保存されたパスワードです。

この2つを利用することで、端末にアクセスできる者だけが知り得る情報と、スクリプトの中身を見ることができるものが知り得る情報を使ったハッシュを生成することができるようになります。

* 日付は`Get ( ホストのタイムスタンプ )` の先頭7文字 = 年月のみを使用しています。

これによって月が変わると認証情報が自動的に一致しなくなり無効になります。永遠に有効な認証情報は危険ですので、日付を加えることをオススメします。

`Get ( タイムスタンプ )` や `Get ( 日付 )` を使わない理由は、それらの値はクライアントの日時を返す関数だからです。クライアントの時刻設定を変えてしまえば古い認証が使いまわせてしまいます。

`CryptDigest`でハッシュ化しているのはパスワードの文字数制限の関係上、一定の文字数に制限する必要があるためです。

この生成されたハッシュをデバイスの安全な場所に保存します。DB上には保存しません。

ローカルへの保存方法としては「フィールド内容のエクスポート」や「データファイルに書き込む」を使うこともできますが、ちょっと面倒なので [BaseElements Plugin](https://baseelementsplugin.zendesk.com/hc/en-us/articles/115002990887-About-the-BaseElements-Plugin)の `BE_PreferenceSet` を利用しています。

`BE_PreferenceSet`を使うとmacOSでは `/Users/NAME/Library/Preferences` に指定した名前のファイルでplistファイル（中身はXML）として保存されます。iOS, Windowsでも利用可能です。

BE_PreferenceSetの詳細 : [BE\_SetPreference – BaseElements Plugin Help Centre](https://baseelementsplugin.zendesk.com/hc/en-us/articles/207324937-BE-SetPreference)

```java:デバイス署名
Let([
  ~deviceIdentifier = Get(UUID);
  ~keyword = "Vsa.YmpdEF💖書き換えてね💖*JosjgdkqK6TH";
  ~date = Left(Get(ホストのタイムスタンプ); 7);

  ~list = List(
    Base64EncodeRFC(4648; CryptDigest(~deviseID; "MD5"));
    Base64EncodeRFC(4648; CryptDigest(~keyword; "MD5"));
    ~date;
  );
  ~password = Substitute(~list; [¶; ""]; ["="; ""]);
  ~hash = PasswordHash(~password; ""; "");

  ~domain = "jp.co.yourCompany.solution-name.💖書き換えてね💖";
_=0];
  Case(
    Left(~hash; 1) = "?"; "FAIL";
    BE_PreferenceSet("DeviceAuthentication"; ~hash; ~domain)
    and BE_PreferenceSet("DeviceIdentifier"; ~deviceIdentifier; ~domain)
  )
)
```

デバイス認証時には、`BE_PreferenceGet` を使ってハッシュとUUIDを取得し、上記と同じようにパスワードを作成して一致するかを検証します。

```java:デバイス認証
Let([

  ~domain = "jp.co.yourCompany.solution-name.💖書き換えてね💖";
  ~hash = BE_PreferenceGet("DeviceAuthentication"; ~domain);
  ~deviceIdentifier = BE_PreferenceGet("DeviceIdentifier"; ~domain);

  ~keyword = "Vsa.YmpdEF💖書き換えてね💖*JosjgdkqK6TH";
  ~date = Left(Get(ホストのタイムスタンプ); 7);

  ~list = List(
    Base64EncodeRFC(4648; CryptDigest(~deviceIdentifier; "MD5"));
    Base64EncodeRFC(4648; CryptDigest(~keyword; "MD5"));
    ~date;
  );
  ~password = Substitute(~list; [¶; ""]; ["="; ""]);
_=0];
  Exact(PasswordVerify(~password; ~hash); True)
)
```

ハッシュが一致することを確認したら「再ログイン」スクリプトステップを使って、ログイン処理を行わせます。

## 実装プロセス

完成した関数を見てもなんのこっちゃとなると思うので、今回の実装プロセスの話を少ししたいと思います。

個人的にrubyが好きなので、上記に書いたようにrubyの実装を元に作成しています。
FileMakerの関数に落とし込む際にもrubyの実装を模写するように行いました。

その時のメモ書きを貼っておきます。
rubyのコードをどうやってFileMakerの関数に変えていったのかがわかると思います。

[hazi/UnixCrypt to FileMaker Function.fmfn](https://gist.github.com/hazi/c63eea3b994eda3598a07aa7a01be84c)

今回なんとか実装できるんじゃないか？と思えたのは下記の2つに気付いた時でした。

- バイナリの連結を行う処理は、両方を `HexEncode` してから連結し、`HexDecode` することでバイナリを崩さずにできる。
- `HexDecode(Left(HexEncode($data); 1))` で4bit (0.5byte) 単位でバイナリが分割できる。

正直 `HexEncode`, `HexDecode` を大量に使った関数を実装する日が来るとは思ってみなかったですw

その他、細かな技術的な点は、[前回の記事](https://qiita.com/HAZI/items/e7d4c1e10ed43be29c8f)や "bcrypt", "UNIX crypt" で検索してみてください。

## 終わりに

基本的には今回の関数は完成していると思っていますが、エッジケースのテストなどはまだまだ甘い部分があり想定外のエラーが発生する場合などがあります。

GitHubに公開したのでissueなどでご指摘いただくか、この記事にコメントなどをいただければアップデートは続けたいと考えていますのでお気軽にご連絡ください。英語版READMEの作成協力なども募集中です。

Discord の「FileMaker Casual」サーバーにもよくいます！ @Hi_Noguchi さんが立ち上げた FileMaker 中心の情報共有チャットです。どなたでも参加できますので是非ご参加ください。
招待リンク=> https://discord.gg/g2gp6Ez