MySQL の認証プロトコル
認証がプラグイン化された最近の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 を見たらわかるかもしれない。
Discussion