Module: Lich::Common::Authentication::CLIPassword

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

Overview

Headless password management operations for CLI execution Thin wrapper around EntryStore and PasswordCipher Handles: change account password, add account, change master password

Examples:

Lich::Common::Authentication::CLIPassword.('DOUG', 'NewPass123')

Class Method Summary collapse

Class Method Details

.add_account(account, password, frontend = nil) ⇒ Integer

Adds new account to entry.yaml Authenticates with game servers to fetch all characters Mimics GUI "Add Account" behavior

Parameters:

  • account (String)

    Account username

  • password (String)

    Account password

  • frontend (String, nil) (defaults to: nil)

    Frontend (wizard, stormfront, avalon, or nil)

Returns:

  • (Integer)

    Exit code (0=success, 1=error, 2=auth failed)



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
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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'documented/common/authentication/cli_password.rb', line 124

def self.(, password, frontend = nil)
  # Normalize account name to uppercase (accounts are stored uppercase)
   = .upcase

  data_dir = DATA_DIR
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  begin
    # Check if account already exists
    if File.exist?(yaml_file)
      yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
      if yaml_data['accounts'] && yaml_data['accounts'][]
        puts "error: Account '#{}' already exists"
        puts "Use --change-account-password to update the password."
        Lich.log "error: CLI add account failed - account '#{}' already exists"
        return 1
      end
    end

    Lich.log "info: Adding account '#{}' via CLI"

    # Authenticate with game servers to fetch characters (like GUI does)
    puts "Authenticating with game servers..."
    Lich.log "info: Authenticating account '#{}' with game servers"
    auth_data = Lich::Common::Authentication.authenticate(
      account: ,
      password: password,
      legacy: true
    )

    unless auth_data && auth_data.is_a?(Array) && !auth_data.empty?
      puts "error: Authentication failed or no characters found"
      Lich.log "error: CLI add account failed - game server authentication failed for '#{}'"
      return 2
    end

    Lich.log "info: Authentication successful - found #{auth_data.length} character(s)"

    # Determine frontend
    selected_frontend = if frontend
                          # Frontend provided via --frontend flag
                          Lich.log "info: Using provided frontend: #{frontend}"
                          frontend
                        else
                          # Check predominant frontend in YAML, or prompt
                          predominant = determine_predominant_frontend(yaml_file)
                          if predominant
                            puts "Using predominant frontend: #{predominant}"
                            Lich.log "info: Using predominant frontend: #{predominant}"
                            predominant
                          else
                            # Prompt user
                            prompt_for_frontend
                          end
                        end

    # Convert authentication data to character list
    character_list = Lich::Common::GUI::AccountManager.convert_auth_data_to_characters(
      auth_data,
      selected_frontend || 'stormfront'
    )

    # Save account + characters using AccountManager
    if Lich::Common::GUI::AccountManager.(data_dir, , password, character_list)
      puts "success: Account '#{}' added with #{character_list.length} character(s)"
      Lich.log "info: Account '#{}' added successfully with #{character_list.length} character(s)"
      if selected_frontend.nil? || selected_frontend.empty?
        puts "note: Frontend not set - use GUI to configure or rerun with --frontend"
        Lich.log "warning: No frontend set for account '#{}'"
      end
      0
    else
      puts "error: Failed to save account"
      Lich.log "error: CLI add account failed - could not save account '#{}'"
      1
    end
  rescue StandardError => e
    # CRITICAL: Only log e.message, NEVER log password values
    puts "error: #{e.message}"
    Lich.log "error: CLI add account failed for '#{}': #{e.message}"
    1
  end
end

.change_account_password(account, new_password) ⇒ Integer

Changes account password in entry.yaml Handles all encryption modes (plaintext, standard, enhanced)

Parameters:

  • account (String)

    Account username

  • new_password (String)

    New plaintext password

Returns:

  • (Integer)

    Exit code (0=success, 1=error, 2=not found)



