認証がプラグイン化された最近のMySQL(5.5くらい?)の認証時のプロトコルをちゃんと理解してなかったので調べてみた。
基本的にはこんな感じ
クライアントが接続するとサーバー(mysqld)から次の内容のパケットが送られる:
- プロトコルバージョン: 現在のところ "10"
- サーバーバージョン: "8.0.27" とか
- スレッドID
- パスワードハッシュ化のためのデータ(チャレンジ)
- サーバーの機能(ケイパビリティ)
- サーバーのデフォルト文字コード(collation)
- 認証方式:
caching_sha2_password
とか
それの応答としてクライアントが次のパケットを送る:
- クライアントフラグ
- クライアント側が受けられるパケットの最大長(
max_allowed_packet
) - クライアントの文字コード(collation)
- ユーザー名
- パスワードをチャレンジでハッシュ化したデータ
- 認証方式:
caching_sha2_password
とか - その他、クライアント名/クライアントのバージョン/プロセスID/OSの種類等々
サーバーから送られたチャレンジを用いてクライアント側でパスワードをハッシュ化した値が、サーバー側で計算した値と一致してれば認証OKとみなす。チャレンジ・レスポンス認証というやつ。
現在の MySQL のデフォルトの認証方式は caching_sha2_password
なので、サーバーとクライアントがそれに従ってハッシュ値を計算すればいい。
ユーザーの認証方式がサーバーのデフォルトと異なっている場合
ところが MySQL はユーザーごとに認証方式を設定できるので、ユーザーによっては caching_sha2_password
じゃないことがある。
初期状態ではサーバーはどのユーザーが接続してくるかわからないし、クライアントはユーザーの認証方式が何なのかを知らない。
サーバーのデフォルトの認証方式とユーザーの認証方式が異なっている場合は、ユーザー名が判明した時点でもう一度チャンレジ・レスポンス認証を行う。
たとえばユーザーの認証方式が mysql_native_password
だった場合はこんな感じ(認証に関するもの以外の情報は省略)。
サーバー:
- 認証方式:
caching_sha2_password
- チャレンジデータ
クライアント:
- ユーザー名
- 認証方式:
caching_sha2_password
- パスワードを
caching_sha2_password
でハッシュ化した値
(ここまでは同じだけど、サーバーはここでユーザーの認証方式が mysql_native_password
だとわかったのでもう一度)
サーバー:
- 認証方式:
mysql_native_password
- チャレンジデータ
クライアント:
- パスワードを
mysql_native_password
でハッシュ化した値
クライアントのデフォルトの認証方式がサーバーのデフォルトと異なっている場合
クライアントが MySQL 5.7 とかだとデフォルトの認証方式が mysql_native_password
なので、サーバーの初期パケットの認証方式と異なる。
そのような場合は最初の応答パケットにハッシュ値を含めない。
サーバー:
- 認証方式:
caching_sha2_password
- チャレンジデータ
クライアント:
- ユーザー名
- 認証方式:
mysql_native_password
- ハッシュ値なし。サーバーが指定した認証方式じゃないので。
サーバー:
- 認証方式:
caching_sha2_password
- チャレンジデータ
クライアント:
- パスワードを
caching_sha2_password
でハッシュ化した値
おまけ
調査のため、こんなプログラムをサーバーとクライアントの間に挟んでパケットデータを眺めてた。
require 'socket' class MysqlProxy MAX_PACKET_LENGTH = 2**24-1 def initialize(local_port, server_name, server_port) @local_port, @server_name, @server_port = local_port, server_name, server_port end def start Socket.tcp_server_loop(@local_port) do |client_socket, _peer| puts "START" server_socket = Socket.tcp(@server_name, @server_port) sockets = { client_socket => [server_socket, "Client:"], server_socket => [client_socket, "Server:"], } while true rr, = IO.select([client_socket, server_socket], nil, nil, nil) r = rr[0] puts sockets[r][1] break if r.eof? raw, data = read_packet(r) p data sockets[r][0].write(raw) end server_socket.close client_socket.close puts "END" end end def read_packet(socket) raw = '' data = '' while true header = socket.read(4) raise Errno::ECONNRESET unless header && header.length == 4 len1, len2, seq = header.unpack("CvC") len = (len2 << 8) + len1 puts "length: #{len}, seq: #{seq}" ret = socket.read(len) raise Errno::ECONNRESET unless ret && ret.length == len raw.concat header + ret data.concat ret break if len < MAX_PACKET_LENGTH end return raw, data end end MysqlProxy.new(*ARGV).start if $0 == __FILE__
これだけ見てもなんだかわからないと思うけど、https://www.slideshare.net/tmtm/mysql-protocol を見たらわかるかもしれない。