Full Code of jleclanche/python-bna for AI

master 8116e41a87b8 cached
14 files
22.2 KB
6.4k tokens
49 symbols
1 requests
Download .txt
Repository: jleclanche/python-bna
Branch: master
Commit: 8116e41a87b8
Files: 14
Total size: 22.2 KB

Directory structure:
gitextract_40tpyfoq/

├── .flake8
├── .github/
│   └── workflows/
│       └── ci.yml
├── .gitignore
├── LICENSE
├── README.md
├── bna/
│   ├── __init__.py
│   ├── __main__.py
│   ├── cli.py
│   ├── constants.py
│   ├── crypto.py
│   ├── http.py
│   └── utils.py
├── pyproject.toml
└── tests/
    └── test_main.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .flake8
================================================
[flake8]
ignore = W191, E117
max-line-length = 88


================================================
FILE: .github/workflows/ci.yml
================================================
name: CI tests

on: [push]

jobs:
  tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: true
      matrix:
        python-version:
          - "3.7"
          - "3.8"
          - "3.9"
          - "3.10"

    steps:
    - uses: actions/checkout@v2
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v2
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install poetry
      run: python -m pip install poetry
    - name: Install application
      run: poetry install
    - name: Test with pytest
      run: poetry run pytest

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
        with:
          python-version: "3.10"
      - name: Install poetry
        run: python -m pip install poetry
      - name: Install application
        run: poetry install
      - name: Check for flake8 issues
        run: poetry run flake8 .
      - name: Check code formatting with Black
        run: poetry run tan . --check
      - name: Check import ordering with isort
        run: poetry run isort . --check --diff

  mypy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
        with:
          python-version: "3.10"
      - name: Install poetry
        run: python -m pip install poetry
      - name: Install application
        run: poetry install
      - name: Check using mypy
        run: poetry run mypy bna


================================================
FILE: .gitignore
================================================
*.py[co]
__pycache__
build
dist
MANIFEST


================================================
FILE: LICENSE
================================================
Copyright (c) Jerome Leclanche

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.


================================================
FILE: README.md
================================================
# python-bna

## Requirements

- Python 3.6+


## Command-line usage

bna is a command line interface to the python-bna library. It can store
and manage multiple authenticators, as well as create new ones.


Remember: Using an authenticator on the same device as the one you log in with
is less secure than keeping the devices separate. Use this at your own risk.

Configuration is stored in `~/.config/bna/bna.conf`. You can pass a
different config path with `bna --config=~/.bna.conf` for example.


### Creating a new authenticator

    $ bna new

If you do not already have an authenticator, it will be set as default.
You can pass `--set-default` otherwise.


### Getting an authentication token

    $ bna
    01234567
    $ bna EU-1234-1234-1234
    76543210


### Getting an authenticator's restore code

    $ bna show-restore-code
    Z45Q9CVXRR
    $ bna restore EU-1234-1234-1234 ABCDE98765
    Restored EU-1234-1234-1234


### Getting an OTPAuth URL

To display the OTPAuth URL (used for setup QR Codes):

    $ bna show-url
    otpauth://totp/Blizzard:EU123412341234:?secret=ASFAS75ASDF75889G9AD7S69AS7697AS&issuer=Blizzard&digits=8

Now paste this to your OTP app, or convert to QRCode and scan, or
manually enter the secret.