38
39
40
41
42
43
44
45
46
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
78
79
80
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
110
111
112
113
114
# File 'documented/common/authentication/cli_password.rb', line 38

def self.(, new_password)
  # Normalize account name to uppercase (accounts are stored uppercase)
   = .upcase

  # Validate master password availability before attempting change
  unless validate_master_password_available
    return 1
  end

  data_dir = DATA_DIR
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  unless File.exist?(yaml_file)
    puts "error: entry.yaml not found at #{yaml_file}"
    return 2
  end

  begin
    yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
    encryption_mode = (yaml_data['encryption_mode'] || 'plaintext').to_sym

    # Find account
    unless yaml_data['accounts'] && yaml_data['accounts'][]
      puts "error: Account '#{}' not found"
      Lich.log "error: CLI change password failed - account '#{}' not found"
      return 2
    end

    Lich.log "info: Changing password for account '#{}' (mode: #{encryption_mode})"

    # Encrypt password based on mode
    encrypted = case encryption_mode
                when :plaintext
                  new_password
                when :standard
                  Lich::Common::GUI::PasswordCipher.encrypt(
                    new_password,
                    mode: :standard,
                    account_name: 
                  )
                when :enhanced
                  master_password = Lich::Common::GUI::MasterPasswordManager.retrieve_master_password
                  if master_password.nil?
                    puts 'error: Enhanced mode requires master password in keychain'
                    Lich.log 'error: CLI change password failed - master password not in keychain'
                    return 1
                  end
                  Lich::Common::GUI::PasswordCipher.encrypt(
                    new_password,
                    mode: :enhanced,
                    account_name: ,
                    master_password: master_password
                  )
                else
                  puts "error: Unknown encryption mode: #{encryption_mode}"
                  Lich.log "error: CLI change password failed - unknown encryption mode: #{encryption_mode}"
                  return 1
                end

    # Update account password
    yaml_data['accounts'][]['password'] = encrypted

    # Save YAML
    File.open(yaml_file, 'w', 0o600) do |file|
      file.write(YAML.dump(yaml_data))
    end

    puts "success: Password changed for account '#{}'"
    Lich.log "info: Password changed successfully for account '#{}'"
    0
  rescue StandardError => e
    # CRITICAL: Only log e.message, NEVER log password values
    puts "error: #{e.message}"
    Lich.log "error: CLI change password failed for '#{}': #{e.message}"
    1
  end
end

.change_master_password(old_password, new_password = nil) ⇒ Integer

Changes master password and re-encrypts all accounts Only works in Enhanced encryption mode Prompts for new password if not provided

Parameters:

  • old_password (String)

    Current master password

  • new_password (String, nil) (defaults to: nil)

    New master password (optional, will prompt if nil)

Returns:

  • (Integer)

    Exit code (0=success, 1=error, 3=wrong mode)



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
# File 'documented/common/authentication/cli_password.rb', line 215

