Class: MysqlReplicator::Connections::Auth

Inherits:
Object
  • Object
show all
Defined in:
lib/mysql_replicator/connections/auth.rb

Constant Summary collapse

CLIENT_PLUGIN_AUTH =

: Integer

0x00080000
CLIENT_SECURE_CONNECTION =

: Integer

0x00008000
CLIENT_PROTOCOL_41 =

: Integer

0x00000200
CLIENT_CONNECT_WITH_DB =

: Integer

0x00000008
CLIENT_MULTI_STATEMENTS =

: Integer

0x00010000
CLIENT_MULTI_RESULTS =

: Integer

0x00020000

Class Method Summary collapse

Class Method Details

.build_caching_sha2_password_hash(password, salt) ⇒ Object

Hash value for caching_sha2_password SHA256(password) XOR SHA256(SHA256(SHA256(password)) + salt)



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/mysql_replicator/connections/auth.rb', line 164

def self.build_caching_sha2_password_hash(password, salt)
  return '' if password.empty?

  # SHA256(password)
  hash1 = Digest::SHA256.digest(password.encode('utf-8'))
  # SHA256(SHA256(password))
  hash2 = Digest::SHA256.digest(hash1)
  # SHA256(SHA256(SHA256(password)), salt)
  hash3 = Digest::SHA256.digest(hash2 + salt)

  # XOR hash1 and hash3
  payload = ''
  hash1.each_byte.with_index do |byte, i|
    payload += (byte ^ hash3[i].to_s.ord).chr
  end
  payload
end

.build_caching_sha2_password_payload(user, password, database, handshake_info) ⇒ Object



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/mysql_replicator/connections/auth.rb', line 116

def self.build_caching_sha2_password_payload(user, password, database, handshake_info)
  # Client feature flag
  client_flags = CLIENT_PROTOCOL_41 |
                 CLIENT_SECURE_CONNECTION |
                 CLIENT_PLUGIN_AUTH |
                 CLIENT_MULTI_STATEMENTS |
                 CLIENT_MULTI_RESULTS
  client_flags |= CLIENT_CONNECT_WITH_DB if database && !database.empty?

  # Max packet size (4 bytes)
  max_packet_size = 0x01000000

  # Character set
  # MySQL 8.0 uses utf8mb4_0900_ai_ci as the default character set
  charset = handshake_info[:charset]

  # Reserved (23 bytes)
  reserved_data = "\x00" * 23

  # Username
  username_data = user + "\x00"

  # Hash for caching_sha2_password
  challenge_hash = build_caching_sha2_password_hash(password, handshake_info[:auth_plugin_data])

  # Database (optional)
  database_data = database && !database.empty? ? database + "\x00" : ''

  # Authentication plugin name
  auth_plugin_name_data = handshake_info[:auth_plugin_name].to_s + "\x00"

  # Payload of packet
  [client_flags].pack('V') +
    [max_packet_size].pack('V') +
    [charset].pack('C') +
    reserved_data +
    username_data +
    [challenge_hash.length].pack('C') + challenge_hash +
    database_data +
    auth_plugin_name_data
end

.build_mysql_native_password_payload(password, salt) ⇒ Object



283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/mysql_replicator/connections/auth.rb', line 283

def self.build_mysql_native_password_payload(password, salt)
  return '' if password.empty?

  # SHA1(password)
  hash1 = Digest::SHA1.digest(password.encode('utf-8'))
  # SHA1(SHA1(password))
  hash2 = Digest::SHA1.digest(hash1)
  # SHA1(salt + SHA1(SHA1(password)))
  hash3 = Digest::SHA1.digest(salt + hash2)

  # XOR hash1 and hash3
  payload = ''
  hash1.each_byte.with_index do |byte, i|
    payload += (byte ^ (hash3[i] || '').ord).chr
  end

  payload
end

