LoginSignup
12
6

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-02

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

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

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

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

普通のパスワードレスのログイン画面

当然ですがログインIDを入力しないとイカンです。
パスワードは入れないで済むけど、これだけだともったいない。
keyAuthinput.PNG


レジデントキーのログイン画面

入力フォームがありません、いきなり認証デバイスのアクションを促します。ものすごい楽です。
rkeyAuth.PNG


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

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

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

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

ResidentKeyはどんな仕組みで動く?

ResidentKeyのキモは user.iduserHandle です。

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

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

ResidentKey登録

限りなく簡単に説明すると登録時にRPサーバーから送った user.id が認証デバイスに記録されます。user.idの決め方詳細はLDAPのエントリー

fido2reg-resident.png

ResidentKey認証

認証時はこのRPサーバーはユーザーが分からないまま認証を開始し、 user.iduserHandle としてクライアントから送られてきます。RPサーバーは 送られたuserHandle=user.idの値(LDAPのentryUUID)からユーザーを特定します。詳細はLDAPのエントリー

fido2auth-resident.png

蛇足ながら...


residentKey=falseとの違いは?

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

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


challengeだけでどうやって認証デバイス内の署名に使う鍵を特定するのか?

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

rkeyselecter.PNG


OpenAM認証ジュール内の処理

ざっくりと ResidentKey について説明した後は、実際のコードを交えてOpenAM上の処理を説明します。
コードはOpenAMコンソーシアムのGitHubにあります。
RPサーバー側の保存データーについてはリンクにも記載しています。
OpenAM で WebAuthn(FIDO2)Credentialの保存先にLDAPを使う

以下の順番に解説します。
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ストアのリンクの通り、ユーザーのentryUUIDを利用しています。

WebAuthnRegister.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()のオプション例

前略
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

前略
        //認証デバイス(クライアント)の応答を検証
        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の認証処理開始のフロー

前略
        //この上のケースはMFAの2段目以降に使う処理
        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サーバーに1個しか鍵が登録されていない場合は、認証デバイス内でrpIdと一緒に保存されたCredentialIDが自明となるため、単純にパスワードレス認証の処理が動作します。

rkeyAuth.PNG

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

rkeyselecter.PNG


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

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

fido2auth-resident.png
※あらかじめresidentKey=trueにして認証デバイスの登録をしないと、user.idが認証デバイスに保存されないので、residentKey=falseで登録したユーザーはuserHandleは空になってしまいます。要は認証できません。


6. 認証処理 RPサーバー

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

W3C Verifying an Authentication Assertionの2番目の処理妙訳を読みます。
パターン2が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

userHandleだけ書き換えられていた場合

  1. fido2UserID=userHandle で ou=Credentials から鍵を探します。
  2. rawId をキーに fido2CredentialId = rawId として fido2PublicKey を選別しますが、該当の fido2CredentialId が存在しないため失敗します。 userhandleng.png

userHandleとrawIdを書き換えられていた場合

  1. fido2UserID=userHandle で ou=Credentials から鍵を探します。
  2. さらに rawId をキーに fido2CredentialId = rawId として fido2PublicKey を選別します。
  3. その鍵で署名(sign)の検証を行います。デバイスが署名した鍵と、LDAPから選別した鍵ペアが合致しないため検証に失敗します。 signng.png

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

        //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に対応していないとはナニゴトかと...ココに書くと出ないフラグなのかなぁ。

12
6
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
12
6