Module: Lich::Common::Authentication::EAccess

Defined in:
documented/common/authentication/eaccess.rb

Overview

Core EAccess protocol implementation for Simutronics game servers Handles SSL socket creation, certificate management, and game authentication protocol

Defined Under Namespace

Classes: AuthenticationError

Constant Summary collapse

PACKET_SIZE =
8192
NEW_CHARACTER_CODE =

Character code that enters the character generator instead of selecting an existing character. When sent via the L command, the game server starts the character creation flow.

"0"
CONNECT_TIMEOUT =

Bounds the TCP connect to eaccess.play.net:7910 so a silently-dropped SYN (firewalled/blocked, no RST) fails in seconds instead of hanging on the OS connect timeout (commonly ~75s on Linux, driven by tcp_syn_retries) -- observed live when the port is unreachable but not actively refused. This only bounds the TCP handshake itself; it is not a substitute for auth_with_timeout's overall watchdog, which also covers the TLS handshake and the K/A/M/F/G/P/C/L exchange.

5
KNOWN_REJECTION_TOKENS =

Simutronics-recognized rejection tokens the A command can return. Distinguishes a normal, expected credential rejection from an unrecognized/empty/malformed response, which points at a different class of problem (relay or backend divergence) -- see docs/eaccess-failure-diagnostics.md.

%w[REJECT NORECORD INVALID PASSWORD].freeze

Class Method Summary collapse

Class Method Details

.auth(password:, account:, character: nil, game_code: nil, legacy: false, generator: false) ⇒ Hash, Array

Authenticates with the EAccess server and launches a character session.

When generator is true, the character lookup is skipped and the server enters the character generator (character code "0") instead of selecting an existing character.

Parameters:

  • password (String) —

    account password (plaintext, will be hashed)

  • account (String) —

    account name

  • character (String, nil) (defaults to: nil) —

    character name to select

  • game_code (String, nil) (defaults to: nil) —

    game instance code (e.g. "DR", "GS3")

  • legacy (Boolean) (defaults to: false) —

    use legacy multi-game enumeration flow

  • generator (Boolean) (defaults to: false) —

    enter the character generator instead of selecting a character

Returns:

  • (Hash, Array) —

    login info hash (normal) or array of character hashes (legacy)

Raises:



201
202
203
204
205
206
207
208
209
210
211
212
213
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
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'documented/common/authentication/eaccess.rb', line 201