def self.change_master_password(old_password, new_password = nil)
  data_dir = DATA_DIR
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  unless File.exist?(yaml_file)
    puts "error: entry.yaml not found at #{yaml_file}"
    return 2
  end

  begin
    yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
    encryption_mode = (yaml_data['encryption_mode'] || 'plaintext').to_sym

    unless encryption_mode == :enhanced
      puts "error: Master password only used in Enhanced encryption mode"
      puts "Current mode: #{encryption_mode}"
      Lich.log "error: CLI change master password failed - wrong encryption mode: #{encryption_mode}"
      return 3
    end

    Lich.log "info: Starting CLI master password change"

    # Validate old password
    validation_test = yaml_data['master_password_validation_test']
    unless Lich::Common::GUI::MasterPasswordManager.validate_master_password(old_password, validation_test)
      puts 'error: Current master password incorrect'
      Lich.log 'error: CLI change master password failed - incorrect current password'
      return 1
    end

    Lich.log "info: Current master password validated successfully"

    # Use provided password or prompt for new password
    if new_password.nil?
      print "Enter new master password: "
      input = $stdin.gets
      if input.nil?
        puts 'error: Unable to read password from STDIN / terminal'
        puts 'Please run this command interactively (not in a pipe or automated script without input)'
        Lich.log 'error: CLI change master password failed - stdin unavailable'
        return 1
      end
      new_password = input.strip

      print "Confirm new master password: "
      input = $stdin.gets
      if input.nil?
        puts 'error: Unable to read password from STDIN / terminal'
        puts 'Please run this command interactively (not in a pipe or automated script without input)'
        Lich.log 'error: CLI change master password failed - stdin unavailable'
        return 1
      end
      confirm_password = input.strip

      unless new_password == confirm_password
        puts "error: Passwords do not match"
        Lich.log "error: CLI change master password failed - password confirmation mismatch"
        return 1
      end
    end

    if new_password.length < 8
      puts "error: Password must be at least 8 characters"
      Lich.log "error: CLI change master password failed - password too short"
      return 1
    end

     = yaml_data['accounts'].length
    Lich.log "info: Re-encrypting #{} account(s) with new master password"

    # Re-encrypt all accounts
    yaml_data['accounts'].each do |_username, |
      # Decrypt with old password
      plaintext = Lich::Common::GUI::PasswordCipher.decrypt(
        ['password'],
        mode: :enhanced,
        master_password: old_password
      )

      # Encrypt with new password
      new_encrypted = Lich::Common::GUI::PasswordCipher.encrypt(
        plaintext,
        mode: :enhanced,
        master_password: new_password
      )

      ['password'] = new_encrypted
    end

    # Update validation test
    new_validation = Lich::Common::GUI::MasterPasswordManager.create_validation_test(new_password)
    yaml_data['master_password_validation_test'] = new_validation

    # Update keychain
    unless Lich::Common::GUI::MasterPasswordManager.store_master_password(new_password)
      puts 'error: Failed to update keychain'
      Lich.log 'error: CLI change master password failed - keychain update failed'
      return 1
    end

    # Save YAML
    File.open(yaml_file, 'w', 0o600) do |file|
      file.write(YAML.dump(yaml_data))
    end

    puts 'success: Master password changed'
    Lich.log 'info: Master password changed successfully via CLI'
    0
  rescue StandardError => e
    # CRITICAL: Only log e.message, NEVER log password values
    puts "error: #{e.message}"
    Lich.log "error: CLI change master password failed: #{e.message}"
    1
  end
end

.determine_predominant_frontend(yaml_file) ⇒ 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.

Determines predominant frontend from existing YAML accounts

Parameters:

  • yaml_file (String)

    Path to entry.yaml

Returns:

  • (String, nil)

    Predominant frontend or nil



336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# File 'documented/common/authentication/cli_password.rb', line 336

def self.determine_predominant_frontend(yaml_file)
  return nil unless File.exist?(yaml_file)

  yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
  return nil unless yaml_data['accounts']

  frontend_counts = Hash.new(0)
  yaml_data['accounts'].each do |_username, |
    next unless ['characters']

    ['characters'].each do |char|
      fe = char['frontend']
      frontend_counts[fe] += 1 if fe && !fe.empty?
    end
  end

  return nil if frontend_counts.empty?

  frontend_counts.max_by { |_fe, count| count }&.first
end

.get_master_password_from_keychain_or_promptString?

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.

Gets master password from keychain or prompts user if not available Used when entering Enhanced mode - checks keychain first, prompts if missing

Returns:

  • (String, nil)

    Master password or nil if unavailable/cancelled



569
570
571
572
573
574
575
576
577
# File 'documented/common/authentication/cli_password.rb', line 569

def self.get_master_password_from_keychain_or_prompt
  # Check if password already exists in keychain
  existing = Lich::Common::GUI::MasterPasswordManager.retrieve_master_password
  return existing if existing

  # Not in keychain, prompt user to create one
  puts "Creating new master password for Enhanced encryption mode..."
  prompt_and_confirm_password("Enter new master password")
