Skip to content
This repository

Add hashed password support. #1011

Merged
merged 11 commits into from over 2 years ago

5 participants

Stefan van der Walt Fernando Perez Satrajit Ghosh Min RK Thomas Kluyver
Stefan van der Walt

Add hashing of passwords to notebook configuration [written with Mateusz Paprocki].

IPython/lib/security.py
((15 lines not shown))
  15
+    ----------
  16
+    passphrase : str
  17
+        Password to hash.
  18
+
  19
+    Returns
  20
+    -------
  21
+    hashed_passphrase : str
  22
+        Hashed password, in the format 'hash_algorithm:salt:passphrase_hash'.
  23
+
  24
+    Examples
  25
+    --------
  26
+    In [1]: passwd('mypassword')
  27
+    Out[1]: 'sha1:7cf3:b7d6da294ea9592a9480c8f52e63cd42cfb9dd12'
  28
+
  29
+    """
  30
+    algorithm = 'sha1'
3
Min RK Owner
minrk added a note November 18, 2011

if algorithm is an option, why isn't it an arg to passwd?

Stefan van der Walt
stefanv added a note November 18, 2011

Because, for now, only sha1 is supported. We could add it though; would that be preferred?

Min RK Owner
minrk added a note November 18, 2011

If the design is that multiple algorithms are to be supported, it should probably be an arg, even if the supported list is only length one at this point.

In fact, if you simply removed the length-check dict, and just let the hash go ahead, you would already have support for more than just sha1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
IPython/lib/security.py
((61 lines not shown))
  61
+    In [3]: passwd_check('sha1:7cf3:b7d6da294ea9592a9480c8f52e63cd42cfb9dd12',
  62
+       ...:              'anotherpassword')
  63
+    Out[3]: False
  64
+
  65
+    """
  66
+    # Algorithm and hash length
  67
+    supported_algorithms = {'sha1': 40}
  68
+
  69
+    try:
  70
+        algorithm, salt, pw_digest = hashed_passphrase.split(':', 2)
  71
+    except (ValueError, TypeError):
  72
+        return False
  73
+
  74
+    if not (algorithm in supported_algorithms and \
  75
+            len(pw_digest) == supported_algorithms[algorithm] and \
  76
+            len(salt) == 4):
2
Min RK Owner
minrk added a note November 18, 2011

The way you generate the salt above, it is not guaranteed to be 4 bytes because the random values can be < 16^3, which will be 3 (or fewer) characters, so this will actually fail once in every ~16 runs.

It also seems unnecessary to check the length of the salt, since it shouldn't actually matter.

Min RK Owner
minrk added a note November 18, 2011

Why not just skip this step, and wrap the next hashlib.new();h.update() bit in try/except: return False?

That's obviously more expensive in the rare cases where input is invalid, but it means we would support pretty much everything in hashlib.algorithms without having to maintain a supported_algorithms dict. I cannot think of a situation where this extra cost would be significant, though.

In this way, less code actually supports more use cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Stefan van der Walt stefanv referenced this pull request November 18, 2011
Merged

Add logout button. #1012

IPython/lib/security.py
((4 lines not shown))
  4
+
  5
+import hashlib
  6
+import random
  7
+
  8
+def passwd(passphrase, algorithm='sha1'):
  9
+    """Generate hashed password and salt for use in notebook configuration.
  10
+
  11
+    In the notebook configuration, set `c.NotebookApp.password` to
  12
+    the generated string.
  13
+
  14
+    Parameters
  15
+    ----------
  16
+    passphrase : str
  17
+        Password to hash.
  18
+    algorithm : str
  19
+        Hashing algorithm to use.
1
Fernando Perez Owner
fperez added a note November 18, 2011

Should indicate here that this string should be any that's a valid input to hashlib.new, so users know what to look for.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
IPython/lib/security.py
((18 lines not shown))
  18
+    algorithm : str
  19
+        Hashing algorithm to use.
  20
+
  21
+    Returns
  22
+    -------
  23
+    hashed_passphrase : str
  24
+        Hashed password, in the format 'hash_algorithm:salt:passphrase_hash'.
  25
+
  26
+    Examples
  27
+    --------
  28
+    In [1]: passwd('mypassword')
  29
+    Out[1]: 'sha1:7cf3:b7d6da294ea9592a9480c8f52e63cd42cfb9dd12'
  30
+
  31
+    """
  32