.build_rsa_encrypt_password_payload(password, public_key, scramble) ⇒ Object



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/mysql_replicator/connections/auth.rb', line 186

def self.build_rsa_encrypt_password_payload(password, public_key, scramble)
  rsa_public_key = OpenSSL::PKey::RSA.new(public_key)

  # Password is null-terminated string
  password_with_null = password + "\x00"

  password_bytes = password_with_null.encode(Encoding::UTF_8).bytes
  scramble_bytes = scramble.bytes

  xor_result = []
  password_bytes.each_with_index do |byte, index|
    scramble_byte = scramble_bytes[index % scramble_bytes.length]
    xor_result << (byte ^ scramble_byte)
  end
  data_to_encrypt = xor_result.pack('C*')

  begin
    # First, try OAEP padding (MySQL 8.0.5+)
    rsa_public_key.public_encrypt(data_to_encrypt, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
  rescue OpenSSL::PKey::RSAError
    # If OAEP fails, use PKCS#1 (MySQL 8.0.4 and earlier)
    rsa_public_key.public_encrypt(data_to_encrypt, OpenSSL::PKey::RSA::PKCS1_PADDING)
  end
end

.caching_sha2_password_auth(connection, user, password, database, handshake_info) ⇒ Object



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/mysql_replicator/connections/auth.rb', line 47

def self.caching_sha2_password_auth(connection, user, password, database, handshake_info)
  auth_payload = build_caching_sha2_password_payload(user, password, database, handshake_info)
  debug_caching_sha2_password_payload(auth_payload, !database.empty?)
  connection.send_packet(auth_payload)

  auth_response_packet = connection.read_packet
  if handle_caching_sha2_password_response(auth_response_packet) == :success
    return
  end

  MysqlReplicator::Logger.debug 'Trying RSA encryption authentication...'

  # Request public key for RSA encryption
  public_key_payload = [0x02].pack('C')
  connection.send_packet(public_key_payload)

  public_key_response_packet = connection.read_packet
  if MysqlReplicator::StringUtil.read_uint8(public_key_response_packet[:payload][0]) != 0x01
    raise MysqlReplicator::Error, 'Failed to retrieve public key'
  end

  # Auth with RSA encryption
  public_key = MysqlReplicator::StringUtil.read_str(public_key_response_packet[:payload][1..])
  encrypted_password_payload = build_rsa_encrypt_password_payload(password, public_key, handshake_info[:auth_plugin_data])
  connection.send_packet(encrypted_password_payload)

  final_auth_response_packet = connection.read_packet
  return unless MysqlReplicator::StringUtil.read_uint8(final_auth_response_packet[:payload][0]) != 0x00

  raise MysqlReplicator::Error, 'RSA encryption authentication failed'
end

.debug_caching_sha2_password_payload(payload, with_database) ⇒ Object



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/mysql_replicator/connections/auth.rb', line 214

def self.debug_caching_sha2_password_payload(payload, with_database)
  offset = 0

  client_flags = MysqlReplicator::StringUtil.read_uint32(payload[offset..(offset + 3)])
  offset += 4

  max_packet_size = MysqlReplicator::StringUtil.read_uint32(payload[offset..(offset + 3)])
  offset += 4

  character_set = MysqlReplicator::StringUtil.read_uint8(payload[offset])
  offset += 1

  reserved = MysqlReplicator::StringUtil.read_str(payload[offset..(offset + 22)])
  offset += 23

  null_pos = payload.index("\x00", offset).to_i
  user = MysqlReplicator::StringUtil.read_str(payload[offset...null_pos])
  offset = null_pos + 1

  challenge_hash_length = MysqlReplicator::StringUtil.read_uint8(payload[offset])
  offset += 1
  if challenge_hash_length > 0
    challenge_hash_data = MysqlReplicator::StringUtil.read_str(payload[offset..(offset + challenge_hash_length - 1)])
    offset += challenge_hash_length
  end

  if with_database
    db_null_pos = payload.index("\x00", offset).to_i
    database = MysqlReplicator::StringUtil.read_str(payload[offset...db_null_pos])
    offset = db_null_pos + 1
  end

  plugin_null_pos = payload.index("\x00", offset).to_i
  plugin_name = MysqlReplicator::StringUtil.read_str(payload[offset...plugin_null_pos]) if plugin_null_pos

  MysqlReplicator::Logger.debug \
    "===== Start Auth Payload =====\n" \
    "Client flags: #{client_flags.to_i.to_s(16)}\n" \
    "Max packet size: #{max_packet_size}\n" \
    "Character set: #{character_set}\n" \
    "Reserved: #{MysqlReplicator::StringUtil.read_array_from_int8(reserved).all?(&:zero?) ? 'All zero' : 'None zero'}\n" \
    "User: #{user}\n" \
    "Challenge hash length: #{challenge_hash_length}\n" \
    "Challenge hash data: #{MysqlReplicator::StringUtil.read_array_from_int8(challenge_hash_data).map { |b| format('%02X', b) }.join(' ')}\n" \
    "Database name: #{database}\n" \
    "Auth plugin name: #{plugin_name}\n" \
    '===== End Auth Payload ====='
end

.execute(connection, user, password, database, handshake_info) ⇒ Object



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/mysql_replicator/connections/auth.rb', line 23

def self.execute(connection, user, password, database, handshake_info)
  auth_plugin_name = handshake_info[:auth_plugin_name]

  case auth_plugin_name
  when 'caching_sha2_password'
    caching_sha2_password_auth(connection, user, password, database, handshake_info)
    MysqlReplicator::Logger.debug 'Authentication by caching_sha2_password is successful!'
  when 'mysql_native_password'
    mysql_native_password_auth(connection, password, handshake_info)
    MysqlReplicator::Logger.debug 'Authentication by mysql_native_password is successful!'
  else
    raise MysqlReplicator::Error, "Unsupported auth plugin name: #{auth_plugin_name}"
  end

  # Clear EOF packet (at finish)
  connection.flush_socket_buffer
end

.handle_caching_sha2_password_response(packet) ⇒ Object



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/mysql_replicator/connections/auth.rb', line 81

def self.handle_caching_sha2_password_response(packet)
  payload = packet[:payload]

  # First byte of the response is result type
  first_byte = MysqlReplicator::StringUtil.read_uint8(payload[0])
  case first_byte
  when 0x00
    :success
  when 0x01
    more_data = payload[1..] || ['']
    command = MysqlReplicator::StringUtil.read_uint8(more_data[0])
    case command
    when 0x03
      :success
    when 0x04
      :challenge
    else
      raise MysqlReplicator::Error, "Unexpected command: #{format('%02X', command)}"
    end
  else
    raise MysqlReplicator::Error,
      'Authentication Error: ' \
      "first_byte = #{first_byte}, " \
      "code = #{(payload[1..2] || '').unpack('v')[0]}, " \
      "sql_state_marker = #{(payload[3] || '').chr}, " \
      "sql_state = #{payload[4..8]}, " \
      "message = #{payload[9..]}"
  end
end

.mysql_native_password_auth(connection, password, handshake_info) ⇒ Object

@return: void



267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/mysql_replicator/connections/auth.rb', line 267

def self.mysql_native_password_auth(connection, password, handshake_info)
  auth_payload = build_mysql_native_password_payload(password, handshake_info[:auth_plugin_data])
  connection.send_packet(auth_payload)

  auth_response_packet = connection.read_packet

  return unless MysqlReplicator::StringUtil.read_uint8(auth_response_packet[:payload][0]) != 0x00

  raise MysqlReplicator::Error,
    'mysql_native_password authentication failed: ' \
    "Payload = #{MysqlReplicator::StringUtil.read_array_from_int8(auth_response_packet[:payload]).map { |b| format('%02X', b) }.join(' ')}"
end