This is compatible with standard TOTP clients and password managers such as:
- [andOTP](https://play.google.com/store/apps/details?id=org.shadowice.flocke.andotp) (Android),
- [KeepassXC](https://keepassxc.org/) (Cross-platform)
- [1Password](https://1password.com/) (Cross-platform)


#### Getting a QR code

To encode to a QRCode on your local system install \'qrencode\'

For a PNG file saved to disk :

    $ bna show-url | qrencode -o ~/BNA-qrcode.png
    # Scan QRCode
    $ rm ~/BNA-qrcode.png

Or to attempt ot display QRCode in terminal as text output :

    $ bna --otpauth-url | qrencode -t ANSI


## Python library usage

### Requesting a new authenticator

```py
import bna
try:
    # region is EU or US
    # note that EU authenticators are valid in the US, and vice versa
    serial, secret = bna.request_new_serial("US")
except bna.HTTPError as e:
    print("Could not connect:", e)
```

### Getting a token

```py
    # Get and print a token using PyOTP
    from pyotp import TOTP
    totp = TOTP(secret, digits=8)
    print(totp.now())
```


================================================
FILE: bna/__init__.py
================================================
"""
python-bna
Blizzard Authenticator routines in Python.

Specification can be found here:
* <http://bnetauth.freeportal.us/specification.html>
Note: Link likely dead. Check webarchive.
"""

import pkg_resources

from .crypto import get_restore_code
from .http import HTTPError, get_time_offset, request_new_serial, restore
from .utils import get_otpauth_url, normalize_serial, prettify_serial

__all__ = [
	"get_restore_code",
	"get_time_offset",
	"HTTPError",
	"request_new_serial",
	"restore",
	"get_otpauth_url",
	"normalize_serial",
	"prettify_serial",
]
__version__ = pkg_resources.require("bna")[0].version


================================================
FILE: bna/__main__.py
================================================
from .cli import main as __main__

__all__ = ["__main__"]

__main__()


================================================
FILE: bna/cli.py
================================================
import base64
import os
import sys
from configparser import ConfigParser
from string import hexdigits
from time import sleep
from typing import List

import click
from pyotp import TOTP

import bna


def get_default_config_path() -> str:
	"""
	Returns the default configuration file path
	"""
	configdir = "bna"
	home = os.environ.get("HOME", "")
	if os.name == "posix":
		base = os.environ.get("XDG_CONFIG_HOME", os.path.join(home, ".config"))
		path = os.path.join(base, configdir)
	elif os.name == "nt":
		base = os.environ["APPDATA"]
		path = os.path.join(base, configdir)
	else:
		path = home

	return os.path.join(path, "bna.conf")


def ishex(s: str) -> bool:
	"""
	Returns True if a string contains only hex digits, False otherwise.
	"""
	return "".join(filter(lambda c: c in hexdigits, s)) == s


class AuthenticatorSerial(click.ParamType):
	name = "serial"

	def convert(self, value: str, param, ctx) -> str:
		if not value:
			value = ctx.obj.get_default_serial()
			if not value:
				if not ctx.obj._serials():
					msg = (
						"You do not have any configured authenticators. "
						"Create a new one with 'bna new' or try "
						"'bna --help' for more information"
					)
				else:
					msg = (
						"You do not have a default authenticator set. "
						"You must provide an authenticator serial or set a "
						"default one with 'bna set-default <serial>'."
					)
				ctx.fail(msg)

		serial = bna.normalize_serial(value)
		if not ctx.obj.config.has_section(serial):
			ctx.fail(f"No such authenticator: {serial}")

		return serial


class App:
	def __init__(self, config: str) -> None:
		self.config_file = os.path.expanduser(config) or get_default_config_path()
		config_dir = os.path.abspath(os.path.dirname(self.config_file))
		if not os.path.exists(config_dir):
			os.makedirs(config_dir)

		self.config = ConfigParser()
		try:
			self.config.read([self.config_file])
		except Exception as e:
			click.echo(f"Could not parse config file {self.config_file}: {e}", err=True)
			exit(1)

	def _serials(self) -> List[str]:
		return [x for x in self.config.sections() if x != "bna"]

	def add_serial(self, serial: str, secret: str, set_default: bool) -> None:
		self.set_secret(serial, secret)

		# We set the serial as default if we don't have one set already
		# Otherwise, we check for --set-default
		if set_default or not self.get_default_serial():
			self.set_default_serial(serial)

	def get_default_serial(self) -> str:
		if not self.config.has_option("bna", "default_serial"):
			return ""
		return self.config.get("bna", "default_serial")

	def set_default_serial(self, serial) -> None:
		if not self.config.has_section("bna"):
			self.config.add_section("bna")
		self.config.set("bna", "default_serial", serial)
		self.write_config()

	def write_config(self) -> None:
		try:
			with open(self.config_file, "w") as f:
				self.config.write(f)
		except IOError as e:
			click.echo(f"Could not open {self.config_file} for writing: {e}", err=True)
			exit(1)

	def get_secret(self, serial: str) -> str:
		if not self.config.has_section(serial):
			return ""

		secret = self.config.get(serial, "secret")

		if len(secret) == 40 and ishex(secret):
			# bna <= 4.0.0 saved secrets as hex instead of more standard base32
			sys.stderr.write("Found old format for secret store. Converting.\n")
			# decode old format
			secret_bytes = bytes.fromhex(secret)
			# re-encode in new format
			secret = base64.b32encode(secret_bytes).decode()
			# save to config (in base32)
			self.set_secret(serial, secret)

		return secret

	def set_secret(self, serial: str, secret: str) -> None:
		if not self.config.has_section(serial):
			self.config.add_section(serial)
		self.config.set(serial, "secret", secret)
		self.write_config()


class DefaultShowGroup(click.Group):
	def parse_args(self, ctx: click.Context, args: List[str]):
		if not args:
			args.append("show")
		return super().parse_args(ctx, args)


@click.group(cls=DefaultShowGroup)
@click.option("--config", default="", help="Path to a different config file to use")
@click.version_option(bna.__version__)
@click.pass_context
def main(ctx: click.Context, config: str) -> None:
	ctx.obj = App(config)


@main.command(help="Show the current authenticator code")
@click.argument("serial", type=AuthenticatorSerial(), default="")
@click.option(
	"--interactive/--no-interactive",
	default=False,
	help="interactive mode: updates the token as soon as it expires",
)
@click.pass_context
def show(ctx: click.Context, serial: str, interactive: bool) -> None:
	secret = ctx.obj.get_secret(serial)
	totp = TOTP(secret, digits=8)
	if interactive:
		click.echo("Ctrl-C to exit")
		while True:
			token = totp.now()
			sys.stdout.write("\r" + token)
			sys.stdout.flush()
			sleep(1)
	else:
		click.echo(totp.now())


@main.command(help="Request a new authenticator")
@click.option("--region", default="US", help="Desired region for the new authenticator")
@click.option("--set-default/--no-set-default", default=True)
@click.pass_context
def new(ctx: click.Context, region: str, set_default: bool) -> None:
	try:
		serial, secret = bna.request_new_serial(region)
	except bna.HTTPError as e:
		click.echo(f"Could not connect: {e}", err=True)
		exit(1)

	serial = bna.normalize_serial(serial)
	ctx.obj.add_serial(serial, secret, set_default=set_default)
	click.echo(f"Success! Your new authenticator is: {bna.prettify_serial(serial)}")


@main.command(help="Delete an authenticator from the configuration")
@click.argument("serial", type=AuthenticatorSerial())
@click.pass_context
def delete(ctx: click.Context, serial: str) -> None:
	if not ctx.obj.config.has_section(serial):
		ctx.fail(f"No such serial: {serial}")
	ctx.obj.config.remove_section(serial)

	# If it's the default serial, remove that
	if serial == ctx.obj.get_default_serial():
		ctx.obj.config.remove_option("bna", "default_serial")

	ctx.obj.write_config()
	click.echo(f"Deleted authenticator: {bna.prettify_serial(serial)}")


@main.command(help="Recover an authenticator from its restore code")
@click.argument("serial")
@click.argument("restore_code")
@click.option("--set-default/--no-set-default", default=False)
@click.pass_context
def restore(
	ctx: click.Context, serial: str, restore_code: str, set_default: bool
) -> None:
	if ctx.obj.config.has_option(serial, "secret"):
		ctx.fail(
			"A secret already exists for this serial. "
			f"Try deleting it first with bna delete {serial}"
		)

	serial = bna.normalize_serial(serial)
	try:
		secret = bna.restore(serial, restore_code)
	except ValueError as e:
		ctx.fail(str(e))

	ctx.obj.add_serial(serial, secret, set_default=set_default)
	click.echo(f"Restored {bna.prettify_serial(serial)}")


@main.command(help="List all configured authenticators")
@click.pass_context
def list(ctx) -> None:
	default = ctx.obj.get_default_serial()
	serials = ctx.obj._serials()
	for serial in serials:
		if serial == default:
			click.echo(f"{serial} (default)")
		else:
			click.echo(serial)

	click.echo(f"{len(serials)} authenticators")


@main.command(help="Set an authenticator as the default one to use for commands")
@click.argument("serial", type=AuthenticatorSerial(), default="")
@click.pass_context
def set_default(ctx: click.Context, serial: str) -> None:
	ctx.obj.set_default_serial(serial)
	click.echo(f"{bna.prettify_serial(serial)} is now your default authenticator.")


@main.command("show-restore-code", help="Display an authenticator's restore code")
@click.argument("serial", type=AuthenticatorSerial(), default="")
@click.pass_context
def show_restore_code(ctx: click.Context, serial: str) -> None:
	secret = ctx.obj.get_secret(serial)
	code = bna.get_restore_code(serial, secret)
	click.echo(code)


@main.command("show-url", help="Display an authenticator's OTPAuth URL (for QR codes)")
@click.argument("serial", type=AuthenticatorSerial(), default="")
@click.pass_context
def show_url(ctx: click.Context, serial: str) -> None:
	secret = ctx.obj.get_secret(serial)

	# Only add a newline if stdout is a tty
	newline = sys.stdout.isatty()
	click.echo(bna.get_otpauth_url(serial, secret), nl=newline)


@main.command("show-secret", help="Display an authenticator's secret")
@click.argument("serial", type=AuthenticatorSerial(), default="")
@click.pass_context
def show_secret(ctx: click.Context, serial: str) -> None:
	secret = ctx.obj.get_secret(serial)
	click.echo(secret)


if __name__ == "__main__":
	main()


================================================
FILE: bna/constants.py
================================================
RSA_MOD = 104890018807986556874007710914205443157030159668034197186125678960287470894290830530618284943118405110896322835449099433232093151168250152146023319326491587651685252774820340995950744075665455681760652136576493028733914892166700899109836291180881063097461175643998356321993663868233366705340758102567742483097  # noqa
RSA_KEY = 257

ENROLL_HOSTS = {
	"CN": "mobile-service.battlenet.com.cn",
	# "EU": "m.eu.mobileservice.blizzard.com",
	# "US": "m.us.mobileservice.blizzard.com",
	# "EU": "eu.mobile-service.blizzard.com",
	# "US": "us.mobile-service.blizzard.com",
	"default": "mobile-service.blizzard.com",
}


================================================
FILE: bna/crypto.py
================================================
from base64 import b32decode
from hashlib import sha1
from typing import Union

from .constants import RSA_KEY, RSA_MOD


def encrypt(data: bytes) -> str:
	base_num = int(data.hex(), 16)
	n = base_num ** RSA_KEY % RSA_MOD
	ret = ""
	while n > 0:
		n, m = divmod(n, 256)
		ret = chr(m) + ret
	return ret


def decrypt(response: bytes, otp: bytes) -> bytearray:
	ret = bytearray()
	for c, e in zip(response, otp):
		ret.append(c ^ e)
	return ret


def bytes_to_restore_code(digest: Union[bytes, bytearray]) -> str:
	ret = []
	for i in digest:
		c = i & 0x1F
		if c < 10:
			c += 48
		else:
			c += 55
			if c > 72:  # I
				c += 1
			if c > 75:  # L
				c += 1
			if c > 78:  # O
				c += 1
			if c > 82:  # S
				c += 1
		ret.append(chr(c))

	return "".join(ret)


def get_restore_code(serial: str, secret: str) -> str:
	secret_bytes = b32decode(secret)
	data = serial.encode() + secret_bytes
	digest = sha1(data).digest()[-10:]
	return bytes_to_restore_code(digest)


def restore_code_to_bytes(code: str) -> bytes:
	ret = bytearray()
	for c in code:
		i = ord(c)
		if 58 > i > 47:
			i -= 48
		else:
			mod = i - 55
			if i > 72:
				mod -= 1
			if i > 75:
				mod -= 1
			if i > 78:
				mod -= 1
			if i > 82:
				mod -= 1
			i = mod
		ret.append(i)

	return bytes(ret)


================================================
FILE: bna/http.py
================================================
import hmac
import struct
from base64 import b32encode
from hashlib import sha1
from http.client import HTTPConnection
from secrets import token_bytes
from time import time
from typing import Optional, Tuple

from .constants import ENROLL_HOSTS
from .crypto import decrypt, encrypt, restore_code_to_bytes
from .utils import normalize_serial


class HTTPError(Exception):
	def __init__(self, msg, response):
		self.response = response
		super().__init__(msg)


class APIClient:
	def __init__(self, *, region: str = "US", host: str = ""):
		self.region = region
		self.host = host or ENROLL_HOSTS.get(region, ENROLL_HOSTS["default"])

	def post(self, path: str, *, data: Optional[str] = None) -> bytes:
		conn = HTTPConnection(self.host)
		conn.request("POST", path, data)
		response = conn.getresponse()

		if response.status != 200:
			raise HTTPError(
				"%s returned status %i" % (self.host, response.status), response
			)

		ret = response.read()
		conn.close()
		return ret

	def enroll(self, data):
		return self.post("/enrollment/enroll.htm", data=data)

	def get_time(self) -> int:
		response = self.post("/enrollment/time.htm")
		return int(struct.unpack(">Q", response)[0])

	def initiate_paper_restore(self, serial: str):
		response = self.post("/enrollment/initiatePaperRestore.htm", data=serial)
		resp_size = len(response)
		if resp_size != 32:
			raise ValueError("Bad challenge response (%i bytes)" % (resp_size))

		return response

	def validate_paper_restore(self, serial: str, encrypted_data: str):
		data = serial + encrypted_data
		try:
			response = self.post("/enrollment/validatePaperRestore.htm", data=data)
		except HTTPError as e:
			if e.response.status == 600:
				raise HTTPError("Invalid serial or restore key", e.response)
			else:
				raise
		return response


def request_new_serial(
	region: str = "US", model: str = "Motorola RAZR v3"
) -> Tuple[str, str]:
	"""
	Requests a new authenticator
	This will connect to the Blizzard servers
	"""

	def base_msg(otp, region, model):
		ret = (otp + b"\0" * 37)[:37]
		ret += region.encode() or b"\0\0"
		ret += (model.encode() + b"\0" * 16)[:16]
		return b"\1" + ret

	otp = token_bytes(37)
	data = base_msg(otp, region, model)
	encrypted_data = encrypt(data)

	client = APIClient(region=region)
	response = client.enroll(encrypted_data)[8:]

	decrypted_response = decrypt(response, otp)

	secret = b32encode(decrypted_response[:20]).decode()
	serial = decrypted_response[20:].decode()

	region = serial[:2]
	if region not in ("CN", "EU", "KR", "US"):
		raise ValueError("Unexpected region: %r" % (region))

	return serial, secret


def get_time_offset(region: str = "US") -> int:
	"""
	Calculates the time difference in seconds as a float
	between the local host and a remote server

	This function returns the difference in milliseconds as an int.
	Negative numbers indicate the local clock is ahead of the
	server clock.
	"""
	client = APIClient(region=region)
	server_time = client.get_time()
	local_time = time()

	# NOTE: The server returns time in milliseconds as an int whereas
	# Python returns it as a float, in seconds.
	return server_time - int(local_time * 1000)


def restore(serial: str, restore_code: str) -> str:
	serial = normalize_serial(serial)
	restore_code = restore_code.upper()
	if len(restore_code) != 10:
		raise ValueError(
			f"invalid restore code (should be 10 characters): {restore_code}"
		)

	# Region is always the first two chars of a restore code
	region = serial[:2]
	client = APIClient(region=region)
	challenge = client.initiate_paper_restore(serial)

	code = restore_code_to_bytes(restore_code)
	hash = hmac.new(code, serial.encode() + challenge, digestmod=sha1).digest()

	otp = token_bytes(20)
	encrypted_data = encrypt(hash + otp)
	response = client.validate_paper_restore(serial, encrypted_data)
	secret = decrypt(response, otp)

	return b32encode(secret).decode()


================================================
FILE: bna/utils.py
================================================
"""
Utility functions
"""
from pyotp import TOTP


def normalize_serial(serial: str) -> str:
	"""
	Normalizes a serial
	Will uppercase it, remove its dashes and strip
	any whitespace
	"""
	return serial.upper().replace("-", "").strip()


def prettify_serial(serial: str) -> str:
	"""
	Returns the prettified version of a serial
	It should look like XX-AAAA-BBBB-CCCC-DDDD
	"""
	serial = normalize_serial(serial)
	if len(serial) != 14:
		raise ValueError("serial %r should be 14 characters long" % (serial))

	def digits(chars):
		if not chars.isdigit():
			raise ValueError("bad serial %r" % (serial))
		return "%04i" % int((chars))

	return "%s%s%s%s" % (
		serial[0:2].upper(),
		digits(serial[2:6]),
		digits(serial[6:10]),
		digits(serial[10:14]),
	)


def get_otpauth_url(serial: str, secret: str) -> str:
	"""
	Get the OTPAuth URL for the serial/secret pair
	https://github.com/google/google-authenticator/wiki/Key-Uri-Format
	"""
	totp = TOTP(secret, digits=8)

	return totp.provisioning_uri(serial, issuer_name="Blizzard")


================================================
FILE: pyproject.toml
================================================
[tool.poetry]
name = "bna"
version = "5.1.0"
description = "Blizzard Authenticator and OTP library in Python"
authors = ["Jerome Leclanche <jerome@leclan.ch>"]
license = "MIT"
readme = "README.md"
repository = "https://github.com/jleclanche/python-bna"
classifiers = [
	"Development Status :: 5 - Production/Stable",
	"Environment :: Console",
	"Intended Audience :: Developers",
	"Intended Audience :: End Users/Desktop",
	"Topic :: Security",
	"Topic :: Security :: Cryptography",
]

[tool.poetry.dependencies]
python = "^3.7"
click = "^8.0.3"
pyotp = "^2.6.0"

[tool.poetry.dev-dependencies]
flake8 = "^4.0.1"
isort = "^5.10.1"
mypy = "^0.931"
pytest = "^7.0.0"
types-setuptools = "^57.4.9"
tan = "^21.14"

[tool.poetry.scripts]
bna = "bna.cli:main"

[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

[tool.black]
use-tabs = true

[tool.isort]
profile = "black"
indent = "tab"


================================================
FILE: tests/test_main.py
================================================
#!/usr/bin/env python

import urllib.parse

from pyotp import TOTP

import bna

SERIAL = "US120910711868"
SECRET = "HA4GCYLGMFRWKNBYGI4TCZJQHFSGGMLFMNSTSYZSMFQTINDEHAZTSOJYGNQTOZTG"


def test_token():
	totp = TOTP(SECRET, digits=8)
	assert totp.at(1347279358) == "93461643"
	assert totp.at(1347279359) == "93461643"
	assert totp.at(1347279360) == "86031001"


def test_restore_code():
	assert bna.get_restore_code(SERIAL, SECRET) == "4B91NQCYQ3"


def test_serial():
	pretty_serial = bna.prettify_serial(SERIAL.lower())
	assert pretty_serial == "US120910711868"
	assert bna.normalize_serial(pretty_serial) == SERIAL


def test_otpauth_url():
	otpauth_url = bna.get_otpauth_url(SERIAL, SECRET)
	p = urllib.parse.urlparse(otpauth_url)
	assert p.scheme == "otpauth"
	assert p.netloc == "totp"
	assert p.path == "/Blizzard:%s" % (SERIAL)
	params = urllib.parse.parse_qs(p.query)
	assert params["secret"] == [SECRET]
	assert params["issuer"] == ["Blizzard"]
	assert params["digits"] == ["8"]
Download .txt
gitextract_40tpyfoq/

├── .flake8
├── .github/
│   └── workflows/
│       └── ci.yml
├── .gitignore
├── LICENSE
├── README.md
├── bna/
│   ├── __init__.py
│   ├── __main__.py
│   ├── cli.py
│   ├── constants.py
│   ├── crypto.py
│   ├── http.py
│   └── utils.py
├── pyproject.toml
└── tests/
    └── test_main.py
Download .txt
SYMBOL INDEX (49 symbols across 5 files)

FILE: bna/cli.py
  function get_default_config_path (line 15) | def get_default_config_path() -> str:
  function ishex (line 33) | def ishex(s: str) -> bool:
  class AuthenticatorSerial (line 40) | class AuthenticatorSerial(click.ParamType):
    method convert (line 43) | def convert(self, value: str, param, ctx) -> str:
  class App (line 68) | class App:
    method __init__ (line 69) | def __init__(self, config: str) -> None:
    method _serials (line 82) | def _serials(self) -> List[str]:
    method add_serial (line 85) | def add_serial(self, serial: str, secret: str, set_default: bool) -> N...
    method get_default_serial (line 93) | def get_default_serial(self) -> str:
    method set_default_serial (line 98) | def set_default_serial(self, serial) -> None:
    method write_config (line 104) | def write_config(self) -> None:
    method get_secret (line 112) | def get_secret(self, serial: str) -> str:
    method set_secret (line 130) | def set_secret(self, serial: str, secret: str) -> None:
  class DefaultShowGroup (line 137) | class DefaultShowGroup(click.Group):
    method parse_args (line 138) | def parse_args(self, ctx: click.Context, args: List[str]):
  function main (line 148) | def main(ctx: click.Context, config: str) -> None:
  function show (line 160) | def show(ctx: click.Context, serial: str, interactive: bool) -> None:
  function new (line 178) | def new(ctx: click.Context, region: str, set_default: bool) -> None:
  function delete (line 193) | def delete(ctx: click.Context, serial: str) -> None:
  function restore (line 211) | def restore(
  function list (line 232) | def list(ctx) -> None:
  function set_default (line 247) | def set_default(ctx: click.Context, serial: str) -> None:
  function show_restore_code (line 255) | def show_restore_code(ctx: click.Context, serial: str) -> None:
  function show_url (line 264) | def show_url(ctx: click.Context, serial: str) -> None:
  function show_secret (line 275) | def show_secret(ctx: click.Context, serial: str) -> None:

FILE: bna/crypto.py
  function encrypt (line 8) | def encrypt(data: bytes) -> str:
  function decrypt (line 18) | def decrypt(response: bytes, otp: bytes) -> bytearray:
  function bytes_to_restore_code (line 25) | def bytes_to_restore_code(digest: Union[bytes, bytearray]) -> str:
  function get_restore_code (line 46) | def get_restore_code(serial: str, secret: str) -> str:
  function restore_code_to_bytes (line 53) | def restore_code_to_bytes(code: str) -> bytes:

FILE: bna/http.py
  class HTTPError (line 15) | class HTTPError(Exception):
    method __init__ (line 16) | def __init__(self, msg, response):
  class APIClient (line 21) | class APIClient:
    method __init__ (line 22) | def __init__(self, *, region: str = "US", host: str = ""):
    method post (line 26) | def post(self, path: str, *, data: Optional[str] = None) -> bytes:
    method enroll (line 40) | def enroll(self, data):
    method get_time (line 43) | def get_time(self) -> int:
    method initiate_paper_restore (line 47) | def initiate_paper_restore(self, serial: str):
    method validate_paper_restore (line 55) | def validate_paper_restore(self, serial: str, encrypted_data: str):
  function request_new_serial (line 67) | def request_new_serial(
  function get_time_offset (line 100) | def get_time_offset(region: str = "US") -> int:
  function restore (line 118) | def restore(serial: str, restore_code: str) -> str:

FILE: bna/utils.py
  function normalize_serial (line 7) | def normalize_serial(serial: str) -> str:
  function prettify_serial (line 16) | def prettify_serial(serial: str) -> str:
  function get_otpauth_url (line 38) | def get_otpauth_url(serial: str, secret: str) -> str:

FILE: tests/test_main.py
  function test_token (line 13) | def test_token():
  function test_restore_code (line 20) | def test_restore_code():
  function test_serial (line 24) | def test_serial():
  function test_otpauth_url (line 30) | def test_otpauth_url():
Condensed preview — 14 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (25K chars).
[
  {
    "path": ".flake8",
    "chars": 50,
    "preview": "[flake8]\nignore = W191, E117\nmax-line-length = 88\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 1512,
    "preview": "name: CI tests\n\non: [push]\n\njobs:\n  tests:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: true\n      matrix:\n"
  },
  {
    "path": ".gitignore",
    "chars": 41,
    "preview": "*.py[co]\n__pycache__\nbuild\ndist\nMANIFEST\n"
  },
  {
    "path": "LICENSE",
    "chars": 1055,
    "preview": "Copyright (c) Jerome Leclanche\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this sof"
  },
  {
    "path": "README.md",
    "chars": 2300,
    "preview": "# python-bna\n\n## Requirements\n\n- Python 3.6+\n\n\n## Command-line usage\n\nbna is a command line interface to the python-bna "
  },
  {
    "path": "bna/__init__.py",
    "chars": 615,
    "preview": "\"\"\"\npython-bna\nBlizzard Authenticator routines in Python.\n\nSpecification can be found here:\n* <http://bnetauth.freeporta"
  },
  {
    "path": "bna/__main__.py",
    "chars": 70,
    "preview": "from .cli import main as __main__\n\n__all__ = [\"__main__\"]\n\n__main__()\n"
  },
  {
    "path": "bna/cli.py",
    "chars": 8410,
    "preview": "import base64\nimport os\nimport sys\nfrom configparser import ConfigParser\nfrom string import hexdigits\nfrom time import s"
  },
  {
    "path": "bna/constants.py",
    "chars": 621,
    "preview": "RSA_MOD = 10489001880798655687400771091420544315703015966803419718612567896028747089429083053061828494311840511089632283"
  },
  {
    "path": "bna/crypto.py",
    "chars": 1271,
    "preview": "from base64 import b32decode\nfrom hashlib import sha1\nfrom typing import Union\n\nfrom .constants import RSA_KEY, RSA_MOD\n"
  },
  {
    "path": "bna/http.py",
    "chars": 3889,
    "preview": "import hmac\nimport struct\nfrom base64 import b32encode\nfrom hashlib import sha1\nfrom http.client import HTTPConnection\nf"
  },
  {
    "path": "bna/utils.py",
    "chars": 1031,
    "preview": "\"\"\"\nUtility functions\n\"\"\"\nfrom pyotp import TOTP\n\n\ndef normalize_serial(serial: str) -> str:\n\t\"\"\"\n\tNormalizes a serial\n\t"
  },
  {
    "path": "pyproject.toml",
    "chars": 911,
    "preview": "[tool.poetry]\nname = \"bna\"\nversion = \"5.1.0\"\ndescription = \"Blizzard Authenticator and OTP library in Python\"\nauthors = "
  },
  {
    "path": "tests/test_main.py",
    "chars": 988,
    "preview": "#!/usr/bin/env python\n\nimport urllib.parse\n\nfrom pyotp import TOTP\n\nimport bna\n\nSERIAL = \"US120910711868\"\nSECRET = \"HA4G"
  }
]

About this extraction

This page contains the full source code of the jleclanche/python-bna GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 14 files (22.2 KB), approximately 6.4k tokens, and a symbol index with 49 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!