+    h = hashlib.new(algorithm)
  33
+    salt = '%04x' % random.getrandbits(16)
1
Fernando Perez Owner
fperez added a note November 18, 2011

I'm not an expert on salting, but from a quick read on wikipedia it seems that the recommended salt length is 48 to 128 bits. Why not make it at least 64 bits?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
IPython/lib/security.py
... ...
@@ -0,0 +1,81 @@
  1
+"""
  2
+Password generation for the IPython notebook.
  3
+"""
  4
+
  5
+import hashlib
  6
+import random
  7
+
  8
+def passwd(passphrase, algorithm='sha1'):
1
Fernando Perez Owner
fperez added a note November 18, 2011

This function should be callable without a passphrase (set to None). In that mode, it should use the getpass module to ask the user for the password interactively, doing it twice to verify correctness, and then return the encoded value. This would let users create passwords without echoing them to the screen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Fernando Perez
Owner

Thanks a ton for this, it's awesome. The fixes should be pretty easy and we can merge soon.

Fernando Perez

Why not use None here? It's a clearer way to indicate that this parameter is in an invalid state...

Fernando Perez

Since this is inherently interactive code, instead of a simple check with a ValueError at the end, it should instead do a 3-loop attempt at getting it right, and if on the third time it still doesn't work, then simply print to stderr a short error message and return.

Fernando Perez

No, not an exception, just an error message to stderr: exceptions are useful for programmatic use, but they are not very user friendly. Alternatively, you can import from IPython.core.error import UsageError. That error is a real exception (in case this code is used by something else) but we special-case it and don't show a traceback, for situations like this.

Fernando Perez fperez merged commit 1f72dda into from November 18, 2011
Fernando Perez fperez closed this November 18, 2011
Min RK minrk commented on the diff November 18, 2011
IPython/lib/security.py
((82 lines not shown))
  82
+    In [3]: passwd_check('sha1:7cf3:b7d6da294ea9592a9480c8f52e63cd42cfb9dd12',
  83
+       ...:              'anotherpassword')
  84
+    Out[3]: False
  85
+
  86
+    """
  87
+    try:
  88
+        algorithm, salt, pw_digest = hashed_passphrase.split(':', 2)
  89
+    except (ValueError, TypeError):
  90
+        return False
  91
+
  92
+    try:
  93
+        h = hashlib.new(algorithm)
  94
+    except ValueError:
  95
+        return False
  96
+
  97
+    if len(pw_digest) == 0 or len(salt) != salt_len:
4
Min RK Owner
minrk added a note November 18, 2011

Checking the salt length seems entirely unnecessary here. It has no valuable effect, because shorter or longer salts would still be perfectly valid.

Fernando Perez Owner
fperez added a note November 18, 2011

Good point. I won't revert it quite yet, let's see if @stefanv had a specific reason to check for that. But I can't think of one and unless Stefan has a good reason for wanting it there, we can remove the length check.

Stefan van der Walt
stefanv added a note November 18, 2011
Fernando Perez Owner
fperez added a note November 19, 2011

Fixed in 4cd1067

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Fernando Perez fperez commented on the diff November 19, 2011
IPython/lib/security.py
((44 lines not shown))
  44
+            p1 = getpass.getpass('Verify password: ')
  45
+            if p0 == p1:
  46
+                passphrase = p0
  47
+                break
  48
+            else:
  49
+                print('Passwords do not match.')
  50
+        else:
  51
+            raise UsageError('No matching passwords found. Giving up.')
  52
+
  53
+    h = hashlib.new(algorithm)
  54
+    salt = ('%0' + str(salt_len) + 'x') % random.getrandbits(4 * salt_len)
  55
+    h.update(passphrase + salt)
  56
+
  57
+    return ':'.join((algorithm, salt, h.hexdigest()))
  58
+
  59
+def passwd_check(hashed_passphrase, passphrase):
1
Fernando Perez Owner
fperez added a note November 19, 2011

BTW, it just occurred to me that we should probably add to this function a delay option, defaulting to 0.25 seconds at least... That will prevent DOS attacks and make brute-force rapid-fire guessing impossible. What do you guys think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Satrajit Ghosh

is there anyway to do this, so it can be done on the command line when starting the notebook? i tried to add a function to the config.py file that asks for the password, but it seems the config file is parsed too many times during a session and hence i kept getting asked for a password.

Thomas Kluyver takluyver commented on the diff November 19, 2011
IPython/lib/security.py
((40 lines not shown))
  40
+    """
  41