def self.auth(password:, account:, character: nil, game_code: nil, legacy: false, generator: false)
  # Set Account module state
  if defined?(Lich::Common::Account)
    Lich::Common::Account.name = 
    Lich::Common::Account.game_code = game_code
    Lich::Common::Account.character = character
  end

  conn = EAccess.socket()
  begin
    # it is vitally important to verify self-signed certs
    # because there is no chain-of-trust for them
    EAccess.verify_pem(conn)

    hashkey = stage("k_response", probable_cause: "a relay or the EAccess target behind 7910 (possible)") do
      conn.puts "K\n"
      key = EAccess.read(conn)
      # A malformed/empty K response would otherwise silently produce
      # garbage password bytes below, surfacing two steps later as an
      # unrelated-looking a_response failure instead of being
      # attributed to this stage.
      raise AuthenticationError, "MALFORMED_K_RESPONSE" if key.to_s.strip.empty?

      key
    end
    # pp "hash=%s" % hashkey
    password = password.split('').map { |c| c.getbyte(0) }
    hashkey = hashkey.split('').map { |c| c.getbyte(0) }
    password.each_index { |i| password[i] = ((password[i] - 32) ^ hashkey[i]) + 32 }
    password = password.map { |c| c.chr }.join

    stage("a_response", probable_cause: ->(e) { classify_a_response_failure(e) }) do
      conn.puts "A\t#{}\t#{password}\n"
      response = EAccess.read(conn)
      unless /KEY\t(?<key>.*)\t/.match(response)
        error_code = response.split(/\s+/).last
        raise AuthenticationError, error_code
      end
    end
    # pp "A:response=%s" % response
    response = stage("m_response", probable_cause: "session accepted but the immediate follow-up command failed -- possible session-affinity issue on a load-balanced backend") do
      conn.puts "M\n"
      m_response = EAccess.read(conn)
      raise StandardError, m_response unless m_response =~ /^M\t/

      m_response
    end
    # pp "M:response=%s" % response

    unless legacy
      stage("entitlement_response", probable_cause: "a backend/DB dependency specific to entitlements, not the auth path itself") do
        conn.puts "F\t#{game_code}\n"
        response = EAccess.read(conn)
        # F reports the account's tier for this instance. NEW_TO_GAME is the
        # normal response for any instance the account is not subscribed to --
        # not an error. The generator path tolerates it because character
        # creation is exactly the flow that targets instances the account does
        # not already hold; whether creation is permitted is decided later by
        # the L response, not here.
        unless response =~ /NORMAL|PREMIUM|TRIAL|INTERNAL|FREE/ || (generator && response =~ /NEW_TO_GAME/)
          raise StandardError, response
        end
        if defined?(Lich::Common::Account)
          Lich::Common::Account.subscription = response
        end
        # pp "F:response=%s" % response
        conn.puts "G\t#{game_code}\n"
        EAccess.read(conn)
        # pp "G:response=%s" % response
        conn.puts "P\t#{game_code}\n"
        EAccess.read(conn)
        # pp "P:response=%s" % response
        conn.puts "C\n"
        response = EAccess.read(conn)
        # pp "C:response=%s" % response
        if defined?(Lich::Common::Account)
          Lich::Common::Account.members = response
        end
      end

      char_code = generator ? NEW_CHARACTER_CODE : resolve_char_code(response, character)

       = stage("l_response", probable_cause: "protocol/backend divergence at the final step") do
        conn.puts "L\t#{char_code}\tSTORM\n"
        l_response = EAccess.read(conn)
        # Both success and failure are prefixed with "L\t" (e.g. the server
        # returns "L\tPROBLEM\t1" when the account is not entitled to create on
        # this instance), so require the explicit OK before parsing the launch
        # payload -- otherwise a PROBLEM line is parsed into a garbage hash.
        unless l_response =~ /^L\tOK\t/
          # On the generator path a PROBLEM here means the account has no
          # entitlement to create a character on this instance (e.g. an
          # unsubscribed Fallen/Shattered). Fail fast with a clear code rather
          # than crash or launch broken data.
          raise AuthenticationError, "GENERATOR_NOT_AVAILABLE" if generator
          raise StandardError, l_response
        end
        # pp "L:response=%s" % l_response
        parsed = l_response.sub(/^L\tOK\t/, '')
                           .split("\t")
                           .map { |kv|
                             k, v = kv.split("=")
                             [k.downcase, v]
                           }.to_h
        LaunchResult.normalize(parsed)
      end
    else
       = Array.new
      for game in response.sub(/^M\t/, '').scan(/[^\t]+\t[^\t\n]+/)
        game_code, game_name = game.split("\t")
        # pp "M:response = %s" % response
        conn.puts "N\t#{game_code}\n"
        response = EAccess.read(conn)
        if response =~ /STORM/
          conn.puts "F\t#{game_code}\n"
          response = EAccess.read(conn)
          if response =~ /NORMAL|PREMIUM|TRIAL|INTERNAL|FREE/
            if defined?(Lich::Common::Account)
              Lich::Common::Account.subscription = response
            end
            conn.puts "G\t#{game_code}\n"
            EAccess.read(conn)
            conn.puts "P\t#{game_code}\n"
            EAccess.read(conn)
            conn.puts "C\n"
            response = EAccess.read(conn)
            if defined?(Lich::Common::Account)
              Lich::Common::Account.members = response
            end
            for code_name in response.sub(/^C\t[0-9]+\t[0-9]+\t[0-9]+\t[0-9]+[\t\n]/, '').scan(/[^\t]+\t[^\t\n]+/)
              char_code, char_name = code_name.split("\t")
              hash = { :game_code => "#{game_code}", :game_name => "#{game_name}",
                      :char_code => "#{char_code}", :char_name => "#{char_name}" }
              .push(hash)
            end
          end
        end
      end
    end
    return 
  ensure
    conn&.close unless conn&.closed?
  end
end

.auth_with_timeout(timeout: 30, **kwargs) ⇒ Hash, Array

Bounds how long the full SGE authentication exchange may block.

auth has no connect or read timeouts of its own -- every step (TCP connect, TLS handshake, and each K/A/M/F/G/P/C/L round-trip) is a bare blocking call. An unresponsive SGE backend (e.g. a stalled connect that never gets a SYN-ACK) hangs the caller indefinitely with no exception and no log output. This wraps the whole exchange the same way GameBase::Game.open_with_timeout bounds the game connect.

