Skip to content

Commit 87f5f37

Browse files
committed
Support passwords stored as SCRAM-SHA-256
This allows passing the password as plain text, a md5 hash or a SCRAM-SHA-256 hash. The password will be compared to whatever hash is stored in Postgres and updated accordingly. The SCRAM-SHA-256 hash algorithm for password storage was introduced in Postgres version 10 and made the default in Postgres version 14.
1 parent 58e46f9 commit 87f5f37

2 files changed

Lines changed: 79 additions & 11 deletions

File tree

‎pgbedrock/attributes.py‎

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import copy
22
import datetime as dt
33
import hashlib
4+
import hmac
5+
import base64
46
import logging
57

68
import click
@@ -87,6 +89,14 @@ def create_md5_hash(rolename, value):
8789
salted_input = (value + rolename).encode('utf-8')
8890
return 'md5' + hashlib.md5(salted_input).hexdigest()
8991

92+
def create_scram_hash(password, salt, iterations):
93+
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
94+
client_key = hmac.digest(digest, "Client Key".encode(), hashlib.sha256)
95+
stored_key = base64.b64encode(hashlib.sha256(client_key).digest()).decode()
96+
server_key = base64.b64encode(hmac.digest(digest, "Server Key".encode(), hashlib.sha256)).decode()
97+
98+
return "SCRAM-SHA-256${}:{}${}:{}".format(iterations, base64.b64encode(salt).decode(), stored_key, server_key)
99+
90100

91101
def is_valid_forever(val):
92102
if val is None or val == 'infinity':
@@ -194,13 +204,47 @@ def get_attribute_value(self, attribute):
194204
return value
195205

196206
def is_same_password(self, value):
197-
""" Convert the input value into a postgres rolname-salted md5 hash and compare
198-
it with the currently stored hash """
199-
if value is None:
200-
return self.current_attributes.get('rolpassword') is None
207+
""" Compare stored password hash to the new password.
208+
209+
Returns ``True`` if both ``value`` and the stored password are ``None`` (empty)
210+
**or** ``value`` is a plain text password its hash matches the stored password
211+
hash **or** ``value`` is a hashed password and matches the stored password hash
212+
type and value. ``False`` in any other case. """
213+
current_value = self.current_attributes.get('rolpassword')
214+
215+
if current_value == value:
216+
return True
217+
218+
if value is None or current_value is None:
219+
return False
220+
221+
if current_value.startswith("SCRAM-SHA-256$"):
222+
if value.startswith("md5"):
223+
return False
224+
225+
if value.startswith("SCRAM-SHA-256$"):
226+
return current_value == value
227+
228+
hash_parts = current_value.split("$")
229+
230+
iterations, salt = hash_parts[1].split(":")
231+
iterations = int(iterations)
232+
salt = base64.b64decode(salt)
233+
234+
return current_value == create_scram_hash(value, salt, iterations)
235+
236+
if current_value.startswith("md5"):
237+
if value.startswith("SCRAM-SHA-256$"):
238+
return False
239+
240+
if value.startswith("md5"):
241+
return current_value == value
242+
243+
return hmac.compare_digest(current_value, create_md5_hash(self.rolename, value))
201244

202-
md5_hash = create_md5_hash(self.rolename, value)
203-
return self.current_attributes.get('rolpassword') == md5_hash
245+
# There’s currently only two hash algorithm supported by Postgres (md5
246+
# and SCRAM-SHA-256) so we should never reach this.
247+
return False
204248

205249
def role_exists(self):
206250
# If current_attributes is empty then the rolname wasn't in pg_authid, i.e. it doesn't exist

‎tests/test_attributes.py‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -382,14 +382,38 @@ def test_set_attribute_value_valid_until(roleconf):
382382
rolpassword=attr.create_md5_hash(ROLE1, 'supersecret'),
383383
))
384384
@pytest.mark.parametrize('desired_value, expected', [
385-
('supersecret', True),
386-
('incorrect_password', False)])
387-
def test_is_same_password(roleconf, desired_value, expected):
385+
('supersecret', True),
386+
('md5c85aa4317b187e73a31e8ab775a10833', True),
387+
('incorrect_password', False),
388+
('SCRAM-SHA-256$4096:c3VwZXJzYWx0$E6lZT4K2olotsu19xYcF825iMPGdJQDYaklVS2mR6js=:Pe9DLNf8idnP59Q5l8Xmz3H+6LrTuiq//bcujQPGsRM=', False),
389+
]
390+
)
391+
def test_is_same_password_md5(roleconf, desired_value, expected):
388392
assert roleconf.is_same_password(desired_value) == expected
389393

390394

391-
def test_is_same_password_if_empty(roleconf):
392-
assert roleconf.is_same_password(None) is True
395+
@nondefault_attributes(dict(
396+
rolpassword=attr.create_scram_hash('supersecret', 'supersalt'.encode(), 4096),
397+
))
398+
@pytest.mark.parametrize('desired_value, expected', [
399+
('supersecret', True),
400+
('SCRAM-SHA-256$4096:c3VwZXJzYWx0$E6lZT4K2olotsu19xYcF825iMPGdJQDYaklVS2mR6js=:Pe9DLNf8idnP59Q5l8Xmz3H+6LrTuiq//bcujQPGsRM=', True),
401+
('incorrect_password', False),
402+
('md5c85aa4317b187e73a31e8ab775a10833', False),
403+
]
404+
)
405+
def test_is_same_password_scram(roleconf, desired_value, expected):
406+
assert roleconf.is_same_password(desired_value) == expected
407+
408+
@pytest.mark.parametrize('desired_value, expected', [
409+
(None, True),
410+
('incorrect_password', False),
411+
('md5c85aa4317b187e73a31e8ab775a10833', False),
412+
('SCRAM-SHA-256$4096:c3VwZXJzYWx0$E6lZT4K2olotsu19xYcF825iMPGdJQDYaklVS2mR6js=:Pe9DLNf8idnP59Q5l8Xmz3H+6LrTuiq//bcujQPGsRM=', False),
413+
]
414+
)
415+
def test_is_same_password_if_empty(roleconf, desired_value, expected):
416+
assert roleconf.is_same_password(desired_value) == expected
393417

394418

395419
@nondefault_attributes(dict(

0 commit comments

Comments
 (0)