diff --git a/src/pyotp/contrib/steam.py b/src/pyotp/contrib/steam.py index 1cd2bdd..be718bd 100644 --- a/src/pyotp/contrib/steam.py +++ b/src/pyotp/contrib/steam.py @@ -13,13 +13,16 @@ class Steam(TOTP): """ def __init__( - self, s: str, name: Optional[str] = None, issuer: Optional[str] = None, interval: int = 30, digits: int = 5 + self, s: str, name: Optional[str] = None, issuer: Optional[str] = None, interval: int = 30, digits: int = 10 ) -> None: """ - :param s: secret in base32 format + :param s: secret in base32 format. :param interval: the time interval in seconds for OTP. This defaults to 30. - :param name: account name - :param issuer: issuer + :param name: account name. + :param issuer: issuer. + :param digits: This parameter is ignored. Steam requires OTPs to be exactly 10 digits long, + so this value is hardcoded to 10 internally. It is only retained for + compatibility with the `pyotp.totp.TOTP` interface. """ self.interval = interval super().__init__(s=s, digits=10, digest=hashlib.sha1, name=name, issuer=issuer) @@ -27,7 +30,7 @@ def __init__( def generate_otp(self, input: int) -> str: """ :param input: the HMAC counter value to use as the OTP input. - Usually either the counter, or the computed integer based on the Unix timestamp + Usually either the counter, or the computed integer based on the Unix timestamp. """ str_code = super().generate_otp(input) int_code = int(str_code) diff --git a/src/pyotp/hotp.py b/src/pyotp/hotp.py index c0276cf..8dc7c3e 100644 --- a/src/pyotp/hotp.py +++ b/src/pyotp/hotp.py @@ -11,13 +11,13 @@ class HOTP(OTP): """ def __init__( - self, - s: str, - digits: int = 6, - digest: Any = None, - name: Optional[str] = None, - issuer: Optional[str] = None, - initial_count: int = 0, + self, + s: str, + digits: int = 6, + digest: Any = None, + name: Optional[str] = None, + issuer: Optional[str] = None, + initial_count: int = 0, ) -> None: """ :param s: secret in base32 format @@ -39,8 +39,8 @@ def at(self, count: int) -> str: """ Generates the OTP for the given count. - :param count: the OTP HMAC counter - :returns: OTP + :param count: the OTP HMAC counter. + :returns: OTP instance. """ return self.generate_otp(self.initial_count + count) @@ -48,17 +48,18 @@ def verify(self, otp: str, counter: int) -> bool: """ Verifies the OTP passed in against the current counter OTP. - :param otp: the OTP to check against - :param counter: the OTP HMAC counter + :param otp: the OTP to check against. + :param counter: the OTP HMAC counter. """ return utils.strings_equal(str(otp), str(self.at(counter))) def provisioning_uri( - self, - name: Optional[str] = None, - initial_count: Optional[int] = None, - issuer_name: Optional[str] = None, - **kwargs, + self, + name: Optional[str] = None, + initial_count: Optional[int] = None, + issuer_name: Optional[str] = None, + image: Optional[str] = None, + **kwargs, ) -> str: """ Returns the provisioning URI for the OTP. This can then be @@ -68,11 +69,12 @@ def provisioning_uri( See also: https://github.com/google/google-authenticator/wiki/Key-Uri-Format - :param name: name of the user account - :param initial_count: starting HMAC counter value, defaults to 0 + :param name: name of the user account. + :param initial_count: starting HMAC counter value, defaults to 0. :param issuer_name: the name of the OTP issuer; this will be the - organization title of the OTP entry in Authenticator - :returns: provisioning URI + organization title of the OTP entry in Authenticator. + :param image: the URL of the image to be displayed in the OTP. + :returns: provisioning URI. """ return utils.build_uri( self.secret, @@ -81,5 +83,6 @@ def provisioning_uri( issuer=issuer_name if issuer_name else self.issuer, algorithm=self.digest().name, digits=self.digits, + image=image, **kwargs, ) diff --git a/src/pyotp/otp.py b/src/pyotp/otp.py index 6420018..6b0134a 100644 --- a/src/pyotp/otp.py +++ b/src/pyotp/otp.py @@ -10,12 +10,12 @@ class OTP(object): """ def __init__( - self, - s: str, - digits: int = 6, - digest: Any = hashlib.sha1, - name: Optional[str] = None, - issuer: Optional[str] = None, + self, + s: str, + digits: int = 6, + digest: Any = hashlib.sha1, + name: Optional[str] = None, + issuer: Optional[str] = None, ) -> None: self.digits = digits if digits > 10: @@ -30,7 +30,7 @@ def __init__( def generate_otp(self, input: int) -> str: """ :param input: the HMAC counter value to use as the OTP input. - Usually either the counter, or the computed integer based on the Unix timestamp + Usually either the counter, or the computed integer based on the Unix timestamp. """ if input < 0: raise ValueError("input must be positive integer") @@ -40,19 +40,22 @@ def generate_otp(self, input: int) -> str: hmac_hash = bytearray(hasher.digest()) offset = hmac_hash[-1] & 0xF code = ( - (hmac_hash[offset] & 0x7F) << 24 - | (hmac_hash[offset + 1] & 0xFF) << 16 - | (hmac_hash[offset + 2] & 0xFF) << 8 - | (hmac_hash[offset + 3] & 0xFF) + (hmac_hash[offset] & 0x7F) << 24 + | (hmac_hash[offset + 1] & 0xFF) << 16 + | (hmac_hash[offset + 2] & 0xFF) << 8 + | (hmac_hash[offset + 3] & 0xFF) ) - str_code = str(10_000_000_000 + (code % 10**self.digits)) - return str_code[-self.digits :] + str_code = str(10_000_000_000 + (code % 10 ** self.digits)) + return str_code[-self.digits:] def byte_secret(self) -> bytes: + """Decode a base32-encoded secret into its raw byte representation.""" secret = self.secret missing_padding = len(secret) % 8 if missing_padding != 0: secret += "=" * (8 - missing_padding) + + # `casefold=True`, which allows the decoder to process both uppercase and lowercase characters. return base64.b32decode(secret, casefold=True) @staticmethod @@ -60,7 +63,7 @@ def int_to_bytestring(i: int, padding: int = 8) -> bytes: """ Turns an integer to the OATH specified bytestring, which is fed to the HMAC - along with the secret + along with the secret. """ result = bytearray() while i != 0: diff --git a/src/pyotp/totp.py b/src/pyotp/totp.py index 9908d55..b67c319 100644 --- a/src/pyotp/totp.py +++ b/src/pyotp/totp.py @@ -14,20 +14,20 @@ class TOTP(OTP): """ def __init__( - self, - s: str, - digits: int = 6, - digest: Any = None, - name: Optional[str] = None, - issuer: Optional[str] = None, - interval: int = 30, + self, + s: str, + digits: int = 6, + digest: Any = None, + name: Optional[str] = None, + issuer: Optional[str] = None, + interval: int = 30, ) -> None: """ - :param s: secret in base32 format + :param s: secret in base32 format. :param digits: number of integers in the OTP. Some apps expect this to be 6 digits, others support more. - :param digest: digest function to use in the HMAC (expected to be SHA1) - :param name: account name - :param issuer: issuer + :param digest: digest function to use in the HMAC (expected to be SHA1). + :param name: account name. + :param issuer: issuer. :param interval: the time interval in seconds for OTP. This defaults to 30. """ if digest is None: @@ -49,9 +49,9 @@ def at(self, for_time: Union[int, datetime.datetime], counter_offset: int = 0) - totp = pyotp.TOTP(...) time_remaining = totp.interval - datetime.datetime.now().timestamp() % totp.interval - :param for_time: the time to generate an OTP for - :param counter_offset: the amount of ticks to add to the time counter - :returns: OTP value + :param for_time: the time to generate an OTP for. + :param counter_offset: the amount of ticks to add to the time counter. + :returns: OTP value. """ if not isinstance(for_time, datetime.datetime): for_time = datetime.datetime.fromtimestamp(int(for_time)) @@ -59,9 +59,9 @@ def at(self, for_time: Union[int, datetime.datetime], counter_offset: int = 0) - def now(self) -> str: """ - Generate the current time OTP + Generate the current time OTP. - :returns: OTP value + :returns: OTP value. """ return self.generate_otp(self.timecode(datetime.datetime.now())) @@ -69,10 +69,10 @@ def verify(self, otp: str, for_time: Optional[datetime.datetime] = None, valid_w """ Verifies the OTP passed in against the current time OTP. - :param otp: the OTP to check against - :param for_time: Time to check OTP at (defaults to now) - :param valid_window: extends the validity to this many counter ticks before and after the current one - :returns: True if verification succeeded, False otherwise + :param otp: the OTP to check against. + :param for_time: Time to check OTP at (defaults to now). + :param valid_window: extends the validity to this many counter ticks before and after the current one. + :returns: True if verification succeeded, False otherwise. """ if for_time is None: for_time = datetime.datetime.now() @@ -85,7 +85,8 @@ def verify(self, otp: str, for_time: Optional[datetime.datetime] = None, valid_w return utils.strings_equal(str(otp), str(self.at(for_time))) - def provisioning_uri(self, name: Optional[str] = None, issuer_name: Optional[str] = None, **kwargs) -> str: + def provisioning_uri(self, name: Optional[str] = None, issuer_name: Optional[str] = None, + image: Optional[str] = None, **kwargs) -> str: """ Returns the provisioning URI for the OTP. This can then be encoded in a QR Code and used to provision an OTP app like @@ -93,7 +94,12 @@ def provisioning_uri(self, name: Optional[str] = None, issuer_name: Optional[str See also: https://github.com/google/google-authenticator/wiki/Key-Uri-Format - + :param name: name of the user account. + :param issuer_name: the name of the OTP issuer; this will be the + organization title of the OTP entry in Authenticator. + :param image: the URL of the image to be displayed in the OTP. + :param kwargs: other query string parameters to include in the URI. + :returns: provisioning URI. """ return utils.build_uri( self.secret, @@ -102,6 +108,7 @@ def provisioning_uri(self, name: Optional[str] = None, issuer_name: Optional[str algorithm=self.digest().name, digits=self.digits, period=self.interval, + image=image, **kwargs, ) @@ -110,7 +117,6 @@ def timecode(self, for_time: datetime.datetime) -> int: Accepts either a timezone naive (`for_time.tzinfo is None`) or a timezone aware datetime as argument and returns the corresponding counter value (timecode). - """ if for_time.tzinfo: return int(calendar.timegm(for_time.utctimetuple()) / self.interval) diff --git a/src/pyotp/utils.py b/src/pyotp/utils.py index ff14d8f..748b336 100644 --- a/src/pyotp/utils.py +++ b/src/pyotp/utils.py @@ -1,18 +1,20 @@ -import unicodedata from hmac import compare_digest from typing import Dict, Optional, Union from urllib.parse import quote, urlencode, urlparse +import unicodedata + def build_uri( - secret: str, - name: str, - initial_count: Optional[int] = None, - issuer: Optional[str] = None, - algorithm: Optional[str] = None, - digits: Optional[int] = None, - period: Optional[int] = None, - **kwargs, + secret: str, + name: str, + initial_count: Optional[int] = None, + issuer: Optional[str] = None, + algorithm: Optional[str] = None, + digits: Optional[int] = None, + period: Optional[int] = None, + image: Optional[str] = None, + **kwargs, ) -> str: """ Returns the provisioning URI for the OTP; works for either TOTP or HOTP. @@ -25,18 +27,19 @@ def build_uri( See also: https://github.com/google/google-authenticator/wiki/Key-Uri-Format - :param secret: the hotp/totp secret used to generate the URI - :param name: name of the account + :param secret: the hotp/totp secret used to generate the URI. + :param name: name of the account. :param initial_count: starting counter value, defaults to None. If none, the OTP type will be assumed as TOTP. :param issuer: the name of the OTP issuer; this will be the - organization title of the OTP entry in Authenticator + organization title of the OTP entry in Authenticator. :param algorithm: the algorithm used in the OTP generation. :param digits: the length of the OTP generated code. :param period: the number of seconds the OTP generator is set to expire every code. - :param kwargs: other query string parameters to include in the URI - :returns: provisioning uri + :param image: the URL of the image to be displayed in the OTP. + :param kwargs: other query string parameters to include in the URI. + :returns: provisioning uri. """ # initial_count may be 0 as a valid param is_initial_count_present = initial_count is not None @@ -64,13 +67,16 @@ def build_uri( url_args["digits"] = digits if is_period_set: url_args["period"] = period + + if image is not None: + image_uri = urlparse(image) + if image_uri.scheme != "https" or not image_uri.netloc or not image_uri.path: + raise ValueError("{} is not a valid url".format(image_uri)) + url_args["image"] = image + for k, v in kwargs.items(): if not isinstance(v, str): raise ValueError("All otpauth uri parameters must be strings") - if k == "image": - image_uri = urlparse(v) - if image_uri.scheme != "https" or not image_uri.netloc or not image_uri.path: - raise ValueError("{} is not a valid url".format(image_uri)) url_args[k] = v uri = base_uri.format(otp_type, label, urlencode(url_args).replace("+", "%20"))