+    if passphrase is None:
  42
+        for i in range(3):
  43
+            p0 = getpass.getpass('Enter password: ')
  44
+            p1 = getpass.getpass('Verify password: ')
  45
+            if p0 == p1:
  46
+                passphrase = p0
  47
+                break
  48
+            else:
  49
+                print('Passwords do not match.')
  50
+        else:
  51
+            raise UsageError('No matching passwords found. Giving up.')
  52
+
  53
+    h = hashlib.new(algorithm)
  54
+    salt = ('%0' + str(salt_len) + 'x') % random.getrandbits(4 * salt_len)
  55
+    h.update(passphrase + salt)
7
Thomas Kluyver Collaborator

It's just occurred to me that this is not going to be portable to Python 3, because you can't directly hash unicode. For that matter, it will fail on non-ascii unicode strings in Python 2 as well.

I think the best thing is to do is: py3compat.cast_bytes((passphrase + salt), 'utf-8').

I realise this has been merged - I'll try to get round to doing another PR, unless someone else beats me to it.

Fernando Perez Owner
fperez added a note November 19, 2011

Sorry about that! We haven't gotten into a good swing of keeping py3 in mind, my fault...

BTW, if you see anything similarly problematic on #1012, let me know. It doesn't have similar low-level pieces so it shouldn't be much of a problem, but still, pitch in if you see anything amiss regarding py3.

Thomas Kluyver Collaborator

I'll have a check.

Stefan van der Walt
stefanv added a note November 20, 2011

Mateusz just reminded me that allowing unicode for a password is probably a bad idea, so I think we'll try to detect that situation.

Thomas Kluyver Collaborator

My feeling is that if people want to use non-ascii characters in their password, that's up to them.

Stefan van der Walt
stefanv added a note November 20, 2011

So how about just doing .encode() on the string? That should work in both 2.7 and 3.

Thomas Kluyver Collaborator

I've done essentially that in PR #1016.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Fernando Perez
Owner

@satra, that's a good point. There's a quick and easy hack (until we have a chance to disentangle why the init file is being parsed more than once): write a small wrapper function in your init file (or a locally loaded utility) that simply tracks its own state and uses passwd:

def password():
  from IPython.lib import passwd
  if password.pwd is None:
    pwd = passwd()
    password.pwd = pwd
    return pwd
  else:
    return password.pwd

password.pwd = None
Fernando Perez fperez referenced this pull request from a commit January 10, 2012
Commit has since been removed from the repository and is no longer available.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
This page is out of date. Refresh to see the latest.
16  IPython/frontend/html/notebook/handlers.py
@@ -28,6 +28,7 @@
28 28
 
29 29
 from IPython.external.decorator import decorator
30 30
 from IPython.zmq.session import Session
  31
+from IPython.lib.security import passwd_check
31 32
 
32 33
 try:
33 34
     from docutils.core import publish_string
@@ -166,16 +167,25 @@ def get(self):
166 167
 
167 168
 class LoginHandler(AuthenticatedHandler):
168 169
 
169  
-    def get(self):
  170
+    def _render(self, message=''):
170 171
         self.render('login.html',
171 172
                 next=self.get_argument('next', default='/'),
172 173
                 read_only=self.read_only,
  174
+                message=message
173 175
         )
174 176
 
  177
+    def get(self):
  178
+        self._render()
  179
