diff options
author | Lars Wirzenius <liw@liw.fi> | 2018-02-01 12:11:03 +0200 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2018-02-02 11:22:14 +0200 |
commit | eb0a8a9d58df1993f7ddb14aad2fde585e9e164f (patch) | |
tree | 8e451c69246d80835589f4f9b17cfe4b912c80cc | |
parent | 5f2cbd0c459518c052276e60477c971051ff4fbf (diff) | |
download | qvisqve-eb0a8a9d58df1993f7ddb14aad2fde585e9e164f.tar.gz |
Add: module to hash cleartext passwords for storage
-rw-r--r-- | debian/control | 2 | ||||
-rw-r--r-- | salami/token_router.py | 15 | ||||
-rw-r--r-- | salami_secrets/__init__.py | 17 | ||||
-rw-r--r-- | salami_secrets/secrets.py | 90 | ||||
-rw-r--r-- | salami_secrets/secrets_tests.py | 89 | ||||
-rw-r--r-- | yarns/200-client-creds.yarn | 9 | ||||
-rw-r--r-- | yarns/lib.py | 21 |
7 files changed, 231 insertions, 12 deletions
diff --git a/debian/control b/debian/control index 3b750c5..98a74d6 100644 --- a/debian/control +++ b/debian/control @@ -20,6 +20,8 @@ Build-Depends: debhelper (>= 9), python3-all, python3-requests, python3-jwt, python-jwt, + python3-pycryptodome, + python-pycryptodome, cmdtest X-Python3-Version: >= 3.5 diff --git a/salami/token_router.py b/salami/token_router.py index c0b373c..6b5cb6e 100644 --- a/salami/token_router.py +++ b/salami/token_router.py @@ -21,6 +21,7 @@ import bottle import salami +import salami_secrets class TokenRouter(salami.Router): @@ -97,10 +98,16 @@ class Clients: def __init__(self, clients): self._clients = clients + self._hasher = salami_secrets.SecretHasher() - def is_correct_secret(self, client_id, secret): - return (client_id in self._clients and - self._clients[client_id].get('client_secret') == secret) + def is_correct_secret(self, client_id, cleartext): + client = self._get_client(client_id) + secret = client.get('client_secret') + return secret and self._hasher.is_correct(secret, cleartext) def get_allowed_scopes(self, client_id): - return self._clients[client_id].get('allowed_scopes', []) + client = self._get_client(client_id) + return client.get('allowed_scopes', []) + + def _get_client(self, client_id): + return self._clients.get(client_id, {}) diff --git a/salami_secrets/__init__.py b/salami_secrets/__init__.py new file mode 100644 index 0000000..d2eeaf3 --- /dev/null +++ b/salami_secrets/__init__.py @@ -0,0 +1,17 @@ +# Copyright (C) 2017 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from .secrets import SecretHasher diff --git a/salami_secrets/secrets.py b/salami_secrets/secrets.py new file mode 100644 index 0000000..b469ed8 --- /dev/null +++ b/salami_secrets/secrets.py @@ -0,0 +1,90 @@ +# Copyright (C) 2018 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import codecs + + +from Cryptodome.Random import get_random_bytes +from Cryptodome.Protocol.KDF import scrypt + + +class SecretHasher: + + # The following have been chosen based on: + # https://www.pycryptodome.org/en/latest/src/protocol/kdf.html + # Corrections, with references, welcome. + _salt_size = 16 + _key_len = 128 + _N = 16384 + _r = 8 + _p = 1 + + _version = 1 + + _ok_args = ['salt', 'key_len', 'N', 'r', 'p'] + + def get_salt(self): + return get_random_bytes(self._salt_size) + + def set_n(self, n): + self._N = n + + def _full_hash(self, cleartext, **kwargs): + assert kwargs == self._filter_kwargs(kwargs) + + hashed_binary = scrypt(cleartext, **kwargs) + params = { + 'version': self._version, + 'hash': self.hex_encode(hashed_binary), + 'salt': self.hex_encode(kwargs['salt']) + } + for key in kwargs: + if key not in params: + params[key] = kwargs[key] + + return params + + def hex_encode(self, byte_string): + return codecs.encode(byte_string, 'hex').decode('ascii') + + def hex_decode(self, hex_string): + return codecs.decode(bytes(hex_string, 'ascii'), 'hex') + + def _filter_kwargs(self, kwargs): + return { + key: kwargs[key] + for key in self._ok_args + } + + def hash(self, cleartext, salt=None): + if salt is None: + salt = self.get_salt() + assert isinstance(salt, bytes) + + kwargs = { + 'salt': salt, + 'key_len': self._key_len, + 'N': self._N, + 'r': self._r, + 'p': self._p, + } + return self._full_hash(cleartext, **kwargs) + + def is_correct(self, hashed, cleartext): + kwargs = self._filter_kwargs(hashed) + salt = self.hex_decode(kwargs.pop('salt')) + h2 = self._full_hash(cleartext, salt=salt, **kwargs) + return hashed == h2 diff --git a/salami_secrets/secrets_tests.py b/salami_secrets/secrets_tests.py new file mode 100644 index 0000000..1f1f970 --- /dev/null +++ b/salami_secrets/secrets_tests.py @@ -0,0 +1,89 @@ +# Copyright (C) 2018 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import unittest + + +import salami + + +class SecretHasherTests(unittest.TestCase): + + def test_byte_string_survives_hex_roundtrip(self): + byte_string = b'\x00\x02\x03' + sh = salami.SecretHasher() + encoded = sh.hex_encode(byte_string) + self.assertEqual(byte_string, sh.hex_decode(encoded)) + + def test_returns_sufficiently_long_salt(self): + sh = salami.SecretHasher() + salt = sh.get_salt() + self.assertTrue(len(salt) >= 8) + + def test_produces_a_hash(self): + cleartext = 'hunter2' + salt = b'nacl' + sh = salami.SecretHasher() + hashed = sh.hash(cleartext, salt) + self.assertTrue(isinstance(hashed, dict)) + + def test_produces_a_hash_with_salt(self): + cleartext = 'hunter2' + sh = salami.SecretHasher() + hashed = sh.hash(cleartext) + self.assertTrue(isinstance(hashed, dict)) + + def test_produces_same_hash_for_same_input(self): + cleartext = 'hunter2' + salt = b'nacl' + sh = salami.SecretHasher() + hashed1 = sh.hash(cleartext, salt) + hashed2 = sh.hash(cleartext, salt) + self.assertEqual(hashed1, hashed2) + + def test_produces_different_hashes_for_different_cleartext(self): + sh = salami.SecretHasher() + salt = b'nacl' + hashed1 = sh.hash('hunter2', salt) + hashed2 = sh.hash('swordfish', salt) + self.assertNotEqual(hashed1, hashed2) + + def test_produces_different_hashes_for_same_cleartext(self): + sh = salami.SecretHasher() + hashed1 = sh.hash('hunter2', b'nacl') + hashed2 = sh.hash('hunter2', b'nh4cl') + self.assertNotEqual(hashed1, hashed2) + + def test_accepts_correct_password(self): + cleartext = 'hunter2' + sh = salami.SecretHasher() + hashed = sh.hash(cleartext, b'nacl') + self.assertTrue(sh.is_correct(hashed, cleartext)) + + def test_rejects_incorrect_password(self): + cleartext = 'swordfish' + sh = salami.SecretHasher() + hashed = sh.hash(cleartext, b'nacl') + self.assertTrue(sh.is_correct(hashed, cleartext)) + + def test_handles_parameter_changes(self): + cleartext = 'hunter2' + salt = b'nacl' + sh = salami.SecretHasher() + hashed = sh.hash(cleartext, salt) + sh.set_n(2**1) + self.assertTrue(sh.is_correct(hashed, cleartext)) + self.assertNotEqual(sh.hash(cleartext, salt), hashed) diff --git a/yarns/200-client-creds.yarn b/yarns/200-client-creds.yarn index 07bbf37..78b082c 100644 --- a/yarns/200-client-creds.yarn +++ b/yarns/200-client-creds.yarn @@ -36,7 +36,14 @@ of clients, which it reads at startup from its configuration file: -----END RSA PRIVATE KEY----- clients: test_api: - client_secret: hunter2 + client_secret: + N: 16384 + hash: 5cf3b9cab1eacc818b73d229db...a023e938ee598f6c49749ef0429a889f7 + key_len: 128 + p: 1 + r: 8 + salt: 18112c4c50993ca5db908a15519c51e1 + version: 1 allowed_scopes: - foo - bar diff --git a/yarns/lib.py b/yarns/lib.py index b2e197b..cabd943 100644 --- a/yarns/lib.py +++ b/yarns/lib.py @@ -28,9 +28,10 @@ import Crypto.PublicKey.RSA import jwt import requests import yaml +from yarnutils import * -from yarnutils import * +import salami_secrets srcdir = os.environ['SRCDIR'] @@ -172,6 +173,17 @@ def start_salami(): V['pid-file'] = 'salami.pid' V['port'] = cliapp.runcmd([os.path.join(srcdir, 'randport' )]).strip() V['API_URL'] = 'http://127.0.0.1:{}'.format(V['port']) + + clients = {} + if V['client_id'] and V['client_secret']: + sh = salami_secrets.SecretHasher() + clients = { + V['client_id']: { + 'client_secret': sh.hash(V['client_secret']), + 'allowed_scopes': V['allowed_scopes'], + }, + } + config = { 'log': [ { @@ -183,12 +195,7 @@ def start_salami(): 'token-issuer': V['iss'], 'token-audience': V['aud'], 'token-lifetime': 3600, - 'clients': { - V['client_id']: { - 'client_secret': V['client_secret'], - 'allowed_scopes': V['allowed_scopes'], - }, - }, + 'clients': clients, } env = dict(os.environ) env['SALAMI_CONFIG'] = os.path.join(datadir, 'salami.yaml') |