2024年1月31日水曜日

OpenID Providerを作る)ユーザ情報をデータベースから取得する

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

まだまだ実装すべき点はたくさんありますが、そろそろユーザを固定で埋め込むのではなくデータベースに保存されたユーザ情報を元にIDトークンなどを生成していきたいと思います。

ただデータベースと言っても、前回紹介したJSONBin.ioを使います。


その前にこれまでのおさらいです。


実装する内容

ユーザ情報を取得する、と言ってもまだユーザ認証画面などを作るところまでは手を出しませんので、これまで固定で埋め込んでいたユーザ情報を止めるところからです。

とりあえずはユーザのログインID(preferred_username)を指定するとJSONBinからユーザ情報を取得してくる仕組みを作り、ハードコード部分を少しだけ減らしていきたいと思います。

今回使うJSONBinのAPIは以下の2つです。

    • コレクションに入っているBinの一覧を取得するAPI
    • 最初の10個のBinを取得してくるので、本来は必要に応じてページングをしなければなりませんが、今回は10ユーザも作らないのでページングの考慮はしません
    • APIの仕様としては「https://api.jsonbin.io/v3/c/{コレクションID]/bins」をGETするだけです
    • 結果、Binの一覧がこんな感じで返却されますので、この中でsnippetMeta.nameがpreferred_usernameと一致している要素のrecordの値を持つbinの中にお目当てのユーザの情報が入っている、という仕掛けです。※このsnippetMeta.nameにユーザ名を入れるためにbinを作る際のname指定をしていたわけです

[
{
"private": true,
"snippetMeta": {
"name": "test2@example.jp"
},
"record": "65b4dfcfdc746540189c4daf",
"createdAt": "2024-01-27T10:49:51.572Z"
},
{
"private": true,
"snippetMeta": {
"name": "test@example.jp"
},
"record": "65b4dacd266cfc3fde81ca50",
"createdAt": "2024-01-27T10:28:29.692Z"
}
]
  • Read a Bin
    • 単体のBinの中身を読み取るAPI
    • 仕様としては「https://api.jsonbin.io/v3/b/{BinのID}」をGETするだけです
    • 結果、指定したBinの中身がこんな感じで返却されてきます
{
"record": {
"sub": "test",
"name": "taro test",
"given_name": "taro",
"family_name": "test",
"middle_name": "",
"nickname": "",
"preferred_username": "test@example.jp",
"profile": "https://twitter.com/phr_eidentity",
"picture": "https://1.gravatar.com/avatar/25eee85430bd0bbdcb9cff75655afa43cc9f69bc8730aec852d8538179646ef1",
"website": "hhtps://idmlab.eidentity.jp",
"gender": "male",
"birthdate": "1900-01-01",
"zoneinfo": "Japan",
"locale": "jp_JP",
"updated_at": 1704034800,
"email": "test@example.jp",
"email_verified": true,
"address": {
"formatted": "Kokyogaien, Chiyoda-ku, Tokyo 1000002 JAPAN",
"street_address": "Kokyogaien",
"locality": "Chiyoda-ku",
"region": "Tokyo",
"postal_code": "1000002",
"country": "JP"
},
"phone_number": "+81-3-1234-5678",
"phone_number_verified": true
},
"metadata": {
"id": "65b4dacd266cfc3fde81ca50",
"private": true,
"createdAt": "2024-01-27T10:28:29.692Z",
"collectionId": "65b474351f5677401f2691de",
"name": "test@example.jp"
}
}

これを上手く組み合わせて実装していきましょう。

ユーザ情報を取得する関数を定義する

utils/user.jsを前回までの実装でも用意していましたが、ここに一つ新しい関数を追加してみます。まず必要な引数はユーザ名です。これは最終的には利用者が画面で入力したユーザIDを利用することなりますが今回は呼び出し側でログインユーザ名だけはハードコードします。
また、これは前回までのコードにも書いていますがIDトークン等にどこまで情報を載せるか、についてスコープを使って制御するため、もう一つの引数はスコープとなります。