Parameters:

  • timeout (Integer, Float) (defaults to: 30) —

    seconds to wait for the full exchange

  • kwargs (Hash) —

    forwarded to auth

Returns:

Raises:

  • (RuntimeError) —

    if the exchange does not complete within timeout

  • (StandardError) —

    re-raises whatever auth raises

See Also:



383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'documented/common/authentication/eaccess.rb', line 383

def self.auth_with_timeout(timeout: 30, **kwargs)
  auth_thread = Thread.new {
    # report_on_exception off: a failed auth is surfaced by the join below
    # (which re-raises it), not by an auto-printed thread warning.
    Thread.current.report_on_exception = false
    auth(**kwargs)
  }
  if auth_thread.join(timeout).nil?
    # The stage marker set by `stage` lives on the thread object
    # itself, readable from here even after kill -- the one case
    # where the exchange hangs with no exception at all (a stalled
    # connect or a read that never returns) otherwise leaves zero
    # indication of which step it was stuck in. See
    # docs/eaccess-failure-diagnostics.md.
    stalled_stage = auth_thread[:eaccess_stage] || "connect (pre-stage)"
    auth_thread.kill rescue nil
    Lich.log "warn: EAccess timed out after #{timeout}s while in stage '#{stalled_stage}'"
    raise "error: timed out authenticating with EAccess after #{timeout}s"
  end
  auth_thread.value
end

.classify_a_response_failure(error) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns a probable-cause hint distinguishing a normal credential rejection from an unrecognized response.

Parameters:

  • error (StandardError) —

    the error raised by the a_response stage

Returns:

  • (String) —

    a probable-cause hint distinguishing a normal credential rejection from an unrecognized response



60
61
62
63
64
65
66
67
# File 'documented/common/authentication/eaccess.rb', line 60

def self.classify_a_response_failure(error)
  code = error.respond_to?(:error_code) ? error.error_code : nil
  if code && KNOWN_REJECTION_TOKENS.any? { |token| code.include?(token) }
    "recognized credential rejection (#{code}) -- normal, not a backend issue"
  else
    "unrecognized/malformed response -- possible application/backend divergence"
  end
end

.download_pem(hostname = "eaccess.play.net", port = 7910) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'documented/common/authentication/eaccess.rb', line 111

def self.download_pem(hostname = "eaccess.play.net", port = 7910)
  # Create an OpenSSL context
  ctx = OpenSSL::SSL::SSLContext.new
  # Get remote TCP socket, bounded so an unreachable port fails fast
  # instead of hanging on the OS connect timeout -- see CONNECT_TIMEOUT.
  sock = stage("tcp_connect:pem_bootstrap", probable_cause: "firewall/routing/load-balancer silently dropping packets (probable), or the host actively refusing (distinct cause)") do
    Socket.tcp(hostname, port, connect_timeout: CONNECT_TIMEOUT)
  end
  # pass that socket to OpenSSL
  ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
  # establish connection, if possible
  stage("tls_handshake:pem_bootstrap", probable_cause: "TLS termination misconfiguration (not probable on its own)") do
    ssl.connect
  rescue StandardError
    # sync_close only closes the underlying TCP socket when the
    # SSLSocket itself is explicitly closed -- a failed connect never
    # reaches that point, and would otherwise leak this descriptor.
    sock.close rescue nil
    raise
  end
  # write the .pem to disk
  File.write(pem, ssl.peer_cert)
end

.pem ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



101
102
103
# File 'documented/common/authentication/eaccess.rb', line 101

def self.pem
  @pem ||= File.join(DATA_DIR, "simu.pem")
end

.pem_exist? ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


106
107
108
# File 'documented/common/authentication/eaccess.rb', line 106

def self.pem_exist?
  File.exist? pem
end

.read(conn) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



364
365
366
# File 'documented/common/authentication/eaccess.rb', line 364

def self.read(conn)
  conn.sysread(PACKET_SIZE)
end

.resolve_char_code(c_response, character) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Resolves the character code for the requested character from the C response.

Parameters:

  • c_response (String) —

    raw C command response from the server

  • character (String) —

    character name to look up

Returns:

  • (String) —

    character code for the L command

Raises:



353
354
355
356
357
358
359
360
361
# File 'documented/common/authentication/eaccess.rb', line 353

