feat: Implement keyring-based OTP secret storage with migration from config.json and allow launcher command configuration.

This commit is contained in:
CPTN Cosmo 2026-02-02 02:22:44 +01:00
parent 1ec9339b26
commit d52743d69c
No known key found for this signature in database
3 changed files with 89 additions and 26 deletions

View file

@ -13,6 +13,8 @@ import hashlib
import os
import shutil
import configparser
import keyring
import getpass
import urllib.parse
@ -165,6 +167,76 @@ def detect_launch_command():
return commands
return commands
def get_secret(config, config_path):
"""
Retrieves the OTP secret in the following priority:
1. System Keyring (SERVICE_NAME, USERNAME)
2. config.json (Legacy) - if found, migrates to Keyring and removes from config.
3. User Input - prompts user and saves to Keyring.
"""
SERVICE_NAME = "XIVLauncherWrapper"
USERNAME = "OTP_SECRET"
# 1. Try Keyring
try:
secret = keyring.get_password(SERVICE_NAME, USERNAME)
if secret:
return secret
except Exception as e:
print(f"Warning: Failed to access keyring: {e}")
# 2. Try Config (Legacy/Migration)
if config and "secret" in config:
secret = config["secret"]
if secret and secret != "YOUR_BASE32_SECRET_HERE":
print("Migrating secret from config.json to system keyring...")
try:
keyring.set_password(SERVICE_NAME, USERNAME, secret)
# Remove from config
del config["secret"]
with open(config_path, 'w') as f:
json.dump(config, f, indent=4)
print("Secret migrated successfully and removed from config.json.")
return secret
except Exception as e:
print(f"Error migrating to keyring: {e}")
print("Continuing with secret from config...")
return secret
# 3. Prompt User
print("\nOTP Secret not found in keyring.")
print("Please enter your TOTP Secret (base32) or otpauth:// URL.")
while True:
try:
secret_input = getpass.getpass("Secret: ").strip()
if not secret_input:
print("Secret cannot be empty.")
continue
# Basic validation/parsing check
parsed = parse_secret(secret_input)
if not parsed:
print("Invalid secret format. Please try again.")
continue
try:
keyring.set_password(SERVICE_NAME, USERNAME, parsed)
print("Secret saved to system keyring.")
return parsed
except Exception as e:
print(f"Error saving to keyring: {e}")
# If we can't save to keyring, we might just return it in memory for this session
# But typically we want persistence.
print("Proceeding with in-memory secret for this session.")
return parsed
except KeyboardInterrupt:
print("\nOperation cancelled.")
sys.exit(1)
def main():
# Determine config path (XDG standard)
xdg_config_home = os.environ.get('XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config'))
@ -194,7 +266,6 @@ def main():
# 1. Check and Update launcher.ini
check_and_update_launcher_ini()
secret = config.get("secret")
cmd = config.get("launcher_cmd")
config_dirty = False
@ -233,8 +304,8 @@ def main():
except ValueError:
pass
print("Invalid selection. Please enter a number from the list.")
# Save config if updated
# Save config if updated (e.g. launcher_cmd detected)
if config_dirty:
try:
with open(config_path, 'w') as f:
@ -243,8 +314,11 @@ def main():
except OSError as e:
print(f"Warning: Could not save updated config: {e}")
if not secret or secret == "YOUR_BASE32_SECRET_HERE":
print("Error: Invalid or missing 'secret' in config.json.")
# Retrieve Secret (Keyring refactor)
secret = get_secret(config, config_path)
if not secret:
print("Error: No secret available.")
sys.exit(1)
print(f"Launch Command: {cmd}")
@ -260,7 +334,11 @@ def main():
import shlex
args = shlex.split(cmd)
proc = subprocess.Popen(args)
# Inject XL_SECRET_PROVIDER=file environment variable
env = os.environ.copy()
env['XL_SECRET_PROVIDER'] = 'file'
proc = subprocess.Popen(args, env=env)
except FileNotFoundError:
print(f"Error: Command executable not found: {cmd}")
sys.exit(1)