Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions lib/mysqlex/auth/native_password.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
defmodule Mysqlex.Auth.NativePassword do
@moduledoc """
The `mysql_native_password` authentication method.

A challenge-response scheme: the server sends a random 20-byte scramble in its
handshake, the client answers with a value derived from both the scramble and
the password. The password itself never crosses the wire, and because the
scramble differs per connection the answer cannot be replayed.

Not the default since MySQL 8 — `caching_sha2_password` is — but it is the
simplest method to get a connection working, and the server will use it for any
account created with `IDENTIFIED WITH mysql_native_password`.
"""

@doc """
Builds the reply to the server's scramble.

`SHA1(password) XOR SHA1(scramble <> SHA1(SHA1(password)))`.

The server holds only `SHA1(SHA1(password))`, which is enough to compute the
same value and compare, and not enough to recover the password.

An account with no password answers with no bytes at all rather than with a
hash of the empty string.
"""
def reply("", _scramble), do: ""

def reply(password, scramble) do
stage1 = :crypto.hash(:sha, password)
token = :crypto.hash(:sha, scramble <> :crypto.hash(:sha, stage1))

:crypto.exor(stage1, token)
end

@doc """
`SHA1(SHA1(password))` — what the server keeps in
`mysql.user.authentication_string`, prefixed there with `*` and hex encoded.
"""
def stored_hash(password), do: :crypto.hash(:sha, :crypto.hash(:sha, password))
end
110 changes: 109 additions & 1 deletion lib/mysqlex/client.ex
Original file line number Diff line number Diff line change
@@ -1,12 +1,74 @@
defmodule Mysqlex.Client do
@moduledoc """
Reads and writes packets over a `Mysqlex.Transport`.
Reads and writes packets over a `Mysqlex.Transport`, and runs the connection
phase.

This is where the socket lives. Everything it hands upwards is a whole
message, so no parser above it ever sees a partial read.
"""

alias Mysqlex.Auth.NativePassword
alias Mysqlex.Packet
alias Mysqlex.Protocol.{Codec, Err, Handshake, HandshakeResponse}

defstruct [:transport, :socket, :capabilities, :connection_id, :server_version, buffer: ""]

@default_port 3306
@default_timeout 5_000

@doc """
Opens a connection and completes the connection phase.
"""
def connect(opts) do
transport = Keyword.get(opts, :transport, Mysqlex.Tcp)
host = opts |> Keyword.fetch!(:hostname) |> to_charlist()
port = Keyword.get(opts, :port, @default_port)
timeout = Keyword.get(opts, :connect_timeout, @default_timeout)
socket_opts = Keyword.get(opts, :socket_options, [])

with {:ok, socket} <- transport.connect(host, port, socket_opts, timeout) do
handshake(transport, socket, opts)
end
end

@doc """
Runs the connection phase over an already-open socket.

The server speaks first, so this reads before it writes: greeting, response,
then however the server answers. Separate from `connect/1` so the conversation
can be driven by a scripted transport.
"""
def handshake(transport, socket, opts) do
timeout = Keyword.get(opts, :connect_timeout, @default_timeout)
username = Keyword.fetch!(opts, :username)
password = Keyword.get(opts, :password) || ""
database = Keyword.get(opts, :database)

client = %__MODULE__{
transport: transport,
socket: socket,
capabilities: HandshakeResponse.capabilities(database)
}

with {:ok, sequence_id, greeting, buffer} <- recv_message(transport, socket, "", timeout),
{:ok, handshake} <- Handshake.decode(greeting),
response =
HandshakeResponse.encode(
username,
NativePassword.reply(password, handshake.auth_plugin_data),
database
),
:ok <- send_message(transport, socket, response, sequence_id + 1),
{:ok, buffer} <- authenticate(client, password, buffer, timeout) do
{:ok,
%{
client
| connection_id: handshake.connection_id,
server_version: handshake.server_version,
buffer: buffer
}}
end
end