def self.resolve_char_code(c_response, character)
  char_entry = c_response.sub(/^C\t[0-9]+\t[0-9]+\t[0-9]+\t[0-9]+[\t\n]/, '')
                         .scan(/[^\t]+\t[^\t\n]+/)
                         .find { |c| c.split("\t")[1] == character }

  raise AuthenticationError, "CHARACTER_NOT_FOUND" unless char_entry

  char_entry.split("\t")[0]
end

.socket(hostname = "eaccess.play.net", port = 7910) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'documented/common/authentication/eaccess.rb', line 156

def self.socket(hostname = "eaccess.play.net", port = 7910)
  download_pem unless pem_exist?
  # Bounded connect -- see CONNECT_TIMEOUT.
  socket = stage("tcp_connect:main", probable_cause: "firewall/routing/load-balancer silently dropping packets (probable), or the host actively refusing (distinct cause)") do
    Socket.tcp(hostname, port, connect_timeout: CONNECT_TIMEOUT)
  end
  cert_store              = OpenSSL::X509::Store.new
  ssl_context             = OpenSSL::SSL::SSLContext.new
  ssl_context.cert_store  = cert_store
  ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER
  cert_store.add_file(pem) if pem_exist?
  ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ssl_context)
  ssl_socket.sync_close = true
  connected = stage("tls_handshake:main", probable_cause: "TLS termination misconfiguration (not probable on its own)") do
    ssl_socket.connect
  rescue StandardError
    # sync_close only closes the underlying TCP socket when the
    # SSLSocket itself is explicitly closed -- a failed connect never
    # reaches that point, and would otherwise leak this descriptor
    # across retries/fallback.
    socket.close rescue nil
    raise
  end
  # Not wrapped in the tls_handshake stage above: a cert mismatch is
  # its own distinct stage (cert_pin_mismatch, logged inside
  # verify_pem), not a handshake failure -- the handshake itself
  # already succeeded by this point.
  EAccess.verify_pem(connected)
  return ssl_socket
end

.stage(name, probable_cause:) { ... } ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Wraps a step of the connect/handshake/protocol exchange so a failure is logged with which stage it happened in, how long that stage ran before failing, and a probable-cause hint -- see docs/eaccess-failure-diagnostics.md for the full taxonomy. Never logs raw protocol response bodies, the account password, or the session key: only the stage name, exception class/message, and the derived probable-cause classification.

Also records the stage name on the current thread so auth_with_timeout can report which stage was in flight if the overall watchdog has to kill a hung attempt.

Parameters:

  • name (String) —

    stage identifier (see docs/eaccess-failure-diagnostics.md)

  • probable_cause (String, Proc) —

    a fixed hint, or a callable receiving the raised error and returning a hint -- used when the classification depends on what failed (e.g. a_response's recognized-rejection-vs-divergence split)

Yields:

  • the stage's work

Returns:

  • the block's return value

Raises:

  • (StandardError) —

    re-raises whatever the block raised, after logging



89
90
91
92
93
94
95
96
97
98
# File 'documented/common/authentication/eaccess.rb', line 89

def self.stage(name, probable_cause:)
  Thread.current[:eaccess_stage] = name
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  yield
rescue StandardError => e
  duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at).round(3)
  cause = probable_cause.respond_to?(:call) ? probable_cause.call(e) : probable_cause
  Lich.log "warn: EAccess stage '#{name}' failed after #{duration}s (#{e.class}: #{e.message}) -- likely cause: #{cause}"
  raise
end

.verify_pem(conn) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'documented/common/authentication/eaccess.rb', line 136

def self.verify_pem(conn)
  # return if conn.peer_cert.to_s = File.read(pem)
  if !(conn.peer_cert.to_s == File.read(pem))
    # Ambiguous by design: a legitimate Simutronics cert rotation and
    # a MITM presenting a different certificate look identical here.
    # Flagged as its own stage (cert_pin_mismatch) rather than folded
    # into a generic warning -- see docs/eaccess-failure-diagnostics.md.
    # The certificate itself is public data, not a secret, so it's
    # safe to log in full for diagnosis.
    Lich.log "warn: EAccess stage 'cert_pin_mismatch' -- peer certificate differs from the " \
             "pinned #{pem}; re-pinning automatically. Expected on a legitimate cert " \
             "rotation, but indistinguishable here from a MITM presenting a different " \
             "certificate. was:\n#{conn.peer_cert}"
    download_pem
  else
    return true
  end
end