summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2019-11-10 14:56:25 -0500
committerEli Collins <elic@assurancetechnologies.com>2019-11-10 14:56:25 -0500
commit8c3470170628cbf6b18b48d95a69800b79b327ec (patch)
tree0ad961aec90b15c9e1e849c1f21c1849ca4c2191
parentc8b36453db3bcfb70017fe79d50c4c461d18e161 (diff)
parenta945d60e814337e668c647a043bfd6adcbd9d47e (diff)
downloadpasslib-8c3470170628cbf6b18b48d95a69800b79b327ec.tar.gz
Merge from stable
-rwxr-xr-xadmin/upload.sh2
-rw-r--r--docs/conf.py37
-rw-r--r--docs/history/1.7.rst44
-rw-r--r--docs/index.rst8
-rw-r--r--docs/install.rst6
-rw-r--r--docs/lib/passlib.hash.bcrypt.rst4
-rw-r--r--docs/lib/passlib.hash.bcrypt_sha256.rst8
-rw-r--r--docs/lib/passlib.pwd.rst20
-rw-r--r--docs/modular_crypt_format.rst5
-rw-r--r--passlib/handlers/argon2.py287
-rw-r--r--passlib/pwd.py7
-rw-r--r--passlib/tests/test_handlers.py6
-rw-r--r--passlib/tests/test_handlers_argon2.py149
-rw-r--r--passlib/tests/test_totp.py6
-rw-r--r--passlib/tests/utils.py58
-rw-r--r--passlib/totp.py30
-rw-r--r--passlib/utils/__init__.py24
-rw-r--r--passlib/utils/handlers.py23
-rw-r--r--setup.py2
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,))
#===================================================================
diff --git a/setup.py b/setup.py
index 2dc32c2..1d6d421 100644
--- a/setup.py
+++ b/setup.py
@@ -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",
},