序
はるかみの勢いがすごい。
2026年始まって1月に7本のプロジェクトを開始、8本のソフトウェアをリリースした。 さらに、月が変わって2月1日に2本のプロジェクトを開始、2月2日には1本はリリースされた。
そしてその中でなかなか重要なプロジェクトが走った。
本命となるプロジェクトはComnpact。 本当の「マイクロブログ」を実現するソフトウェアだ。 これ自体は比較的簡素なプログラムになる予定である。
これだけだとあまり見どころがない。 言ってみれば、PureBuilderシリーズの簡素なバージョンで、WebUIを持つ程度のものに過ぎない。 だが、実は裏で見どころが用意された。
公開鍵認証である。
この機能はずっと温めていたもので、Comnpactという大義名分を得て実現された。
pubkeyauth.js
もともとはComnpactの機能として実証実験をするつもりだったが、汎用性のあるJavaScriptモジュールとして切り出すことにした。
それがpubkeyauth.jsで、npmで公開している。
独自に開発したものなので既存のなにかと互換性のある実装ではないため、通常このライブラリを必要とすることはない。 「新しい」認証方法として公開鍵認証を自分のアプリに実装したいと考える人にとって手間が省ける程度のものだ。
だが、「新しい」認証方式は、新たに大きな可能性を拓くものでもあるだろう。
公開鍵認証
まず公開鍵認証とは何なのかを説明したほうがいいだろう。
公開鍵認証は秘密鍵によって署名されたものを公開鍵によって検証し、検証が成功したならば相手を公開鍵に対応する秘密鍵の正当な所有者とみなし、公開鍵からユーザー(や権限)を引くものである。
よくある誤解として、公開鍵で暗号化して秘密鍵で復号化することで証明するという説明がなされることが多いのだが、それは公開鍵で認証していない。 そういうチャレンジレスポンス式の認証もあるのだが、それは公開鍵認証とは異なる。
この誤解が広がった理由は、恐らくSSH1にあったRSAAuthenticationと、SSH2のpublickey authenticationとの混同である。 詳しくはSSHの公開鍵認証に関する誤謬と実際参照のこと。
説明は以上なんだけれども、もう少し噛み砕いた説明も付け加えておこう。
まず、非対称鍵暗号というものがあり、これは自身だけが持つ秘密鍵と、相手に渡す公開鍵の鍵ペアがある。 この鍵ペアの基本的な使い方としては「秘密鍵で署名し、公開鍵で検証する」と「公開鍵で暗号化し、秘密鍵で復号化する」である。
秘密鍵は自分だけが持っているので、秘密鍵で署名できるのは自分だけである。 そして、公開鍵は相手が持てるので、公開鍵を使って署名を検証し、そのメッセージが確かに秘密鍵所有者によるものであると確認することができる。
公開鍵は相手が持てるので、相手が公開鍵を使って暗号化することができる。 公開鍵を用いて暗号化した場合、これを復号化するには秘密鍵が必要であり、自分しか見ることができないものになる。 つまり、暗号化した人は暗号化されたものを元に戻すことはできない。
非対称鍵暗号の鍵は暗号鍵なので暗号化できて当たり前のように思うかもしれないが、むしろ署名のほうがメインだし、署名のほうが圧倒的に多く使われている。 そして、鍵の種類によってはそもそも暗号化には使えなかったりする。 暗号化はAESのような共通鍵暗号を用いるのが普通で、暗号化に用いる「共通の秘密」をいかに漏洩せずに受け渡しするかというのが鍵交換アルゴリズムである。
OpenSSHの場合、かなり複雑な方法によってやりとりされるが、一番重要な部分について言うと、クライアントは
- 公開鍵暗号アルゴリズム
- 公開鍵
- 署名
の3つを送信し、サーバーがこれを検証する。 クライアントが署名するのはセキュアトンネルの暗号化に使われた共通の秘密であり、署名する対象は認証プロセスの中で送信されない。
WebAuthn
ウェブブラウザで利用可能な別の認証手段についても確認しておこう。 WebAuthnだ。
WebAuthnの認証を完結にまとめると、FIDO/FIDO2を用いた認証方法で、FIDO/FIDO2はプラットフォームがそこにユーザーがいることを確認するものである。
私がWebAuthnを採用しなかったのは、これがあまりにも多くの問題がある最悪の認証方法であるということからだ。
いや、この表現は正しくない。考え方の異なる認証から選択し組み合わせるのはセキュアで正しい。だが、WebAuthnを強制するのは最悪であるという話だ。
分かりやすいところでいくと、FIDOは誰もが当たり前に利用できるものではない。 きっとFIDOを推進する人たちはMacbookとiPhone以外のコンピュータがあることを知らないのだろう。
まずデスクトップコンピューターならば生体認証デバイスがない可能性は普通にあることだ。 だからWindowsでさえも満たせない可能性はある。 ましてLinuxやその他unixなどにおいてはなおさら、対応されていない可能性が高い。
Windowsの場合はWindows Helloを有効にすることが必須となる。 認証方法がプラットフォームにおけるユーザーのオプトアウトの権利を奪っているのだ。
そして私はWindows Helloは信用に値しないと思っている。 実際私が遭遇したケースとして、Windows Helloによる認証はできるがそれがアカウントの認証に失敗してコンピュータがロックされる、というケースに遭遇した。 Windows Helloが有効だとWindows Helloでの認証が強制されるのでどうすることもできない。 加えて、そもそもPINが入力できれば安全というのは盲目的すぎて、オフラインセキュリティという概念を失っている。
YubiKeyのようなハードウェアを使う方法もあるが、デスクトップPCだとUSBポートが触れる場所にあるとは限らない。加えて、ハードウェア自体の持ち歩きが強制される。 また、ここにハードウェア選択肢が十分あるわけでもなく、特定のハードウェアをユーザーに強制する意味合いが強い。
そもそもこの認証自体がベンダーに寄り添いすぎていて、「ついでだからこれをユーザーに受け入れさせよう」の塊になっている。
もっと根本的な話をすると、FIDOがこのようにしなければいけないと主張する「安全が脅かされる状態」はFIDOにも適用可能で、結局「規格通りセキュアな実装がセキュアに使われれば安全」という話でしかないのに、自分には甘く都合の良い考え方を適用している。
だから私としてはこれはkind of evilだという認識である。
だが、そうだとしてもこれがoptionalであれば大した問題ではない。対応可能で、かつ受け入れられる人だけがFIDOを使い、そうでない人は他の手段を使えばいい。 しかし、それが強制されるのであればそれは本当にevilになる。
で、今回の場合「基本的な認証機能」として提供されるものだから、ここにWebAuthnを採用すると必然的にFIDO強制になる。 だから私は採用しなかった。
pubkeyauth.jsの考え方
pubkeyauth.jsはSSHの認証を良いものと考えるスタンスに基づいているので、SSHと似ている。 しかし、SSHほど複雑な認証を構成するのは大変だし、そもそもSSHと違ってHTTPSのTLSで使われている共通の秘密にアクセスできないので、SSHの方式をそのまま採用することができない。
なのでモデルを簡略化するとともに、共通の秘密をサーバーから送信するチャレンジレスポンス方式を採用した。 まとめるとこうだ。
- 認証が必要な場合、サーバーは「認証セッションのID」「認証に使用する共通の秘密」を生成し、
401 Unauthorizedで応答する - クライアントは応答された共通の秘密に署名し、署名と公開鍵を送信する
- サーバーは送られたきた署名を送られてきた公開鍵で検証し、成功した場合公開鍵からユーザー情報を引く
簡略化が効く部分として、クライアントはドメインに対して1種類の認証用鍵ペアを持つため、鍵の選択に関わる部分が不要。
鍵の永続化にIndexedDBを使い、IndexedDBはオリジンに固有のデータベースを提供するので、IndexedDBの特定のキーを認証鍵の保存に使えばそのドメインに唯一の認証鍵ペアになる。
メカニズム
ではその仕組みを細かく見ていこう。 pubkeyauth.jsは特定の既存実装を想定したものではなく、新規実装用のクライアントライブラリ、かつ汎用性をもたせたものにしている。 このため、ここでの話のサーバーの部分は「pubkeyauth.jsが想定しているサーバーの挙動」という話である。 ちなみに、サーバーのリファレンス実装はある。
サーバーは認証トークンを用いて認証を行う(typ セッションクッキー)。 つまり、認証済みの状態であれば「有効なトークンを持っている」ことでアクセス権が得られるため、この状態が通常の利用状態である。
トークンが有効でない場合(typ 期限切れ)、あるいは未認証である場合、サーバーは401 Unauthorizedで応答する。
この応答にはシークレット(署名する対象)とチャレンジトークン(チャレンジレスポンスのセッションID、検証時にシークレットを引くためのもの)が含まれる。
これを受け取ったクライアントはIndexedDBから秘密鍵を取り出し、シークレットに署名。 署名、対応する公開鍵、チャレンジトークンをログインAPIに向けて送信する。
サーバーはログインAPIでこれを受け取り、チャレンジトークンからシークレットを引く。 そしてシークレットと送られてきた公開鍵で署名を検証、成功した場合はこの公開鍵を真正のものとみなす。
失敗した場合、サーバーは401 Unauthorizedまたは403 Forbiddenを返す。
成功した場合、真正とみなした公開鍵をキーとしてデータベースを検索する。 この公開鍵が正当なものであればユーザーIDが引ける。
ユーザーIDを引くことができ、かつexpireを超過していなければ、認証トークンをセットして200 Okを返す。
API
APIが決まっている部分は少ない。
まずは、認証トークン無効による401
Status: 401 Unauthorized
WWW-Authenticate: PublicKey
{
"secret": "String",
"challenge_token": "String"
}
クライアントからの認証要求
{
"signature": "String",
"publicKey": "Stinrg/Base64(SPKI_Exported)",
}
これに対してサーバーは401または403を返す。
どのような場合にこれらを返すかは規定しておらず、リトライを認めるならば401、認めないならば403を返す。
ただ、鍵は複数種類ある想定ではないため、基本的に「署名の検証はできたが、公開鍵からユーザーIDが引けなかった」場合は403を返すべきである。
利益と欠点
公開鍵認証は極めて強力な認証である。 パスワード認証と比べて、突破されるリスクは非常に小さい。
また、鍵ペアはIndexedDBに格納され、認証が要求された場合に裏で自動的に認証を行う。 これにより、ユーザーには認証要求されたことを見せることなく、少し通信がひっかかる程度で処理が続行される。
さらに、認証要求がユーザーエクスペリエンスに与える要求が小さいため、常に変わらないリスクとなるセッショントークンの期限を短くすることができる。 これにより、セッションハイジャックされた場合のダメージを軽減できる。
また、パスワード認証の場合一般的にパスワード変更が可能で、セッションハイジャックがそのような経路を与えてしまう可能性があるが、この公開鍵認証は認証情報が完全にデバイス(ブラウザ)にバインドされており、横取りは不可能である。 また、IndexedDBはドメイン単位、かつセキュアコンテキストでしか使えないため、パスワードのように盗みとることが難しく、XSSに対しても高い耐性がある。 盗み取るにはスプーフィングで正規のドメインで悪意あるサイトを表示するくらいしかできないが、この場合はTLSの証明書というハードルがあり、あまり現実的に盗み取れるシナリオは考えられない。
このように強固なセキュリティと利便性を両立できるが、万能というわけではない。 最大の問題は、「ブラウザに触れること」を正当な所有者とみなすため、ブラウザに他の人が触れる状態だと安全ではない。
また、常に自動的に認証されるため、ログアウトができない。 例えば秘密鍵を暗号化するという方法もあるが、そうすると裏で自動的に認証されるというメリットを失うことになる。
「ログアウト」ステートを付与することも考えられるが、ではどうやって「ログイン」するのかという問題も発生する。
このほか、同一ドメイン内の同一ブラウザでマルチアカウント制御することができない。 ただこれは、拡張してIndexedDBの格納キーを分けるという方法で実現できなくもない。
鍵登録という障壁
当たり前だが、公開鍵認証をするためには事前に公開鍵をユーザーに結びつける必要がある。これが鍵登録である。
OpenSSHの場合、~/.ssh/authorized_keysに書いておくことが求められる。
しかし鍵登録をするには、何らかの他の方法で本人であることを証明した上で鍵を登録する必要がある。 そして、ほとんどの場合それは公開鍵認証よりも脆弱である。 ということはそこがウィーケストリンクになる。
SSHにおいてもそれは同様で、一般的にはパスワード認証によって公開鍵を登録し、その後パスワード認証を無効にするという流れが一般的だ。 この場合、ウィーケストリンクを消すことができるのだが、そのフローはちょっと複雑だ。
pubkeyauth.jsではその点を無視することにした。 つまり、pubkeyauth.jsにおいては「すでに鍵登録がされている」という前提に立っており、それをいかに成立させるかには関与しない。 状態を管理して登録前はパスワード認証させるのか、あらかじめひとつの鍵を送信するものとするのか、SFTP等で書き込むのか、そういったことはアプリ側で好きにやってくれ、ということだ。
XXWMP
pubkeyauth.jsはComnpactのために作られたものだが、このアイディア自体はもっと昔からあり、可能性を探っていた。 考え始めたのはSSH公開鍵認証について詳しく知ってからなので、多分2018年から。
この考え方はおよそデバイス認証である。 XXWMPは「信頼されたデバイスのみで接続できる」を実現したかったし、その前段としてLWMPをmTLSで接続するのを試したが、結論としては満足できなかった。
だが、公開鍵認証によるデバイス認証という考え方は、ちゃんと理屈を整理しないと難しい。 XXWMPを実装する段階では重すぎて手が出なかった。 XXWMPは私が使うものだから、あまり長引かせたくもなかった。
対してComnpactは私としてはあまり需要がない。 もしやるとしてもPureBuilder Simplyで実現できることであり、それをコマンドが叩けない人にフレンドリーにするだけだから。
そして、あまり見どころのないソフトウェアなのでそのままではあまり魅力的なものにならない。 だがここに「公開鍵認証」が加われば大きなトピックになるだろうと考えて実装した。
そして、XXWMPでやりたいことだったのだから当然XXWMPに実装するのだ。
というわけで、実装した。
XXWMPにおいて公開鍵認証はoptionalである。
auth_method: publickeyとすることで有効になる。
前述のとおり鍵登録という問題があるが、XXWMPはCLIツールで行う。 XXWMPは広く公開するようなものではないため、管理者による手動登録でも問題はないだろう。
npm
さて、今回npmでパッケージを公開したわけだが、ここで先に述べたWebAuthnが立ちはだかった。
npmはパッケージ公開にWebAuthnによる2要素認証を要求する。
しかし、前述の通りLinuxでは専用の(選択肢のほとんどない)ハードウェアを必要とするし、WebAuthnはプラットフォームとの結合が非常に強いものだ。
最終的にはスマートフォンで認証することでなんとかしたが、これも一筋縄ではいかなかった。
皮肉にも、WebAuthn以外の選択肢を公開しようとしたところWebAuthnに阻まれる形となり、やはりWebAuthnは邪悪であるという思いを新たにした。