「人ではない相手」を認証する — サービス間の決済呼び出しを、手がかりを渡さずに守る

セキュリティ 認証 マイクロサービス 設計 分散システム

はじめに

自作の銀行連携システムでは、決済が複数のサービスにまたがる。決済サービスが、口座サービスの内部APIを呼んでお金を動かす。このとき呼び出しているのは人ではなく、サービス自身だ。サービスをどう分けてあるかを含めた全体像は 自作の銀行系システムは実際に何が動いているか に一巡した。

人のログインなら、IDとパスワードやパスキーで本人を確かめる。だがサービス間では、確かめる相手が人ではない。ここで人のログインの常識をそのまま持ち込むと、攻撃者に手がかりを渡す穴が開く。この記事は、その穴を塞ぐために入れた三つの設計の話だ。

なお、これは「呼び出し元が誰か」を確かめる認証の話で、「その人が自分の口座しか触れないか」という認可とは別レイヤだ。認可の側で踏んだ落とし穴(他人の口座を操作できてしまう欠陥)は 参照(GET)では守っていた本人確認を、更新(POST)がまるごと落としていた — 口座APIで作りかけたIDOR に書いた。


人ではない呼び出し元を、どう認証するか

決済サービスが口座サービスの内部APIを呼ぶには、人間のログイントークンではなく、サービス自身のトークンが要る。仕組みはこうだ。決済サービスは、あらかじめ共有しておいたIDと秘密鍵で認証サービスに名乗り出て、短命のトークンを一枚受け取る。そのトークンを添えて、口座サービスの内部APIを叩く。

この「事前に共有したIDと秘密で、人ではなくプログラムが認証する」やり方を client_credentials(クライアント認証) と呼ぶ。受け取るトークンには「これは内部サービスからの呼び出しだ」という印(role: service)だけが入り、口座サービスはその印を見て内部APIの利用を許す。

決済サービス ──① ID+秘密で名乗る──▶ 認証サービス
決済サービス ◀─② 短命トークン(role:service)─┘
決済サービス ──③ トークンを添えて──▶ 口座サービスの内部API(role:service を要求)

素直に見える。問題は、この「名乗り出て、トークンを受け取る」入口の作り方にある。ここを人のログインと同じ感覚で作ると、三つの穴が開く。


その一:トークンを、短命にする

まず、受け取るトークンの寿命を5分に切った。

人のログインは、利便性のために数十分〜数時間もつことが多い。だがサービス間トークンにその感覚を持ち込むと、一度漏れたトークンが長く効き続ける。ログや通信の隙間から一枚漏れただけで、攻撃者はその間ずっと内部APIを叩けてしまう。

短命なら、漏れても被害の窓が短い。決済サービス側は、トークンの寿命に合わせて手元に持っておき、切れる前に取り直す。「便利だから長く」ではなく「漏れたら困るから短く」——人のログインとは逆の重み付けをする。


その二:「無い」と「違う」を、区別しない

二つ目が、この記事で一番伝えたいところだ。認証の入口は、攻撃者に手がかりを一切返してはいけない

素朴に作ると、名乗ってきたIDが未登録なら「そのIDは無い」、登録済みだが秘密が違うなら「秘密が違う」と、別々の反応を返してしまう。親切なようで、これは攻撃者に「どのIDが実在するか」を教える。実在するIDさえ分かれば、あとは秘密だけを総当たりすればいい。反応の違いそのものが、手がかり(オラクル)になる。

だから、未登録のIDも、秘密の不一致も、まったく同じ失敗として返す。攻撃者は、自分の入れたIDが実在したのかどうかすら分からない。

【素朴】ID未登録 → 「IDが無い」   ┐ 反応が違う
       秘密が違う → 「秘密が違う」 ┘ → どのIDが実在するか漏れる(総当りの足場)

【対策】ID未登録 → 同じ失敗 ┐
       秘密が違う → 同じ失敗 ┘ → 実在するIDすら分からない(手がかりゼロ)

もう一段ある。秘密鍵の照合を、ふつうの文字列比較(先頭から1文字ずつ照合し、違った時点で止める)でやると、一致した文字数だけ比較時間が伸びる。この時間差を測れば、秘密を1文字ずつ手繰り寄せられる(タイミング攻撃)。だから比較は、必ず最後まで同じ時間で走る方式(timing-safe な比較)を使う。

// 未登録IDも秘密不一致も、同一の失敗で返す(どのIDが実在するか秘匿)
var matched = clients.stream()
        .filter(c -> c.clientId().equals(clientId))
        .findFirst()
        .orElseThrow(InvalidInternalServiceClientException::new);
// 秘密の照合は timing-safe(比較時間から秘密を推測させない)
boolean ok = MessageDigest.isEqual(
        matched.clientSecret().getBytes(UTF_8),
        clientSecret.getBytes(UTF_8));
if (!ok) throw new InvalidInternalServiceClientException(); // ↑と同一例外

親切さと安全は、認証の入口では逆を向く。利用者に分かりやすいエラーは、攻撃者にも分かりやすい。


その三:内部と外部の「台帳」を、混ぜない

三つ目は、トークンを発行する相手の信頼境界を分けることだ。

このシステムには、トークンを受け取る相手が二種類いる。一つは内部の別サービス(決済サービスなど、banklink の中の身内)。もう一つは外部の加盟店(決済を利用する外の事業者)。この二つを、同じ発行口・同じ名簿で扱ってはいけない。

そこで、名簿(台帳)そのものを分けた。内部サービスの登録簿と、外部加盟店の登録簿は別物として持つ。発行されるトークンの性質も分けてある——内部向けは「内部サービスだ」という印(role: service)だけを持ち、外部加盟店向けは「加盟店決済のためのトークンで、この顧客宛て」という別の印(purpose: merchant_payment と対象顧客)を持つ。同じ「トークン発行」でも、身内に出すものと外部に出すものは、名簿もクラスも意図的に別にした。

             ┌─ 内部サービス台帳 ──▶ role:service(内部APIを叩ける)
トークン発行 ─┤
             └─ 外部加盟店台帳 ───▶ purpose:merchant_payment(顧客宛ての決済のみ)

        この二つを一つの名簿・一つの発行口にまとめると、
        外部の相手に内部権限のトークンが出かねない(境界の崩壊)

もし両者を一つの発行口にまとめれば、コードのちょっとした取り違えで、外部の加盟店に内部サービス用の強い権限を持つトークンが出てしまいかねない。境界は、混同できない形に物理的に分けておくことで守る。分けるのが面倒だから一つにする、はここでは選ばない。


認証の入口は、親切さより「手がかりを渡さない」を選ぶ

三つの設計は、根が一つだった。人のログインの常識を、そのままサービス間に持ち込まない。

  • トークンは、便利さのためでなく漏れた時のために短くする。
  • エラーは、分かりやすさのためでなく手がかりを渡さないために区別しない
  • 発行口は、手間を惜しまず信頼境界ごとに分ける

どれも「利用者への親切」を一度捨てて選んだ設計だ。人向けの画面なら、分かりやすいエラーも長いセッションも正しい。だが認証の入口で相手が人とは限らないとき、親切はそのまま攻撃者への手がかりになる。相手が人でないことを前提に、便利さの重み付けを逆に振る——それが、サービス間認証で腹に落ちたことだった。


関連する記事

気軽にメッセージください

仕事の依頼、案件紹介、ご感想・ご質問なんでもお待ちしております。 高い志をもった同士の皆様と繋がることを切に願っております。 これからも人生を掛けたチャレンジを続けていきます。 何卒よろしくお願い致します。