[remark] Context binding password-based authentication

by Ciprian Dorin Craciun (https://volution.ro/ciprian) on 

Experimenting with password-based authentication implemented directly in Postgres, all sprinkled with a few twists.

// permanent-link // Lobsters // HackerNews // index // RSS



Warning

The following scheme and code should be considered highly experimental!

I have not analyzed the security strength and implications of the proposed scheme and code.

Also, I am not a cryptographer, thus I might be making huge mistakes.

Any feedback is welcome.


While working on an experimental project I wanted to try something "new" with regard to password-based authentication, something that would fulfill the following requirements (starting with the most important ones):


Why should the password hash be bound to the user email and the internal account identity?

Because we get the following feature: someone can't just copy-paste the (hashed) password from one account to another.

For example, an attacker who has write access to the accounts database, but doesn't have access to other parts of the database holding important user data (which he wants to obtain), could create a new account (that he controls and thus knows the password of), then swap the two accounts (the fake one and the targeted one) hashed passwords in the database, then authenticate normally via the web (or another) interface, exfiltrate the important user data, and finally swap back the hashed passwords in the database. Thus, by binding the password hash to the internal account identity, he can't undertake this type of attack.

(Obviously, the attacker can swap the user email address and undertake a password reset attack, and this should be handled as an independent issue.)

Why store the password salt and hash as UUIDs?

Because UUIDs are ubiquitous, available in most programming languages and databases (if not available, they can be easily stored as strings), can easily be printed (as strings), and if needed, can easily be converted to raw bytes through hex encoding.

Moreover, at least in Postgres, they are efficiently represented as 16 bytes. Thus, two UUIDs have an overhead of 32 bytes, compared to a standard Bcrypt string of 60 characters (which depending on the database encoding might mean more than 60 bytes).

Why implement the whole scheme directly in Postgres?

Because we can move most of the "sensitive" code paths within the database, and if one needs to change the backend, most code wouldn't need to be rewriten.

Also, because one would get fully-featured REPL for free, via the standard Postgres psql shell.


About choosing the password hashing function...

Well, it's Bcrypt, for no other reason than:

The work factor is chosen (and hard-coded) as 10, which given that Bcrypt uses a logarithmic work factor, implies 1024 iterations. It is the current minimum recommended by OWASP. It takes on average around 100 ms per hash, thus not a large burden on the database.

(Hard-coding the password hash function and iterations could make future migrations to other schemes difficult, however it is not a concern, as one can just add a new column stating the scheme used.)


There are a few concerns about Bcrypt (specifically to the way it is implemented or used):

Thus, because of the previous concerns and other practicalities, the following choices are made:


Instead of pseudo-code, here is the working Python3 implementation, that one can easily copy-paste into a Python3 shell:


Observations:


Running the code above, and printing all the intermediary steps, yields:

print ("## inputs")
print ("-> handle-raw  %s (%s) | %s" % (_handle_raw.hex (), len (_handle_raw), _handle_input))
print ("-> nonce-raw   %s (%d) | %s" % (_nonce_raw.hex (), len (_nonce_raw), _nonce_input))
print ("-> email-raw   %s (%d) | %s (%d)" % (_email_raw.hex (), len (_email_raw), _email_input, len (_email_input)))
print ("-> passw-raw   %s (%d) | %s (%d)" % (_password_raw.hex (), len (_password_raw), _password_input, len (_password_input)))
print ("-> derive-key  %s (%d) | %s (%d)" % (_derive_key.hex (), len (_derive_key), _purpose_derive.hex (), len (_purpose_derive)))
print ("-> passw-key   %s (%d) | %s (%d)" % (_password_key.hex (), len (_password_key), _purpose_password.hex (), len (_purpose_password)))
print ("-> salt-key    %s (%d) | %s (%d)" % (_salt_key.hex (), len (_salt_key), _purpose_salt.hex (), len (_purpose_salt)))
print ("## crypt")
print ("-> pass-base64 %s (%d)" % (_password_base64, len (_password_base64)))
print ("-> salt-bytes  %s (%d)" % (_salt_raw.hex (), len (_salt_raw)))
print ("-> salt-base64 %s (%d)" % (_salt_base64, len (_salt_base64)))
print ("-> salt-crypt  %s (%d)" % (_salt_crypt, len (_salt_crypt)))
print ("-> crypt-salt  %s (%d)" % (_crypt_salt, len (_crypt_salt)))
print ("-> crypt-hash  %s (%d)" % (_crypt_hash, len (_crypt_hash)))
print ("-> hash-crypt  %s (%d)" % (_hash_crypt, len (_hash_crypt)))
print ("-> hash-base64 %s (%d)" % (_hash_base64, len (_hash_base64)))
print ("-> hash-raw    %s (%d)" % (_hash_raw.hex (), len (_hash_raw)))
print ("## outputs")
print ("-> hash-key    %s (%d) | %s (%d)" % (_hash_key.hex (), len (_hash_key), _purpose_hash.hex (), len (_purpose_hash)))
print ("-> hash-hex    %s (%d)" % (_hash_hex, len (_hash_hex)))
print ("-> hash-output %s" % (_hash_output))
## inputs
-> handle-raw  6a9e4086b11e483386eb09aa2676c13f (16) | 6a9e4086b11e483386eb09aa2676c13f
-> nonce-raw   94b81ffc1803418b8eb4b73243c34bfb (16) | 94b81ffc1803418b8eb4b73243c34bfb
-> email-raw   706572736f6e406578616d706c652e636f6d (18) | person@example.com (18)
-> passw-raw   70617373776f7264 (8) | password (8)
-> derive-key  eb4c3b0b8dd6cc06310ee0d759116fbc1a1b8b706e38de8dfc293d2fcff81390 (32) | 1c666ffbc42e8225563d7f6b0988dc6c218a882d545b62935c34071c14f2bcb2 (32)
-> passw-key   71622f4d8649f3e7c73f1365e14e5fe0802dc2f0cb339d438f2357fdb63db90c (32) | 2e197c7520e2159323f97d1fa62f96a64e4c495df0a17ea05e127743bcfd35b7 (32)
-> salt-key    8694b08e77be9b68d5bb6566121376f9d515ae9a6a9d035e60557fcbc4d84d5c (32) | 66a7b84a9bb80a91b477e5746a4d29e8c674a8fe8c5f2b145db533b303f14644 (32)
## crypt
-> pass-base64 cWIvTYZJ8+fHPxNl4U5f4IAtwvDLM51DjyNX/bY9uQw= (44)
-> salt-bytes  8694b08e77be9b68d5bb6566121376f9 (16)
-> salt-base64 hpSwjne+m2jVu2VmEhN2+Q (22)
-> salt-crypt  fnQuhlc8k0hTs0TkCfL08O (22)
-> crypt-salt  $2a$10$fnQuhlc8k0hTs0TkCfL08O (29)
-> crypt-hash  $2a$10$fnQuhlc8k0hTs0TkCfL08O3xz8XB3LioTk8TpXk/VZWXxrVuPXfCi (60)
-> hash-crypt  3xz8XB3LioTk8TpXk/VZWXxrVuPXfCi (31)
-> hash-base64 5z1+ZD5NkqVm+VrZmBXbYZztXwRZhEk (31)
-> hash-raw    e73d7e643e4d92a566f95ad99815db619ced5f04598449 (23)
## outputs
-> hash-key    cb2408885909a9b0112fd0bfa423e42547219b10fce04bf887a1bd1aea25d872 (32) | 1b289a071d0c392722e415298c7b5140ce1fbabaecf2232bf7e4bce738276df2 (32)
-> hash-hex    cb2408885909a9b0112fd0bfa423e42547219b10fce04bf887a1bd1aea25d872 (64)
-> hash        c119df3b-d187-5414-9c62-78d3ce67fcf8

And here is the corresponding Postgres function implementation:

create or replace function password_hash
  (
    in _handle uuid,
    in _nonce uuid,
    in _email text,
    in _password text
  )
  returns uuid
  returns null on null input
  immutable
  leakproof

  language 'plpgsql'

as $__plpgsql_code__$

  declare

    _handle_input constant uuid default _handle;
    _nonce_input constant uuid default _nonce;
    _email_input constant text default _email;
    _password_input constant text default _password;

    _handle_raw constant bytea default uuid_send (_handle_input);
    _nonce_raw constant bytea default uuid_send (_nonce_input);
    _email_raw constant bytea default convert_to (normalize (_email_input, nfc), 'utf8');
    _password_raw constant bytea default convert_to (normalize (_password_input, nfc), 'utf8');

    _purpose_core constant text default 'skeldvakt:password-based-authentication:2024a';
    _purpose_derive bytea default digest (_purpose_core || ':derive', 'sha3-256');
    _purpose_password bytea default digest (_purpose_core || ':password', 'sha3-256');
    _purpose_salt bytea default digest (_purpose_core || ':salt', 'sha3-256');
    _purpose_hash bytea default digest (_purpose_core || ':hash', 'sha3-256');

    _derive_key constant bytea default hmac (_handle_raw, _purpose_derive || _nonce_raw, 'sha3-256');
    _password_key constant bytea default hmac (_password_raw, _purpose_password || _derive_key, 'sha3-256');
    _salt_key constant bytea default hmac (_email_raw, _purpose_salt || _derive_key, 'sha3-256');

    _password_base64 constant text default encode (_password_key, 'base64');
    _salt_raw constant bytea default substring (_salt_key, 1, 16);
    _salt_base64 constant text default substring (encode (_salt_raw, 'base64'), 1, 22);
    _salt_crypt constant text default translate (_salt_base64, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789');
    _crypt_strength constant int8 default 10;
    _crypt_salt constant text default ('$2a$' || lpad (format ('%s', _crypt_strength), 2, '0') || '$' || _salt_crypt);
    _crypt_hash constant text default crypt (_password_base64, _crypt_salt);
    _hash_crypt constant text default substring (_crypt_hash, 30);
    _hash_base64 constant text default translate (_hash_crypt, './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/');
    _hash_raw constant bytea default decode (_hash_base64 || '=', 'base64');

    _hash_key constant bytea default hmac (_hash_raw, _purpose_hash || _derive_key, 'sha3-256');
    _hash_hex constant text default encode (_hash_key, 'hex');

    _hash_output constant text default uuid_generate_v5 (uuid_nil (), _hash_hex);

  begin
    return _hash_output;
  end;

$__plpgsql_code__$;
> select password_hash (
        '6a9e4086-b11e-4833-86eb-09aa2676c13f',
        '94b81ffc-1803-418b-8eb4-b73243c34bfb',
        'person@example.com',
        'password'
    );

            password_hash
--------------------------------------
 c119df3b-d187-5414-9c62-78d3ce67fcf8
(1 row)