##############################################################################
#
# Copyright (c) 2009 Zope Foundation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE.
#
##############################################################################
"""Legacy password managers, using now-outdated, insecure methods for hashing
"""
from codecs import getencoder
try:
from crypt import crypt
from random import choice
except ImportError: # pragma: no cover
# The crypt module is not universally available, apparently
crypt = None
from zope.interface import implementer
from zope.password.interfaces import IMatchingPasswordManager
_encoder = getencoder("utf-8")
if crypt is not None:
[docs]
@implementer(IMatchingPasswordManager)
class CryptPasswordManager:
"""Crypt password manager.
Implements a UNIX crypt(3) hashing scheme. Note that crypt is
considered far inferior to more modern schemes such as SSHA hashing,
and only uses the first 8 characters of a password.
>>> from zope.interface.verify import verifyObject
>>> from zope.password.interfaces import IMatchingPasswordManager
>>> from zope.password.legacy import CryptPasswordManager
>>> manager = CryptPasswordManager()
>>> verifyObject(IMatchingPasswordManager, manager)
True
>>> password = u"right \N{CYRILLIC CAPITAL LETTER A}"
>>> encoded = manager.encodePassword(password, salt="..")
>>> encoded
'{CRYPT}..I1I8wps4Na2'
>>> manager.match(encoded)
True
>>> manager.checkPassword(encoded, password)
True
Note that this object fails to return bytes from the ``encodePassword``
function:
>>> isinstance(encoded, str)
True
Unfortunately, crypt only looks at the first 8 characters, so matching
against an 8 character password plus suffix always matches. Our test
password (including utf-8 encoding) is exactly 8 characters long, and
thus affixing 'wrong' to it tests as a correct password:
>>> manager.checkPassword(encoded, password + u"wrong")
True
Using a completely different password is rejected as expected:
>>> manager.checkPassword(encoded, 'completely wrong')
False
Using the `openssl passwd` command-line utility to encode ``secret``,
we get ``erz50QD3gv4Dw`` as seeded hash.
Our password manager generates the same value when seeded with the
same salt, so we can be sure, our output is compatible with
standard LDAP tools that also use crypt:
>>> salt = 'er'
>>> password = 'secret'
>>> encoded = manager.encodePassword(password, salt)
>>> encoded
'{CRYPT}erz50QD3gv4Dw'
>>> manager.checkPassword(encoded, password)
True
>>> manager.checkPassword(encoded, password + u"wrong")
False
>>> manager.encodePassword(password) != manager.encodePassword(
... password)
True
The manager only claims to implement CRYPT encodings, anything not
starting with the string {CRYPT} returns False:
>>> manager.match('{MD5}someotherhash')
False
"""
def encodePassword(self, password, salt=None):
if salt is None:
choices = ("ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789./")
salt = choice(choices) + choice(choices)
return '{CRYPT}%s' % crypt(password, salt)
def checkPassword(self, encoded_password, password):
return encoded_password == self.encodePassword(
password, encoded_password[7:9])
def match(self, encoded_password):
return encoded_password.startswith('{CRYPT}')
[docs]
@implementer(IMatchingPasswordManager)
class MySQLPasswordManager:
"""A MySQL digest manager.
This Password Manager implements the digest scheme as implemented in the
MySQL PASSWORD function in MySQL versions before 4.1. Note that this method
results in a very weak 16-byte hash.
>>> from zope.interface.verify import verifyObject
>>> from zope.password.interfaces import IMatchingPasswordManager
>>> from zope.password.legacy import MySQLPasswordManager
>>> manager = MySQLPasswordManager()
>>> verifyObject(IMatchingPasswordManager, manager)
True
>>> password = u"right \N{CYRILLIC CAPITAL LETTER A}"
>>> encoded = manager.encodePassword(password)
>>> isinstance(encoded, bytes)
True
>>> print(encoded.decode())
{MYSQL}0ecd752c5097d395
>>> manager.match(encoded)
True
>>> manager.match(encoded.decode())
True
>>> manager.checkPassword(encoded.decode(), password)
True
>>> manager.checkPassword(encoded, password)
True
>>> manager.checkPassword(encoded, password + u"wrong")
False
Using the password 'PHP & Information Security' should result in the hash
``379693e271cd3bd6``, according to
http://phpsec.org/articles/2005/password-hashing.html
Our password manager generates the same value when seeded with the same
seed, so we can be sure, our output is compatible with MySQL versions
before 4.1:
>>> password = 'PHP & Information Security'
>>> encoded = manager.encodePassword(password)
>>> isinstance(encoded, bytes)
True
>>> print(encoded.decode())
{MYSQL}379693e271cd3bd6
>>> manager.checkPassword(encoded, password)
True
>>> manager.checkPassword(encoded, password + u"wrong")
False
The manager only claims to implement MYSQL encodings, anything not starting
with the string {MYSQL} returns False:
>>> manager.match('{MD5}someotherhash')
False
Spaces and tabs are ignored:
>>> encoded = manager.encodePassword('\tign or ed')
>>> print(encoded.decode())
{MYSQL}75818366052c6a78
>>> encoded = manager.encodePassword('ignored')
>>> print(encoded.decode())
{MYSQL}75818366052c6a78
"""
def encodePassword(self, password):
nr = 1345345333
add = 7
nr2 = 0x12345671
for i in _encoder(password)[0]:
if i == ord(b' ') or i == ord(b'\t'):
continue # pragma: no cover (this is actually hit, but ...
# coverage isn't reporting it)
nr ^= (((nr & 63) + add) * i) + (nr << 8)
nr2 += (nr2 << 8) ^ nr
add += i
r0 = nr & ((1 << 31) - 1)
r1 = nr2 & ((1 << 31) - 1)
return ("{{MYSQL}}{:08x}{:08x}".format(r0, r1)).encode()
def checkPassword(self, encoded_password, password):
if not isinstance(encoded_password, bytes):
encoded_password = encoded_password.encode('ascii')
return encoded_password == self.encodePassword(password)
def match(self, encoded_password):
if not isinstance(encoded_password, bytes):
encoded_password = encoded_password.encode('ascii')
return encoded_password.startswith(b'{MYSQL}')