+
175 180
     def post(self):
176 181
         pwd = self.get_argument('password', default=u'')
177  
-        if self.application.password and pwd == self.application.password:
178  
-            self.set_secure_cookie('username', str(uuid.uuid4()))
  182
+        if self.application.password:
  183
+            if passwd_check(self.application.password, pwd):
  184
+                self.set_secure_cookie('username', str(uuid.uuid4()))
  185
+            else:
  186
+                self._render(message='Invalid password')
  187
+                return
  188
+
179 189
         self.redirect(self.get_argument('next', default='/'))
180 190
 
181 191
 
11  IPython/frontend/html/notebook/notebookapp.py
@@ -208,7 +208,16 @@ def _ip_changed(self, name, old, new):
208 208
     )
209 209
 
210 210
     password = Unicode(u'', config=True,
211  
-                      help="""Password to use for web authentication"""
  211
+                      help="""Hashed password to use for web authentication.
  212
+
  213
+                      To generate, do:
  214
+
  215
+                        from IPython.lib import passwd
  216
+
  217
+                        passwd('mypassphrase')
  218
+
  219
+                      The string should be of the form type:salt:hashed-password.
  220
+                      """
212 221
     )
213 222
     
214 223
     open_browser = Bool(True, config=True,
12  IPython/frontend/html/notebook/static/css/layout.css
@@ -101,3 +101,15 @@
101 101
 	-moz-box-pack: center;
102 102
 	box-pack: center;
103 103
 }
  104
+
  105
+#message {
  106
+    border: 1px solid red;
  107
+    background-color: #FFD3D1;
  108
+    text-align: center;
  109
+    padding: 0.5em;
  110
+    margin: 0.5em;
  111
+}
  112
+
  113
+#content_panel {
  114
+    margin: 0.5em;
  115
+}
2  IPython/frontend/html/notebook/static/css/projectdashboard.css
@@ -31,7 +31,7 @@ body {
31 31
 }
32 32
 
33 33
 #content_toolbar {
34  
-    padding: 10px 5px 5px 5px;
  34
+    padding: 5px;
35 35
     height: 25px;
36 36
     line-height: 25px;
37 37
 }
6  IPython/frontend/html/notebook/templates/login.html
@@ -31,6 +31,12 @@
31 31
     </div>
32 32
     
33 33
     <div id="content_panel">
  34
+        {% if message %}
  35
+        <div id="message">
  36
+            {{message}}
  37
+        </div>
  38
+        {% end %}
  39
+
34 40
         <form action="/login?next={{url_escape(next)}}" method="post">
35 41
             Password: <input type="password" name="password">
36 42
             <input type="submit" value="Sign in" id="signin">
2  IPython/lib/__init__.py
@@ -25,6 +25,8 @@
25 25
     current_gui
26 26
 )
27 27
 
  28
+from IPython.lib.security import passwd
  29
+
28 30
 #-----------------------------------------------------------------------------
29 31
 # Code
30 32
 #-----------------------------------------------------------------------------
102  IPython/lib/security.py
... ...
@@ -0,0 +1,102 @@
  1
+"""
  2
+Password generation for the IPython notebook.
  3
+"""
  4
+
  5
+import hashlib
  6
+import random
  7
+import getpass
  8
+
  9
+from IPython.core.error import UsageError
  10
+
  11
+# Length of the salt in nr of hex chars, which implies salt_len * 4
  12
+# bits of randomness.
  13
+salt_len = 12
  14
+
  15
+def passwd(passphrase=None, algorithm='sha1'):
  16
+    """Generate hashed password and salt for use in notebook configuration.
  17
+
  18
+    In the notebook configuration, set `c.NotebookApp.password` to
  19
+    the generated string.
  20
+
  21
+    Parameters
  22
+    ----------
  23
+    passphrase : str
  24
+        Password to hash.  If unspecified, the user is asked to input
  25
+        and verify a password.
  26
+    algorithm : str
  27
+        Hashing algorithm to use (e.g, 'sha1' or any argument supported
  28
+        by :func:`hashlib.new`).
  29
+
  30
+    Returns
  31
+    -------
  32
+    hashed_passphrase : str
  33
+        Hashed password, in the format 'hash_algorithm:salt:passphrase_hash'.
  34
+
  35
+    Examples
  36
+    --------
  37
+    In [1]: passwd('mypassword')
  38
+    Out[1]: 'sha1:7cf3:b7d6da294ea9592a9480c8f52e63cd42cfb9dd12'
  39
+
  40
+    """
  41
