MySQL connection handshake, authentication and TLS upgrade.
A pure protocol module: it takes an already-connected {:gen_tcp, socket} plus
resolved options and drives, in order, the initial-handshake parse, an optional
TLS upgrade, and authentication — returning an authenticated, transport-tagged
socket ({:gen_tcp, _} or {:ssl, _}, per Capstan.Protocol.Packet) and the
negotiated server capabilities. It owns no process, no config validation and no
socket lifecycle; Capstan.Connection supplies the socket and the options.
Transport posture (design Q6 / Q17)
TLS is expected to be on by default (the caller passes ssl: false to opt out).
Peer verification is an explicit operator choice, never a silent default: with
ssl: true the caller must supply EITHER a cacertfile/cacerts OR an explicit
verify: in ssl_opts. Given neither, connect/2 fails closed with
:tls_verification_unspecified rather than silently selecting verify_none —
OTP would otherwise default to verify_peer, which cannot validate MySQL's
self-signed auto-generated certificate, and the tempting "fix" is unauthenticated
TLS chosen by nobody.
Authentication (design Q6)
caching_sha2_password is the preferred plugin, including its RSA-public-key
full-auth path. mysql_native_password is honoured only when named in
auth_plugins; using it logs a deprecation warning (removed in MySQL 9.x).
The full-auth secret handling is fail-closed: on a full-auth request over TLS the
protocol permits the secure-channel fast path (the password crosses the already
encrypted channel). Over a plaintext channel the password is never sent in
the clear — it is XORed with the auth nonce and RSA-OAEP encrypted with the
server's public key; if that key cannot be obtained, connect/2 fails closed
with :insecure_auth_refused, and a server-supplied key that does not decode
fails closed with :bad_public_key (never a raise).
Rule 1
The connection password appears in no returned term, error, or log line emitted by this module.
Summary
Types
Resolved connection options.
Parsed Protocol::HandshakeV10 initial handshake.
Result of a successful handshake.
A transport-tagged socket, as defined by Capstan.Protocol.Packet.
Functions
The caching_sha2_password fast-auth scramble.
Reads the initial handshake, optionally upgrades to TLS, and authenticates.
Encrypts the password for the caching_sha2_password full-auth path.
The mysql_native_password scramble (opt-in plugin).
Parses a Protocol::HandshakeV10 initial handshake packet.
Types
@type connect_opt() :: {:username, String.t()} | {:password, String.t()} | {:database, String.t() | nil} | {:ssl, boolean()} | {:ssl_opts, keyword()} | {:auth_plugins, [atom()]} | {:host, charlist() | String.t()} | {:timeout, timeout()}
Resolved connection options.
@type initial_handshake() :: %{ server_version: String.t(), connection_id: non_neg_integer(), salt: binary(), auth_plugin: String.t(), capabilities: non_neg_integer(), charset: byte(), status: non_neg_integer() }
Parsed Protocol::HandshakeV10 initial handshake.
@type result() :: %{ socket: socket(), server_version: String.t(), capabilities: non_neg_integer(), connection_id: non_neg_integer(), tls: boolean() }
Result of a successful handshake.
@type socket() :: Capstan.Protocol.Packet.socket()
A transport-tagged socket, as defined by Capstan.Protocol.Packet.
Functions
The caching_sha2_password fast-auth scramble.
SHA256(password) XOR SHA256(SHA256(SHA256(password)) <> nonce). An empty
password scrambles to an empty response.
@spec connect(socket(), [connect_opt()]) :: {:ok, result()} | {:error, term()}
Reads the initial handshake, optionally upgrades to TLS, and authenticates.
socket is an already-connected {:gen_tcp, socket}. On success returns
{:ok, result} carrying the authenticated (possibly TLS-upgraded) socket and the
negotiated capabilities; on failure returns {:error, reason} with a value-free
reason.
Encrypts the password for the caching_sha2_password full-auth path.
The NUL-terminated password is XORed with the auth nonce (cycled) and then
RSA-OAEP encrypted with the server's PEM-encoded public key. A public key that
does not decode fails closed with {:error, :bad_public_key} — the password is
never sent when the key is unusable.
The mysql_native_password scramble (opt-in plugin).
SHA1(password) XOR SHA1(nonce <> SHA1(SHA1(password))). An empty password
scrambles to an empty response.
@spec parse_initial_handshake(binary()) :: {:ok, initial_handshake()} | {:error, term()}
Parses a Protocol::HandshakeV10 initial handshake packet.
Returns {:ok, handshake} or {:error, reason} (a server error packet becomes
{:error, {:handshake_error, code}}).