1. はじめに
最近、不正アクセスや情報流出のニュースが急激に増え始めましたね。これを機に、自分のパスワードを見直している人も多いと思います。自分もその1人です。
見直す中で、いくつかのサービスでパスキーを登録しました。パスキーっていつからか出てきて、最近徐々に登録する機会が増えてきましたよね。なんとなく安全なイメージはありますが、なぜ安全なのか、どういう仕組みなのかはあんまりわかっていないのが正直なところです。
(パスキーを使い始めた最初の頃は、なんかスマホのロック解除みたいにログインできて便利な機能、くらいにしか思っていませんでした)
せっかくの機会なので今回は、パスキーの仕組みを自分で実装しながらどうやって安全を実現しているのか調べてみました。
2. ログインでは何を証明しているのか
パスキーは、認証方法の一つです。
ではそもそも、認証とは何をすることなのでしょうか?
それは、サービスに対して、「自分がそのアカウントの本人である」ことを証明することです。
その証明のために、「本人しか知らない(はずの)情報」をサーバーに送ります。
アカウント登録時に設定するパスワードや、二要素認証でSMSやメールに届くコードが、これにあたります。
裏を返すと、この「本人しか知らない情報」を他人に知られてしまうと、他人でも本人として認証されてしまいます。
最も一般的な認証方法であるメールアドレス / パスワード認証で不正にログインされるのは、だいたい次のような場合です。
- 簡単なパスワードを、推測や総当たりで当てられる
- 偽サイトに入力してしまい、そのまま盗まれる(フィッシング)
- サービスから漏れた情報をもとに、パスワードを割り出される
- 使い回していたパスワードがほかのサービスから漏れて、それで入られる
簡単なパスワードや使い回しは、使う側が気をつければ防げます。
でも、偽サイトに入力してしまったり、サービスから漏れたりすることは、気をつけていても起きてしまいます。
どちらも、「本人しか知らない情報」そのものをサービスに送っているから起きることです。
3. 秘密鍵と公開鍵・署名
「本人しか知らない情報」を送ることなく、自分が本人だと証明する方法があれば、前述の問題は解決しますね。
SSHやHTTPSなどでも使われている、秘密鍵・公開鍵の仕組みが、実はそれを実現しています。
秘密鍵と公開鍵は、ペアで作られる2つの数値です。
秘密鍵は自分だけが持ち、公開鍵は相手に渡します。
この2つの数値は計算でつながっていて、次のような関係を持っています。
- 秘密鍵を使うと、あるデータから、別の数値を計算できる
- その数値が本当にペアの秘密鍵で計算されたものかは、公開鍵を使って確かめられる
- 公開鍵から秘密鍵を(現実的な時間では)計算することはできない
この、秘密鍵でデータから計算した数値を「署名」と呼びます。
鍵の発行・署名の仕組み詳細(RSAの場合)
補足:鍵の発行・署名の仕組み詳細(RSAの場合)
ここでは、仕組みがいちばんわかりやすいRSAという方式で説明します。
鍵ペアは、次の手順で作ります。
- 大きな素数を2つ、ランダムに選ぶ
- その2つを掛けた数を作る
- 公開鍵に使う数をもう1つ決める(ふつうはどの鍵でも同じ65537を使う)
- 選んだ2つの素数を使って、秘密鍵の数を計算する
公開鍵として相手に渡すのは、2つの素数を掛けた数と、65537です。
秘密鍵の数は、自分だけが持ちます。(実際の鍵ファイルには、計算を速くするために元の2つの素数も保存されています。これらも外に出してはいけない値です)
秘密鍵の数を計算するには、元の2つの素数が必要です。
公開鍵から秘密鍵を求めようとすると、掛けた数を元の2つの素数に分解(素因数分解)しなければなりません。
掛け算は一瞬でできますが、その逆の素因数分解は、数が大きくなると極端に難しくなるらしいです。
どのくらい難しいかというと、2020年に829ビット(250桁)の数が素因数分解されたときは、CPU 1コア換算で約2700年分の計算がかかっているとのこと。
実際のRSAでは、2つの素数を掛けた数に、これよりずっと大きい2048ビット(617桁ほど)以上の数を使います。
ただし、十分に大きな量子コンピュータができると、素因数分解を速く解けることが知られています。
そのため、量子コンピュータでも破れない新しい方式への移行が進められているそうです。(がんばれ)
署名するときは、データをハッシュ関数(SHA-256など)にかけて決まった長さの数にし、その数を秘密鍵の数を使って計算します。
その結果が署名です。
元のデータはそのままの形で、署名と一緒に相手に届きます。
署名を確かめるときは、署名を公開鍵の数を使って計算します。
秘密鍵と公開鍵は、「秘密鍵で計算したものを、公開鍵で計算すると元に戻る」ように作られています。
そのため、戻った数が元のデータのハッシュと一致すれば、ペアの秘密鍵で、そのデータに対して作られた署名だとわかります。
秘密鍵を持っていない人が作った署名や、途中でデータが書き換えられた場合は、一致しません。
なお、パスキーで主に使われているのはRSAではなくECDSAという別の方式らしいです。ECDSAでは「元に戻る」のではなく、「秘密鍵で計算したものを、公開鍵で検算すると答えが合う」という関係を使って署名を確かめます。
これを認証に使うと、次のような流れになります。
- 事前に、公開鍵をサービスに登録しておく
- ログインのとき、サービスが毎回ちがうランダムな文字列を送ってくる
- その文字列に秘密鍵で署名して返す
- サービスが、登録されている公開鍵で署名を確かめる
署名が正しければ、「この公開鍵とペアの秘密鍵を持っている人」、つまり本人だとわかります。
このとき、秘密鍵そのものは一度も送っていません。
文字列が毎回ちがうのは、署名の使い回しを防ぐためです。
もし毎回同じ文字列だと、一度盗み見た署名をそのまま送るだけで、ログインできてしまいます。
この方法を使えば、「本人しか知らない情報」を送らずに、本人だと証明できるようになります。
パスキーは、この仕組みをWebのログインで使えるようにしたものです。
実際にはどんなやりとりをしているのか、自分でサーバーを作って確かめてみます。
4. 実際に作って流れを見てみる
登場するもの
パスキーのやりとりには、3つの登場人物がいます。
- サーバー: ログインさせる側のサービス
- ブラウザ: SafariやChrome
- 認証器: 秘密鍵を持ち、署名するもの。今回はMac(Touch ID)
登録
「パスキーを作成」を押すと、次のようなやりとりになります。
ページのJavaScriptからは、ブラウザに用意されている、Webでパスキーを使うための標準の仕組み(WebAuthn)を呼ぶだけです。
手元のMacで試したので、コードではドメインをlocalhostにしています。
const credential = await navigator.credentials.create({
publicKey: {
challenge, // サーバーから届いたランダムな文字列
rp: { id: "localhost", name: "パスキーの実験" }, // ログインさせる側のサービス。id はパスキーを紐づけるドメイン
user: { id: userId, name: "user@example.com", displayName: "Example User" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }], // -7 は ES256
authenticatorSelection: {
residentKey: "required", // 鍵を端末に保存させる(ユーザー名なしで選べる)
userVerification: "required", // 指紋・顔・PIN での本人確認を求める
},
},
});
// credential.response に公開鍵などが入っているので、サーバーに送る
実際に登録してみると、サーバーに保存されたのは、次のようなものだけでした。
{
"userId": "(アカウントの ID)",
"alg": -7,
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
}
ログイン
「パスキーでログイン」を押すと、次のようなやりとりになります。
ページのJavaScriptのコードは、次のとおりです。
const assertion = await navigator.credentials.get({
publicKey: { challenge, rpId: "localhost" },
});
// assertion.response に署名などが入っているので、サーバーに送る
サーバー側では、次の4つを確かめています。
client_data = json.loads(client_data_json)
assert client_data["challenge"] == sent_challenge # さっき送った文字列か
assert client_data["origin"] == "http://localhost:8000" # 自分のページで作られたか
assert authenticator_data[:32] == sha256(b"localhost") # 自分のドメイン用の鍵か
public_key.verify( # 署名が正しいか
signature,
authenticator_data + sha256(client_data_json),
ec.ECDSA(hashes.SHA256()),
)
秘密鍵や指紋のデータは、サーバーにもページにも渡りません。
サーバーが知っているのは登録時に受け取った「公開鍵」だけで、ログインのときに受け取るのはその場で計算された「署名」だけです。
これまでパスワードで証明していた「本人しか知らない秘密を知っていること」を、パスキーでは、その秘密自体を明かすことなく証明できます。
秘密をサービスに送らないので、サービスからデータが漏れても、秘密を解析されることはありません。
実際に署名されているもの
Macで試したときのclientDataJSONは、次のとおりでした。
{"type":"webauthn.get","challenge":"UWchdLYUTsgE144X33m08oLBbCgSnmgYlbS65ZdqTY8","origin":"http://localhost:8000","crossOrigin":false}
前の章では「ランダムな文字列に署名する」と書きましたが、実際には、ページのドメイン(origin)も一緒に署名されています。
originはブラウザが自分で書き込むので、ページ側からは変えられません。
さらに認証器は、次のような印も一緒に署名します。
- UP: 人がその場で操作した
- UV: 指紋・顔・PINで本人だと確かめた
- BE: ほかの端末に同期できる鍵である
- BS: 実際に同期されている
サーバーに届くのは「本人を確かめた」という印(UV)だけで、指紋そのものは届きません。
5. 作ったサーバーを攻撃してみる
ここまでで、パスキーの仕組みはだいたいわかりました。
では、2章であげた不正ログインの手口は、パスキーだと本当に通らないのでしょうか。
作ったサーバーに、実際に攻撃を仕掛けて確かめてみました。
(ブラウザとTouch IDの代わりに、同じ動きをする疑似的な認証器をプログラムで用意しています。本物のサイトをexample.com、偽サイトをexamp1e.com(lが数字の1)としました)
偽サイトに誘い込まれた場合
偽サイトのexamp1e.comでパスキーを使おうとしても、example.com用のパスキーは出てきませんでした。
パスキーはドメインごとに保存されているので、ドメインが違えば使えません。
ドメイン名がそっくりで人が気づけなくても、ブラウザがドメインで判断してくれるということですね。
念のため、疑似的な認証器に無理やり本物用の鍵で署名させてみました。
それでもサーバーで弾かれました。
メモに書かれたドメインがexamp1e.comになっていたからです。
先ほどの検証で、ページのドメインまで一緒に署名していたのはこのためでした。
サービスからデータが漏れた場合
サーバーに保存されているのは公開鍵だけなので、漏れても公開鍵しか手に入りません。
攻撃者が自分で作った鍵で署名を送ってみましたが、公開鍵と合わずに弾かれました。
推測・使い回し
秘密鍵は端末が作るランダムな数値なので、推測できません。
また、パスキーはサイトごとに別の鍵ペアを作るので、使い回しも起きません。
通信を盗み見られた場合
ログインのやりとりを盗み見て、そのまま送り直してみましたが、これも弾かれました。
ランダムな文字列がログインのたびに変わるので、前回の署名は使えません。
というわけで、2章であげた手口は、今回作ったサーバーではどれも通りませんでした。
ただ、パスワードやSMSでのログインが残っているアカウントなら、そちらから入られる可能性はあります。
端末そのものを乗っ取られた場合も、パスキーだけでは防げません。
6. まとめ: パスキーはなぜ安全なのか
パスキーは、「本人しか知らない秘密」を送らずに、本人であることを証明する仕組みでした。
- 秘密鍵は、サーバーにもページにも渡らない
- サーバーには公開鍵しかないので、漏れても困らない
- ページのドメインまで署名するので、偽サイトでは使えない
なんとなく安全なイメージで登録していたパスキーでしたが、自分で作ってみると、SSHやHTTPSと同じ署名の仕組みでできていることがわかりました。あとスムーズにログインできて普通に便利。どんどん使っていきたいですね!
それにしても、最近は情報漏洩のニュースが多くて怖いですね。
パスキーでさえ、何か天才的な(AIが考えた)アイデアで破られてもおかしくないような気がします。
物理認証最強の時代への回帰に備えて、セキュリティキーを買うか……。

