OpenAM 14 パスワードレス認証モジュールでResidentKeyを実装した話

# OpenAM 14 パスワードレス認証モジュールのResidentKey実装例


甘美な響きのパスワードレス(FIDO2)認証、その中でもひときわ目を引く(と勝手に思ってる) **residentKey=true** オプション、通称**レジデントキー** WebAuthnのオプションを利用して、ユーザーネームレスログインを実現できます。

OpenAM14に入れるなら是非実装したいと思っておりました。

ログインID入力が要らずに「楽」で、利便性が格段にアップして良いと思います。

#### 普通のパスワードレスのログイン画面
当然ですがログインIDを入力しないとイカンです。
パスワードは入れないで済むけど、これだけだともったいない。
![keyAuthinput.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316098/0fb80fe0-b5aa-e591-934c-07043a0025f6.png)

-----

#### レジデントキーのログイン画面
入力フォームがありません、いきなり認証デバイスのアクションを促します。ものすごい楽です。
![rkeyAuth.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316098/ba572a1f-a4d0-6f40-40e1-3e12eab9131f.png)

-----


ただロカール認証でPINを入力する認証デバイスの場合は、キーを打たないとイカンので、個人的には微妙です。

しかも、以下のように３回アクションが必要で残念です。

1. 認証デバイスタッチ
2. PIN入力
3. 認証デバイスタッチ

指紋が登録されたWindowsHelloや、macOS TouchID、指紋リーダー搭載のUSBキーなど、ローカル認証で指紋を要求するタイプであれば、認証デバイスを一度タッチすれば済むので超便利。


## ResidentKeyはどんな仕組みで動く？

ResidentKeyのキモは **user.id** と **userHandle** です。

user.id、userHandle、CredentialIDの違いは以下表の通り。

名称 | 用途 |
----------  |----------  |
**user.id** | **=userHandle**です。 RPサーバー内でユーザーを特定できる値です。登録時にRPサーバーからクライアントへ送信されます。**RPサーバーが決めます**。|
**userHandle** | **=user.id**です。 RPサーバー内でユーザーを特定できる値です。認証時にクライアントからRPサーバーへ送信されます。**登録時にRPサーバーが指定した値を保存して使います**。|
**CredentialID**|認証デバイス内で鍵ペアを特定できる値です。**認証デバイスが決めます**。|

