2024年1月15日月曜日

OpenID Providerを作る)Pairwise識別子を実装する

こんにちは、富士榮です。

前回はHybridフローに必要なc_hashやat_hashの実装をしてみました。


今回はユーザの識別子の話をしたいと思います。
識別子の話はそれだけでご飯が3杯くらい食べられるくらいのおかずなので、今後も触れるかもしれませんが今回はPairwise識別子と実装についてみていこうと思います。

そもそもPairwise識別子とは

OpenID Providerを構築する場合、複数のRelying Partyと連携することが前提となると思います。(そうでなければ単にアプリケーションにローカルで認証する仕組みを組み込めば良いはずです)
その場合に全てのRelying Partyがいわゆる1stパーティ(例えば自社サービス)である場合だけでなく、3rdパーティ(多事業者のサービス)との連携を行うケースも想定しなければならないことがあります。
基本的にOpenID Providerが各Relying Partyに提供するユーザの情報は「ユーザの同意」に基づく「必要最低限」である必要があります。この辺りの原則は故Kim Cameronが提唱した「The Laws of Identity」の第1原則「User Control and Consent(ユーザによる制御と同意)」、第2原則「Minimal Disclosure for a Constraint Use(制限された利用に対する最低限の開示)」に準じて設計を行う必要があります。この辺りも2009年にポストしましたし、先日ポストしたVerifiable CredentialsにおけるSD-JWTの話題もこの原則を実現するために開発された仕様です。

前置きが長くなりましたが、この話と識別子の話がどのように関係してくるかというと、簡単にいうと「名寄せ」を簡単に&機械的にできないようにするために識別子をRelying Party毎にユニークな値にしていきましょう、というのが今回のテーマである「Pairwise識別子」の役割です。なお、Pairwise識別子のことをPPID(Pairwise Pseudonymous Identifier)とか仮名という言い方をすることもあります。

例えば、以下の図のような状態だと全てのRelying Partyへ同じ識別子(sub)の値(123)が提供されるので、例えばRelying Party同士が結託すると本当はメールアドレスしか提供するつもりではなかったのに名前や誕生日をユーザが意図しない形で収集されてしまう、という状態が発生します。もちろんこれが必ずしも悪なのか、というとそういうわけではなく先に書いたRelying Partyが全て1stパーティなどのケースでユーザが包括的に属性提供に関する同意をしているケースについては問題にはなりません。このようなケースにおける識別子のタイプをOpenID Connectの仕様では「public」として定義しています。

しかしながら、主に3rdパーティのRelying Partyとの連携を前提とすると先ほどのThe Laws of Identityの原則に従うと各Relying Party同士が結託しても簡単には属性情報を取得できないようにする必要があるわけです。そこで識別子のタイプに「pairwise」を使用し、以下のような状態を作り上げます。

この状態であれば各Relying Partyは隣のRelying Partyにアクセスしにきているユーザが自分のところにアクセスしてきているユーザと同一のユーザなのかを機械的に判断するのは難しくなります。(なお、識別子をpairwiseにしてもメールアドレスなど他の属性で識別可能になってしまう実装は世の中にたくさんあります。この辺りはユーザに対して提供する利便性とのバランスを含め考えていく必要があると思います)

そういえば10年くらいまでに仮名に関する整理をした記憶が蘇ってきました。当時はSAMLについて書いてました。
※しかしslideshare、広告が出まくるので使いにくくなりましたね。。

識別子というからにはRelying Partyが利用者を識別できなければならない、という話はあるのですが、SAMLにおいてはtransientというタイプの匿名識別子も定義されており、仮名(pairwise)と明確に区別されているのが面白いですね。

この辺りは一貫性のあるPairwise識別子の提供を求めるOpenID Connectの思想とは少し異なるのかもしれません。

Pairwise識別子を実装する

OpenID Connect coreの仕様の中にPairwise識別子の生成に関する記述がありますので、そちらを参照しつつ実装していきましょう。

Dynamic Client Registrationを使う場合は追加で色々と考えることがありますが、基本的に以下の原則に従って実装する必要があります。
  • Subject Identifier 値が, OpenID Provider 以外の Party にとって, 可逆であってはならない (MUST NOT).
  • 異なる Sector Identifier 値は, 異なる Subject Identifier 値にならなければならない (MUST).
  • 同じ入力に対して必ず同じ結果となる決定的アルゴリズムでなければならない (MUST).
また、アルゴリズムの例として以下のパターンが提示されています。
  • Sector Identifierを, ローカルアカウントIDおよび Provider によって秘密にされているソルト値と連結する. そして連結した文字列を適切なアルゴリズムによってハッシュ化する.
    • sub = SHA-256 ( sector_identifier || local_account_id || salt ) を計算する.
  • Sector Identifierを, ローカルアカウントIDおよび Provider によって秘密にされているソルト値と連結する. そして連結した文字列を適切なアルゴリズムによって暗号化する.
    • sub = AES-128 ( sector_identifier || local_account_id || salt ) を計算する.
  • Issuer は, Sector Identifier とローカルアカウントIDのペアに対し, Globally Unique Identifier (GUID) を作成する.
このSector IdentifierはクライアントであるRelying Partyがコントロールする値であるべきということもあり特にDynamic Client Registrationをサポートする場合はsector_identifier_uriを利用し、Sector Identifierは当該URLのホスト部を利用するのが一般的ですが、今回はredirect_uriのホスト部を使っておきましょう。ちなみに仕様上はホスト部(原文host component)とあるのでurl.hostname(ポート番号を含まないホスト名)ではなくurl.host(ポート番号指定がある場合はポート番号を含む)を利用すべきなんだと思いますが世の中の実装がどうなっているかは知りません。

ということで今回はこんな仕様で実装してみます。
  • sector_identifierはredirect_uriのホスト部を利用する
  • local_identifierはユーザ認証をしていないので固定値を使う
  • saltはOpenID Providerにハードコードした値を使う
  • 上記3つの値を連結した文字列をSHA256でハッシュした値をPairwise識別子としてsubにセットする

ということで識別子を生成するfunctionを書いていきます。
まずsaltは固定値をコードにかいちゃいます。
// Pairwise識別子生成用のsalt
const saltForPPID = "1234567890";

その上で生成する関数を作ります。
// Pairwise識別子の生成
exports.createPPID = function createPPID(local_identifier, redirect_uri){
// sector_identifierの生成
const url = new URL(redirect_uri);
const sector_identifier = url.host;
return crypto.createHash('sha256').update(sector_identifier + local_identifier + saltForPPID).digest('hex');
}

ユーザ情報を含むペイロード生成時に上記関数で作成したPairwise識別子を埋め込みます。
// Pairwise識別子の生成
const PPID = utils.createPPID("test", req.query.redirect_uri);
// ペイロード(暫定なので固定値。有効期限関係だけ個別に含める)
let payload = {
iss: baseUrl,
aud: req.query.client_id,
sub: PPID,
email: "test@example.jp",
name: "taro test",
given_name: "taro",
family_name: "test",
nonce: req.query.nonce
};

これで実装は完了です。

一応Discovery側も変更しておきましょう。
const subject_types = ["pairwise"];

こちらがredirect_uriにhttps://jwt.msを指定した際のsubを含むIDトークンです。


こちらが別のredirect_uriを指定した際のsubの値です。


識別子(sub)の値が異なることがわかりますね。
ということで今回はPairwise識別子の実装をしてみました。

ここまでの実装を含むコードはこちらに公開してありますので参考にしてください。

0 件のコメント: