30 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
Nighthawk 0153a420ac Update bnet_auth_tool.py
Forgot `import os` like a dud.
2025-01-21 22:41:23 -05:00
Nighthawk 99203b9bd7 Update bnet_auth_tool.py
Title Update
2025-01-21 22:40:09 -05:00
Nighthawk f4427427a8 Code update.
Refactors some of the code, making it cleaner and more concise. 

Added an offline method of reconstructing TOTP keys and QR code provided the user has the required information.
2025-01-21 22:33:20 -05:00
Nighthawk 9f5bf24e19 Update README.md 2024-12-22 19:17:00 -05:00
Nighthawk 352c195e4d Update README.md 2024-11-29 19:47:17 -05:00
Nighthawk e2c70ecdad Update README.md 2024-11-29 19:46:07 -05:00
Nighthawk e9cfc2a207 Update README.md 2024-11-29 19:45:25 -05:00
Nighthawk 76a4becb30 Update README.md 2024-11-29 19:44:13 -05:00
Nighthawk 1aa4eae45d Create requirements.txt 2024-11-29 19:43:48 -05:00
5 changed files with 758 additions and 201 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
+141 -19
View File
@@ -1,31 +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.
**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
1. Clone the repository:
```bash
git clone https://github.com/Nighthawk42/bnet-authenticator-tool.git
cd battlenet-authenticator-tool
**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.
2. Run the script:
```bash
py bnet_auth_tool.py`
**Manual (using Python):**
4. Follow the instructions from the console window.
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]"
```
## Usage
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)
+574 -182
View File
@@ -1,12 +1,79 @@
import json
import base64
import binascii
import requests
from pathlib import Path
import sys
import qrcode
import os
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"""
____ _ _ _ _
| __ ) __ _ | |_ | |_ | | ___ _ __ ___ | |_
| _ \ / _` || __|| __|| | / _ \ | '_ \ / _ \| __|
@@ -27,236 +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")
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 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 _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)
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
self.bearer_token: Optional[str] = None
self.session = requests.Session()
self.session.headers.update({'User-Agent': f'{AppConfig.TITLE}/{AppConfig.VERSION}'})
@staticmethod
def save_plain_json(filename, data):
"""
Saves data to a JSON file, prompting to overwrite if the file already exists.
"""
if Path(filename).exists():
overwrite = input(f"{filename} already exists. Do you want to overwrite it? (y/n): ").strip().lower()
if overwrite != "y":
print("Data not saved.")
return
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:
request_headers = self.session.headers.copy()
if headers:
request_headers.update(headers)
with open(filename, "w") as f:
json.dump(data, f, indent=4)
print(f"Data saved to {filename}.\n")
print("IMPORTANT: Ensure you securely back up this file and its contents.")
response = self.session.request(method, url, headers=request_headers, data=data, json=json_payload, timeout=20)
response.raise_for_status()
@staticmethod
def generate_qr_code(totp_url, filename):
"""
Generates a QR code for the given TOTP URL and saves it as an image.
"""
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)
if response.status_code == 204:
return {}
content_type = response.headers.get('Content-Type', '')
if 'application/json' in content_type:
return response.json()
else:
raise AuthenticatorError(f"Unexpected content type '{content_type}' received from {url}.")
img = qr.make_image(fill="black", back_color="white")
qr_filename = f"{filename}.png"
img.save(qr_filename)
print(f"QR Code saved to {qr_filename}. You can scan this code using your authenticator app.")
except requests.exceptions.HTTPError as e:
status = e.response.status_code
raw_body = e.response.text or ""
def get_bearer_token(self, session_token):
"""
Retrieves the Bearer Token using the provided Session Token.
"""
# 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
# 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.")
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
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
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}")
print(f"Attempting to retrieve secret for serial {serial}...")
response_data = self._make_request("POST", url, json_payload=payload)
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}
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."
)
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 "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 convert_secret_to_base32(device_secret):
"""
Converts the device secret from hex to Base32 format for TOTP.
"""
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:
hex_secret = binascii.unhexlify(device_secret)
return base64.b32encode(hex_secret).decode("utf-8").replace("=", "")
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
@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:
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
@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 Exception(f"Failed to convert secret: {e}")
raise ValueError(f"Failed to convert secret to Base32: Invalid hex input. ({e})") from e
@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 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")
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 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)
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")
def interactive_cli():
"""
Main interactive CLI for the Battle.net Authenticator Tool.
"""
authenticator = BattleNetAuthenticator()
if not all([serial, restore_code, device_secret]):
raise ValueError("Incomplete device information from API.")
show_session_token_instructions()
print("\n" + "-" * 30)
print("Authenticator Details:")
print(f" Serial: {serial}")
print(f" Restore Code: {restore_code}")
print("-" * 30)
session_token = input("Enter your Session Token (or type 'exit' to quit): ").strip()
if session_token.lower() == "exit":
graceful_exit()
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"
if not session_token:
print("Session Token is required!")
return
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:
print("Fetching Bearer Token...")
bearer_token = authenticator.get_bearer_token(session_token)
print(f"Bearer Token: {bearer_token}")
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)
print("\nChoose an action:")
print("1. Attach a new authenticator")
print("2. Retrieve existing device secret")
print("3. Exit")
choice = input("Enter your choice (1/2/3): ").strip()
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
if choice == "1":
print("Attaching Authenticator...")
device_info = authenticator.attach_authenticator()
serial = device_info["serial"]
restore_code = device_info["restoreCode"]
device_secret = device_info["deviceSecret"]
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
print(f"Serial: {serial}")
print(f"Restore Code: {restore_code}")
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)
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\n")
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
# 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)
print("\nFound the following JSON files:")
for i, file in enumerate(json_files, 1):
print(f"{i}. {file.name}")
elif choice == "2":
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!")
return
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
print("Retrieving Device Secret...")
device_info = authenticator.retrieve_device_secret(serial, restore_code)
device_secret = device_info["deviceSecret"]
print(f"Device Secret: {device_secret}")
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
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\n")
def _handle_reconstruct_action(authenticator: BattleNetAuthenticator) -> None:
selected_file = _select_json_file("Select JSON file to reconstruct from")
if not selected_file: return
# 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)
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
elif choice == "3":
graceful_exit()
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("Invalid choice!")
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:")
for key, (desc, _) in actions.items():
print(f"{key}. {desc}")
try:
choice = input("Enter your choice: ").strip()
if choice in actions:
actions[choice][1](authenticator)
else:
print("Invalid choice.")
except (EOFError, KeyboardInterrupt):
graceful_exit()
except Exception as e:
print(f"Error: {e}")
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)
+4
View File
@@ -0,0 +1,4 @@
cryptography
qrcode
Pillow
requests