#### ResidentKey登録
限りなく簡単に説明すると登録時にRPサーバーから送った **user.id** が認証デバイスに記録されます。user.idの決め方詳細は[LDAPのエントリー](https://qiita.com/tonoki/items/5f7d42cada753c93dd63#userid)

![fido2reg-resident.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316098/d250cee8-7807-c759-53aa-7ce967e0af9b.png)

#### ResidentKey認証
認証時はこのRPサーバーはユーザーが分からないまま認証を開始し、 **user.id** が **userHandle** としてクライアントから送られてきます。RPサーバーは 送られたuserHandle=user.idの値(LDAPのentryUUID)からユーザーを特定します。[詳細はLDAPのエントリー](https://qiita.com/tonoki/items/5f7d42cada753c93dd63#userid)

![fido2auth-resident.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316098/a6aee0d0-d094-8cbb-b833-00aca3bc33ac.png)


蛇足ながら...

-------
#### residentKey=falseとの違いは？
**residentKey=true** の場合は、ユーザーが不明で、ユーザーが所持する認証デバイスの **CredentialID** を使えないため、 **challenge** のみで認証を開始します。

**residentKey=false** の場合、ユーザー名が入力され、所持する鍵の **CredentialID** が取得できるので、RPサーバーから **CredentialID** を指定して認証を開始します。
もしユーザーが **CredentialID** に合致する認証デバイスを所持していなければ認証できません。
ユーザーが所持する **CredentialID** 複数個あれば、その全てをクライアントへ送信し、どの鍵を使うか？の判断はユーザーの操作に委ねます。 

-------

#### challengeだけでどうやって認証デバイス内の署名に使う鍵を特定するのか？
認証デバイス内では **rpId≒RPサーバーのFQDN** と **CredentialID** の組み合わせで一意な鍵となるように実装されています。
**challenge** だけの場合は、RPサーバーのURLから **rpId** が取得できるので、同じ **rpId** (RPサーバー)の複数の **CredentialID** が存在する場合があります。その場合は同じRPサーバー向けの存在する全ての鍵をリストした選択画面が表示され、どの鍵で認証するかはユーザーの選択に委ねることになります。
この鍵リスト表示にnameやdisplayname、RP Nameが使われます。CredentialIDや鍵はバイナリで人間には？？？なのです。

![rkeyselecter.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316098/9309124c-dc96-c16f-db37-d00d483d65ca.png)

-------

## OpenAM認証ジュール内の処理
ざっくりと ResidentKey について説明した後は、実際のコードを交えてOpenAM上の処理を説明します。
コードはOpenAMコンソーシアムの[GitHub](https://github.com/openam-jp/openam/tree/issues/62-webauthn-multiKey)にあります。
RPサーバー側の保存データーについてはリンクにも記載しています。
[OpenAM で WebAuthn(FIDO2)Credentialの保存先にLDAPを使う](https://qiita.com/tonoki/items/5f7d42cada753c93dd63)

以下の順番に解説します。
1. 登録開始 RPサーバー
2. 登録 認証デバイス
3. 登録処理 RPサーバー
4. 認証開始 RPサーバー
5. 認証 認証デバイス
6. 認証処理 RPサーバー

### 1. 登録開始 RPサーバー

RPサーバー側でResidentKey登録時に意識するべきことは、navigator.credentials.create()に渡すauthenticatorSelection に　residentKey=true を追加するだけです。
登録時のオプションに指定した **user.id** からユーザーを特定できるようにするのは、OpenAMにおいてはResidentKeyに限った話ではないので常時同じ動作です。
OpenAMの場合、user.idを生成して保存するのではなく、先程の[LDAPストアのリンク](https://qiita.com/tonoki/items/5f7d42cada753c93dd63)の通り、ユーザーのentryUUIDを利用しています。

WebAuthnRegister.java

``` java
前略
　　//ウチはuser.idじゃなくてモロuserHandleIdで名前つけてます。
    private byte[] userHandleIdBytes; 
中略
        //実行中ユーザーのentryUUIDをゲット！
        // Use LDAP entryUUID as userHandleId
        userHandleIdBytes = lookupByteData("entryUUID"); 
    
```


### 2.ResidentKey登録時 認証デバイス動作
ブラウザから呼ばれた認証デバイスは、認証デバイス側でresidentKey=trueだった場合のみ、
認証デバイスにuser.id、user.name、user.displayname、rp.nameが保存されます。
FIDO2に対応したデバイス以外(Google TitanやU2Fのみ対応のキー)は動作しないようです。residentKey=trueにすると、ローカル認証が必須となるため、その機能の無いデバイスではブラウザにエラー(このデバイスは対応していないので、他のデバイスを使うベシ！）が出て登録処理が進みません。**userVerification=required** にしたのと同じ状態になります。

RPサーバーから来たnavigator.credentials.create()のオプション例

```json
前略
rp:{name:OpenAM RP}
user:{id:[99,56,33....](バイナリ),name:testuser01,displayname:Test User01},
authenticatorSelection:{requireResidentKey:true},
後略
```

認証デバイスの中には、以下の値が関連付けられ保存されている状態となります。

名前　| 値 | 説明 |
-----|-----|-----|
rp.id|openam.example.com|(string)RPサーバーのFQDN指定しなくてもブラウザが処理
rp.name|OpenAM RP|(string)上オプションのrp.nameで渡した値（表示用）
user.id|[99,56,33....]|(byte)RPサーバーでuidに紐づけられた値,LDAPのentryUUID
user.name|testuser01|(string)ユーザー名 LDAPならuidなど（表示用）
user.displayname|Test User01|(string)ユーザーの表示名　LDAPならcn（表示用）
CredentialID|生成した鍵のID|(byte)デバイス内で鍵を一意にする
Key(認証デバイス依存)|Key|(byte)鍵

### 3. 登録処理 RPサーバー
RPサーバーとしては鍵の登録処理はresidentKey=falseと差は無いです。
CredentialIDと鍵とカウンターを保存します。

WebAuthnRegister.java

```java
前略
        //認証デバイス（クライアント）の応答を検証
        attestedAuthenticator = webauthnValidator.validateCreateResponse(
                getValidationConfig(), _responseJson, userHandleIdBytes, DEBUG);
        //検証済みの結果を保存
        boolean _storeResult = webauthnService.createAuthenticator(attestedAuthenticator);
後略
```
登録は以上です。認証デバイス側が対応していれば、RPサーバー側は**residentKey=true**を付けるだけです。

### 4. 認証開始 RPサーバー

challengeをnavigator.credentials.get()オプションとして送るだけです。
※type=public-keyも必須なので送ります。

WebAuthnAuthenticate.javaの認証処理開始のフロー

```java
前略
        //この上のケースはMFAの２段目以降に使う処理
        if (useMfaConfig.equalsIgnoreCase("true")) {
中略
            
            // ユーザーがわかっているのでCredentialIDを取得
            // MFA login starting.
            getStoredCredentialId();
            // 取得したCredentialIDを元にnavigator.credentials.get()オプションを生成
            createLoginScript();
            
            nextState = STATE_LOGIN_SCRIPT;

        //このケースがresidentKey処理
        } else if (residentKeyConfig.equalsIgnoreCase("true")) {
中略
            // ユーザー名が不明なのでCredentialIDが探せない
            // CredentialID無い状態でnavigator.credentials.get()用オプションを生成
            // resident key login starting.
            createLoginScript();
            
            nextState = STATE_LOGIN_SCRIPT;
        } else //この後は普通のパスワードレス認証
後略
```

### 5. 認証時 認証デバイス
認証デバイスは、ブラウザ側から渡された以下2つの値からなんとかしないとイカンです。
1. **rpId(RPサーバーのFQDN)**
2. **challenge**
該当のRPサーバーに１個しか鍵が登録されていない場合は、認証デバイス内でrpIdと一緒に保存されたCredentialIDが自明となるため、単純にパスワードレス認証の処理が動作します。

![rkeyAuth.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316098/66ce88b1-4182-eecd-fb7f-880c459ed09b.png)

しかし複数のResidentKeyユーザー登録が有る場合は、ユーザーの選択画面が出ます。
選択すれば、後は普通の認証処理となります。

![rkeyselecter.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316098/77aff21d-3a55-dbdc-185a-5aa88f36e537.png)

-----
### ResidentKeyのキモ **userHandle** はココからです。

ResidentKeyを使った認証の場合は、認証デバイスの戻りオブジェクトに **userHandle** の値が入っています。これが認証済みのuser.idとなるので署名済みのchallengeなどと一緒にRPサーバーへの応答に含めます。

![fido2auth-resident.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316098/a6aee0d0-d094-8cbb-b833-00aca3bc33ac.png)
※あらかじめresidentKey=trueにして認証デバイスの登録をしないと、user.idが認証デバイスに保存されないので、residentKey=falseで登録したユーザーはuserHandleは空になってしまいます。要は認証できません。

-----

### 6. 認証処理 RPサーバー
やっと最後の処理です。userHandleにuser.id(LDAPのentryUUID)が入ってクライアントから認証レスポンスが来ました。もちろん署名その他も帰ってきます。

W3C [Verifying an Authentication Assertion](https://www.w3.org/TR/webauthn/#verifying-assertion)の２番目の処理妙訳を読みます。
パターン２がResidentKeyというかユーザーネームレス認証の処理。
>認証処理中のユーザーがCredentialIdで特定されるpublic keyの所持者であることをベリファイする。
以下2通りのパターン
パターン1. 認証処理が始まる前にユーザーが特定されている場合は、public keyのオーナであるかベリファイする。credential.response.userHandleが有る場合はその値が特定済みのユーザーと同じかベリファイする。

>パターン2. 認証処理が始まる時点でユーザーが特定されていない場合は、credential.response.userHandleをベリファイし、特定されたユーザーがpublic keyのオーナーとする。
※credential.response.userHandleにはユーザー名が特定できる　IDやemailなどの情報は入れてはならない。

単純に署名検証してuserHandle=entryUUIDで検索して該当のユーザー名にセッションを発行してしまうとイカンです。
鍵は自分のを使いchallengeに署名をして、userHandleを別ユーザーのentryUUIDで上書きして送ってこられてもエラーになるようにします。

userHandleは署名されてない値で、改竄の恐れあることを念頭に、rawIdも同じく改竄の恐れがあることを念頭に認証処理をします。
#### 問題無い場合
1. **fido2UserID=userHandle** で ou=Credentials から鍵を探します。
2. さらに **rawId** をキーに **fido2CredentialId = rawId** として **fido2PublicKey** を選別します。
3. その鍵で署名(sign)の検証を行います。問題なければログイン成功です。
![allok.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316098/1b3c3b8d-6a27-5f38-6cb2-4a32dd00be98.png)

#### userHandleだけ書き換えられていた場合
1. **fido2UserID=userHandle** で ou=Credentials から鍵を探します。
2. **rawId** をキーに **fido2CredentialId = rawId** として **fido2PublicKey** を選別しますが、該当の **fido2CredentialId** が存在しないため失敗します。
![userhandleng.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316098/1f963733-ea89-0fd0-9574-6f8d704931ad.png)

#### userHandleとrawIdを書き換えられていた場合
1. **fido2UserID=userHandle** で ou=Credentials から鍵を探します。
2. さらに **rawId** をキーに **fido2CredentialId = rawId** として **fido2PublicKey** を選別します。
3. その鍵で署名(sign)の検証を行います。デバイスが署名した鍵と、LDAPから選別した鍵ペアが合致しないため検証に失敗します。
![signng.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316098/55d9e9e4-ac23-3ba7-523b-50f9cb7da9b3.png)


コードとしては以下の処理、検証する部分はこ後に呼ばれますが省略してます。

```java
        //userHandleさん
        byte[] _userHandleBytes = Base64url.decode(_responseJson.getUserHandle());
        
        /*
         * if residentKey = true
         * get UserHandle in Client Response
         * and using this base64encoded binary as entryUUID
         * to search userName from datastore
         */
        if (residentKeyConfig.equalsIgnoreCase("true")) {
            String _userHandleIdStr = byteArrayToAsciiString(_userHandleBytes);
            //userHandle=entryUUIDでou=Usersから該当のユーザー名を検索
            userName = searchUserNameWithAttrValue(_userHandleIdStr, "entryUUID");
            //userHandleでou=Credentialsから該当のCredentialエントリーを探す。
            authenticators = webauthnService.getAuthenticators(_userHandleBytes);
            if (authenticators.isEmpty()) {
                DEBUG.error("WebAuthnAuthenticate.verifyAuthenticatorCallback() :  User authenticators not found");
                throw new MessageLoginException(BUNDLE_NAME, "msgNoDevice", null);
            }
        }
        //userHandleで見つかったCredentialエントリーから、rawIdに合致するPublicKeyを探す。
        //もし見つからなければPublicKey無しで検証エラーとなる、
        //rawIdまで詐称されていても検証エラーとなる。
        WebAuthnAuthenticator _selectedAuthenticator = null;
        for (WebAuthnAuthenticator authenticator : authenticators) {
            if (authenticator.isSelected(_responseJson.getRawId())) {
                _selectedAuthenticator = authenticator;
                break;
            }
        }
```

これで、無事challengeだけ送ったら、認証済みuserHandleにLDAPのentryUUIDが入って帰ってきて、ログイン完了となります。
TouchID搭載のMacbook 12"が出なかったは仕方ないとして、
iOSのTouchIDがWebAuthnに対応していないとはナニゴトかと...ココに書くと出ないフラグなのかなぁ。