Capstan.Protocol.Handshake (Capstan v0.1.0)

Copy Markdown View Source

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

connect_opt()

@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.

initial_handshake()

@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.

result()

@type result() :: %{
  socket: socket(),
  server_version: String.t(),
  capabilities: non_neg_integer(),
  connection_id: non_neg_integer(),
  tls: boolean()
}

Result of a successful handshake.

socket()

@type socket() :: Capstan.Protocol.Packet.socket()

A transport-tagged socket, as defined by Capstan.Protocol.Packet.

Functions

caching_sha2_scramble(password, nonce)

@spec caching_sha2_scramble(binary(), binary()) :: binary()

The caching_sha2_password fast-auth scramble.

SHA256(password) XOR SHA256(SHA256(SHA256(password)) <> nonce). An empty password scrambles to an empty response.

connect(socket, opts)

@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.

encrypt_password(password, nonce, public_key_pem)

@spec encrypt_password(binary(), binary(), binary()) ::
  {:ok, binary()} | {:error, :bad_public_key}

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.

native_scramble(password, nonce)

@spec native_scramble(binary(), binary()) :: binary()

The mysql_native_password scramble (opt-in plugin).

SHA1(password) XOR SHA1(nonce <> SHA1(SHA1(password))). An empty password scrambles to an empty response.

parse_initial_handshake(arg1)

@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}}).