@doc """
Reads one message, reading again for as long as the buffer holds less than a
Expand All @@ -27,4 +89,50 @@ defmodule Mysqlex.Client do
end
end
end

@doc """
Frames a payload and writes it to the socket.
"""
def send_message(transport, socket, payload, sequence_id) do
payload
|> IO.iodata_to_binary()
|> Packet.encode(sequence_id)
|> then(&transport.send(socket, &1))
end

# The greeting names the server's default authentication plugin, chosen before
# it knows which account is connecting. When the account uses a different one
# the server answers with an AuthSwitchRequest naming what it actually wants,
# with a fresh scramble. So the plugin in the greeting is not something to
# refuse on — switching is how the protocol settles the mismatch.
defp authenticate(%__MODULE__{} = client, password, buffer, timeout) do
with {:ok, sequence_id, payload, buffer} <-
recv_message(client.transport, client.socket, buffer, timeout) do
case payload do
<<0x00, _rest::binary>> ->
{:ok, buffer}

<<0xFF, _rest::binary>> ->
{:ok, error} = Err.decode(payload, client.capabilities)
{:error, error}

<<0xFE, rest::binary>> ->
{plugin, data} = Codec.decode_string_nul(rest)
switch_auth(client, plugin, data, password, buffer, sequence_id, timeout)
end
end
end

defp switch_auth(client, "mysql_native_password", data, password, buffer, sequence_id, timeout)
when byte_size(data) >= 20 do
# the fresh scramble is 20 bytes and a terminator, as in the greeting
reply = NativePassword.reply(password, :binary.part(data, 0, 20))

with :ok <- send_message(client.transport, client.socket, reply, sequence_id + 1) do
authenticate(client, password, buffer, timeout)
end
end

defp switch_auth(_client, plugin, _data, _password, _buffer, _sequence_id, _timeout),
do: {:error, {:unsupported_auth_plugin, plugin}}
end
66 changes: 66 additions & 0 deletions lib/mysqlex/protocol/handshake.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
defmodule Mysqlex.Protocol.Handshake do
@moduledoc """
`Protocol::HandshakeV10`, the greeting the server sends on connect.

MySQL speaks first, so this is the first packet any client sees and the one
that decides everything after it: which capabilities are on offer, and which
authentication method the server wants.

See `docs/mysql-protocol/page_protocol_connection_phase_packets_protocol_handshake_v10.md`.
"""

import Bitwise

alias Mysqlex.Protocol.Codec

defstruct [
:protocol_version,
:server_version,
:connection_id,
:capabilities,
:character_set,
:status_flags,
:auth_plugin_name,
:auth_plugin_data
]

@doc """
Decodes a HandshakeV10 payload.

Two fields are easy to get wrong and both are sized by the protocol rather
than fixed. `server_version` is NUL-terminated, not a fixed width — it is
"8.4.4" on one server and "8.0.33-0ubuntu0.22.04.2" on another. The scramble
arrives in two pieces with unrelated fields between them, and the second piece
is `MAX(13, auth_plugin_data_len - 8)` bytes of which the last is a
terminator, so the scramble itself is `auth_plugin_data_len - 1` bytes.
"""
def decode(<<10, rest::binary>>) do
{server_version, rest} = Codec.decode_string_nul(rest)

<<connection_id::little-32, scramble_head::binary-size(8), 0, capabilities_lower::little-16,
character_set, status_flags::little-16, capabilities_upper::little-16, auth_plugin_data_len,
_reserved::binary-size(10), rest::binary>> = rest

tail_length = max(13, auth_plugin_data_len - 8)
<<scramble_tail::binary-size(^tail_length), rest::binary>> = rest
{auth_plugin_name, _rest} = Codec.decode_string_nul(rest)

scramble_length = auth_plugin_data_len - 1

<<auth_plugin_data::binary-size(^scramble_length), _terminator::binary>> =
scramble_head <> scramble_tail

{:ok,
%__MODULE__{
protocol_version: 10,
server_version: server_version,
connection_id: connection_id,
# the server splits its capability bitmask across two fields, nine bytes apart
capabilities: capabilities_upper <<< 16 ||| capabilities_lower,
character_set: character_set,
status_flags: status_flags,
auth_plugin_name: auth_plugin_name,
auth_plugin_data: auth_plugin_data
}}
end
end
70 changes: 70 additions & 0 deletions lib/mysqlex/protocol/handshake_response.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
defmodule Mysqlex.Protocol.HandshakeResponse do
@moduledoc """
`Protocol::HandshakeResponse41` — the client's answer to the server's greeting,
and the first packet this driver ever sends.

