diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2019-11-10 14:56:25 -0500 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2019-11-10 14:56:25 -0500 |
commit | 8c3470170628cbf6b18b48d95a69800b79b327ec (patch) | |
tree | 0ad961aec90b15c9e1e849c1f21c1849ca4c2191 | |
parent | c8b36453db3bcfb70017fe79d50c4c461d18e161 (diff) | |
parent | a945d60e814337e668c647a043bfd6adcbd9d47e (diff) | |
download | passlib-8c3470170628cbf6b18b48d95a69800b79b327ec.tar.gz |
Merge from stable
-rwxr-xr-x | admin/upload.sh | 2 | ||||
-rw-r--r-- | docs/conf.py | 37 | ||||
-rw-r--r-- | docs/history/1.7.rst | 44 | ||||
-rw-r--r-- | docs/index.rst | 8 | ||||
-rw-r--r-- | docs/install.rst | 6 | ||||
-rw-r--r-- | docs/lib/passlib.hash.bcrypt.rst | 4 | ||||
-rw-r--r-- | docs/lib/passlib.hash.bcrypt_sha256.rst | 8 | ||||
-rw-r--r-- | docs/lib/passlib.pwd.rst | 20 | ||||
-rw-r--r-- | docs/modular_crypt_format.rst | 5 | ||||
-rw-r--r-- | passlib/handlers/argon2.py | 287 | ||||
-rw-r--r-- | passlib/pwd.py | 7 | ||||
-rw-r--r-- | passlib/tests/test_handlers.py | 6 | ||||
-rw-r--r-- | passlib/tests/test_handlers_argon2.py | 149 | ||||
-rw-r--r-- | passlib/tests/test_totp.py | 6 | ||||
-rw-r--r-- | passlib/tests/utils.py | 58 | ||||
-rw-r--r-- | passlib/totp.py | 30 | ||||
-rw-r--r-- | passlib/utils/__init__.py | 24 | ||||
-rw-r--r-- | passlib/utils/handlers.py | 23 | ||||
-rw-r--r-- | setup.py | 2 |
19 files changed, 593 insertions, 133 deletions
diff --git a/admin/upload.sh b/admin/upload.sh index 8b6c321..be3b047 100755 --- a/admin/upload.sh +++ b/admin/upload.sh @@ -47,7 +47,7 @@ if [ -z "$SKIP_PYPI" ]; then # upload docs echo "\n$SEP1\nbuilding and uploading docs to pypi\n$SEP2" - PASSLIB_DOCS="for-pypi" python setup.py build_sphinx $UPLOAD_DOCS_ARG + SPHINX_BUILD_TAGS="for-pypi" python setup.py build_sphinx $UPLOAD_DOCS_ARG fi diff --git a/docs/conf.py b/docs/conf.py index 6883743..ab34062 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,6 +6,11 @@ This file is execfile()d with the current directory set to its containing dir. Note that not all possible configuration values are present in this autogenerated file. All configuration values have a default; values that are commented out serve to show the default. + +This honors the following sphinx tags (passed via -t or $SPHINX_BUILD_TAGS): + + * for-pypi -- generate special version to upload to pypi + """ #============================================================================= # environment setup @@ -31,9 +36,10 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, import datetime -# build option flags: -# "for-pypi" -- enable analytics tracker for pypi documentation -options = os.environ.get("PASSLIB_DOCS", "").split(",") +# read env var for tags, needed since "python setup.py build_sphinx" +# doesn't support sphinx-build's "-t" option. +for _tag in os.environ.get("SPHINX_BUILD_TAGS", "").split(): + tags.add(_tag) # building the docs requires the Cloud Sphinx theme & extensions (>= v1.4), # which contains some sphinx extensions used by Passlib. @@ -45,7 +51,7 @@ import cloud_sptheme as csp #============================================================================= # If your documentation needs a minimal Sphinx version, state it here. -needs_sphinx = '1.3' +needs_sphinx = '1.4' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. @@ -109,8 +115,6 @@ from passlib import __version__ as release version = csp.get_version(release) if ".dev" in release: tags.add("devcopy") -if 'for-pypi' in options: - tags.add("pypi") # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -199,11 +203,6 @@ if csp.is_cloud_theme(html_theme): toc_local_bg_color='#FFE8C4', toc_local_trim_color='#FFC68A', ) - if 'for-pypi' in options: - html_theme_options.update( - googleanalytics_id = 'UA-22302196-2', - googleanalytics_path = '/passlib/', - ) # Add any paths that contain custom themes here, relative to this directory. html_theme_path = [csp.get_theme_dir()] @@ -274,6 +273,22 @@ html_sidebars = {'**': ['searchbox.html', 'globaltoc.html']} htmlhelp_basename = project + 'Doc' #============================================================================= +# site-specific html output +#============================================================================= +if tags.has("for-pypi"): + + extensions.append('cloud_sptheme.ext.auto_redirect') + auto_redirect_domain_url = "https://passlib.readthedocs.io" + auto_redirect_domain_root = "/en/stable" + + if csp.is_cloud_theme(html_theme): + + html_theme_options.update( + googleanalytics_id = 'UA-22302196-2', + googleanalytics_path = '/passlib/', + ) + +#============================================================================= # Options for LaTeX output #============================================================================= diff --git a/docs/history/1.7.rst b/docs/history/1.7.rst index 14e115f..632e4e4 100644 --- a/docs/history/1.7.rst +++ b/docs/history/1.7.rst @@ -2,6 +2,50 @@ Passlib 1.7 =========== +**1.7.2** (NOT YET RELEASED) +============================ + +This release rolls up assorted bug & compatibility fixes since 1.7.1. + +New Features +------------ + +* .. py:currentmodule:: passlib.hash + + :class:`argon2`: Now supports Argon2 "ID" and "D" hashes (assuming new enough backend library). + Now defaults to "ID" hashes instead of "I" hashes, but this can be overridden via ``type`` keyword. + (:issue:`101`) + +Bugfixes +-------- + +* Python 3.8 compatibility fixes + +* .. py:currentmodule:: passlib.totp + + :mod:`passlib.totp`: The :meth:`TOTP.to_uri` method now prepends the issuer to URI label, + (per the KeyURI spec). This should fix some compatibility issues with older TOTP clients + (:issue:`92`) + +* .. py:currentmodule:: passlib.hash + + Fixed error in :meth:`argon2.parsehash` (:issue:`97`) + +* **unittests**: ``crypt()`` unittests now account for linux systems running libxcrypt + (such as recent Fedora releases) + +Other Changes +------------- + +* **setup.py**: now honors ``$SOURCE_DATE_EPOCH`` to help with reproducible builds + +* .. py:currentmodule:: passlib.hash + + :class:`argon2`: Now throws helpful error if "argon2" package is actually an incompatible + or supported version of argon2_cffi (:issue:`99`). + +* **documentation**: Various updates & corrections. + **1.7.1** (2017-1-30) ===================== diff --git a/docs/index.rst b/docs/index.rst index a055828..c3cad52 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,14 +19,6 @@ Passlib |release| documentation For documentation of the latest stable version, see `<https://passlib.readthedocs.io>`_. -.. only:: pypi - - .. warning:: - - The official Passlib documentation have moved to `<https://passlib.readthedocs.io>`_. - Documentation at this location is still being maintained, - but will be updated much less frequently. - Welcome ======= Passlib is a password hashing library for Python 2 & 3, which provides diff --git a/docs/install.rst b/docs/install.rst index dfe40d6..00cab65 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -44,8 +44,8 @@ Optional Libraries Use ``pip install passlib[bcrypt]`` to get the recommended bcrypt setup. -* `argon2_cffi <https://pypi.python.org/pypi/argon2_cffi>`_, or - `argon2pure <https://pypi.python.org/pypi/argon2pure>`_ (>= 1.2.2) +* `argon2_cffi <https://pypi.python.org/pypi/argon2_cffi>`_ (>= 18.2.0), or + `argon2pure <https://pypi.python.org/pypi/argon2pure>`_ (>= 1.3) If any of these packages are installed, they will be used to provide support for the :class:`~passlib.hash.argon2` hash algorithm. @@ -139,7 +139,7 @@ If you wish to generate your own copy of the documentation, you will need to: 1. Install `Sphinx <http://sphinx.pocoo.org/>`_ (1.4 or newer) -2. Install the `Cloud Sphinx Theme <http://packages.python.org/cloud_sptheme>`_ (1.8.2 or newer). +2. Install the `Cloud Sphinx Theme <http://packages.python.org/cloud_sptheme>`_ (1.9.2 or newer). 3. Download the Passlib source 4. From the Passlib source directory, run :samp:`python setup.py build_sphinx`. 5. Once Sphinx completes its run, point a web browser to the file at :samp:`{SOURCE}/build/sphinx/html/index.html` diff --git a/docs/lib/passlib.hash.bcrypt.rst b/docs/lib/passlib.hash.bcrypt.rst index c7c5951..e116983 100644 --- a/docs/lib/passlib.hash.bcrypt.rst +++ b/docs/lib/passlib.hash.bcrypt.rst @@ -19,8 +19,8 @@ for new applications. This class can be used directly as follows:: '$2a$12$NT0I31Sa7ihGEWpka9ASYrEFkhuTNeBQ2xfZskIiiJeyFXhRgS.Sy' >>> # the same, but with an explicit number of rounds - >>> bcrypt.using(rounds=8).hash("password") - '$2a$08$8wmNsdCH.M21f.LSBSnYjQrZ9l1EmtBc9uNPGL.9l75YE8D8FlnZC' + >>> bcrypt.using(rounds=13).hash("password") + '$2b$13$HMQTprwhaUwmir.g.ZYoXuRJhtsbra4uj.qJPHrKsX5nGlhpts0jm' >>> # verify password >>> bcrypt.verify("password", h) diff --git a/docs/lib/passlib.hash.bcrypt_sha256.rst b/docs/lib/passlib.hash.bcrypt_sha256.rst index c9d2870..20ef5ab 100644 --- a/docs/lib/passlib.hash.bcrypt_sha256.rst +++ b/docs/lib/passlib.hash.bcrypt_sha256.rst @@ -21,13 +21,13 @@ This class can be used directly as follows:: '$bcrypt-sha256$2a,12$LrmaIX5x4TRtAwEfwJZa1.$2ehnw6LvuIUTM0iz4iz9hTxv21B6KFO' >>> # the same, but with an explicit number of rounds - >>> bcrypt.using(rounds=8).hash("password") - '$bcrypt-sha256$2a,8$UE3dIZ.0I6XZtA/LdMrrle$Ag04/5zYu./12.OSqInXZnJ.WZoh1ua' + >>> bcrypt_sha256.using(rounds=13).hash("password") + '$bcrypt-sha256$2b,13$Mant9jKTadXYyFh7xp1W5.$J8xpPZR/HxH7f1vRCNUjBI7Ev1al0hu' >>> # verify password - >>> bcrypt.verify("password", h) + >>> bcrypt_sha256.verify("password", h) True - >>> bcrypt.verify("wrong", h) + >>> bcrypt_sha256.verify("wrong", h) False .. note:: diff --git a/docs/lib/passlib.pwd.rst b/docs/lib/passlib.pwd.rst index de4ee50..bb36844 100644 --- a/docs/lib/passlib.pwd.rst +++ b/docs/lib/passlib.pwd.rst @@ -43,20 +43,10 @@ but are exported by this module for general use: Password Strength Estimation ============================ -Passlib does not current offer any password strength estimation routines. +Passlib does not currently offer any password strength estimation routines. However, the (javascript-based) `zxcvbn <https://github.com/dropbox/zxcvbn>`_ -project is a very good choice. There are a few python ports of ZCVBN library, though as of 2016-11, -none of them seem active and up to date. +project is a *very* good choice. -The following is a list of known ZCVBN python ports, though it's not clear which of these -is active and/or official: - -* https://github.com/dropbox/python-zxcvbn -- seemingly official python version, - but not updated since 2013, and not published on pypi. - -* https://github.com/rpearl/python-zxcvbn -- fork of official version, - also not updated since 2013, but released to pypi as `"zxcvbn" <https://pypi.python.org/pypi/zxcvbn>`_. - -* https://github.com/gordon86/python-zxcvbn -- fork that has some updates as of july 2015, - released to pypi as `"zxcvbn-py3" <https://pypi.python.org/pypi/zxcvbn-py3>`_ (and compatible - with 2 & 3, despite the name). +Though there are a few different python ports of ZXCVBN library, as of 2019-3-4, +`zxcvbn <https://pypi.python.org/pypi/zxcvbn>` is the most up-to-date, +and is endorsed by the upstream zxcvbn developers. diff --git a/docs/modular_crypt_format.rst b/docs/modular_crypt_format.rst index c70933b..a734bef 100644 --- a/docs/modular_crypt_format.rst +++ b/docs/modular_crypt_format.rst @@ -174,6 +174,11 @@ and indicates which operating systems offer native support: :class:`~passlib.hash.sha1_crypt` ``$sha1$`` y ==================================== ==================== =========== =========== =========== =========== ======= +.. note:: + + Linux systems using `libxcrypt <https://github.com/besser82/libxcrypt>`_ instead of ``libcrypt`` + will have native support for additional formats, including nearly all those listed above. + Additional Platforms -------------------- The modular crypt format is also supported to some degree diff --git a/passlib/handlers/argon2.py b/passlib/handlers/argon2.py index 1844eff..1f03549 100644 --- a/passlib/handlers/argon2.py +++ b/passlib/handlers/argon2.py @@ -28,9 +28,9 @@ _argon2pure = None # dynamically imported by _load_backend_argon2pure() # pkg from passlib import exc from passlib.crypto.digest import MAX_UINT32 -from passlib.utils import to_bytes +from passlib.utils import classproperty, to_bytes from passlib.utils.binary import b64s_encode, b64s_decode -from passlib.utils.compat import unicode, bascii_to_str +from passlib.utils.compat import unicode, bascii_to_str, PY2 import passlib.utils.handlers as uh # local __all__ = [ @@ -38,34 +38,76 @@ __all__ = [ ] #============================================================================= +# helpers +#============================================================================= + +# NOTE: when adding a new argon2 hash type, need to do the following: +# * add TYPE_XXX constant, and add to ALL_TYPES +# * make sure "_backend_type_map" constructors handle it correctly for all backends +# * make sure _hash_regex & _ident_regex (below) support type string. +# * add reference vectors for testing. + +#: argon2 type constants -- subclasses handle mapping these to backend-specific type constants. +#: (should be lowercase, to match representation in hash string) +TYPE_I = u"i" +TYPE_D = u"d" +TYPE_ID = u"id" # new 2016-10-29; passlib 1.7.2 requires backends new enough for support + +#: list of all known types; first (supported) type will be used as default. +ALL_TYPES = (TYPE_ID, TYPE_I, TYPE_D) +ALL_TYPES_SET = set(ALL_TYPES) + +#============================================================================= # import argon2 package (https://pypi.python.org/pypi/argon2_cffi) #============================================================================= -# import package +# import cffi package +# NOTE: we try to do this even if caller is going to use argon2pure, +# so that we can always use the libargon2 default settings when possible. +_argon2_cffi_error = None try: import argon2 as _argon2_cffi except ImportError: _argon2_cffi = None - -# get default settings for hasher -_PasswordHasher = getattr(_argon2_cffi, "PasswordHasher", None) -if _PasswordHasher: - # we have argon2_cffi >= 16.0, use their default hasher settings - _default_settings = _PasswordHasher() +else: + if not hasattr(_argon2_cffi, "Type"): + # they have incompatible "argon2" package installed, instead of "argon2_cffi" package. + _argon2_cffi_error = ( + "'argon2' module points to unsupported 'argon2' pypi package; " + "please install 'argon2-cffi' instead." + ) + _argon2_cffi = None + elif not hasattr(_argon2_cffi, "low_level"): + # they have pre-v16 argon2_cffi package + _argon2_cffi_error = "'argon2-cffi' is too old, please update to argon2_cffi >= 18.2.0" + _argon2_cffi = None + +# init default settings for our hasher class -- +# if we have argon2_cffi >= 16.0, use their default hasher settings, otherwise use static default +if hasattr(_argon2_cffi, "PasswordHasher"): + # use cffi's default settings + _default_settings = _argon2_cffi.PasswordHasher() _default_version = _argon2_cffi.low_level.ARGON2_VERSION else: - # use these as our fallback settings (for no backend, or argon2pure) - class _default_settings: + # use fallback settings (for no backend, or argon2pure) + class _DummyCffiHasher: """ - dummy object to use as source of defaults when argon2 mod not present. - synced w/ argon2 16.1 as of 2016-6-16 + dummy object to use as source of defaults when argon2_cffi isn't present. + this tries to mimic the attributes of ``argon2.PasswordHasher()`` which the rest of + this module reads. + + .. note:: values last synced w/ argon2 19.2 as of 2019-11-09 """ time_cost = 2 memory_cost = 512 parallelism = 2 salt_len = 16 hash_len = 16 - _default_version = 0x13 + # NOTE: "type" attribute added in argon2_cffi v18.2; but currently not reading it + # type = _argon2_cffi.Type.ID + + _default_settings = _DummyCffiHasher() + _default_version = 0x13 # v1.9 #============================================================================= # handler @@ -99,6 +141,7 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, "parallelism", "digest_size", "hash_len", # 'digest_size' alias for compat w/ argon2 package + "type", # the type of argon2 hash used ) # TODO: could support the optional 'data' parameter, @@ -109,11 +152,19 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, #------------------------ # GenericHandler #------------------------ - ident = u"$argon2i" + + # NOTE: ident -- all argon2 hashes start with "$argon2<type>$" + # XXX: could programmaticaly generate "ident_values" string from ALL_TYPES above + checksum_size = _default_settings.hash_len - # NOTE: from_string() relies on the ordering of these... - ident_values = (u"$argon2i$", u"$argon2d$") + #: force parsing these kwds + _always_parse_settings = uh.GenericHandler._always_parse_settings + \ + ("type",) + + #: exclude these kwds from parsehash() result (most are aliases for other keys) + _unparsed_settings = uh.GenericHandler._unparsed_settings + \ + ("salt_len", "time_cost", "hash_len", "digest_size") #------------------------ # HasSalt @@ -159,10 +210,29 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, #: rather than subprocesses. pure_use_threads = False + #: internal helper used to store mapping of TYPE_XXX constants -> backend-specific type constants; + #: this is populated by _load_backend_mixin(); and used to detect which types are supported. + #: XXX: could expose keys as class-level .supported_types property? + _backend_type_map = {} + + @classproperty + def type_values(cls): + """ + return tuple of types supported by this backend + + .. versionadded:: 1.7.2 + """ + cls.get_backend() # make sure backend is loaded + return tuple(cls._backend_type_map) + #=================================================================== # instance attrs #=================================================================== + #: argon2 hash type, one of ALL_TYPES -- class value controls the default + #: .. versionadded:: 1.7.2 + type = TYPE_ID + #: parallelism setting -- class value controls the default parallelism = _default_settings.parallelism @@ -173,8 +243,14 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, #: memory cost -- class value controls the default memory_cost = _default_settings.memory_cost - #: flag indicating a Type D hash - type_d = False + @property + def type_d(self): + """ + flag indicating a Type D hash + + .. deprecated:: 1.7.2; will be removed in passlib 2.0 + """ + return self.type == TYPE_D #: optional secret data data = None @@ -184,7 +260,7 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, #=================================================================== @classmethod - def using(cls, memory_cost=None, salt_len=None, time_cost=None, digest_size=None, + def using(cls, type=None, memory_cost=None, salt_len=None, time_cost=None, digest_size=None, checksum_size=None, hash_len=None, max_threads=None, **kwds): # support aliases which match argon2 naming convention if time_cost is not None: @@ -210,6 +286,10 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, # create variant subcls = super(_Argon2Common, cls).using(**kwds) + # set type + if type is not None: + subcls.type = subcls._norm_type(type) + # set checksum size relaxed = kwds.get("relaxed") if digest_size is not None: @@ -254,10 +334,13 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, # public api #=================================================================== + #: shorter version of _hash_regex, used to quickly identify hashes + _ident_regex = re.compile(r"^\$argon2[a-z]+\$") + @classmethod def identify(cls, hash): hash = uh.to_unicode_for_identify(hash) - return hash.startswith(cls.ident_values) + return cls._ident_regex.match(hash) is not None # hash(), verify(), genhash() -- implemented by backend subclass @@ -282,7 +365,7 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, #: regex to parse argon hash _hash_regex = re.compile(br""" ^ - \$argon2(?P<type>[id])\$ + \$argon2(?P<type>[a-z]+)\$ (?: v=(?P<version>\d+) \$ @@ -312,6 +395,7 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, @classmethod def from_string(cls, hash): # NOTE: assuming hash will be unicode, or use ascii-compatible encoding. + # TODO: switch to working w/ str or unicode if isinstance(hash, unicode): hash = hash.encode("utf-8") if not isinstance(hash, bytes): @@ -322,11 +406,10 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, type, version, memory_cost, time_cost, parallelism, keyid, data, salt, digest = \ m.group("type", "version", "memory_cost", "time_cost", "parallelism", "keyid", "data", "salt", "digest") - assert type in [b"i", b"d"], "unexpected type code: %r" % (type,) if keyid: raise NotImplementedError("argon2 'keyid' parameter not supported") return cls( - type_d=(type == b"d"), + type=type.decode("ascii"), version=int(version) if version else 0x10, memory_cost=int(memory_cost), rounds=int(time_cost), @@ -337,28 +420,41 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, ) def to_string(self): - ident = str(self.ident_values[self.type_d]) version = self.version if version == 0x10: vstr = "" else: vstr = "v=%d$" % version + data = self.data if data: kdstr = ",data=" + bascii_to_str(b64s_encode(self.data)) else: kdstr = "" + # NOTE: 'keyid' param currently not supported - return "%s%sm=%d,t=%d,p=%d%s$%s$%s" % (ident, vstr, self.memory_cost, - self.rounds, self.parallelism, - kdstr, - bascii_to_str(b64s_encode(self.salt)), - bascii_to_str(b64s_encode(self.checksum))) + return "$argon2%s$%sm=%d,t=%d,p=%d%s$%s$%s" % ( + self.type, + vstr, + self.memory_cost, + self.rounds, + self.parallelism, + kdstr, + bascii_to_str(b64s_encode(self.salt)), + bascii_to_str(b64s_encode(self.checksum)), + ) #=================================================================== # init #=================================================================== - def __init__(self, type_d=False, version=None, memory_cost=None, data=None, **kwds): + def __init__(self, type=None, type_d=False, version=None, memory_cost=None, data=None, **kwds): + + # handle deprecated kwds + if type_d: + warn('argon2 `type_d=True` keyword is deprecated, and will be removed in passlib 2.0; ' + 'please use ``type="d"`` instead') + assert type is None + type = TYPE_D # TODO: factor out variable checksum size support into a mixin. # set checksum size to specific value before _norm_checksum() is called @@ -370,8 +466,10 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, super(_Argon2Common, self).__init__(**kwds) # init type - # NOTE: we don't support *generating* type I hashes, but do support verifying them. - self.type_d = type_d + if type is None: + assert uh.validate_default_value(self, self.type, self._norm_type, param="type") + else: + self.type = self._norm_type(type) # init version if version is None: @@ -400,6 +498,27 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, #------------------------------------------------------------------- @classmethod + def _norm_type(cls, value): + # type check + if not isinstance(value, unicode): + if PY2 and isinstance(value, bytes): + value = value.decode('ascii') + else: + raise uh.exc.ExpectedTypeError(value, "str", "type") + + # check if type is valid + if value in ALL_TYPES_SET: + return value + + # translate from uppercase + temp = value.lower() + if temp in ALL_TYPES_SET: + return temp + + # failure! + raise ValueError("unknown argon2 hash type: %r" % (value,)) + + @classmethod def _norm_version(cls, version): if not isinstance(version, uh.int_types): raise uh.exc.ExpectedTypeError(version, "integer", "version") @@ -427,14 +546,27 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, # NOTE: _calc_checksum implemented by backend subclass + @classmethod + def _get_backend_type(cls, value): + """ + helper to resolve backend constant from type + """ + try: + return cls._backend_type_map[value] + except KeyError: + pass + # XXX: pick better error class? + msg = "unsupported argon2 hash (type %r not supported by %s backend)" % \ + (value, cls.get_backend()) + raise ValueError(msg) + #=================================================================== # hash migration #=================================================================== def _calc_needs_update(self, **kwds): cls = type(self) - if self.type_d: - # type 'd' hashes shouldn't be used for passwords. + if self.type != cls.type: return True minver = cls.min_desired_version if minver is None or minver > cls.max_version: @@ -461,11 +593,23 @@ class _Argon2Common(uh.SubclassBackendMixin, uh.ParallelismMixin, invoked after backend imports have been loaded, and performs feature detection & testing common to all backends. """ + # check argon2 version max_version = mixin_cls.max_version assert isinstance(max_version, int) and max_version >= 0x10 if max_version < 0x13: warn("%r doesn't support argon2 v1.3, and should be upgraded" % name, uh.exc.PasslibSecurityWarning) + + # prefer best available type + for type in ALL_TYPES: + if type in mixin_cls._backend_type_map: + mixin_cls.type = type + break + else: + warn("%r lacks support for all known hash types" % name, uh.exc.PasslibRuntimeWarning) + # NOTE: class will just throw "unsupported argon2 hash" error if they try to use it... + mixin_cls.type = TYPE_ID + return True @classmethod @@ -559,12 +703,30 @@ class _CffiBackend(_Argon2Common): @classmethod def _load_backend_mixin(mixin_cls, name, dryrun): + # make sure we write info to base class's __dict__, not that of a subclass + assert mixin_cls is _CffiBackend + # we automatically import this at top, so just grab info if _argon2_cffi is None: + if _argon2_cffi_error: + raise exc.PasslibSecurityError(_argon2_cffi_error) return False max_version = _argon2_cffi.low_level.ARGON2_VERSION log.debug("detected 'argon2_cffi' backend, version %r, with support for 0x%x argon2 hashes", _argon2_cffi.__version__, max_version) + + # build type map + TypeEnum = _argon2_cffi.Type + type_map = {} + for type in ALL_TYPES: + try: + type_map[type] = getattr(TypeEnum, type.upper()) + except AttributeError: + # TYPE_ID support not added until v18.2 + assert type not in (TYPE_I, TYPE_D), "unexpected missing type: %r" % type + mixin_cls._backend_type_map = type_map + + # set version info, and run common setup mixin_cls.version = mixin_cls.max_version = max_version return mixin_cls._finalize_backend_mixin(name, dryrun) @@ -579,7 +741,7 @@ class _CffiBackend(_Argon2Common): # XXX: doesn't seem to be a way to make this honor max_threads try: return bascii_to_str(_argon2_cffi.low_level.hash_secret( - type=_argon2_cffi.low_level.Type.I, + type=cls._get_backend_type(cls.type), memory_cost=cls.memory_cost, time_cost=cls.default_rounds, parallelism=cls.parallelism, @@ -590,19 +752,24 @@ class _CffiBackend(_Argon2Common): except _argon2_cffi.exceptions.HashingError as err: raise cls._adapt_backend_error(err) + #: helper for verify() method below -- maps prefixes to type constants + _byte_ident_map = {b"$argon2%s$" % type.encode("ascii"): type for type in ALL_TYPES} + @classmethod def verify(cls, secret, hash): # TODO: add in 'encoding' support once that's finalized in 1.8 / 1.9. uh.validate_secret(secret) secret = to_bytes(secret, "utf-8") hash = to_bytes(hash, "ascii") - if hash.startswith(b"$argon2d$"): - type = _argon2_cffi.low_level.Type.D - else: - type = _argon2_cffi.low_level.Type.I + + # read type from start of hash + # NOTE: don't care about malformed strings, lowlevel will throw error for us + type = cls._byte_ident_map.get(hash[:1+hash.find(b"$", 1)], TYPE_I) + type_code = cls._get_backend_type(type) + # XXX: doesn't seem to be a way to make this honor max_threads try: - result = _argon2_cffi.low_level.verify_secret(hash, secret, type) + result = _argon2_cffi.low_level.verify_secret(hash, secret, type_code) assert result is True return True except _argon2_cffi.exceptions.VerifyMismatchError: @@ -617,14 +784,10 @@ class _CffiBackend(_Argon2Common): uh.validate_secret(secret) secret = to_bytes(secret, "utf-8") self = cls.from_string(config) - if self.type_d: - type = _argon2_cffi.low_level.Type.D - else: - type = _argon2_cffi.low_level.Type.I # XXX: doesn't seem to be a way to make this honor max_threads try: result = bascii_to_str(_argon2_cffi.low_level.hash_secret( - type=type, + type=cls._get_backend_type(self.type), memory_cost=self.memory_cost, time_cost=self.rounds, parallelism=self.parallelism, @@ -663,6 +826,9 @@ class _PureBackend(_Argon2Common): @classmethod def _load_backend_mixin(mixin_cls, name, dryrun): + # make sure we write info to base class's __dict__, not that of a subclass + assert mixin_cls is _PureBackend + # import argon2pure global _argon2pure try: @@ -686,6 +852,16 @@ class _PureBackend(_Argon2Common): "for adequate security. Installing argon2_cffi (via 'pip install argon2_cffi') " "is strongly recommended", exc.PasslibSecurityWarning) + # build type map + type_map = {} + for type in ALL_TYPES: + try: + type_map[type] = getattr(_argon2pure, "ARGON2" + type.upper()) + except AttributeError: + # TYPE_ID support not added until v1.3 + assert type not in (TYPE_I, TYPE_D), "unexpected missing type: %r" % type + mixin_cls._backend_type_map = type_map + mixin_cls.version = mixin_cls.max_version = max_version return mixin_cls._finalize_backend_mixin(name, dryrun) @@ -702,10 +878,6 @@ class _PureBackend(_Argon2Common): # TODO: add in 'encoding' support once that's finalized in 1.8 / 1.9. uh.validate_secret(secret) secret = to_bytes(secret, "utf-8") - if self.type_d: - type = _argon2pure.ARGON2D - else: - type = _argon2pure.ARGON2I kwds = dict( password=secret, salt=self.salt, @@ -713,7 +885,7 @@ class _PureBackend(_Argon2Common): memory_cost=self.memory_cost, parallelism=self.parallelism, tag_length=self.checksum_size, - type_code=type, + type_code=self._get_backend_type(self.type), version=self.version, ) if self.max_threads > 0: @@ -737,13 +909,19 @@ class _PureBackend(_Argon2Common): class argon2(_NoBackend, _Argon2Common): """ This class implements the Argon2 password hash [#argon2-home]_, and follows the :ref:`password-hash-api`. - (This class only supports generating "Type I" argon2 hashes). Argon2 supports a variable-length salt, and variable time & memory cost, and a number of other configurable parameters. The :meth:`~passlib.ifc.PasswordHash.replace` method accepts the following optional keywords: + :type type: str + :param type: + Specify the type of argon2 hash to generate. + Can be one of "ID", "I", "D". + + This defaults to "ID" if supported by the backend, otherwise "I". + :type salt: str :param salt: Optional salt string. @@ -790,6 +968,11 @@ class argon2(_NoBackend, _Argon2Common): will be issued instead. Correctable errors include ``rounds`` that are too small or too large, and ``salt`` strings that are too long. + .. versionchanged:: 1.7.2 + + Added the "type" keyword, and support for type "D" and "ID" hashes. + (Prior versions could verify type "D" hashes, but not generate them). + .. todo:: * Support configurable threading limits. diff --git a/passlib/pwd.py b/passlib/pwd.py index 52e1e64..12d6ecb 100644 --- a/passlib/pwd.py +++ b/passlib/pwd.py @@ -5,7 +5,12 @@ from __future__ import absolute_import, division, print_function, unicode_literals # core import codecs -from collections import defaultdict, MutableMapping +from collections import defaultdict +try: + from collections.abc import MutableMapping +except ImportError: + # py2 compat + from collections import MutableMapping from math import ceil, log as logf import logging; log = logging.getLogger(__name__) import pkg_resources diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py index 5e613fb..291170a 100644 --- a/passlib/tests/test_handlers.py +++ b/passlib/tests/test_handlers.py @@ -176,7 +176,8 @@ class _bsdi_crypt_test(HandlerCase): platform_crypt_support = [ ("freebsd|openbsd|netbsd|darwin", True), - ("linux|solaris", False), + ("solaris", False), + # linux - may be present in libxcrypt ] def test_77_fuzz_input(self, **kwds): @@ -1253,7 +1254,8 @@ class _sha1_crypt_test(HandlerCase): platform_crypt_support = [ ("netbsd", True), - ("freebsd|openbsd|linux|solaris|darwin", False), + ("freebsd|openbsd|solaris|darwin", False), + # linux - may be present in libxcrypt ] # create test cases for specific backends diff --git a/passlib/tests/test_handlers_argon2.py b/passlib/tests/test_handlers_argon2.py index 5e9af43..e771769 100644 --- a/passlib/tests/test_handlers_argon2.py +++ b/passlib/tests/test_handlers_argon2.py @@ -5,10 +5,12 @@ # core import logging log = logging.getLogger(__name__) +import re import warnings # site # pkg from passlib import hash +from passlib.utils.compat import unicode from passlib.tests.utils import HandlerCase, TEST_MODE from passlib.tests.test_handlers import UPASS_TABLE, PASS_TABLE_UTF8 # module @@ -21,6 +23,7 @@ def hashtest(version, t, logM, p, secret, salt, hex_digest, hash): return dict(version=version, rounds=t, logM=logM, memory_cost=1<<logM, parallelism=p, secret=secret, salt=salt, hex_digest=hex_digest, hash=hash) +# version 1.3 "I" tests version = 0x10 reference_data = [ hashtest(version, 2, 16, 1, "password", "somesalt", @@ -61,6 +64,7 @@ reference_data = [ "$eaEDuQ/orvhXDLMfyLIiWXeJFvgza3vaw4kladTxxJc"), ] +# version 1.9 "I" tests version = 0x13 reference_data.extend([ hashtest(version, 2, 16, 1, "password", "somesalt", @@ -101,6 +105,43 @@ reference_data.extend([ "$sDV8zPvvkfOGCw26RHsjSMvv7K2vmQq/6cxAcmxSEnE"), ]) +# version 1.9 "ID" tests +version = 0x13 +reference_data.extend([ + hashtest(version, 2, 16, 1, "password", "somesalt", + "09316115d5cf24ed5a15a31a3ba326e5cf32edc24702987c02b6566f61913cf7", + "$argon2id$v=19$m=65536,t=2,p=1$c29tZXNhbHQ" + "$CTFhFdXPJO1aFaMaO6Mm5c8y7cJHAph8ArZWb2GRPPc"), + hashtest(version, 2, 18, 1, "password", "somesalt", + "78fe1ec91fb3aa5657d72e710854e4c3d9b9198c742f9616c2f085bed95b2e8c", + "$argon2id$v=19$m=262144,t=2,p=1$c29tZXNhbHQ" + "$eP4eyR+zqlZX1y5xCFTkw9m5GYx0L5YWwvCFvtlbLow"), + hashtest(version, 2, 8, 1, "password", "somesalt", + "9dfeb910e80bad0311fee20f9c0e2b12c17987b4cac90c2ef54d5b3021c68bfe", + "$argon2id$v=19$m=256,t=2,p=1$c29tZXNhbHQ" + "$nf65EOgLrQMR/uIPnA4rEsF5h7TKyQwu9U1bMCHGi/4"), + hashtest(version, 2, 8, 2, "password", "somesalt", + "6d093c501fd5999645e0ea3bf620d7b8be7fd2db59c20d9fff9539da2bf57037", + "$argon2id$v=19$m=256,t=2,p=2$c29tZXNhbHQ" + "$bQk8UB/VmZZF4Oo79iDXuL5/0ttZwg2f/5U52iv1cDc"), + hashtest(version, 1, 16, 1, "password", "somesalt", + "f6a5adc1ba723dddef9b5ac1d464e180fcd9dffc9d1cbf76cca2fed795d9ca98", + "$argon2id$v=19$m=65536,t=1,p=1$c29tZXNhbHQ" + "$9qWtwbpyPd3vm1rB1GThgPzZ3/ydHL92zKL+15XZypg"), + hashtest(version, 4, 16, 1, "password", "somesalt", + "9025d48e68ef7395cca9079da4c4ec3affb3c8911fe4f86d1a2520856f63172c", + "$argon2id$v=19$m=65536,t=4,p=1$c29tZXNhbHQ" + "$kCXUjmjvc5XMqQedpMTsOv+zyJEf5PhtGiUghW9jFyw"), + hashtest(version, 2, 16, 1, "differentpassword", "somesalt", + "0b84d652cf6b0c4beaef0dfe278ba6a80df6696281d7e0d2891b817d8c458fde", + "$argon2id$v=19$m=65536,t=2,p=1$c29tZXNhbHQ" + "$C4TWUs9rDEvq7w3+J4umqA32aWKB1+DSiRuBfYxFj94"), + hashtest(version, 2, 16, 1, "password", "diffsalt", + "bdf32b05ccc42eb15d58fd19b1f856b113da1e9a5874fdcc544308565aa8141c", + "$argon2id$v=19$m=65536,t=2,p=1$ZGlmZnNhbHQ" + "$vfMrBczELrFdWP0ZsfhWsRPaHppYdP3MVEMIVlqoFBw"), +]) + #============================================================================= # argon2 #============================================================================= @@ -127,9 +168,15 @@ class _base_argon2_test(HandlerCase): # ensure trailing null bytes handled correctly ('password\x00', '$argon2i$v=19$m=512,t=2,p=2$c29tZXNhbHQ$Fb5+nPuLzZvtqKRwqUEtUQ'), + # sample with type D (generated via argon_cffi2.PasswordHasher) + ("password", '$argon2d$v=19$m=102400,t=2,p=8$g2RodLh8j8WbSdCp+lUy/A$zzAJqL/HSjm809PYQu6qkA'), + ] known_malformed_hashes = [ + # unknown hash type + "$argon2qq$v=19$t=2,p=4$c29tZXNhbHQAAAAAAAAAAA$QWLzI4TY9HkL2ZTLc8g6SinwdhZewYrzz9zxCo0bkGY", + # missing 'm' param "$argon2i$v=19$t=2,p=4$c29tZXNhbHQAAAAAAAAAAA$QWLzI4TY9HkL2ZTLc8g6SinwdhZewYrzz9zxCo0bkGY", @@ -146,6 +193,12 @@ class _base_argon2_test(HandlerCase): "$argon2i$v=19$m=127,t=2,p=16$c29tZXNhbHQ$IMit9qkFULCMA/ViizL57cnTLOa5DiVM9eMwpAvPwr4", ] + known_parsehash_results = [ + ('$argon2i$v=19$m=256,t=2,p=3$c29tZXNhbHQ$AJFIsNZTMKTAewB4+ETN1A', + dict(type="i", memory_cost=256, rounds=2, parallelism=3, salt=b'somesalt', + checksum=b'\x00\x91H\xb0\xd6S0\xa4\xc0{\x00x\xf8D\xcd\xd4')), + ] + def setUpWarnings(self): super(_base_argon2_test, self).setUpWarnings() warnings.filterwarnings("ignore", ".*Using argon2pure backend.*") @@ -239,13 +292,93 @@ class _base_argon2_test(HandlerCase): "$argon2i$v=19$m=65536,t=2,p=4,keyid=ABCD,data=EFGH$c29tZXNhbHQ$" "IMit9qkFULCMA/ViizL57cnTLOa5DiVM9eMwpAvPwr4") + def test_type_kwd(self): + cls = self.handler + + # XXX: this mirrors test_30_HasManyIdents(); + # maybe switch argon2 class to use that mixin instead of "type" kwd? + + # check settings + self.assertTrue("type" in cls.setting_kwds) + + # check supported type_values + for value in cls.type_values: + self.assertIsInstance(value, unicode) + self.assertTrue("i" in cls.type_values) + self.assertTrue("d" in cls.type_values) + + # check default + self.assertTrue(cls.type in cls.type_values) + + # check constructor validates ident correctly. + handler = cls + hash = self.get_sample_hash()[1] + kwds = handler.parsehash(hash) + del kwds['type'] + + # ... accepts good type + handler(type=cls.type, **kwds) + + # XXX: this is policy "ident" uses, maybe switch to it? + # # ... requires type w/o defaults + # self.assertRaises(TypeError, handler, **kwds) + handler(**kwds) + + # ... supplies default type + handler(use_defaults=True, **kwds) + + # ... rejects bad type + self.assertRaises(ValueError, handler, type='xXx', **kwds) + + def test_type_using(self): + handler = self.handler + + # XXX: this mirrors test_has_many_idents_using(); + # maybe switch argon2 class to use that mixin instead of "type" kwd? + + orig_type = handler.type + for alt_type in handler.type_values: + if alt_type != orig_type: + break + else: + raise AssertionError("expected to find alternate type: default=%r values=%r" % + (orig_type, handler.type_values)) + + def effective_type(cls): + return cls(use_defaults=True).type + + # keep default if nothing else specified + subcls = handler.using() + self.assertEqual(subcls.type, orig_type) + + # accepts alt type + subcls = handler.using(type=alt_type) + self.assertEqual(subcls.type, alt_type) + self.assertEqual(handler.type, orig_type) + + # check subcls actually *generates* default type, + # and that we didn't affect orig handler + self.assertEqual(effective_type(subcls), alt_type) + self.assertEqual(effective_type(handler), orig_type) + + # rejects bad type + self.assertRaises(ValueError, handler.using, type='xXx') + + # honor 'type' alias + subcls = handler.using(type=alt_type) + self.assertEqual(subcls.type, alt_type) + self.assertEqual(handler.type, orig_type) + + # check type aliases are being honored + self.assertEqual(effective_type(handler.using(type="I")), "i") + def test_needs_update_w_type(self): handler = self.handler hash = handler.hash("stub") self.assertFalse(handler.needs_update(hash)) - hash2 = hash.replace("$argon2i$", "$argon2d$") + hash2 = re.sub(r"\$argon2\w+\$", "$argon2d$", hash) self.assertTrue(handler.needs_update(hash2)) def test_needs_update_w_version(self): @@ -268,7 +401,7 @@ class _base_argon2_test(HandlerCase): # 8 byte salt salt = b'somesalt' temp = handler.using(memory_cost=256, time_cost=2, parallelism=2, salt=salt, - checksum_size=32) + checksum_size=32, type="i") hash = temp.hash("password") self.assertEqual(hash, "$argon2i$v=19$m=256,t=2,p=2" "$c29tZXNhbHQ" @@ -277,7 +410,7 @@ class _base_argon2_test(HandlerCase): # 16 byte salt salt = b'somesalt\x00\x00\x00\x00\x00\x00\x00\x00' temp = handler.using(memory_cost=256, time_cost=2, parallelism=2, salt=salt, - checksum_size=32) + checksum_size=32, type="i") hash = temp.hash("password") self.assertEqual(hash, "$argon2i$v=19$m=256,t=2,p=2" "$c29tZXNhbHQAAAAAAAAAAA" @@ -286,7 +419,10 @@ class _base_argon2_test(HandlerCase): class FuzzHashGenerator(HandlerCase.FuzzHashGenerator): settings_map = HandlerCase.FuzzHashGenerator.settings_map.copy() - settings_map.update(memory_cost="random_memory_cost") + settings_map.update(memory_cost="random_memory_cost", type="random_type") + + def random_type(self): + return self.rng.choice(self.handler.type_values) def random_memory_cost(self): if self.test.backend == "argon2pure": @@ -321,6 +457,10 @@ class argon2_argon2_cffi_test(_base_argon2_test.create_backend_case("argon2_cffi ('password', "$argon2d$v=19$m=65536,t=2,p=4$c29tZXNhbHQ$" "cZn5d+rFh+ZfuRhm2iGUGgcrW5YLeM6q7L3vBsdmFA0"), + # v1.3, type ID + ('password', "$argon2id$v=19$m=65536,t=2,p=4$c29tZXNhbHQ$" + "GpZ3sK/oH9p7VIiV56G/64Zo/8GaUw434IimaPqxwCo"), + # # custom # @@ -350,6 +490,7 @@ class argon2_argon2pure_test(_base_argon2_test.create_backend_case("argon2pure") # add reference hashes from argon2 clib tests known_correct_hashes = _base_argon2_test.known_correct_hashes[:] + known_correct_hashes.extend( (info['secret'], info['hash']) for info in reference_data if info['logM'] < 16 diff --git a/passlib/tests/test_totp.py b/passlib/tests/test_totp.py index b2afa72..1789d46 100644 --- a/passlib/tests/test_totp.py +++ b/passlib/tests/test_totp.py @@ -1372,7 +1372,7 @@ class TotpTest(TestCase): # with label & issuer otp = TOTP(KEY4, alg="sha1", digits=6, period=30) self.assertEqual(otp.to_uri("alice@google.com", "Example Org"), - "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP&" + "otpauth://totp/Example%20Org:alice@google.com?secret=JBSWY3DPEHPK3PXP&" "issuer=Example%20Org") # label is required @@ -1390,13 +1390,13 @@ class TotpTest(TestCase): # with default label & default issuer from constructor otp.issuer = "Example Org" self.assertEqual(otp.to_uri(), - "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP" + "otpauth://totp/Example%20Org:alice@google.com?secret=JBSWY3DPEHPK3PXP" "&issuer=Example%20Org") # reject invalid label self.assertRaises(ValueError, otp.to_uri, "label:with:semicolons") - # reject invalid issue + # reject invalid issuer self.assertRaises(ValueError, otp.to_uri, "alice@google.com", "issuer:with:semicolons") #------------------------------------------------------------------------- diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index 3f8cea8..af04baa 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -2484,6 +2484,64 @@ class HandlerCase(TestCase): self.do_identify('abc\x91\x00') # non-utf8 #=================================================================== + # test parsehash() + #=================================================================== + + #: optional list of known parse hash results for hasher + known_parsehash_results = [] + + def require_parsehash(self): + if not hasattr(self.handler, "parsehash"): + raise SkipTest("parsehash() not implemented") + + def test_70_parsehash(self): + """ + parsehash() + """ + # TODO: would like to enhance what this test covers + + self.require_parsehash() + handler = self.handler + + # calls should succeed, and return dict + hash = self.do_encrypt("stub") + result = handler.parsehash(hash) + self.assertIsInstance(result, dict) + # TODO: figure out what invariants we can reliably parse, + # or maybe make subclasses specify that? + + # w/ checksum=False, should omit that key + result2 = handler.parsehash(hash, checksum=False) + correct2 = result.copy() + correct2.pop("checksum", None) + self.assertEqual(result2, correct2) + + # w/ sanitize=True + # correct output should mask salt / checksum; + # but all else should be the same + result3 = handler.parsehash(hash, sanitize=True) + correct3 = result.copy() + for key in ("salt", "checksum"): + if key in result3: + self.assertNotEqual(result3[key], correct3[key]) + correct3[key] = result3[key] + self.assertEqual(result3, correct3) + + def test_71_parsehash_results(self): + """ + parsehash() -- known outputs + """ + self.require_parsehash() + samples = self.known_parsehash_results + if not samples: + raise self.skipTest("no samples present") + # XXX: expand to test w/ checksum=False and/or sanitize=True? + # or read "_unsafe_settings"? + for hash, correct in self.known_parsehash_results: + result = self.handler.parsehash(hash) + self.assertEqual(result, correct, "hash=%r:" % hash) + + #=================================================================== # fuzz testing #=================================================================== def test_77_fuzz_input(self, threaded=False): diff --git a/passlib/totp.py b/passlib/totp.py index e9edf59..a5027b4 100644 --- a/passlib/totp.py +++ b/passlib/totp.py @@ -6,7 +6,6 @@ from __future__ import absolute_import, division, print_function from passlib.utils.compat import PY3 # core import base64 -import collections import calendar import json import logging; log = logging.getLogger(__name__) @@ -331,13 +330,12 @@ class AppWallet(object): raise ValueError("unrecognized secrets string format") # ensure we have iterable of (tag, value) pairs - # XXX: could support lists/iterable, but not yet needed... - # if isinstance(source, list) or isinstance(source, collections.Iterator): - # pass if source is None: return {} elif isinstance(source, dict): source = source.items() + # XXX: could support iterable of (tag,value) pairs, but not yet needed... + # elif check_type and (isinstance(source, str) or not isinstance(source, Iterable)): elif check_type: raise TypeError("'secrets' must be mapping, or list of items") @@ -1415,11 +1413,12 @@ class TOTP(object): if ":" in label: try: issuer, label = label.split(":") - except ValueError: # too many ":" + except ValueError: # too many ":" raise cls._uri_parse_error("malformed label") else: issuer = None if label: + # NOTE: KeyURI spec says there may be leading spaces label = label.strip() or None # parse query params @@ -1519,6 +1518,11 @@ class TOTP(object): >>> uri = tp.to_uri("user@example.org", "myservice.another-example.org") >>> uri 'otpauth://totp/user@example.org?secret=S3JDVB7QD2R7JPXX&issuer=myservice.another-example.org' + + .. versionchanged:: 1.7.2 + + This method now prepends the issuer URI label. This is recommended by the KeyURI + specification, for compatibility with older clients. """ # encode label if label is None: @@ -1532,21 +1536,25 @@ class TOTP(object): label = quote(label, '@') # encode query parameters - args = self._to_uri_params() + params = self._to_uri_params() if issuer is None: issuer = self.issuer if issuer: self._check_issuer(issuer) - args.append(("issuer", issuer)) + # NOTE: per KeyURI spec, including issuer as part of label is deprecated, + # in favor of adding it to query params. however, some QRCode clients + # don't recognize the 'issuer' query parameter, so spec recommends (as of 2018-7) + # to include both. + label = "%s:%s" % (quote(issuer, '@'), label) + params.append(("issuer", issuer)) # NOTE: not using urllib.urlencode() because it encodes ' ' as '+'; # but spec says to use '%20', and not sure how fragile # the various totp clients' parsers are. - argstr = u"&".join(u"%s=%s" % (key, quote(value, '')) - for key, value in args) - assert argstr, "argstr should never be empty" + param_str = u"&".join(u"%s=%s" % (key, quote(value, '')) for key, value in params) + assert param_str, "param_str should never be empty" # render uri - return u"otpauth://totp/%s?%s" % (label, argstr) + return u"otpauth://totp/%s?%s" % (label, param_str) def _to_uri_params(self): """return list of (key, param) entries for URI""" diff --git a/passlib/utils/__init__.py b/passlib/utils/__init__.py index e879118..2fc0e95 100644 --- a/passlib/utils/__init__.py +++ b/passlib/utils/__init__.py @@ -6,7 +6,13 @@ from passlib.utils.compat import JYTHON # core from binascii import b2a_base64, a2b_base64, Error as _BinAsciiError from base64 import b64encode, b64decode -import collections +try: + from collections.abc import Sequence + from collections.abc import Iterable +except ImportError: + # py2 compat + from collections import Sequence + from collections import Iterable from codecs import lookup as _lookup_codec from functools import update_wrapper import itertools @@ -30,6 +36,7 @@ else: import time if stringprep: import unicodedata +import timeit import types from warnings import warn # site @@ -272,14 +279,14 @@ def batch(source, size): """ if size < 1: raise ValueError("size must be positive integer") - if isinstance(source, collections.Sequence): + if isinstance(source, Sequence): end = len(source) i = 0 while i < end: n = i + size yield source[i:n] i = n - elif isinstance(source, collections.Iterable): + elif isinstance(source, Iterable): itr = iter(source) while True: chunk_itr = itertools.islice(itr, size) @@ -836,14 +843,7 @@ def test_crypt(secret, hash): assert secret and hash return safe_crypt(secret, hash) == hash -# pick best timer function to expose as "tick" - lifted from timeit module. -if sys.platform == "win32": - # On Windows, the best timer is time.clock() - from time import clock as timer -else: - # On most other platforms the best timer is time.time() - from time import time as timer - +timer = timeit.default_timer # legacy alias, will be removed in passlib 2.0 tick = timer @@ -900,7 +900,7 @@ def genseed(value=None): # the current time, to whatever precision os uses time.time(), - time.clock(), + tick(), # if urandom available, might as well mix some bytes in. os.urandom(32).decode("latin-1") if has_urandom else 0, diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py index cc9d151..50440de 100644 --- a/passlib/utils/handlers.py +++ b/passlib/utils/handlers.py @@ -811,13 +811,25 @@ class GenericHandler(MinimalHandler): # experimental - the following methods are not finished or tested, # but way work correctly for some hashes #=================================================================== + + #: internal helper for forcing settings to be included, even if default matches + _always_parse_settings = () + + #: internal helper for excluding certain setting_kwds from parsehash() result _unparsed_settings = ("salt_size", "relaxed") + + #: parsehash() keys that need to be sanitized _unsafe_settings = ("salt", "checksum") @classproperty def _parsed_settings(cls): - return (key for key in cls.setting_kwds - if key not in cls._unparsed_settings) + """ + helper for :meth:`parsehash` -- + returns list of attributes which should be extracted by parse_hash() from hasher object. + + default implementation just takes setting_kwds, and excludes _unparsed_settings + """ + return tuple(key for key in cls.setting_kwds if key not in cls._unparsed_settings) # XXX: make this a global function? @staticmethod @@ -857,9 +869,13 @@ class GenericHandler(MinimalHandler): self = cls.from_string(hash) # XXX: could split next few lines out as self._parsehash() for subclassing # XXX: could try to resolve ident/variant to publically suitable alias. + # XXX: for v1.8, consider making "always" the default policy, and compare to class default + # only for whitelisted attrs? or make this whole method obsolete by reworking + # so "hasher" object & it's attrs are public? UNSET = object() + always = self._always_parse_settings kwds = dict((key, getattr(self, key)) for key in self._parsed_settings - if getattr(self, key) != getattr(cls, key, UNSET)) + if key in always or getattr(self, key) != getattr(cls, key, UNSET)) if checksum and self.checksum is not None: kwds['checksum'] = self.checksum if sanitize: @@ -1114,6 +1130,7 @@ class HasManyIdents(GenericHandler): return value # failure! + # XXX: give this it's own error type? raise ValueError("invalid ident: %r" % (ident,)) #=================================================================== @@ -46,7 +46,7 @@ opts = dict( # NOTE: 'download_url' set below extras_require={ - "argon2": "argon2_cffi>=16.2", + "argon2": "argon2_cffi>=18.2.0", "bcrypt": "bcrypt>=3.1.0", "totp": "cryptography", }, |