end

.prompt_and_confirm_password(prompt = "Enter password") ⇒ 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.

Helper: Prompts user for a password with confirmation Used for creating new master passwords or changing existing ones

Parameters:

  • prompt (String) (defaults to: "Enter password")

    Prompt to show user (default: "Enter password")

Returns:

  • (String, nil)

    Password if valid, nil if cancelled or error



525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
# File 'documented/common/authentication/cli_password.rb', line 525

def self.prompt_and_confirm_password(prompt = "Enter password")
  print "#{prompt}: "
  input = $stdin.gets
  if input.nil?
    puts 'error: Unable to read password from STDIN / terminal'
    puts 'Please run this command interactively (not in a pipe or automated script without input)'
    Lich.log 'error: Password prompt failed - stdin unavailable'
    return nil
  end
  password = input.strip

  print "Confirm #{prompt.downcase}: "
  input = $stdin.gets
  if input.nil?
    puts 'error: Unable to read password from STDIN / terminal'
    puts 'Please run this command interactively (not in a pipe or automated script without input)'
    Lich.log 'error: Password confirmation failed - stdin unavailable'
    return nil
  end
  confirm_password = input.strip

  unless password == confirm_password
    puts "error: Passwords do not match"
    Lich.log "error: Password confirmation mismatch"
    return nil
  end

  if password.length < 8
    puts "error: Password must be at least 8 characters"
    Lich.log "error: Password too short (minimum 8 characters)"
    return nil
  end

  password
rescue StandardError => e
  Lich.log "error: Password prompt failed: #{e.message}"
  nil
end

.prompt_for_frontendString?

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.

Prompts user for frontend selection

Returns:

  • (String, nil)

    Selected frontend or nil (user skipped)



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'documented/common/authentication/cli_password.rb', line 361

def self.prompt_for_frontend
  puts "\nSelect frontend (or press Enter to skip):"
  puts "  1. wizard"
  puts "  2. stormfront"
  puts "  3. avalon"
  print "Choice (1-3 or Enter): "

  input = $stdin.gets
  if input.nil?
    puts 'error: Unable to read input from STDIN / terminal'
    puts 'Please run this command interactively (not in a pipe or automated script without input)'
    return nil
  end
  choice = input.strip
  return nil if choice.empty?

  case choice
  when '1' then 'wizard'
  when '2' then 'stormfront'
  when '3' then 'avalon'
  else
    puts "Invalid choice, skipping frontend selection"
    nil
  end
end

.prompt_for_master_passwordString?

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.

Prompts for master password (single prompt, no confirmation) Used when validating current password when leaving Enhanced mode

Returns:

  • (String, nil)

    Master password or nil if unavailable



584
585
586
587
588
589
590
591
592
593
594
595
596
# File 'documented/common/authentication/cli_password.rb', line 584

def self.prompt_for_master_password
  print "Enter master password: "
  input = $stdin.gets
  if input.nil?
    puts 'error: Unable to read password from STDIN / terminal'
    Lich.log 'error: Master password prompt failed - stdin unavailable'
    return nil
  end
  input.strip
rescue StandardError => e
  Lich.log "error: Master password prompt failed: #{e.message}"
  nil
end

.recover_master_password(master_password = nil) ⇒ Integer

Recovers access to keychain by validating and restoring original master password User re-enters original master password which is validated against validation_test Account passwords remain unchanged (already encrypted with original password) Only works in Enhanced encryption mode when validation test exists

Parameters:

  • master_password (String, nil) (defaults to: nil)

    Original master password (optional, will prompt if nil)

Returns:

  • (Integer)

    Exit code (0=success, 1=error, 2=not found, 3=wrong mode)



443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
# File 'documented/common/authentication/cli_password.rb', line 443