+    if passphrase is None:
  42
+        for i in range(3):
  43
+            p0 = getpass.getpass('Enter password: ')
  44
+            p1 = getpass.getpass('Verify password: ')
  45
+            if p0 == p1:
  46
+                passphrase = p0
  47
+                break
  48
+            else:
  49
+                print('Passwords do not match.')
  50
+        else:
  51
+            raise UsageError('No matching passwords found. Giving up.')
  52
+
  53
+    h = hashlib.new(algorithm)
  54
+    salt = ('%0' + str(salt_len) + 'x') % random.getrandbits(4 * salt_len)
  55
+    h.update(passphrase + salt)
  56
+
  57
+    return ':'.join((algorithm, salt, h.hexdigest()))
  58
+
  59
+def passwd_check(hashed_passphrase, passphrase):
  60
+    """Verify that a given passphrase matches its hashed version.
  61
+
  62
+    Parameters
  63
+    ----------
  64
+    hashed_passphrase : str
  65
+        Hashed password, in the format returned by `passwd`.
  66
+    passphrase : str
  67
+        Passphrase to validate.
  68
+
  69
+    Returns
  70
+    -------
  71
+    valid : bool
  72
+        True if the passphrase matches the hash.
  73
+
  74
+    Examples
  75
+    --------
  76
+    In [1]: from IPython.lib.security import passwd_check
  77
+
  78
+    In [2]: passwd_check('sha1:7cf3:b7d6da294ea9592a9480c8f52e63cd42cfb9dd12',
  79
+       ...:              'mypassword')
  80
+    Out[2]: True
  81
+
  82
+    In [3]: passwd_check('sha1:7cf3:b7d6da294ea9592a9480c8f52e63cd42cfb9dd12',
  83
+       ...:              'anotherpassword')
  84
+    Out[3]: False
  85
+
  86
+    """
  87
+    try:
  88
+        algorithm, salt, pw_digest = hashed_passphrase.split(':', 2)
  89
+    except (ValueError, TypeError):
  90
+        return False
  91
+
  92
+    try:
  93
+        h = hashlib.new(algorithm)
  94
+    except ValueError:
  95
+        return False
  96
+
  97
+    if len(pw_digest) == 0 or len(salt) != salt_len:
  98
+        return False
  99
+
  100
+    h.update(passphrase + salt)
  101
+
  102
+    return h.hexdigest() == pw_digest
21  IPython/lib/tests/test_security.py
... ...
@@ -0,0 +1,21 @@
  1
+from IPython.lib import passwd
  2
+from IPython.lib.security import passwd_check, salt_len
  3
+import nose.tools as nt
  4
+
  5
+def test_passwd_structure():
  6
+    p = passwd('passphrase')
  7
+    algorithm, salt, hashed = p.split(':')
  8
+    nt.assert_equals(algorithm, 'sha1')
  9
+    nt.assert_equals(len(salt), salt_len)
  10
+    nt.assert_equals(len(hashed), 40)
  11
+
  12
+def test_roundtrip():
  13
+    p = passwd('passphrase')
  14
+    nt.assert_equals(passwd_check(p, 'passphrase'), True)
  15
+
  16
+def test_bad():
  17
+    p = passwd('passphrase')
  18
+    nt.assert_equals(passwd_check(p, p), False)
  19
+    nt.assert_equals(passwd_check(p, 'a:b:c:d'), False)
  20
+    nt.assert_equals(passwd_check(p, 'a:b'), False)
  21
+
Commit_comment_tip

Tip: You can add notes to lines in a file. Hover to the left of a line to make a note

Something went wrong with that request. Please try again.