21 Commits
Author SHA1 Message Date
Nighthawk 0db5fa287e Merge pull request #5 from weslenng/fix/realign-authenticator-api-v2
fix: realign authenticator online flows with current Blizzard API
2026-06-13 19:28:18 -04:00
weslenng 70e5d0fc15 fix: realign authenticator online flows with current Blizzard API
The Authenticator REST API changed and broke the online flows. Realign
them with the contract used by the current official Battle.net client.

- Retrieve moved from POST /v1/authenticator/device to
  POST /v2/authenticator/device and now requires an accountIdentifier
  (account email or phone) alongside serial and restoreCode.
- Drop the SSO/bearer requirement from retrieve: the v2 endpoint
  authenticates via accountIdentifier + serial + restoreCode, mirroring
  restoreAuthenticator in the official app.
- Split the hardcoded base URL into host + per-endpoint versioned paths
  (attach stays on v1: POST /v1/authenticator).
- Surface the server's {errorCode, message} and add root-cause hints
  (404 route-moved vs 401/403 server-side gating) on HTTP errors.
- Handle requireHealup responses with a clear message instead of
  emitting invalid credentials.
2026-06-13 12:04:13 -03:00
Nighthawk e0ee0067f0 Update README.md 2026-06-12 17:54:33 -04:00
Nighthawk 9f1860a0a4 Revise README for non-maintained status and usage info
Updated README to reflect the current state of the tool as non-maintained and broken due to Blizzard's API changes. Added information on offline usage and legacy functions.
2026-06-12 17:53:50 -04:00
Nighthawk 9cd1dcbbdf Create bug report template for API changes
Added a bug report template for API endpoint updates with acknowledgment checkboxes.
2026-06-12 17:45:39 -04:00
Nighthawk fbd349d653 Add contact link for Blizzard Support in issue template 2026-06-12 17:45:13 -04:00
Nighthawk e13b27d2ac Delete .github directory 2026-06-12 17:44:36 -04:00
Nighthawk a6c065acc6 Create ISSUE_TEMPLATE.md for bug reports and help requests
Added a bug report/help request template with guidelines for users regarding API endpoint issues and account recovery.
2026-06-12 17:43:39 -04:00
Nighthawk 0199878312 Archive project and update README with deprecation notice
Updated README to indicate project is archived and deprecated due to changes in the Battle.net API.
2026-06-12 17:37:06 -04:00
Nighthawk fa02a8f4b7 Fix grammar in project status update
Corrected grammar in the README regarding project status.
2026-01-20 00:32:22 -05:00
Nighthawk 351c9e59b6 Fix grammar in README project status update
Corrected grammatical error in project status update.
2026-01-20 00:31:51 -05:00
Nighthawk 50c7a64c85 Revise README for project status and API changes
Updated the README to reflect the latest status of the project and changes to the Battle.net API.
2026-01-18 03:19:17 -05:00
Nighthawk cd8e6c315d Add donations section to README
Added a donations section with a Ko-fi button.
2025-12-22 18:19:01 -05:00
Nighthawk 9dc551c434 Update README.md 2025-08-31 01:26:23 -04:00
Nighthawk 2fdee4de71 Update bnet_auth_tool.py
This small update focuses on a significant refactoring of the codebase to improve maintainability, readability, and structure. 

The core functionality remains unchanged.

Key changes include:

- Documentation Removal: Docstrings and comments have been removed to reduce code clutter.

- Configuration Consolidation: All global constants (e.g., version info, API URLs, cryptographic parameters) are now centralized in a single AppConfig class for better organization and easier management.

- Streamlined Logic: Simplified the error handling for API requests and refined several helper functions for improved clarity and more idiomatic Python.

- Structural Enhancements: Minor improvements to the overall code structure for a cleaner and more direct implementation.
2025-08-11 00:24:50 -04:00
Nighthawk 0e4797d8ca Update bnet_auth_tool.py 2025-04-01 22:08:55 -04:00
Nighthawk 17a291c9bf Version 1.3.0
Version bump.