こんな関数になります。
exports.getUserIdentityByLoginId = async function(login_id, scopes) {

まずは、JSONBinを実行するための準備です。
今回はX-Master-Keyにマスターキーをセットします。環境変数などへ仕込んでおくことができます。ちなみにJSONBinではマスターキーとアクセスキーの2種類のキーを発行・管理しています。マスターキーは名前の通りなんでもできるマスターキーなので本来は用途によって権限を絞り込むことができるアクセスキーを使うべきなのかもしれません。
// JSONBin用のヘッダ
const headers = new Headers({
"X-Master-Key": process.env.JSONBIN_MASTER_KEY,
"Content-Type": "application/json"
});

いよいよJSONBinのFetch Binsを使ってbinの一覧を取得していきます。
// JSONBinのユーザCollectionからユーザbinのidを取得する
const collectionUrl = new URL(`${process.env.JSONBIN_BASEURL}/c/${process.env.JSONBIN_USERCOLLECTION_ID}/bins`);
const collectionResponse = await fetch(collectionUrl, {
headers: headers
});
const userCollection = await collectionResponse.json();

先ほどのsnippetMeta.nameが関数の引数に指定したlogin_idと一致しているものを抽出します。該当がなければエラーなのでコンソールにメッセージを出しておきます。この辺りはエラーハンドリングやページングの考慮もそのうち必要になりますが今回はスキップしておきます。
const userBin = userCollection.find(i => i.snippetMeta.name === login_id);
if(typeof userBin === "undefined"){
console.log("user not found");
}else{

ここまでで取得できた当該ユーザのBinのID(record)をベースに実際のBinの中身を取得し、userIdentityというオブジェクトにセットしておきます。一応ここまでで前回までハードコードしていたユーザの属性情報をJSONBinから取得できた状態になりました。
// 当該ユーザのBin idからBinの中身を読み出す
const userBinUrl = new URL(`${process.env.JSONBIN_BASEURL}/b/${userBin.record}`);
const userBinResponse = await fetch(userBinUrl, {
headers: headers
});
const userJson = await userBinResponse.json();
const userIdentity = userJson.record;

なお、PPIDへの対応をするためにlocal_identifierをユーザのオブジェクトに指定しておきたいので、subの値を一旦local_identifierの値に待避しておきます。
// subをlocal_identifierへセット
userIdentity.local_identifier = userIdentity.sub;

あとは、前回のスコープに応じた処理を行うという意味で全く同じコードとなります。
// スコープによって返却する属性の絞り込み
if(!scopes.includes("profile")){
delete userIdentity.name;
delete userIdentity.given_name;
delete userIdentity.family_name;
delete userIdentity.middle_name;


ユーザ情報を取得する関数を呼び出す

もともと認可エンドポイントでユーザ情報を固定で呼び出す処理を書いていたので当該部分を書き換えます。
oauth2/oauth2.js
// scopeに応じたユーザの情報を取得する
// let payload = userIdentity.getUserIdentity(scopes);
// ユーザ名を指定して属性情報を取得する
let payload = await userIdentity.getUserIdentityByLoginId("test@example.jp", scopes);

こんな感じです。元のユーザ情報をハードコードしていた関数をコールする部分をコメントアウトして、今回新しく作った関数にログインIDを指定して呼び出すように変更しています。

これで完了です。

JSONBinに入ったユーザ情報がちゃんと取れました。

ということで今回はここまでです。

2024年1月30日火曜日

Appleがアプリ規約を変えたのでApple IDに変わるIdPを考えてみる

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

AppleがApp Storeの規約を変更したニュースが出ていますね。

https://gigazine.net/news/20240129-apple-sign-in-with-apple-remove/

元々はAppleがApp Storeの審査を行う上でソーシャルログインなど複数のIdentity Providerによるログイン機能を有するアプリにはApple IDでのログイン”も”サポートすることを要求していたのですが、今回の規約の改訂で以下の条件を満たすIdPをサポートすれば必ずしもApple IDとのID連携は行わなくても良い、という形に変更されました。

とはいえ、上記の記事にもある通り、以下の条件を満たすことのできるIdPってApple IDくらいしかないのでは?というなかなか厳しい状態です。

  • the login service limits data collection to the user’s name and email address
  • the login service allows users to keep their email address private as part of setting up their account
  • the login service does not track users as they interact with your app


ということは、APIでリレーエントリーを作成することができるリレーサービスがあれば実現できるのでは?と思い少し調べてみました。

触ってみたのはImprovmx(https://improvmx.com/ )というサービスです。※単純に検索して引っかかったので触ってみただけです。

APIドキュメントを見ているとどうやらAPI経由でエイリアスとなるメールアドレスから実際のメールアドレスへのリレーの定義ができるようです。

ということで触ってみます。

まずはベースとなるドメインの定義をします。

MXとTXTレコードの設定が求められるので、使うドメインのDNSサーバにレコードを作成し、ImprovMXのダッシュボードで確認を行います。

またAPIを実行するのでAPIキーの発行をしておきます。

API実行時はBasic認証でユーザ名に「api」、パスワードにAPIキーを設定してあげるだけです。

やりたいことはユーザがサインアップするときにランダムの仮名メールアドレスを発行し、実メールアドレスを隠した状態でアプリ側へ提供すること、アプリ側から仮名メールアドレスへのメールが送信されたときに実メールに対してリレーされることです。

そのためにAPIを使って仮名メールアドレスと実メールアドレスを紐づけるAliasを作成することができればOKです。

この辺りのAPIをつけば良さそうです。

https://improvmx.com/api/#alias-add

BodyにJSONでAliasとForwardを指定してPOSTするだけですね。


GET APIで設定状態の確認をすることができます。


無料版だと配送までに少し時間がかかりますが、「設定したAlias@設定したドメイン」に対してメールを送るとForwardに指定した実メールアドレスにメールが届きます。

このAPIコールをIdentity Providerの内部処理として組み込めばAppleが求めるIdPが実装できそうですね。

旧mac.comのApple IDもトラブルが出ているようですし、特定のIdPだけに頼らずにシステムを作っていけるようにしていきましょう。

2024年1月29日月曜日

JSONbin.ioを使ってユーザデータベースを作ってみる

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

ちょっと前にXで局所的に話題になっていたJSONに特化したストレージサービス「JSONBin.io」が気になったので触ってみています。


何ができるサービスなのか?

トップページにも記載があるとおり、ざっくりいうと「JSONデータをクラウドに保存してREST APIで操作できるようにしたシンプルなストレージサービス」というところです。

JSONBin.io provides a simple REST interface to store & retrieve your JSON data from the cloud. It helps developers focus more on app development by taking care of their Database Infrastructure.
APIを見ると、以下のようなことができるようです。
  • JSONデータ(bin)の管理(作成・更新・読み取り・削除)
    • このサービスではbinという単位でJSONデータを読んでいます
  • コレクションの管理(作成・更新・読み取り)
    • なぜか削除がないです
    • コレクションの配下にbinを入れることでカテゴライズして管理することができます
    • また、スキーマ定義を紐づけることでbinに入れるデータのValidationもできます
  • スキーマの管理(作成・更新・読み取り・削除)
    • コレクションに紐づけるスキーマ定義です。
    • binを作成・更新する際にValidationをするために利用します

利用プラン

結構おもしろい考え方で運営されています。現状FreeとProの2つしかプランはありませんが、大きな違いはbinのバージョン管理やスキーマ定義の利用がProしか使えない、という以外はAPIコール数や容量が違うように見えます。
ここまでだと普通のクラウドサービスっぽいなぁ、と思いますがリクエスト数の考え方と課金の単位が結構面白いです。
実はFreeプランのリクエスト数に「10,000」とあるのは「Freeプランは上限(月額ではなく)として10,000リクエストまで実行可能ですよ」という意味です。つまり10,000リクエストを超えてこのサービスを利用しようとするとPro($20)にアップグレードするか、Additional Request($15)を購入する必要があります。
このProもAdditional Requestも月額ではなくリクエスト数を使い果たすまでは払いきりで使えますよ、という課金形態です。

ですので、例えばスキーマ定義は必要ないけど10,000を超えたリクエストを処理したい、という場合はFree + Additional Requests(500,000リクエスト)を購入となるので$15、スキーマ定義を使いたいが500,000リクエストはいらない、という人はFree + Pro(100,000リクエストまで) を購入するので$20、という形になります。
※どちらの場合は一旦はFreeプランについてくる10,000リクエストに加えて各プランのリクエスト数を追加することになりますので、例えばProを契約すると110,000リクエスト使えることになります。

OpenID Connect coreの標準クレームをサポートするユーザDBを作る

標準クレームをサポートする、となるとスキーマ定義をしてValidationをかけたくなるのでProを契約する必要があります。支払いはカードやPayPalでできます。私はPayPalを使いました。

以下の順番で定義をしていきます。
  1. スキーマ定義
  2. コレクションの作成とスキーマ定義の紐付け
  3. binの作成(実際のユーザデータ)
まずはスキーマ定義です。
OpenID Connectの標準スキーマはこちらに定義されているのでこれをベースに定義ファイルを作成します。
こんな感じのデータを作りました。
{
"description": "OpenID Connect core 1.0 standard claims",
"type": "object",
"properties": {
"sub": {
"description": "Subject - Identifier for the End-User at the Issuer.",
"type": "string"
},
"name": {
"description": "End-User's full name in displayable form including all name parts, possibly including titles and suffixes, ordered according to the End-User's locale and preferences.",
"type": "string"
},
"given_name": {
"description": "Given name(s) or first name(s) of the End-User. Note that in some cultures, people can have multiple given names; all can be present, with the names being separated by space characters.",
"type": "string"
},
"family_name": {
"description": "Surname(s) or last name(s) of the End-User. Note that in some cultures, people can have multiple family names or no family name; all can be present, with the names being separated by space characters.",
"type": "string"
},
"middle_name": {
"description": "Middle name(s) of the End-User. Note that in some cultures, people can have multiple middle names; all can be present, with the names being separated by space characters. Also note that in some cultures, middle names are not used.",
"type": "string"
},
"nickname": {
"description": "Casual name of the End-User that may or may not be the same as the given_name. For instance, a nickname value of Mike might be returned alongside a given_name value of Michael.",
"type": "string"
},
"preferred_username": {
"description": "Shorthand name by which the End-User wishes to be referred to at the RP, such as janedoe or j.doe. This value MAY be any valid JSON string including special characters such as @, /, or whitespace. The RP MUST NOT rely upon this value being unique, as discussed in Section 5.7.",
"type": "string"
},
"profile": {
"description": "URL of the End-User's profile page. The contents of this Web page SHOULD be about the End-User.",
"type": "string"
},
"picture": {
"description": "URL of the End-User's profile picture. This URL MUST refer to an image file (for example, a PNG, JPEG, or GIF image file), rather than to a Web page containing an image. Note that this URL SHOULD specifically reference a profile photo of the End-User suitable for displaying when describing the End-User, rather than an arbitrary photo taken by the End-User.",
"type": "string"
},
"website": {
"description": "URL of the End-User's Web page or blog. This Web page SHOULD contain information published by the End-User or an organization that the End-User is affiliated with.",
"type": "string"
},
"email": {
"description": "End-User's preferred e-mail address. Its value MUST conform to the RFC 5322 [RFC5322] addr-spec syntax. The RP MUST NOT rely upon this value being unique, as discussed in Section 5.7.",
"type": "string"
},
"email_verified": {
"description": "True if the End-User's e-mail address has been verified; otherwise false. When this Claim Value is true, this means that the OP took affirmative steps to ensure that this e-mail address was controlled by the End-User at the time the verification was performed. The means by which an e-mail address is verified is context specific, and dependent upon the trust framework or contractual agreements within which the parties are operating.",
"type": "boolean"
},
"gender": {
"description": "End-User's gender. Values defined by this specification are female and male. Other values MAY be used when neither of the defined values are applicable.",
"type": "string"
},
"birthdate": {
"description": "End-User's birthday, represented as an ISO 8601-1 [ISO8601-1] YYYY-MM-DD format. The year MAY be 0000, indicating that it is omitted. To represent only the year, YYYY format is allowed. Note that depending on the underlying platform's date related function, providing just year can result in varying month and day, so the implementers need to take this factor into account to correctly process the dates.",
"type": "string"
},
"zoneinfo": {
"description": "String from IANA Time Zone Database [IANA.time-zones] representing the End-User's time zone. For example, Europe/Paris or America/Los_Angeles.",
"type": "string"
},
"locale": {
"description": "End-User's locale, represented as a BCP47 [RFC5646] language tag. This is typically an ISO 639 Alpha-2 [ISO639] language code in lowercase and an ISO 3166-1 Alpha-2 [ISO3166‑1] country code in uppercase, separated by a dash. For example, en-US or fr-CA. As a compatibility note, some implementations have used an underscore as the separator rather than a dash, for example, en_US; Relying Parties MAY choose to accept this locale syntax as well.",
"type": "string"
},
"phone_number": {
"description": "End-User's preferred telephone number. E.164 [E.164] is RECOMMENDED as the format of this Claim, for example, +1 (425) 555-1212 or +56 (2) 687 2400. If the phone number contains an extension, it is RECOMMENDED that the extension be represented using the RFC 3966 [RFC3966] extension syntax, for example, +1 (604) 555-1234;ext=5678.",
"type": "string"
},
"phone_number_verified": {
"description": "True if the End-User's phone number has been verified; otherwise false. When this Claim Value is true, this means that the OP took affirmative steps to ensure that this phone number was controlled by the End-User at the time the verification was performed. The means by which a phone number is verified is context specific, and dependent upon the trust framework or contractual agreements within which the parties are operating. When true, the phone_number Claim MUST be in E.164 format and any extensions MUST be represented in RFC 3966 format.",
"type": "boolean"
},
"address": {
"description": "End-User's preferred postal address. The value of the address member is a JSON [RFC8259] structure containing some or all of the members defined in Section 5.1.1.",
"type": "object"
},
"updated_at": {
"description": "Time the End-User's information was last updated. Its value is a JSON number representing the number of seconds from 1970-01-01T00:00:00Z as measured in UTC until the date/time.",
"type": "number"
}
},
"required": ["sub"]
}

API経由でスキーマ定義(Schema Doc)を作成しても良いですし、管理ポータルから作成することもできます。


次はコレクションの作成です。
作成する際にスキーマの関連付けができるので上記で作成したスキーマ定義を紐づけておきます。

これで準備は完了です。
では実際のユーザデータをbinとして作成します。
気をつけるべき点としてはカテゴリとして先ほど作成したコレクションを指定することくらいです。あとはbinに名前をつけておくと一覧を見るときに便利なのでユーザのpreferred_usernameの値などをNameに指定しておくと良いです。(これは理由があるので今後解説します)


これで利用者の情報がJSONデータとしてサービスに登録できました。
BIN IDが生成されるので、Postmanなどで実際にAPI経由で情報を参照してみます。
なお、認証のためヘッダにX-Master-Key(もしくはX-Access-Key)をつけてダッシュボードから確認できるキーの値をセットする必要があります。


OpenID Providerを作る上で簡易的なユーザDBとしては結構便利な気がしてきましたので、次回以降で組み込んでみたいと思います。




2024年1月28日日曜日

デジタル認証アプリがやってくる

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


噂のデジタル認証アプリですが、パブコメが出てますね。

電子署名等に係る地方公共団体情報システム機構の認証業務に関する法律施行規則の一部を改正する命令案に対する意見募集について

https://public-comment.e-gov.go.jp/servlet/Public?CLASSNAME=PCMMSTDETAIL&id=290310311&Mode=0

2月末までの募集のようなのでぜひ皆さんみておくと良いと思います。

ざっくり見てみたいと思います。色々と課題もありそうです・・・

概要

OpenID Connect/OAuth2.0もちゃんと採用されていますね。


サービス提供領域

個人的に一番気になっていた民間PF事業者との棲み分けにについても記載されていますね。

準公共サービスの範疇をどこにおくのか、で既存事業者とのせめぎ合いがありそうな感じです。

システム構成

しかしながら、このシステムイメージ図を見ると色々と問題点が浮かび上がってきます。

パッと見た感じ普通のID連携モデルです。サービス事業者(先に述べた公共・準公共・民間)がデジタル庁の認証サーバ(OAuthの認可サーバ)に対してクライアント登録をするということになるはずです。このクライアント登録をする段階で公共・準公共・民間の区分で審査をした上で登録していく、という感じで運用していくことになるはずですね。
しかし、そうなると2点問題があるように感じます。

課題はなにか?

  1. 多段フェデレーションへの対応が困難
    • これはID連携モデルだけにとどまらずオンプレミスのActive Directoryフォレストの設計を行う上でも昔から考慮点となっていたところですが、デジタル庁の認可サーバに登録されるクライアントが更にID基盤として実際のサービスとID連携を行なっているケースがありえます。例えば、IDaaSなどのサービスを使っている事業者の場合、デジタル庁にクライアント登録されるのはIDaaSとなり、実際のサービスが登録されるわけではありません。このことにより準公共という触れ込みでデジタル庁に登録されたとしても、その先で民間のサービスとID連携をしてしまっていた、、というケースに対応できなくなります。この辺りはルールで縛りを入れる形になるんだと思いますが
  2. デジタル庁のIdPによる行動把握問題
    • フェデレーションモデルということは利用者(今回の場合はマイナンバーカードを持っている国民)がクライアントとなるサービスを使う都度、デジタル庁のIdPへリダイレクトされることになります。今回のケースではクライアントとなりえる公共・準公共・民間のサービスを認証対象となるユーザが使っていることをデジタル庁のIdPは知ることができる、という状態が発生します。まさにGoogle Knows You Better Than You Know Yourselfならぬ「デジタル庁はあなたよりあなた自身のことを知っている」なんてことになるのでは?という疑念を抱かせないように丁寧な説明が必須になると思います。


この辺りはパブコメだしますかね・・・
こういうユースケースこそVCを使ったIssuer(デジ庁IdP)→Wallet(個人のスマホ)→Verifier(民間を含むサービス)の3パーティモデルを使ってIssuerとVerifierを分離することが有効なのかもしれません。

いずれにしても4月に出てくるということなので楽しみにしておきましょう。

2024年1月27日土曜日

JWTのデコードをターミナル上で行う

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

皆さんIDトークンなどのJWT(JSON Web Token)の中身を確認するのに何を使っていますか?

私はOkta(Auth0)が提供しているhttps://jwt.ioやMicrosoftが提供しているhttps://jwt.msをよく使うのですが、いちいちブラウザを立ち上げるのが面倒な場合もありますよね。

もちろんjwt-cliを使ってコマンドラインで見るのもいいのですが、もうちょっとUIも凝ったものも欲しいよね、というVZ Editorが忘れられない人たちもこの界隈にはいるはずです。


ということで今回紹介するのはjwt.ioと同じくOkta(Auth0)が提供するjwt-uiです。

図)githubより


早速導入してみます。(私はMac環境ですが、Windowsでも使えるみたいです)

インストール

brewでインストールするようです。

brew tap jwt-rs/jwt-ui
brew install jwt-ui

これだけです。

起動

jwtui

これだけです。立ち上がりました。

デコード

UIを起動した状態でEnterを押下すると入力フィールドにフォーカスが当たるので、デコードするJWTをペーストできるようになります。
こんな感じで使えます。
もちろんコマンドラインから直接JWTを引数に指定してもデコードができます。
% jwtui eyJ・・・・という感じです。
するとUIが起動してデコードされた状態が表示されます。

jwt-cliと同じように標準出力にデコードされたものを出力することもできます。
% jwtui -sn eyJ・・・・という感じで使います。(-sは標準出力への出力、-nは署名検証をしない、というオプションです)

他にも色々とオプションがあるので使ってみてください。
  • -S, --secret <SECRET> Secret for validating the JWT. Can be text, file path (beginning with @) or base64 encoded string (beginning with b64:) [default: ]
  • -s, --stdout Print to STDOUT instead of starting the CLI in TUI mode
  • -n, --no-verify Do not validate the signature of the JWT when printing to STDOUT.
  • -j, --json Format STDOUT as JSON
  • -t, --tick-rate <TICK_RATE> Set the tick rate (milliseconds): the lower the number the higher the FPS. Must be less than 1000 [default: 250]
  • -h, --help Print help
  • -V, --version Print version






2024年1月26日金曜日

引き続きOpenID for Verifiable Credentialsの正式セキュリティ分析

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

前回に引き続きOpenID for Verifiable CredentialsのFormal Security Analysisです。

前回はIssuanceに関する分析結果を見てきましたが、今回はPresentationに関してみていきましょう。

Presentationについては2点あります。

1. Crossデバイスフローにおける認証攻撃

  • modeがdirect_postかつCrossデバイスの場合の話ですね。まぁ一番オーソドックスなフローではあると思いますのでなかなか難しい問題です。
  • 攻撃者がVerifierに対して認証インタラクションを開始し、認証要求をフィッシング等で被害者のWalletに読み込ませることで被害者のWalletからvp_tokenがPOSTされてしまう、という話です。


前回の分析でもフィッシングとの組み合わせで攻撃が成り立つケースが紹介されていましたので、やはりここはなんらかの対策をしないといけないポイントですね。


2. セッション完全性攻撃

  • これもカスタムURLスキームの話ですね。ただでさえOAuthやOpenID Connectはステートレスな仕組みの中でトランザクションを成立させるためのセッション維持が難しい課題なのにOID4VCxだとクロスデバイスが入ることで更に難しくなっている印象です。(参考:OID4VPの場合のカスタムURLスキームはopenid4vp://)
  • これも攻撃者のWallet(被害者のスマホにインストールされた悪意のあるWallet)が攻撃者のIDを含むvp_tokenをdirect_postでVerifierへPresentできてしまう、という攻撃です。

やっぱりEUがやっているようにWalletの認定は大事なテーマなんだなぁ、と思わされる分析でした。

ということで、一旦セキュリティ分析の話はおしまいです。





2024年1月25日木曜日

OpenID for Verifiable Credentialsの正式セキュリティ分析が完了

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

先日のOpenID Summitや前日のOpenID Foundation Workshopでも触れられましたが、OpenID for Verifiable Credentialsの正式セキュリティ分析(Formal Security Analysis)が完了した、というニュースが1月18日にリリースされています。


このセキュリティ分析はドイツのシュトゥットガルト大学によって開発されたモデルを用いていて、これまでもOpenID ConnectやFAPIなどのプロトコルやプロファイルとしての安全性を検証するための用いられてきています。

特にFAPIが金融グレードのセキュリティを求めるプロファイルに対応するのと同様に、OpenID for Verifiable CredentialsはEUにおけるeIDAS2.0の枠組みの中で採用されるなど、国が発行するデジタル資格証明を安全にやり取りする上で非常に重要なプロトコルとなります。
そのため、このようなセキュリティ分析とお墨付きは相互運用性に加えて非常に重要な要素となりますね。

こちらが実際の分析結果のレポートへのリンクです。

100ページ以上ある詳細な分析なので、全部に目を通すのは難易度が高いですが、ざっくりいうと、以下の4つのシナリオを定義して詳細な分析をしていくという形態をとっています。
  • Proof of Presentation Authentication
  • Proof of Issuance Authentication
  • Proof of Presentation Session Integrity
  • Proof of Issuance Session Integrity
非常にシンプルで相手方をどのように認証するのか、という話およびSessionの完全性がどのように担保されるのか、という話をVCの発行(Issuance)と提示(Presentation)の両面で分析をかけていくという方法のようです。この辺りは通常のOpenID ConnectにおけるOpenID Provider、Relying Party、User-Agentでもstateやnonce、client_id/secretなどを使って同じことをやっているわけです。

分析結果を少し紹介します。
今回はIssuanceに関する指摘です。3点指摘がされています。

1. Cross-deviceシナリオにおけるIssuance時のカスタムURLスキーム
  • これはOpenID for Verifiable Credentialsに関わらず、ですがQRコードをスマートフォンに読み込ませるなど、PCブラウザと別のデバイスと組み合わせるシナリオにはつきもののカスタムURLスキーム(OID4VCIではopenid-credential-offer://)を使う場合に絶対に発生する課題です。
  • QRコードなどでこのカスタムURLスキーム起動されると、当該のスキームが登録されたネイティブアプリケーションが起動するわけですが、複数のWalletアプリケーションが同一の端末にインストールされている場合は、どのWalletが起動するのかの制御をIssuer側は制御できません。(少なくともiOSの場合は最後にインストールしたアプリ、Androidの場合はユーザが選択したアプリが選ばれる)
  • そうなると例えば悪意のあるアプリが当該カスタムURLスキームを使うと意図しないアプリ事業者へVerifiable Credentialsが渡されてしまうことが想定されます。
なお、このリスクに対応するためにQRコードを読み取る際にWallet側でPINコードを入力させるなど対策を行うわけですが、悪意のあるWalletだったとしても利用者はPINを入力してしまうことも想定されるため、根本的な解決は難しそうです。この辺りはUIの工夫なども必要な領域のようですね。

2. 認可コードフローにおける認証攻撃
  • この攻撃は悪意のあるWalletアプリケーションが認可コードフローを用いる場合に発生します。
  • よくある話としてアプリケーションを起動する際にユーザ認証を求め、多くの場合ユーザはなんの疑いもなくログインをしてしまいます。
  • こうなると悪意のあるアプリケーションに認可コードが渡るので(これは先のカスタムURLスキームの場合も含め)、悪意のあるアプリケーションがアクセストークンを取得、結果としてVerifiable Credentialsの発行をされてしまいます。
この攻撃は悩ましいところですね。アプリ内ブラウザでの認証と外部ブラウザ呼び出し〜カスタムURLスキームでの戻しの区別とかユーザにはつかないと思いますし。。

3. 事前認可コードフローにおけるセッションの完全性攻撃
  • 攻撃者が事前に認証し、クレデンシャルオファーを入手しておきます。
  • この攻撃者のIDを持つクレデンシャルをフィッシングサイトなどにQRコードとして埋め込んでおき、攻撃対象者の読み込ませることで、攻撃対象者のWalletに攻撃者のIDを持つクレデンシャルを発行することができてしまいます。
  • このクレデンシャル自体は正しく署名されたものなので、このクレデンシャルを用いてアクセスコントロールをしているサイトへ攻撃対象が気づかずに情報をアップロードするなどすると情報漏洩に繋がってしまいます。
この辺りもなかなか気づきにくいのでしょうが、他のフィッシング対策と合わせて防御していくしかないんじゃないかな、と思います。

とはいえ、引き続き仕様のアップデートは続いていますし、このフィードバックを受けて仕様のブラッシュアップも進んでいるので今後どうなっていくのかは引き続き要注目です。

今日はこのくらいにしておきます。
機会があればVerifiable Presentation側の分析結果も紹介します。



2024年1月24日水曜日

OpenID Providerを作る)認可エンドポイントでクライアントの登録状態を検証する

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

前回はトークンエンドポイントでクライアント認証を実装したので、今回は認可エンドポイントに戻り、クライアントの登録状態および指定されたredirect_uriが正しいかどうかの検証処理を入れてみます。


その前にこれまでのおさらいです。


さて始めていきましょう。
今回は検証ということでエラー処理に関する仕様を中心に確認していく必要があります。
ポイントになるのは、この辺りです。

これらの章から分かることは、以下の2点です。
  • redirect_uriが不正ならOpenID Providerが直接エラーを返し、それ以外の場合はredirect_uriへコールバックすること(関連する記載:Redirection URI が無効でない限り, Authorization Server は適切なエラーと state パラメータで Authorization Request にて指定された Redirection URI に Client を返す.)
  • エラーレスポンスは各フローのredirect_uriへのコールバック方法に従って返却すること(要はコードフローなら&、インプリシット・ハイブリッドなら#で返せ、ということです)

また、redirect_uriのマッチングルールは認証リクエストに関する仕様に以下のように定められていますので、この辺りも確認ポイントです。
この URI は, Client が OpenID Provider に対して事前に登録済みの Redirection URI のいずれかと完全一致しなければならない (MUST). マッチングルールは [RFC3986] (Simple String Comparison) の Section 6.2.1 に従うこと.

要するに完全一致が必要ってことですね。ワイルドカードが使いたい、という要望はよく聞かれますがいわゆるオープンリダイレクトになってしまう可能性があることから仕様上は完全一致が要求されます。 


それらを踏まえ実装してみます。

まずはエラーの返却方法がresponse_typeによって異なることからクライアント検証を行う前にresponse_typeを判別しておく必要があります。これは前回のスコープのところで実装したロジックをそのまま使います。

// エラーの返却方法にも関連するためresponse_typeの判別は最初にやっておく
// response_typeの取得
const response_types = req.query.response_type.split(" ");

次にこれもトークンエンドポイントのところで実装したクライアント情報の取得ロジックを流用して登録状態を判別します。

// - client_idの登録状態の確認
// クライアント情報の読み取り
const clients = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../database/clients.json")));
// クライアント登録状態の確認
const client = clients.find(i => i.client_id === req.query.client_id);
if(typeof client === "undefined"){
// クライアントが未登録
res.redirect(req.query.redirect_uri + errors.errorOnAuthZ(response_types, "invalid_request", "unknown_client_id", req.query.state));
}else{

エラーハンドリングは共通処理となるのでこんな感じで外部化しておきます。

exports.errorOnAuthZ = function(response_types, error_code, error_description, state) {
// implicitもしくはHybridを判定するフラグ(フラグメントでレスポンスを返すかどうかの判定)
let mode;
if(response_types.includes("token") || response_types.includes("id_token")){
// ImplicitもしくはHybridフロー
mode = "#";
}else{
// codeフロー
mode = "&";
}
return(mode + "error=" + error_code + "&error_description=" + error_description + "&state=" + state);
}

次はredirect_uriのマッチングです。ここも登録状態の確認とほぼほぼ類似のロジックですが、未登録のredirect_uriの場合はOpenID Providerから直接エラーを返却するようにしています。

// redirect_uriがclient設定に合致していることの確認
if(!client.redirect_uris.includes(req.query.redirect_uri)){
// redirect_uri未登録
// redirect_uriが不正なのでOPから直接エラーを返却する
res.status = 400;
res.json({
error: "invalid_request",
error_description: "unknown_redirect_uri",
state: req.query.state
})
}else{


と、こんなところです。

ここまでの実装もこちらにプッシュしてありますので参考にしてください。


2024年1月23日火曜日

OpenID Providerを作る)トークンエンドポイントにクライアント認証を実装する

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

そろそろトークンエンドポイントに手を入れていきましょう。今回はクライアントの登録状態の確認と認証を行ってみます。このあたりから登録情報を持つ必要が出てきます。(といっても まだデータベースを使うまでもないのでjsonファイルを使っていきます)

その前にこれまでのおさらいです。


クライアントに関する情報として何を管理すべきか

今回の実装においてはクライアント認証さえできればいいので最低限client_idとclient_secretがあれば問題ありませんが、本来はclient_idと紐付けて管理されるべき情報としてredirect_uriや同意画面を出そうと思うとクライアント名やクライアントに関する説明やロゴ画像、利用規約などの情報も必要になりますし、登録状態の管理も行おうと思うと登録日や更新日、有効・無効などのステータス、管理者の連絡先などの情報も必要になってくると思います。

といってもそこまで必要になるのはもう少し先の話なので、まずは最低限+αということで名称、ID、シークレット、redirect_uriをファイルに保存しておきます。なお、当然複数のクライアントを登録できるようにするためJSON配列でデータを保存しておきます。
/database/clients.json
[
{
"client_id": "123",
"client_secret": "secret",
"redirect_uris": [
"https://jwt.ms",
"http://localhost:3000/cb"
]
},
{
"client_id": "456",
"client_secret": "secret",
"redirect_uris": [
"https://rp.example.jp/cb",
"https://rp.example.com/cb"
]
}
]

redirect_uriも複数登録できる必要があるので配列にしています。(今回は利用しませんが)

クライアント認証方式を決定する

仕様の9章にclient_authenticationの定義があります。
  • client_secret_basic
    • いわゆるBASIC認証を行う
  • client_secret_post
    • リクエストのBodyにclient_idとclient_secretを入れて送信する
  • client_secret_jwt
    • client_assertionパラメータにJWTを入れて送信する。署名アルゴリズムはHMAC
  • private_key_jwt
    • client_secret_jwtとの違いは署名に秘密鍵を利用すること
  • none
    • クライアント認証を行わない
ベーシックな実装では基本的にclient_secret_basicとclient_secret_postくらいを実装しておけば問題ないので、まずはこの2つを実装しておきます。

クライアント認証を実装する

対象はタイトルの通りトークンエンドポイントなので、当該のエンドポイントにexpress-basic-authを使っても良かったのですが、client_secret_postもサポートするためにミドルウェアを使わずに実装します。といってもAuthorizationヘッダに"BASIC client_id(base64エンコード):client_secret(base64エンコード)”という形で値が渡ってきているだけなので、パースしてデコードするだけです。
今回は簡易実装なのでAuthorizationヘッダがあればclient_secret_basic、そうでなければclient_secret_postとして判別しています。
// - クライアントの認証
let client_id, client_secret;
if(typeof req.headers.authorization === "undefined"){
// client_secret_post
client_id = req.body.client_id;
client_secret = req.body.client_secret;
}else{
// client_secret_basic
const b64auth = req.headers.authorization.split(" ")[1];
[ client_id, client_secret ] = Buffer.from(b64auth, "base64").toString().split(":");
}

ちなみにclient_secret_postの場合はそのままclient_idとclient_secretがボディに入ってくるだけなので値を取得しています。

次は認証処理です。といっても先のjsonファイル内のclient_id/client_secretとマッチしているかどうかを確認するだけです。
// クライアント情報の読み取り
const clients = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../database/clients.json")));
// クライアント登録状態の確認
const client = clients.find(i => i.client_id === client_id);
if(typeof client === "undefined"){
// クライアントが未登録
res.statusCode = 400;
res.json({
errorMessage: "client not found"
});
}else{
// クライアント登録確認、シークレットの検証
if(client.client_secret !== client_secret){
// クライアント認証エラー
res.statusCode = 400;
res.json({
errorMessage: "client authentication was failed"
});
}else{
// クライアント認証成功

シンプルです。
ファイルを読み込んで、client_idで情報を検索、登録されているclient_secretの値を比較するだけです。

ということでこれでトークンエンドポイントのクライアント認証が実装できました。

client_secret_basicの認証エラーです。

client_secret_postの認証エラー(未登録)です。


今回はこんなところです。






2024年1月22日月曜日

OpenID Providerを作る)定義済み属性の値として何を返却すべきか

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

前回はscopeの定義と返却すべき属性について整理をしました。

ちなみに仕様の5章に定義されているStandard ClaimsはIANAのレジストリに登録されているのでOpenID ProviderとRelying Partyの間で個別にフォーマットや値のすり合わせをしなくても良いように標準化されています。登録済みのClaimについては18章に記載されています。

ですので、OpenID Providerを作る時は、基本的にこのStandard Claimをサポートするようにし不足している属性はRelying Partyと個別に調整を行った上で定義する必要があります。

今回はStandard Claimsとして実際にどのような値を返すべきか確認していきましょう。


その前にこれまでのおさらいです。


では、早速確認していきます。
昨日のStandard Claims一覧を再掲しておきます。
scope属性形式備考
profile
namestring
family_namestring
given_namestring
middle_namestring
nick_namestring
preffered_usernamestring
profilestringURL
picturestringURL
websitestringURL
genderstringfemale/maleを利用
birthdatestringISO 8601:2004形式(YYYY-MM-DD)
zoneinfostringIANAタイムゾーン形式(JapanやAmerica/Los_Angelesなど)
localestringBCP47言語タグ表現(ja_JP、en_USなど)
updated_atnumberunixtime
email
emailstring
email_verifiedboolean検証済みならtrue
address
formattedstring
JSON
street_addressstring
localitystring
regionstring
postal_codestring
countrystring
phone
phone_numberstringE.164形式
phone_number_verifiedboolean検証済みならtrue

nameなどのstring形式の属性についてはあまり迷うことはないと思うので、迷いそうなところだけピックアップしておきたいと思います。
  • profile
    • この属性にはURLを返却することが想定されています。仕様をみると「この Web ページに掲載されるコンテンツはこの End-User に関するものであるべきである (SHOULD).」とあるので、もしOpenID Providerがマイページやプロフィールページを持っているならそのURLなどを使うと良いと思います。
  • picture
    • この属性も同じくURLですが、このような注意点が定義されています。
      • この URL は画像ファイル (PNG, JPEG, GIF 画像ファイル等) を参照すること (MUST). またこの画像は End-User が撮影した任意の写真ではなく, End-User に言及する際の表示に適切なプロフィール画像とするべきである (SHOULD).
    • 利用者自身でアップロードできたる編集するものじゃなさそうですが、OpenID Providerの種類(コンシューマ向けなのかエンタープライズ向けなのか)でどのような写真を使うのかは変わってきそうです
  • website
    • この属性もURLです。こちらも以下の注意点が記載されています。
      • この Web ページは End-User 自身や End-User が所属する組織が発信する情報を含むべきである (SHOULD).
    • こちらもシナリオ次第ですね
  • email
    • 想像通りだと思いますがメールアドレスのSyntax(RFC 5322)に従う必要があります
  • gender
    • string形式ですが使用上はmale/femaleが定義されています。しかしながら最近のID基盤ではそれ以外の値を定義することもあります。仕様にはその他の値を設定することも許容しています
      • 定義済の値に適切なものがない場合, その他の値を利用してもよい (MAY).
  • birthdate
    • こちらもstring形式ですがISO 8601:2004に準拠すべきです。仕様には以下の記載があります。
      • ISO 8601:2004 YYYY-MM-DD 形式で表現される. 生年を 0000 とすることで生年を省略することもできる (MAY). 生年のみを提示する場合は YYYY 形式としても良い. 利用するプラットフォームの日付関連の関数の実装によって, 生年のみを提供した場合の月日の扱いは様々であるため, 実装者はこの点を考慮にいれて日付を処理すべきである.
  • zoneinfo
    • こちらもstring形式です。ユーザのタイムゾーンを表す属性ですので、IANAのタイムゾーンレジストリに登録されているコードを使うことが想定されています。
    • 例えば日本ならJapan、北米/ロサンゼルスならAmerica/Los_Angelesです
  • locale
    • こちらもstring形式ですが、結構鬼門だと思っています。仕様には以下の記載があります
      • BCP47言語タグ表現. これは通常 ISO 639-1 Alpha-2 言語コードを小文字表記, ISO 3166-1 Alpha-2国コードを大文字表記し, ダッシュでつなげたものである. (en-US, fr-CA 等) 実装によってはダッシュの代わりにアンダースコアを区切り文字に用いる場合もあるため, 互換性の観点からは注意すること. (en_US 等) 
    • いわゆるISOのAlpha-2言語コード(jaとかen)とAlpha-2区にコード(jpとかus)を大文字表記したものをダッシュ(ハイフン)もしくはアンダースコアで繋げたものという緩めの仕様なので、ja-JPもしくはja_JPという形で値が表現されます。この辺りはRelying Partyに対してどのような表現になるのか事前に伝達しておく必要があります
  • phone_number
    • こちらもstring形式ですが、locale同様に少し揺れる要素があります。原則はE.164形式を推奨するようですが、内線番号の有無や区切り文字やカッコの有無などは考慮が必要です
      • この Claim のフォーマットとしては E.164を推奨する (RECOMMENDED). (+1 (425) 555-1212, +56 (2) 687 2400 等) 電話番号が拡張を含む場合, その拡張は RFC 3966 拡張シンタックスで表記することを推奨する (RECOMMENDED). (+1 (604) 555-1234;ext=5678 等)
    • E.164の仕様だけ見ていると+[国番号]に市外局番の最初の0をとったものと局番をつなげたものということですが、実装を見ているとハイフンの有無やカッコの有無が揺れているようにも見えます。この辺りも事前にRelying Partyに伝達しておいた方が良さそうです
  • updated_at
    • こちらはnumberとして定義されていますので要注意です。UTC 1970年1月1日の0:00:00からの経過秒数、いわゆるunix timeをセットする必要があります

今日はこのくらいにしておきたいと思います。