🐬

MySQL の認証プロトコル

2021/10/25に公開

認証がプラグイン化された最近の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