Rewritten for better clarity, improved encryption.
2025-04-01 21:55:11 -04:00
Nighthawk dd620fff08 Update requirements.txt 2025-01-23 19:15:23 -05:00
Nighthawk 6a010c68fb Update README.md 2025-01-23 19:13:09 -05:00
Nighthawk a3f9acaee1 Encryption Update
Added optional AES-256 encryption to the resulting JSON files. This is TOTP information and should be handled securely.
2025-01-23 19:11:00 -05:00
Nighthawk b4edf73b2b Update README.md 2025-01-21 23:40:54 -05:00
5 changed files with 766 additions and 324 deletions
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Official Blizzard Support
url: https://us.battle.net/support/en/
about: If you are locked out of your Battle.net account, contact Blizzard. The project maintainer cannot recover your account.
+34
View File
@@ -0,0 +1,34 @@
name: "Bug Report / API Endpoint Update"
description: "Read before opening. Account recovery requests will be closed instantly."
title: "[API Update]: "
labels: ["help wanted"]
body:
- type: markdown
attributes:
value: |
# ⚠️ STOP AND READ THIS ⚠️
**Blizzard changed their API endpoints. This tool is currently broken.**
* **Account Recovery:** I cannot help you. You must contact Blizzard Customer Support.
* **Fixing the Tool:** This tool will remain broken until a developer sniffs out and provides the updated Blizzard endpoints/payload configurations.
- type: checkboxes
id: acknowledgment
attributes:
label: Mandatory Acknowledgment
description: You must agree to these terms before submitting.
options:
- label: "I understand that this tool is currently broken due to Blizzard's API changes."
required: true
- label: "I understand that the repository maintainer CANNOT recover my Battle.net account."
required: true
- type: textarea
id: technical-details
attributes:
label: Updated Endpoint Data / Technical Contribution
description: "If you have intercepted the new Blizzard API traffic, please provide the new endpoints, request headers, or payload structure here."
placeholder: "e.g., New authentication endpoint is found at..."
validations:
required: false
+139 -24
View File
@@ -1,38 +1,153 @@
# Battle.net Authenticator Tool
A Python-based tool for managing Battle.net authenticators. This tool allows you to attach new authenticators, retrieve existing device secrets, and generate TOTP keys and QR codes for easy integration with authenticator apps.
---
## Features
# 🛑 NOT MAINTAINED / BROKEN BY BLIZZARD 🛑
- Attach a new Battle.net authenticator to your account.
- Retrieve existing device secrets using serial and restore codes.
- Generate TOTP URLs and QR codes for use with TOTP-compatible authenticator apps.
- Support for both US/EU accounts. - TW/CN accounts are still unknown.
**This repository is functional ONLY as a local backup manager. Online features are dead.**
### 💻 What Happened?
Blizzard recently modified their identity API endpoints and restricted authentication scopes. This change was implemented to prevent users from extracting their raw device secrets and forcing them to use the heavy, official Battle.net mobile app for 2FA.
This tool was built using completely legal, public API access. However, because Blizzard has locked down these endpoints to restrict user choice, **you can no longer attach new authenticators or retrieve secrets online using this script.**
### 🚨 Read Before Opening an Issue:
* **Will this be fixed?** Only if a someone manages to legally map the new endpoints or payload schemas. Pull Requests are welcome.
* **Are you locked out of your account?** I have zero association with Blizzard and zero access to their backend. **Do not open an issue.** You must contact [Blizzard Customer Support](https://us.battle.net/support/en/) directly to have the authenticator detached from your account.
* **Spam Policy:** Any issue opened asking for a "fix," reporting an API connection failure, or asking for account help will be locked and deleted immediately.
---
### 🔍 Legacy Local Functions (What Still Works)
If you already ran this tool in the past and have your `battlenet_authenticator_SERIAL.json` backup file, the tool is 100% operational offline:
* **Reconstruct TOTP:** Generates your standard RFC 6238 TOTP keys (8 digits, 30s period) and outputs a QR code to import into **Aegis, Bitwarden, 1Password, or Google Authenticator**.
* **Local Security:** Encrypt or decrypt your local JSON backups using strong AES-256-GCM encryption (upgraded to 600k PBKDF2 iterations in v1.3.0).
---
## 🛠️ Offline Usage
If you are running the tool locally to regenerate keys from an existing backup:
```bash
pip install requests cryptography "qrcode[pil]"
python bnet_auth_tool.py
```
---
# Version History
Version: 1.3.0
A Python-based command-line tool for managing Battle.net software authenticators. This tool allows you to:
* Attach a new software authenticator to your Battle.net account.
* Retrieve the secret details of an *existing* software authenticator using its Serial Number and Restore Code.
* Generate standard TOTP (Time-Based One-Time Password) configuration (Base32 secret, `otpauth://` URL) and a QR code compatible with common authenticator apps (like Google Authenticator, Authy, Microsoft Authenticator, etc.).
* Optionally encrypt the saved authenticator details using strong AES-256-GCM encryption derived from a user-provided passphrase.
* Load previously saved authenticator details (plain or encrypted) to regenerate the TOTP URL and QR code.
* Encrypt previously saved plain-text authenticator files.
* Decrypt previously encrypted authenticator files (for viewing or saving as plain text).
**Disclaimer:** This tool interacts with your Battle.net account and handles sensitive security information (authenticator secrets). Use it responsibly and at your own risk. Ensure you understand the security implications and securely manage any generated files and passphrases. The author is not responsible for any damage or loss resulting from the use of this tool.
# Features
* **Attach New Authenticator:** Guides through attaching a new virtual authenticator.
* **Retrieve Existing Secret:** Recovers the secret key if you have the Serial and Restore Code.
* **Standard TOTP Output:** Generates Base32 secrets and `otpauth://` URLs compatible with RFC 6238 (SHA1, 8 Digits, 30s period for Battle.net).
* **QR Code Generation:** Creates `.png` QR codes for easy import into authenticator apps.
* **Secure File Encryption (Optional):** Uses AES-256-GCM with PBKDF2 (increased to 600k iterations in v1.3.0) for strong protection of saved secrets.
* **File Management:** Load, reconstruct, encrypt, and decrypt saved authenticator files (`.json`).
* **Backward Compatibility:** Can decrypt files encrypted with older versions (v1.2) that used fewer PBKDF2 iterations (100k).
* **Region Support:** Works with session tokens from various Battle.net regions (US, EU, KR, TW, CN detected).
## Security Warning
* **Backup Your Data:** The `.json` file generated by this tool contains your authenticator's Serial, Restore Code, and the critical Device Secret. **Losing this file (especially if unencrypted) and the Restore Code means you could lose access to your authenticator.** Back up this file securely (e.g., encrypted external drive, password manager).
* **Protect Your Passphrase:** If you choose to encrypt the `.json` file, your passphrase is the *only* way to decrypt it. **There is no recovery for a lost passphrase.** Choose a strong, unique passphrase and store it securely.
* **Secure QR Codes:** The generated `.png` QR code also contains your secret key. Treat it as securely as the `.json` file. Delete it after successfully importing it into your authenticator app(s).
* **Session Token Exposure:** The process requires obtaining a temporary session token from your browser. Ensure you do this in a secure environment and log out afterwards if using a public computer.
## Important Notice for Users Upgrading from v1.2
Version `1.3.0` introduces a significant improvement to the security of *newly encrypted* files by increasing the **PBKDF2 iteration count**. This makes brute-force attacks against the encryption passphrase much harder.
**Compatibility:**
* **✅ v1.3.0 CAN decrypt files encrypted by v1.2:** The new version automatically detects if a file is missing the iteration count field and assumes the old count for decryption. Your old encrypted files will work fine with v1.3.0.
* **❌ v1.2 CANNOT decrypt files encrypted by v1.3.0:** If you encrypt a file using v1.3.0 (either by attaching/retrieving and choosing encrypt, or using the "Encrypt existing" option), the older v1.2 script will *not* be able to decrypt it due to the mismatch in iteration counts.
**Recommendation:**
* **Upgrade:** All users should upgrade to v1.3.0 or later for the improved security and compatibility handling.
* **(Optional) Re-encrypt:** For maximum security benefit on your existing files, you can:
1. Use v1.3.0 to **decrypt** your old `.json` file (using option 5 and saving to a *new* plain file).
2. Use v1.3.0 to **encrypt** that newly saved plain file (using option 4). This will re-encrypt it with the stronger 600k iterations.
3. Securely delete the intermediate plain text file.
## Requirements
- Python 3.7+
- Required libraries:
- `requests`
- `pillow`
- `qrcode`
* Python 3.7+
* Required Python libraries (install via pip):
* `requests`
* `cryptography`
* `qrcode[pil]` (This installs both `qrcode` and the `Pillow` imaging library)
## Installation
Easiest Method?
Use the Release build.
https://github.com/Nighthawk42/bnet_auth_tool/releases/
**Recommended:** Download the pre-compiled executable from the [Releases page](https://github.com/Nighthawk42/bnet_auth_tool/releases/). This avoids needing Python or manual library installation.
Manual Method?
**Manual (using Python):**
1. Clone the repository:
```bash
git clone https://github.com/Nighthawk42/bnet-authenticator-tool.git
cd bnet-authenticator-tool
1. Ensure Python 3.7+ and `pip` are installed and accessible from your command line.
2. Clone the repository or download the source code (`.zip`).
```bash
git clone https://github.com/Nighthawk42/bnet_auth_tool.git
cd bnet_auth_tool
```
3. Install the required libraries:
```bash
pip install -r requirements.txt
# Or: pip install requests cryptography "qrcode[pil]"
```
2. Run the script:
```bash
pip install -r requirements.txt
py bnet_auth_tool.py`
## Usage
4. Follow the instructions from the console window.
1. Open your terminal or command prompt.
2. Navigate to the directory where you placed the script or executable.
3. Run the tool:
* If using the Python script:
```bash
python bnet_auth_tool.py
# or potentially: python3 bnet_auth_tool.py
```
* If using the executable (Windows example):
```bash
bnet_auth_tool.exe
```
4. The tool will display a menu with available actions:
* **Attach a new authenticator:** Guides you through getting a session token and attaches a new virtual authenticator, saving the details.
* **Retrieve existing device secret:** Guides you through getting a session token and uses your existing Serial/Restore code to retrieve the secret, saving the details.
* **Reconstruct TOTP from JSON:** Loads a saved `.json` file (plain or encrypted, prompts for passphrase if needed) and displays the TOTP info / generates a QR code.
* **Encrypt existing plain JSON file(s):** Finds unencrypted `.json` files in the directory, prompts you to select which ones to encrypt, and asks for a passphrase. *Overwrites the original file.*
* **Decrypt an encrypted JSON file:** Prompts you to select an encrypted `.json` file, asks for the passphrase, and then offers to display the decrypted data or save it to a *new* plain-text `.json` file.
* **Exit:** Closes the tool.
5. Follow the on-screen prompts for each action. Pay close attention to instructions for obtaining the session token and handling passphrases.
---
## Output Files
* **`.json` File:** (`battlenet_authenticator_SERIAL.json`)
* Contains the Serial Number, Restore Code, raw hexadecimal Device Secret, Base32 secret, `otpauth://` URL, and a timestamp. (Encrypted files also contain salt, nonce, and iteration count).
* This file is crucial for backup and recovery.
* Can be saved as plain text or encrypted (recommended).
* **`.png` File:** (`battlenet_authenticator_SERIAL.png` or `reconstructed_SERIAL.png`)
* A QR code image containing the `otpauth://` URL.
* Scan this with your authenticator app to add the key.
* Securely delete after successful import.
## Donations
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/P5P21QRW51)
+585 -298
View File
@@ -1,14 +1,79 @@
import json
import base64
import binascii
import requests
from pathlib import Path
import sys
import os
import qrcode
from typing import Any, Dict
import getpass
import platform
from pathlib import Path
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple, List
print(r"""
try:
import requests
import qrcode
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.backends import default_backend
from cryptography.exceptions import InvalidTag
except ImportError as e:
print(f"Error: Missing required library. {e}")
print("Please install dependencies using: pip install requests qrcode[pil] cryptography")
sys.exit(1)
if platform.system() == "Windows":
import ctypes
class AppConfig:
TITLE = "Battle.net Authenticator Tool"
VERSION = "1.3.1"
AUTHOR = "Nighthawk42"
LICENSE = "MIT"
GITHUB_URL = "https://github.com/Nighthawk42/bnet_auth_tool"
# Authenticator REST API host (GLOBAL region: US/EU/KR/PTR).
# CN uses "https://authenticator.api.battle.net" in the official app.
API_HOST = "https://authenticator-rest-api.bnet-identity.blizzard.net"
# Attach stays on v1 (setupAuthenticator: POST /v1/authenticator).
ATTACH_URL = f"{API_HOST}/v1/authenticator"
# Retrieve/restore moved to v2 and now requires accountIdentifier
# (restoreAuthenticator: POST /v2/authenticator/device).
DEVICE_URL = f"{API_HOST}/v2/authenticator/device"
SSO_URL = "https://oauth.battle.net/oauth/sso"
CLIENT_ID = "baedda12fe054e4abdfc3ad7bdea970a"
LEGACY_PBKDF2_ITERATIONS = 100_000
DEFAULT_PBKDF2_ITERATIONS = 600_000
SALT_SIZE = 16
NONCE_SIZE = 12
AES_KEY_SIZE = 32
class AuthenticatorError(Exception):
pass
class EncryptionError(Exception):
pass
class DecryptionError(Exception):
pass
def set_console_title(title: str = AppConfig.TITLE) -> None:
try:
if platform.system() == "Windows":
ctypes.windll.kernel32.SetConsoleTitleW(title)
else:
sys.stdout.write(f"\x1b]2;{title}\x07")
sys.stdout.flush()
except Exception as e:
print(f"Warning: Could not set console title - {e}", file=sys.stderr)
def graceful_exit(exit_code: int = 0) -> None:
print("\nExiting the program. Ensure you have securely backed up your data.")
sys.exit(exit_code)
def print_header() -> None:
print(r"""
____ _ _ _ _
| __ ) __ _ | |_ | |_ | | ___ _ __ ___ | |_
| _ \ / _` || __|| __|| | / _ \ | '_ \ / _ \| __|
@@ -29,339 +94,561 @@ print(r"""
| | | (_) || (_) || |
|_| \___/ \___/ |_|
""")
""")
print(f"{AppConfig.TITLE}")
print(f"Version: {AppConfig.VERSION}")
print(f"Author: {AppConfig.AUTHOR}")
print(f"License: {AppConfig.LICENSE}")
print(f"Github: {AppConfig.GITHUB_URL}")
print("-" * 40)
print("Battle.net Authenticator Tool - by Nighthawk42 - Version 1.1 (01/21/2025)")
class EncryptionManager:
def __init__(self, passphrase: str):
if not passphrase:
raise ValueError("Passphrase cannot be empty.")
self.passphrase = passphrase.encode('utf-8')
self.backend = default_backend()
self.default_iterations = AppConfig.DEFAULT_PBKDF2_ITERATIONS
class Title:
"""Console/Window Title."""
if sys.platform == "win32":
os.system('title Battle.net Authenticator Tool')
else:
sys.stdout.write("\x1b]2;Battle.net Authenticator Tool\x07")
def _derive_key(self, salt: bytes, iterations: int) -> bytes:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=AppConfig.AES_KEY_SIZE,
salt=salt,
iterations=iterations,
backend=self.backend
)
return kdf.derive(self.passphrase)
class Config:
"""Configuration for Battle.net Authenticator API."""
BASE_URL = "https://authenticator-rest-api.bnet-identity.blizzard.net/v1/authenticator"
SSO_URL = "https://oauth.battle.net/oauth/sso"
CLIENT_ID = "baedda12fe054e4abdfc3ad7bdea970a"
def encrypt(self, data: Dict[str, Any]) -> bytes:
try:
json_data_bytes = json.dumps(data, ensure_ascii=False).encode('utf-8')
salt = os.urandom(AppConfig.SALT_SIZE)
key = self._derive_key(salt, self.default_iterations)
aesgcm = AESGCM(key)
nonce = os.urandom(AppConfig.NONCE_SIZE)
ciphertext = aesgcm.encrypt(nonce, json_data_bytes, None)
encrypted_package = {
'salt': base64.b64encode(salt).decode('utf-8'),
'nonce': base64.b64encode(nonce).decode('utf-8'),
'ciphertext': base64.b64encode(ciphertext).decode('utf-8'),
'kdf_iterations': self.default_iterations
}
return json.dumps(encrypted_package, indent=4).encode('utf-8')
except Exception as e:
raise EncryptionError(f"Encryption failed: {e}") from e
def decrypt(self, encrypted_bytes: bytes) -> Dict[str, Any]:
missing_iterations_field = False
try:
encrypted_data = json.loads(encrypted_bytes.decode('utf-8'))
salt = base64.b64decode(encrypted_data['salt'])
nonce = base64.b64decode(encrypted_data['nonce'])
ciphertext = base64.b64decode(encrypted_data['ciphertext'])
if 'kdf_iterations' in encrypted_data:
stored_iterations = int(encrypted_data['kdf_iterations'])
else:
missing_iterations_field = True
stored_iterations = AppConfig.LEGACY_PBKDF2_ITERATIONS
print(f"Warning: 'kdf_iterations' field missing. Assuming legacy count ({AppConfig.LEGACY_PBKDF2_ITERATIONS}). Re-encrypt for better security.")
key = self._derive_key(salt, stored_iterations)
aesgcm = AESGCM(key)
decrypted_data_bytes = aesgcm.decrypt(nonce, ciphertext, None)
return json.loads(decrypted_data_bytes.decode('utf-8'))
except InvalidTag:
raise DecryptionError("Decryption failed: Authentication tag mismatch. Check passphrase or data integrity.")
except (KeyError, ValueError, TypeError, binascii.Error, json.JSONDecodeError) as e:
extra_info = " (Note: Assumed legacy KDF iterations as field was missing)." if missing_iterations_field else ""
raise DecryptionError(f"Decryption failed: Invalid data format or content. {e}{extra_info}") from e
except Exception as e:
raise DecryptionError(f"An unexpected error occurred during decryption: {e}") from e
class BattleNetAuthenticator:
"""
Handles Battle.net Authenticator operations, including attaching an authenticator,
retrieving device secrets, and generating TOTP keys.
"""
def __init__(self):
self.bearer_token = None
@staticmethod
def save_plain_json(filename: str, data: Dict[str, Any]) -> None:
"""
Saves data to a JSON file, prompting to overwrite if the file already exists.
"""
if Path(filename).exists():
while True:
overwrite = input(f"{filename} already exists. Do you want to overwrite it? (y/n): ").strip().lower()
if overwrite in {"y", "n"}:
break
print("Invalid input. Please enter 'y' or 'n'.")
if overwrite != "y":
print("Data not saved.")
return
self.bearer_token: Optional[str] = None
self.session = requests.Session()
self.session.headers.update({'User-Agent': f'{AppConfig.TITLE}/{AppConfig.VERSION}'})
def _make_request(self, method: str, url: str, headers: Optional[Dict] = None,
data: Optional[Any] = None, json_payload: Optional[Dict] = None) -> Dict[str, Any]:
try:
with open(filename, "w") as f:
json.dump(data, f, indent=4)
print(f"Data saved to {filename}.")
print("IMPORTANT: Ensure you securely back up this file and its contents.")
except IOError as e:
print(f"Failed to save data to {filename}: {e}")
request_headers = self.session.headers.copy()
if headers:
request_headers.update(headers)
@staticmethod
def load_json(filename: str) -> Dict[str, Any]:
"""
Loads data from a JSON file.
"""
try:
with open(filename, "r") as f:
data = json.load(f)
return data
except IOError as e:
print(f"Failed to load data from {filename}: {e}")
return {}
response = self.session.request(method, url, headers=request_headers, data=data, json=json_payload, timeout=20)
response.raise_for_status()
@staticmethod
def convert_secret_to_base32(secret: str) -> str:
"""
Converts a device secret to base32 encoding.
"""
try:
hex_secret = binascii.unhexlify(secret)
return base64.b32encode(hex_secret).decode("utf-8").replace("=", "")
except (binascii.Error, TypeError) as e:
raise Exception(f"Failed to convert secret: {e}")
@staticmethod
def generate_qr_code(totp_url: str, filename: str) -> None:
"""
Generates a QR code for the TOTP URL and saves it as an image file.
"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(totp_url)
qr.make(fit=True)
img = qr.make_image(fill='black', back_color='white')
img.save(f"{filename}.png")
print(f"QR code saved as {filename}.png")
def reconstruct_totp_from_json(self) -> None:
"""
Reconstructs the TOTP key and QR code from a JSON file or prompts the user for information.
"""
json_files = list(Path('.').glob('*.json'))
if len(json_files) == 1:
filename = json_files[0]
elif len(json_files) > 1:
print("Multiple JSON files found:")
for i, file in enumerate(json_files, 1):
print(f"{i}. {file}")
choice = input("Enter the number of the file to use, or type 'manual' to enter information manually: ").strip()
if choice.lower() == 'manual':
filename = None
if response.status_code == 204:
return {}
content_type = response.headers.get('Content-Type', '')
if 'application/json' in content_type:
return response.json()
else:
try:
index = int(choice) - 1
filename = json_files[index]
except (ValueError, IndexError):
print("Invalid choice.")
return
else:
filename = None
raise AuthenticatorError(f"Unexpected content type '{content_type}' received from {url}.")
if filename:
data = self.load_json(filename)
if not data:
print("Failed to load JSON file. Prompting for manual input.")
data = {}
else:
data = {}
except requests.exceptions.HTTPError as e:
status = e.response.status_code
raw_body = e.response.text or ""
serial = data.get("serial") or input("Enter Serial: ").strip()
restore_code = data.get("restoreCode") or input("Enter Restore Code: ").strip()
device_secret = data.get("deviceSecret") or input("Enter Device Secret: ").strip()
# The MFA server returns errors as JSON {errorCode, message},
# with codes shaped like BLZBNTARA1000xxxx. Surface them when present.
blz_detail = ""
try:
err_json = e.response.json()
if isinstance(err_json, dict) and (err_json.get("errorCode") or err_json.get("message")):
blz_detail = f" [errorCode={err_json.get('errorCode')} message={err_json.get('message')}]"
except (ValueError, json.JSONDecodeError):
pass
if not serial or not restore_code or not device_secret:
print("Incomplete data provided.")
return
# Hints about the root cause, since the server contract can change.
# If the body carries a BLZ errorCode, the route exists and processed the
# request (e.g. 404 BLZBNTARA...312 = authenticator not found) — not a moved route.
hint = ""
if status == 404 and not blz_detail:
hint = (" Hint: route not found. The endpoint may have changed version"
" (e.g. retrieve moved from /v1/authenticator/device to /v2/authenticator/device).")
elif status in (401, 403):
hint = (" Hint: authorization failed (token/scope rejected). For attach, the route is"
" identical to the official app, so a 401/403 points to server-side gating of"
" the client_sso flow for this client.")
base32_secret = self.convert_secret_to_base32(device_secret)
totp_url = f"otpauth://totp/Battle.net?secret={base32_secret}&digits=8"
print(f"TOTP URL: {totp_url}")
print("\nImportant: When importing the key, use these settings:")
print(" - Digits: 8")
print(" - Algorithm: SHA1")
print(" - Timeout: 30 seconds")
self.generate_qr_code(totp_url, f"reconstructed_{serial}")
error_details = f" Server Response: {raw_body[:500]}"
raise AuthenticatorError(
f"HTTP error {status} from {url}.{blz_detail}{hint}{error_details}"
) from e
except requests.exceptions.RequestException as e:
raise AuthenticatorError(f"Request failed for {url}: {e}") from e
except json.JSONDecodeError as e:
raise AuthenticatorError(f"Failed to decode JSON response from {url}: {e}") from e
input("\nPress any key to return to the main menu...")
def get_bearer_token(self, session_token):
"""
Retrieves the Bearer Token using the provided Session Token.
"""
def get_bearer_token(self, session_token: str) -> None:
payload = {
"client_id": Config.CLIENT_ID,
"client_id": AppConfig.CLIENT_ID,
"grant_type": "client_sso",
"scope": "auth.authenticator",
"token": session_token,
}
headers = {"content-type": "application/x-www-form-urlencoded; charset=utf-8"}
print("Requesting Bearer Token...")
response_data = self._make_request("POST", AppConfig.SSO_URL, headers=headers, data=payload)
try:
response = requests.post(Config.SSO_URL, data=payload, headers=headers)
response.raise_for_status()
self.bearer_token = response.json().get("access_token")
return self.bearer_token
except requests.exceptions.RequestException as e:
raise Exception(f"Failed to fetch bearer token: {e}")
access_token = response_data.get("access_token")
if not access_token:
raise AuthenticatorError("Bearer token not found in SSO response.")
def attach_authenticator(self):
"""
Attaches a new authenticator and returns its details.
"""
headers = {
"accept": "application/json",
"Authorization": f"Bearer {self.bearer_token}",
self.session.headers['Authorization'] = f"Bearer {access_token}"
self.bearer_token = access_token
print("Bearer Token obtained successfully.")
def attach_authenticator(self) -> Dict[str, Any]:
if 'Authorization' not in self.session.headers:
raise AuthenticatorError("Bearer token not set. Call get_bearer_token first.")
print("Attempting to attach a new authenticator...")
response_data = self._make_request("POST", AppConfig.ATTACH_URL, headers={"accept": "application/json"})
if response_data.get("requireHealup"):
raise AuthenticatorError(
"Server returned requireHealup=true: the account requires a 'heal up' step before "
"credentials can be issued. The official app handles this flow; this tool cannot."
)
if not all(key in response_data for key in ["serial", "restoreCode", "deviceSecret"]):
raise AuthenticatorError(f"API response missing expected keys. Got: {response_data.keys()}")
print("Authenticator attached successfully.")
return response_data
def retrieve_device_secret(self, account_identifier: str, serial: str, restore_code: str) -> Dict[str, Any]:
# The v2/device endpoint does NOT require a bearer/SSO: it authenticates via
# accountIdentifier + serial + restoreCode (like restoreAuthenticator in the official app).
# v2 contract: the server now requires accountIdentifier (account email or phone)
# in addition to serial and restoreCode. Fields are only trimmed (as in the official app).
payload = {
"accountIdentifier": account_identifier,
"serial": serial,
"restoreCode": restore_code,
}
url = AppConfig.DEVICE_URL
print(f"Attempting to retrieve secret for serial {serial}...")
response_data = self._make_request("POST", url, json_payload=payload)
if response_data.get("requireHealup"):
raise AuthenticatorError(
"Server returned requireHealup=true: the account requires a 'heal up' step before "
"credentials can be issued. The official app handles this flow; this tool cannot."
)
if "deviceSecret" not in response_data:
raise AuthenticatorError(f"API response missing 'deviceSecret'. Got: {response_data.keys()}")
print("Device secret retrieved successfully.")
return response_data
@staticmethod
def save_json(filename: str, data: Dict[str, Any], encryption_manager: Optional[EncryptionManager] = None) -> None:
file_path = Path(filename)
if file_path.exists():
while True:
try:
overwrite = input(f"'{filename}' already exists. Overwrite? (y/n): ").strip().lower()
if overwrite == "y": break
if overwrite == "n": print("Data not saved."); return
print("Invalid input.")
except EOFError:
print("\nOperation cancelled."); return
try:
response = requests.post(Config.BASE_URL, headers=headers)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise Exception(f"Failed to attach authenticator: {e}")
if encryption_manager:
encrypted_data = encryption_manager.encrypt(data)
file_path.write_bytes(encrypted_data)
print(f"Encrypted data saved to '{filename}'.")
else:
file_path.write_text(json.dumps(data, indent=4, ensure_ascii=False), encoding='utf-8')
print(f"Data successfully saved to '{filename}'.")
print("IMPORTANT: Securely back up this file and your passphrase if encrypted!")
except IOError as e:
raise IOError(f"Failed to write to '{filename}': {e}") from e
def retrieve_device_secret(self, serial, restore_code):
"""
Retrieves the device secret for an existing authenticator using its serial and restore code.
"""
headers = {
"accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Bearer {self.bearer_token}",
}
payload = {"serial": serial, "restoreCode": restore_code}
@staticmethod
def load_json(filename: str, encryption_manager: Optional[EncryptionManager] = None) -> Dict[str, Any]:
file_path = Path(filename)
if not file_path.is_file():
raise FileNotFoundError(f"File not found: '{filename}'")
try:
response = requests.post(f"{Config.BASE_URL}/device", headers=headers, json=payload)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise Exception(f"Failed to retrieve device secret: {e}")
if encryption_manager:
encrypted_bytes = file_path.read_bytes()
data = encryption_manager.decrypt(encrypted_bytes)
print(f"Decrypted data loaded from '{filename}'.")
return data
else:
data = json.loads(file_path.read_text(encoding='utf-8'))
print(f"Data loaded from '{filename}'.")
return data
except IOError as e:
raise IOError(f"Failed to read from '{filename}': {e}") from e
def show_session_token_instructions():
"""
Displays instructions for retrieving the Battle.net Session Token.
"""
print("\nHow to Get the Session Token:")
print("1. Open your web browser and navigate to:")
print(" https://account.battle.net/login/en/?ref=localhost")
print("2. Log in to your Battle.net account.")
print("3. After logging in, you'll see a 404 error page.")
print("4. Look at the URL in your browser's address bar.")
print(" The Session Token is the value after `ST=`.")
print(" Example: US-abcdef12345678 or EU-12345678abcdef")
print("5. Copy the Session Token and paste it into this tool when prompted.\n")
@staticmethod
def convert_secret_to_base32(hex_secret: str) -> str:
try:
secret_bytes = binascii.unhexlify(hex_secret)
return base64.b32encode(secret_bytes).decode("utf-8").rstrip("=")
except (binascii.Error, TypeError) as e:
raise ValueError(f"Failed to convert secret to Base32: Invalid hex input. ({e})") from e
def graceful_exit():
"""
Gracefully exits the program with a backup reminder.
"""
print("\nExiting the program. Ensure you have securely backed up your data.")
sys.exit(0)
@staticmethod
def generate_qr_code(totp_url: str, filename_base: str) -> None:
filename = f"{filename_base}.png"
try:
print(f"Generating QR code '{filename}'...")
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4)
qr.add_data(totp_url)
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
img.save(filename)
print(f"QR code saved successfully as '{filename}'.")
except IOError as e:
raise IOError(f"Failed to save QR code image to '{filename}': {e}") from e
except Exception as e:
raise Exception(f"Error generating QR code image: {e}") from e
def interactive_cli():
"""
Main interactive CLI for the Battle.net Authenticator Tool.
"""
def _prompt_for_encryption() -> bool:
print("\nEncryption adds a layer of security. You MUST remember your passphrase.")
while True:
try:
choice = input("Encrypt the saved JSON file? (y/n): ").strip().lower()
if choice in ['y', 'n']: return choice == 'y'
print("Invalid input.")
except (EOFError, KeyboardInterrupt):
print("\nOperation cancelled.")
return False
def _prompt_for_passphrase(prompt_message: str = "Enter encryption passphrase: ") -> Optional[EncryptionManager]:
while True:
try:
passphrase = getpass.getpass(prompt_message)
if not passphrase:
print("Passphrase cannot be empty.")
continue
if passphrase == getpass.getpass("Confirm passphrase: "):
return EncryptionManager(passphrase)
else:
print("Passphrases do not match.")
except (EOFError, KeyboardInterrupt):
print("\nOperation cancelled.")
return None
def _get_session_token() -> Optional[str]:
print("\n--- How to Get the Session Token ---")
print("1. In a private browser window, navigate to: https://account.battle.net/login/en/?ref=localhost")
print("2. Log in. You will land on an expected 'Page Not Found' on 'localhost'.")
print("3. From the URL, copy the token value that looks like `ST=XX-...` (e.g., 'US-abc...').")
print("-" * 36)
try:
session_token = input("Enter your Session Token (or 'exit'): ").strip()
if session_token.lower() == "exit": return None
if not session_token:
print("Error: Session Token cannot be empty.")
return None
if not any(session_token.startswith(p) for p in ["US-", "EU-", "KR-", "TW-", "CN-"]) or len(session_token) < 20:
print("Warning: Token format looks unusual. Ensure you copied the full value.")
return session_token
except (EOFError, KeyboardInterrupt):
print("\nOperation cancelled.")
return None
def _process_and_save_results(authenticator: BattleNetAuthenticator, device_info: Dict[str, Any], encryption_manager: Optional[EncryptionManager]) -> None:
serial = device_info.get("serial")
restore_code = device_info.get("restoreCode")
device_secret = device_info.get("deviceSecret")
if not all([serial, restore_code, device_secret]):
raise ValueError("Incomplete device information from API.")
print("\n" + "-" * 30)
print("Authenticator Details:")
print(f" Serial: {serial}")
print(f" Restore Code: {restore_code}")
print("-" * 30)
print("Generating TOTP Information...")
base32_secret = authenticator.convert_secret_to_base32(device_secret)
label = f"Battle.net:{serial}"
totp_url = f"otpauth://totp/{label}?secret={base32_secret}&issuer=Battle.net&digits=8&algorithm=SHA1&period=30"
print("\n--- TOTP Key Details ---")
print(f"Base32 Secret: {base32_secret}")
print(f"TOTP URL: {totp_url}")
print("\nApp Settings: Type=TOTP, Algorithm=SHA1, Digits=8, Period=30s")
print("-" * 24)
data_to_save = {
"serial": serial,
"restoreCode": restore_code,
"deviceSecret": device_secret,
"base32Secret": base32_secret,
"totpUrl": totp_url,
"timestamp": datetime.now(timezone.utc).isoformat(timespec='seconds')
}
filename_base = f"battlenet_authenticator_{serial}"
json_filename = f"{filename_base}.json"
authenticator.save_json(json_filename, data_to_save, encryption_manager)
authenticator.generate_qr_code(totp_url, filename_base)
def _handle_attach_action(authenticator: BattleNetAuthenticator) -> None:
session_token = _get_session_token()
if not session_token: return
encryption_manager = None
if _prompt_for_encryption():
encryption_manager = _prompt_for_passphrase("Enter passphrase to encrypt new file: ")
if not encryption_manager: return
try:
authenticator.get_bearer_token(session_token)
device_info = authenticator.attach_authenticator()
_process_and_save_results(authenticator, device_info, encryption_manager)
except (AuthenticatorError, EncryptionError, IOError, ValueError, Exception) as e:
print(f"\nError during attach process: {e}", file=sys.stderr)
def _handle_retrieve_action(authenticator: BattleNetAuthenticator) -> None:
# Retrieve via v2/device needs no session token / bearer: the server
# authenticates via accountIdentifier + serial + restoreCode.
encryption_manager = None
if _prompt_for_encryption():
encryption_manager = _prompt_for_passphrase("Enter passphrase to encrypt retrieved file: ")
if not encryption_manager: return
try:
# The v2 endpoint requires the account identifier (email or phone), besides serial/restoreCode.
account_identifier = input("Enter your account email or phone number: ").strip()
serial = input("Enter the Authenticator Serial number: ").strip()
restore_code = input("Enter the Authenticator Restore Code: ").strip()
if not account_identifier or not serial or not restore_code:
print("Error: Account identifier, Serial and Restore Code are required.")
return
retrieved_info = authenticator.retrieve_device_secret(account_identifier, serial, restore_code)
device_info = {"serial": serial, "restoreCode": restore_code, "deviceSecret": retrieved_info["deviceSecret"]}
_process_and_save_results(authenticator, device_info, encryption_manager)
except (AuthenticatorError, EncryptionError, IOError, ValueError, Exception) as e:
print(f"\nError during retrieve process: {e}", file=sys.stderr)
def _select_json_file(prompt: str) -> Optional[Path]:
json_files = sorted([p for p in Path('.').glob('*.json') if p.is_file()])
if not json_files:
print("No JSON files found in the current directory.")
return None
print("\nFound the following JSON files:")
for i, file in enumerate(json_files, 1):
print(f"{i}. {file.name}")
while True:
try:
choice = input(f"{prompt} (number or 'c' to cancel): ").strip().lower()
if choice == 'c': return None
index = int(choice) - 1
if 0 <= index < len(json_files): return json_files[index]
else: print(f"Invalid selection. Enter a number between 1 and {len(json_files)}.")
except (ValueError, EOFError, KeyboardInterrupt):
print("\nInvalid input or operation cancelled.")
return None
def _is_file_likely_encrypted(file_path: Path) -> bool:
try:
content = file_path.read_text(encoding='utf-8', errors='ignore')
data = json.loads(content[:1024])
return isinstance(data, dict) and all(k in data for k in ('salt', 'nonce', 'ciphertext'))
except (IOError, json.JSONDecodeError, ValueError):
return False
def _handle_reconstruct_action(authenticator: BattleNetAuthenticator) -> None:
selected_file = _select_json_file("Select JSON file to reconstruct from")
if not selected_file: return
encryption_manager: Optional[EncryptionManager] = None
try:
if _is_file_likely_encrypted(selected_file):
print(f"File '{selected_file.name}' appears to be encrypted.")
encryption_manager = _prompt_for_passphrase(f"Enter passphrase for '{selected_file.name}': ")
if not encryption_manager: return
data = authenticator.load_json(str(selected_file), encryption_manager)
except (FileNotFoundError, IOError, DecryptionError, json.JSONDecodeError) as e:
print(f"\nError loading file: {e}", file=sys.stderr)
return
serial = data.get("serial")
if not serial:
print("Could not find 'serial' in the JSON file.")
return
totp_url = data.get("totpUrl")
if not totp_url:
base32_secret = data.get("base32Secret") or authenticator.convert_secret_to_base32(data.get("deviceSecret", ""))
if not base32_secret:
print("Error: Could not find or derive a secret from the JSON file.")
return
label = f"Battle.net:{serial}"
totp_url = f"otpauth://totp/{label}?secret={base32_secret}&issuer=Battle.net&digits=8&algorithm=SHA1&period=30"
print(f"Reconstructed TOTP URL: {totp_url}")
print("\n--- Reconstructed TOTP Details ---")
print(f"URL: {totp_url}")
print("App Settings: Algorithm=SHA1, Digits=8, Period=30s")
try:
authenticator.generate_qr_code(totp_url, f"reconstructed_{serial}")
except (IOError, Exception) as e:
print(f"Error generating QR code: {e}", file=sys.stderr)
input("\nPress Enter to return to the main menu...")
def _handle_encrypt_files_action(authenticator: BattleNetAuthenticator) -> None:
json_files = sorted([p for p in Path('.').glob('*.json') if p.is_file()])
if not json_files:
print("No JSON files found to encrypt."); return
plain_files = []
print("\nChecking JSON files:")
for file_path in json_files:
if not _is_file_likely_encrypted(file_path):
try:
json.loads(file_path.read_text(encoding='utf-8'))
plain_files.append(file_path)
print(f" - {file_path.name} (Plain Text)")
except (json.JSONDecodeError, IOError):
print(f" - {file_path.name} (Not a valid plain JSON, skipping)")
else:
print(f" - {file_path.name} (Already Encrypted)")
if not plain_files:
print("\nNo plain text JSON files found to encrypt."); return
encryption_manager = _prompt_for_passphrase("Enter passphrase for encryption: ")
if not encryption_manager: return
success, fail = 0, 0
for file_path in plain_files:
print(f"\nEncrypting '{file_path.name}'...")
try:
plain_data = json.loads(file_path.read_text(encoding='utf-8'))
authenticator.save_json(str(file_path), plain_data, encryption_manager)
success += 1
except (IOError, EncryptionError, json.JSONDecodeError) as e:
print(f"Error encrypting '{file_path.name}': {e}", file=sys.stderr)
fail += 1
print(f"\nEncryption complete. {success} succeeded, {fail} failed.")
def _handle_decrypt_file_action(authenticator: BattleNetAuthenticator) -> None:
selected_file = _select_json_file("Select JSON file to decrypt")
if not selected_file: return
if not _is_file_likely_encrypted(selected_file):
print(f"Warning: File '{selected_file.name}' may not be encrypted. Proceeding anyway.")
encryption_manager = _prompt_for_passphrase(f"Enter passphrase for '{selected_file.name}': ")
if not encryption_manager: return
try:
decrypted_data = authenticator.load_json(str(selected_file), encryption_manager)
print("\nDecryption successful.")
print(json.dumps(decrypted_data, indent=4, ensure_ascii=False))
if input("\nSave decrypted data to a new file? (y/n): ").strip().lower() == 'y':
new_filename = input("Enter new filename (e.g., decrypted.json): ").strip()
if new_filename:
authenticator.save_json(new_filename, decrypted_data, None)
else:
print("Invalid filename. Save cancelled.")
except (IOError, DecryptionError, json.JSONDecodeError) as e:
print(f"\nError during decryption: {e}", file=sys.stderr)
def interactive_cli() -> None:
set_console_title()
print_header()
authenticator = BattleNetAuthenticator()
actions = {
"1": ("Attach a new authenticator", _handle_attach_action),
"2": ("Retrieve existing device secret", _handle_retrieve_action),
"3": ("Reconstruct TOTP from JSON", _handle_reconstruct_action),
"4": ("Encrypt existing plain JSON file(s)", _handle_encrypt_files_action),
"5": ("Decrypt an encrypted JSON file", _handle_decrypt_file_action),
"6": ("Exit", lambda _: graceful_exit()),
}
while True:
print("\nChoose an action:")
print("1. Attach a new authenticator")
print("2. Retrieve existing device secret")
print("3. Reconstruct TOTP from JSON")
print("4. Exit")
choice = input("Enter your choice (1/2/3/4): ").strip()
for key, (desc, _) in actions.items():
print(f"{key}. {desc}")
if choice == "1":
show_session_token_instructions()
session_token = input("Enter your Session Token (or type 'exit' to quit): ").strip()
if session_token.lower() == "exit":
graceful_exit()
if not session_token:
print("Session Token is required!")
continue
try:
print("Fetching Bearer Token...")
bearer_token = authenticator.get_bearer_token(session_token)
print(f"Bearer Token: {bearer_token}")
print("Attaching Authenticator...")
device_info = authenticator.attach_authenticator()
serial = device_info["serial"]
restore_code = device_info["restoreCode"]
device_secret = device_info["deviceSecret"]
print(f"Serial: {serial}")
print(f"Restore Code: {restore_code}")
print("Generating TOTP URL...")
base32_secret = authenticator.convert_secret_to_base32(device_secret)
totp_url = f"otpauth://totp/Battle.net?secret={base32_secret}&digits=8"
print(f"TOTP URL: {totp_url}")
print("\nImportant: When importing the key, use these settings:")
print(" - Digits: 8")
print(" - Algorithm: SHA1")
print(" - Timeout: 30 seconds")
# Save data and generate QR code
filename = f"authenticator_{serial}"
authenticator.save_plain_json(f"{filename}.json", device_info)
authenticator.generate_qr_code(totp_url, filename)
except Exception as e:
print(f"Error: {e}")
graceful_exit()
elif choice == "2":
show_session_token_instructions()
session_token = input("Enter your Session Token (or type 'exit' to quit): ").strip()
if session_token.lower() == "exit":
graceful_exit()
if not session_token:
print("Session Token is required!")
continue
try:
print("Fetching Bearer Token...")
bearer_token = authenticator.get_bearer_token(session_token)
print(f"Bearer Token: {bearer_token}")
serial = input("Enter Serial: ").strip()
restore_code = input("Enter Restore Code: ").strip()
if not serial or not restore_code:
print("Serial and Restore Code are required!")
continue
print("Retrieving Device Secret...")
device_info = authenticator.retrieve_device_secret(serial, restore_code)
device_secret = device_info["deviceSecret"]
print(f"Device Secret: {device_secret}")
print("Generating TOTP URL...")
base32_secret = authenticator.convert_secret_to_base32(device_secret)
totp_url = f"otpauth://totp/Battle.net?secret={base32_secret}&digits=8"
print(f"TOTP URL: {totp_url}")
print("\nImportant: When importing the key, use these settings:")
print(" - Digits: 8")
print(" - Algorithm: SHA1")
print(" - Timeout: 30 seconds")
# Save data and generate QR code
filename = f"authenticator_{serial}"
authenticator.save_plain_json(f"{filename}.json", {"serial": serial, "restoreCode": restore_code, "deviceSecret": device_secret})
authenticator.generate_qr_code(totp_url, filename)
except Exception as e:
print(f"Error: {e}")
graceful_exit()
elif choice == "3":
authenticator.reconstruct_totp_from_json()
elif choice == "4":
try:
choice = input("Enter your choice: ").strip()
if choice in actions:
actions[choice][1](authenticator)
else:
print("Invalid choice.")
except (EOFError, KeyboardInterrupt):
graceful_exit()
else:
print("Invalid choice!")
graceful_exit()
if __name__ == "__main__":
try:
interactive_cli()
except KeyboardInterrupt:
graceful_exit()
except Exception as e:
print(f"\nFATAL ERROR: An unhandled exception occurred: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
graceful_exit(1)
+3 -2
View File
@@ -1,3 +1,4 @@
requests
pillow
cryptography
qrcode
Pillow
requests