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

@ -1,3 +1,3 @@
{ {
"secret": "YOUR_BASE32_SECRET_HERE" "launcher_cmd": "flatpak run dev.goats.xivlauncher"
} }

View file

@ -142,25 +142,10 @@ if [ -f "$CONFIG_FILE" ]; then
fi fi
if [ "$SETUP_CONFIG" = true ]; then if [ "$SETUP_CONFIG" = true ]; then
echo "We will now set up the configuration file." echo "Creating empty configuration file..."
echo "You will need your generic TOTP secret (base32 format) OR a full otpauth:// URL." echo "{}" > "$CONFIG_FILE"
read -p "Enter your TOTP Secret or URL: " SECRET echo "Configuration created at: $CONFIG_FILE"
echo "You will be prompted for your OTP secret when you run the wrapper for the first time."
if [ -z "$SECRET" ]; then
echo "Error: Secret cannot be empty."
# We can continue to install files even if config fails?
# Or should we exit? Probably exit or just warn.
# Let's exit for safety if they intended to set it up but failed.
exit 1
fi
cat > "$CONFIG_FILE" <<EOF
{
"secret": "$SECRET"
}
EOF
echo "Configuration created successfully at: $CONFIG_FILE"
fi fi
echo "" echo ""

View file

@ -13,6 +13,8 @@ import hashlib
import os import os
import shutil import shutil
import configparser import configparser
import keyring
import getpass
import urllib.parse import urllib.parse
@ -165,6 +167,76 @@ def detect_launch_command():
return commands 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(): def main():
# Determine config path (XDG standard) # Determine config path (XDG standard)
xdg_config_home = os.environ.get('XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config')) 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 # 1. Check and Update launcher.ini
check_and_update_launcher_ini() check_and_update_launcher_ini()
secret = config.get("secret")
cmd = config.get("launcher_cmd") cmd = config.get("launcher_cmd")
config_dirty = False config_dirty = False
@ -233,8 +304,8 @@ def main():
except ValueError: except ValueError:
pass pass
print("Invalid selection. Please enter a number from the list.") 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: if config_dirty:
try: try:
with open(config_path, 'w') as f: with open(config_path, 'w') as f:
@ -243,8 +314,11 @@ def main():
except OSError as e: except OSError as e:
print(f"Warning: Could not save updated config: {e}") print(f"Warning: Could not save updated config: {e}")
if not secret or secret == "YOUR_BASE32_SECRET_HERE": # Retrieve Secret (Keyring refactor)
print("Error: Invalid or missing 'secret' in config.json.") secret = get_secret(config, config_path)
if not secret:
print("Error: No secret available.")
sys.exit(1) sys.exit(1)
print(f"Launch Command: {cmd}") print(f"Launch Command: {cmd}")
@ -260,7 +334,11 @@ def main():
import shlex import shlex
args = shlex.split(cmd) 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: except FileNotFoundError:
print(f"Error: Command executable not found: {cmd}") print(f"Error: Command executable not found: {cmd}")
sys.exit(1) sys.exit(1)