import hashlib from re import split from typing import Any, Dict, Sequence from urllib.parse import parse_qsl, unquote, urlparse from . import contrib # noqa:F401 from .compat import random from .hotp import HOTP as HOTP from .otp import OTP as OTP from .totp import TOTP as TOTP def random_base32(length: int = 32, chars: Sequence[str] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") -> str: # Note: the otpauth scheme DOES NOT use base32 padding for secret lengths not divisible by 8. # Some third-party tools have bugs when dealing with such secrets. # We might consider warning the user when generating a secret of length not divisible by 8. if length < 32: raise ValueError("Secrets should be at least 160 bits") return "".join(random.choice(chars) for _ in range(length)) def random_hex(length: int = 40, chars: Sequence[str] = "ABCDEF0123456789") -> str: if length < 40: raise ValueError("Secrets should be at least 160 bits") return random_base32(length=length, chars=chars) def parse_uri(uri: str) -> OTP: """ Parses the provisioning URI for the OTP; works for either TOTP or HOTP. See also: https://github.com/google/google-authenticator/wiki/Key-Uri-Format :param uri: the hotp/totp URI to parse :returns: OTP object """ # Secret (to be filled in later) secret = None # Encoder (to be filled in later) encoder = None # Digits (to be filled in later) digits = None # Data we'll parse to the correct constructor otp_data: Dict[str, Any] = {} # Parse the URI structure first, then unquote individual components. # Unquoting the whole URI before splitting the label would turn an # encoded colon (%3A) inside the issuer or account name into a literal # ":" and mis-parse the issuer:account separator (see GitHub issue #174). parsed_uri = urlparse(uri) if parsed_uri.scheme != "otpauth": raise ValueError("Not an otpauth URI") # Parse issuer/accountname info. The label uses a literal ":" as the # issuer:account separator; a percent-encoded "%3A" is a colon that is # part of the issuer or account name, not a separator. Split on the # literal ":" while the components are still percent-encoded, then # unquote each component on its own. accountinfo_parts = split(":", parsed_uri.path[1:], maxsplit=1) if len(accountinfo_parts) == 1: otp_data["name"] = unquote(accountinfo_parts[0]) else: otp_data["issuer"] = unquote(accountinfo_parts[0]) otp_data["name"] = unquote(accountinfo_parts[1]) # Parse values for key, value in parse_qsl(parsed_uri.query): if key == "secret": secret = value elif key == "issuer": if "issuer" in otp_data and otp_data["issuer"] is not None and otp_data["issuer"] != value: raise ValueError("If issuer is specified in both label and parameters, it should be equal.") otp_data["issuer"] = value elif key == "algorithm": if value == "SHA1": otp_data["digest"] = hashlib.sha1 elif value == "SHA256": otp_data["digest"] = hashlib.sha256 elif value == "SHA512": otp_data["digest"] = hashlib.sha512 else: raise ValueError("Invalid value for algorithm, must be SHA1, SHA256 or SHA512") elif key == "encoder": encoder = value elif key == "digits": digits = int(value) otp_data["digits"] = digits elif key == "period": otp_data["interval"] = int(value) elif key == "counter": otp_data["initial_count"] = int(value) if encoder != "steam": if digits is not None and digits not in [6, 7, 8]: raise ValueError("Digits may only be 6, 7, or 8") if not secret: raise ValueError("No secret found in URI") # Create objects if encoder == "steam": return contrib.Steam(secret, **otp_data) if parsed_uri.netloc == "totp": return TOTP(secret, **otp_data) elif parsed_uri.netloc == "hotp": return HOTP(secret, **otp_data) raise ValueError("Not a supported OTP type")