「人ではない相手」を認証する — サービス間の決済呼び出しを、手がかりを渡さずに守る
はじめに
自作の銀行連携システムでは、決済が複数のサービスにまたがる。決済サービスが、口座サービスの内部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(顧客宛ての決済のみ)
▲
この二つを一つの名簿・一つの発行口にまとめると、
外部の相手に内部権限のトークンが出かねない(境界の崩壊)
もし両者を一つの発行口にまとめれば、コードのちょっとした取り違えで、外部の加盟店に内部サービス用の強い権限を持つトークンが出てしまいかねない。境界は、混同できない形に物理的に分けておくことで守る。分けるのが面倒だから一つにする、はここでは選ばない。
認証の入口は、親切さより「手がかりを渡さない」を選ぶ
三つの設計は、根が一つだった。人のログインの常識を、そのままサービス間に持ち込まない。
- トークンは、便利さのためでなく漏れた時のために短くする。
- エラーは、分かりやすさのためでなく手がかりを渡さないために区別しない。
- 発行口は、手間を惜しまず信頼境界ごとに分ける。
どれも「利用者への親切」を一度捨てて選んだ設計だ。人向けの画面なら、分かりやすいエラーも長いセッションも正しい。だが認証の入口で相手が人とは限らないとき、親切はそのまま攻撃者への手がかりになる。相手が人でないことを前提に、便利さの重み付けを逆に振る——それが、サービス間認証で腹に落ちたことだった。
関連する記事
- 「呼び出し元が誰か(認証)」の裏側、「その人が自分の資源しか触れないか(認可)」で踏んだ落とし穴は 参照(GET)では守っていた本人確認を、更新(POST)がまるごと落としていた — 口座APIで作りかけたIDOR に書いた。
- このサービス間トークンが実際に使われる、決済サービスが口座サービスを跨いで振込を成立させる整合の話は 振込の途中でサービスが落ちても、お金が消えないように — Saga と Outbox で分散した口座間の整合を守る にまとめた。