OpenAM 14 パスワードレス認証モジュールのResidentKey実装例
甘美な響きのパスワードレス(FIDO2)認証、その中でもひときわ目を引く(と勝手に思ってる) residentKey=true オプション、通称レジデントキー WebAuthnのオプションを利用して、ユーザーネームレスログインを実現できます。
OpenAM14に入れるなら是非実装したいと思っておりました。
ログインID入力が要らずに「楽」で、利便性が格段にアップして良いと思います。
普通のパスワードレスのログイン画面
当然ですがログインIDを入力しないとイカンです。
パスワードは入れないで済むけど、これだけだともったいない。
レジデントキーのログイン画面
入力フォームがありません、いきなり認証デバイスのアクションを促します。ものすごい楽です。
ただロカール認証でPINを入力する認証デバイスの場合は、キーを打たないとイカンので、個人的には微妙です。
しかも、以下のように3回アクションが必要で残念です。
- 認証デバイスタッチ
- PIN入力
- 認証デバイスタッチ
指紋が登録された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のエントリー
ResidentKey認証
認証時はこのRPサーバーはユーザーが分からないまま認証を開始し、 user.id が userHandle としてクライアントから送られてきます。RPサーバーは 送られたuserHandle=user.idの値(LDAPのentryUUID)からユーザーを特定します。詳細はLDAPのエントリー
蛇足ながら...
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や鍵はバイナリで人間には???なのです。
OpenAM認証ジュール内の処理
ざっくりと ResidentKey について説明した後は、実際のコードを交えてOpenAM上の処理を説明します。
コードはOpenAMコンソーシアムのGitHubにあります。
RPサーバー側の保存データーについてはリンクにも記載しています。
OpenAM で WebAuthn(FIDO2)Credentialの保存先にLDAPを使う
以下の順番に解説します。
- 登録開始 RPサーバー
- 登録 認証デバイス
- 登録処理 RPサーバー
- 認証開始 RPサーバー
- 認証 認証デバイス
- 認証処理 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つの値からなんとかしないとイカンです。
- rpId(RPサーバーのFQDN)
-
challenge
該当のRPサーバーに1個しか鍵が登録されていない場合は、認証デバイス内でrpIdと一緒に保存されたCredentialIDが自明となるため、単純にパスワードレス認証の処理が動作します。
しかし複数のResidentKeyユーザー登録が有る場合は、ユーザーの選択画面が出ます。
選択すれば、後は普通の認証処理となります。
ResidentKeyのキモ userHandle はココからです。
ResidentKeyを使った認証の場合は、認証デバイスの戻りオブジェクトに userHandle の値が入っています。これが認証済みのuser.idとなるので署名済みのchallengeなどと一緒にRPサーバーへの応答に含めます。
※あらかじめ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も同じく改竄の恐れがあることを念頭に認証処理をします。
問題無い場合
- fido2UserID=userHandle で ou=Credentials から鍵を探します。
- さらに rawId をキーに fido2CredentialId = rawId として fido2PublicKey を選別します。
- その鍵で署名(sign)の検証を行います。問題なければログイン成功です。
userHandleだけ書き換えられていた場合
- fido2UserID=userHandle で ou=Credentials から鍵を探します。
-
rawId をキーに fido2CredentialId = rawId として fido2PublicKey を選別しますが、該当の fido2CredentialId が存在しないため失敗します。
userHandleとrawIdを書き換えられていた場合
- fido2UserID=userHandle で ou=Credentials から鍵を探します。
- さらに rawId をキーに fido2CredentialId = rawId として fido2PublicKey を選別します。
- その鍵で署名(sign)の検証を行います。デバイスが署名した鍵と、LDAPから選別した鍵ペアが合致しないため検証に失敗します。
コードとしては以下の処理、検証する部分はこ後に呼ばれますが省略してます。
//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に対応していないとはナニゴトかと...ココに書くと出ないフラグなのかなぁ。