def self.recover_master_password(master_password = nil)
  data_dir = DATA_DIR
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  unless File.exist?(yaml_file)
    puts "error: entry.yaml not found at #{yaml_file}"
    return 2
  end

  begin
    yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
    encryption_mode = (yaml_data['encryption_mode'] || 'plaintext').to_sym

    unless encryption_mode == :enhanced
      puts "error: Master password recovery only works in Enhanced encryption mode"
      puts "Current mode: #{encryption_mode}"
      Lich.log "error: CLI recover master password failed - wrong encryption mode: #{encryption_mode}"
      return 3
    end

    # Must have validation test to validate password
    validation_test = yaml_data['master_password_validation_test']
    unless validation_test
      puts "error: No validation test found - cannot recover master password"
      Lich.log "error: CLI recover master password failed - no validation test"
      return 1
    end

    Lich.log "info: Starting master password recovery"

    # Get password to validate
    if master_password.nil?
      print "Enter master password: "
      input = $stdin.gets
      if input.nil?
        puts 'error: Unable to read password from STDIN / terminal'
        puts 'Please run this command interactively (not in a pipe or automated script without input)'
        Lich.log 'error: CLI recover master password failed - stdin unavailable'
        return 1
      end
      master_password = input.strip
    end

    if master_password.length < 8
      puts "error: Password must be at least 8 characters"
      Lich.log "error: CLI recover master password failed - password too short"
      return 1
    end

    # Validate password against validation test
    unless Lich::Common::GUI::MasterPasswordManager.validate_master_password(master_password, validation_test)
      puts "error: Password validation failed"
      Lich.log "error: CLI recover master password failed - password validation failed"
      return 1
    end

    Lich.log "info: Password validated successfully"

    # Store validated password in keychain
    unless Lich::Common::GUI::MasterPasswordManager.store_master_password(master_password)
      puts 'error: Failed to store master password in keychain'
      Lich.log 'error: CLI recover master password failed - keychain storage failed'
      return 1
    end

    puts 'success: Master password recovered and restored to keychain'
    Lich.log 'info: Master password recovered successfully via CLI'
    0
  rescue StandardError => e
    # CRITICAL: Only log e.message, NEVER log password values
    puts "error: #{e.message}"
    Lich.log "error: CLI recover master password failed: #{e.message}"
    1
  end
end

.validate_master_password_available(data_dir: DATA_DIR) ⇒ Boolean

Validates if master password is available in keychain for enhanced mode Checks: YAML exists, validation test present, keychain available with password

Parameters:

  • data_dir (String) (defaults to: DATA_DIR)

    directory containing entry.yaml (defaults to DATA_DIR)

Returns:

  • (Boolean)

    true if ready for operations, false if issues found



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
# File 'documented/common/authentication/cli_password.rb', line 392

def self.validate_master_password_available(data_dir: DATA_DIR)
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  unless File.exist?(yaml_file)
    puts "error: entry.yaml not found"
    return false
  end

  begin
    yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
    encryption_mode = (yaml_data['encryption_mode'] || 'plaintext').to_sym

    # Non-enhanced modes don't need master password
    return true unless encryption_mode == :enhanced

    # Check if validation test exists (indicator of Enhanced mode setup)
    unless yaml_data['master_password_validation_test']
      puts "error: No validation test found in entry.yaml"
      puts "Master password recovery may be needed"
      return false
    end

    # Check if keychain is available and has the password
    unless Lich::Common::GUI::MasterPasswordManager.keychain_available?
      puts "error: Keychain not available on this system"
      return false
    end

    master_password = Lich::Common::GUI::MasterPasswordManager.retrieve_master_password
    if master_password.nil? || master_password.empty?
      puts "error: Master password not found in keychain"
      puts "Use: lich --recover-master-password"
      puts "     to restore the master password from your accounts"
      Lich.log "info: Master password validation failed - keychain missing, user can recover"
      return false
    end

    true
  rescue StandardError => e
    Lich.log "error: Master password validation failed: #{e.message}"
    false
  end
end