The capability flags here are a claim rather than a question. Whatever is set
decides the shape of nearly every packet that follows, which is why
`capabilities/1` is public: the same value has to be handed to the response
parsers so they read the right fields.

See
`docs/mysql-protocol/page_protocol_connection_phase_packets_protocol_handshake_response.md`.
"""

import Bitwise

alias Mysqlex.Protocol.Codec

@connect_with_db 1 <<< 3
@protocol_41 1 <<< 9
@transactions 1 <<< 13
# named CLIENT_RESERVED2 in current docs; modern servers behave as if it is
# always set, older ones require it for 4.1-style authentication
@secure_connection 1 <<< 15
@plugin_auth 1 <<< 19

@capabilities @protocol_41 ||| @transactions ||| @secure_connection ||| @plugin_auth

# Deliberately not claimed:
# CLIENT_LOCAL_FILES a malicious server can ask the client for any file
# CLIENT_MULTI_STATEMENTS turns one injected query into several
# CLIENT_SESSION_TRACK changes the OK packet's shape; not parsed yet
# CLIENT_DEPRECATE_EOF changes how resultsets terminate; a phase 3 call
# CLIENT_CONNECT_ATTRS sends client metadata the driver has no use for

# utf8mb4_general_ci, understood by every server since 5.5
@character_set 45
@max_packet_size 0xFFFFFF
@auth_plugin_name "mysql_native_password"

@doc """
The capability flags the driver claims, which depend only on whether a
database is being selected on connect.
"""
def capabilities(nil), do: @capabilities
def capabilities(_database), do: @capabilities ||| @connect_with_db

@doc """
Builds the response payload as iodata.

`auth_response` is the output of the authentication method — for
`mysql_native_password` that is `Mysqlex.Auth.NativePassword.reply/2`. It is
length-prefixed with a single byte, which is the form used when
`CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA` is not claimed.
"""
def encode(username, auth_response, database) do
[
<<capabilities(database)::little-32, @max_packet_size::little-32, @character_set,
0::size(23)-unit(8)>>,
Codec.encode_string_nul(username),
<<byte_size(auth_response)>>,
auth_response,
database_field(database),
Codec.encode_string_nul(@auth_plugin_name)
]
end

defp database_field(nil), do: []
defp database_field(database), do: Codec.encode_string_nul(database)
end
3 changes: 3 additions & 0 deletions lib/mysqlex/tcp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule Mysqlex.Tcp do

@socket_opts [packet: :raw, mode: :binary, active: false]

@impl true
def connect(host, port, opts \\ [], timeout \\ 5_000) do
:gen_tcp.connect(host, port, @socket_opts ++ opts, timeout)
end
Expand All @@ -16,10 +17,12 @@ defmodule Mysqlex.Tcp do
:gen_tcp.recv(socket, bytes, timeout)
end

@impl true
def send(socket, packet) do
:gen_tcp.send(socket, packet)
end

@impl true
def close(socket) do
:gen_tcp.close(socket)
end
Expand Down
15 changes: 11 additions & 4 deletions lib/mysqlex/transport.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@ defmodule Mysqlex.Transport do
@moduledoc """
The socket operations the client needs.

Reading is expressed against this behaviour rather than `:gen_tcp` directly so
that framing and, later, the connection phase can be driven by a scripted
transport in tests. `Mysqlex.Tcp` is the real implementation.
The client is written against this rather than `:gen_tcp` directly so the
connection phase can be driven by a scripted transport in tests — capability
negotiation, authentication failures and plugin mismatches are all branches
that would otherwise need specially configured live servers.

Callbacks are added as the client comes to need them.
`Mysqlex.Tcp` is the real implementation. `:ssl` has the same shape, so TLS
will be another module behind the same behaviour rather than a branch threaded
through every read and write.
"""

@type socket :: term

@callback connect(host :: charlist, port :: :inet.port_number(), opts :: keyword, timeout) ::
{:ok, socket} | {:error, term}
@callback recv(socket, bytes :: non_neg_integer, timeout) :: {:ok, binary} | {:error, term}
@callback send(socket, iodata) :: :ok | {:error, term}
@callback close(socket) :: :ok
end
Binary file added test/fixtures/handshake_v10_8.4.4.bin
Binary file not shown.
Loading
Loading