mirror of
https://github.com/Nighthawk42/bnet_auth_tool.git
synced 2026-08-30 06:22:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0db5fa287e | ||
|
|
70e5d0fc15 | ||
|
|
e0ee0067f0 | ||
|
|
9f1860a0a4 | ||
|
|
9cd1dcbbdf | ||
|
|
fbd349d653 | ||
|
|
e13b27d2ac | ||
|
|
a6c065acc6 | ||
|
|
0199878312 | ||
|
|
fa02a8f4b7 | ||
|
|
351c9e59b6 | ||
|
|
50c7a64c85 | ||
|
|
cd8e6c315d | ||
|
|
9dc551c434 | ||
|
|
2fdee4de71 | ||
|
|
0e4797d8ca | ||
|
|
17a291c9bf | ||
|
|
dd620fff08 | ||
|
|
6a010c68fb | ||
|
|
a3f9acaee1 | ||
|
|
b4edf73b2b |
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
[](https://ko-fi.com/P5P21QRW51)
|
||||
|
||||
+585
-298
@@ -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
@@ -1,3 +1,4 @@
|
||||
requests
|
||||
pillow
|
||||
cryptography
|
||||
qrcode
|
||||
Pillow
|
||||
requests
|
||||
|
||||
Reference in New Issue
Block a user