yf_password class

Password hashing and verification shared by auth_user, auth_admin, the validators and the reset flow.

Strategy: the algorithm of a stored value is detected from its shape, so every row that is
already in the database keeps working: 32 hex = md5, 40 hex = sha1, 64 hex = sha256 (all `hash(algo, pass . SALT)`),
a `$2y$`/`$argon2` string = password_hash(), anything else = the plaintext itself. New hashes use ALGO, sha256 by
default; 'bcrypt' or 'argon2id' switch to password_hash(). Every switch defaults to the original yfix/yf behaviour, so
md5/plaintext storage keeps working until a site enables HASH_ON_WRITE and REHASH_ON_LOGIN.

Fileclasses/yf_password.class.php
Use_class('password')

Public properties

A site sets them in its config with $PROJECT_CONF['password']['NAME'] = ...;

PropertyDefaultDescription
$ALGO
string
'sha256' Algorithm for new hashes: 'sha256' (default), 'sha1', 'md5', 'bcrypt', 'argon2id'
$ALLOW_LEGACY
bool
true Accept stored passwords in every detected form; false accepts ALGO only (set once every row is re-hashed)
$HASH_ON_WRITE
bool
false password_update/md5_not_empty/password_store validators produce hash() instead of md5/plain. Widen the password columns first (https://github.com/yfix/yf/blob/master/docs/UPGRADING.md): the validators cannot check them
$MIN_COLUMN_LENGTH
int|null
derived from ALGO */ Property [ public $MIN_COLUMN_LENGTH = NULL Shortest column that holds a hash(); null = derived from ALGO
$OPTIONS
array
[] password_hash() options for bcrypt/argon2id, e.g. ['cost' => 12]
$REHASH_ON_LOGIN
bool
false A successful login with a password stored in another form than ALGO stores hash() of it
$SALT
string
'' Appended to the password before a digest (md5/sha1/sha256); keep it per install and do not change it later
$_column_fits
array
[] @conf_skip

Public methods 11

column_fits

public function column_fits($table, $column = 'password')

A hash must not be truncated by a short legacy column (user.password was varchar(32)).

    public function column_fits($table, $column = 'password')
    {
        $key = $table . '.' . $column;
        if ( ! isset($this->_column_fits[$key])) {
            $info = db()->utils()->column_info($table, $column);
            $length = (int) ($info['length'] ?? 0);
            $type = strtolower((string) ($info['type'] ?? ''));
            $min = $this->min_column_length();
            $this->_column_fits[$key] = ($length >= $min) || in_array($type, ['text', 'mediumtext', 'longtext', 'blob']);
            if ( ! $this->_column_fits[$key]) {
                trigger_error(__CLASS__ . ': column ' . $key . ' is too short for ' . $this->_algo() . ' (needs ' . $min . '), re-hash skipped; see https://github.com/yfix/yf/blob/master/docs/UPGRADING.md', E_USER_WARNING);
            }
        }
        return $this->_column_fits[$key];
    }

detect

public function detect($stored)

'md5', 'sha1', 'sha256', 'bcrypt', 'argon2id' or 'plain' ('' for an empty value).

    public function detect($stored)
    {
        $stored = (string) $stored;
        if ($stored === '') {
            return '';
        }
        if (ctype_xdigit($stored)) {
            $by_length = [32 => 'md5', 40 => 'sha1', 64 => 'sha256'];
            if (isset($by_length[strlen($stored)])) {
                return $by_length[strlen($stored)];
            }
        }
        if ($stored[0] === '$') {
            $info = password_get_info($stored);
            if ( ! empty($info['algo'])) {
                return strpos($stored, '$argon2') === 0 ? 'argon2id' : 'bcrypt';
            }
        }
        return 'plain';
    }

hash

public function hash($plain, $algo = null)
  • $plain string
  • $algo null|string
    public function hash($plain, $algo = null)
    {
        $algo = $this->_algo($algo);
        if ($algo === 'bcrypt') {
            return password_hash((string) $plain, PASSWORD_BCRYPT, $this->OPTIONS);
        }
        if ($algo === 'argon2id') {
            return password_hash((string) $plain, PASSWORD_ARGON2ID, $this->OPTIONS);
        }
        return hash($algo, (string) $plain . $this->SALT);
    }

hash_token

public function hash_token($token)
    public function hash_token($token)
    {
        return hash('sha256', (string) $token);
    }

is_hash

public function is_hash($stored)
    public function is_hash($stored)
    {
        return ! in_array($this->detect($stored), ['', 'plain'], true);
    }
public function min_column_length()
    public function min_column_length()
    {
        if ($this->MIN_COLUMN_LENGTH !== null) {
            return (int) $this->MIN_COLUMN_LENGTH;
        }
        $lengths = ['md5' => 32, 'sha1' => 40, 'sha256' => 64, 'bcrypt' => 60, 'argon2id' => 97];
        return $lengths[$this->_algo()];
    }

new_token

public function new_token()

Single-use reset token: the caller stores hash_token() and mails the plain token.

    public function new_token()
    {
        return bin2hex(random_bytes(32));
    }

rehash

public function rehash($table, $id, $old_stored, $plain, $column = 'password')

Store a fresh hash for a row after a successful login in another form and return it (false when skipped or not written); the WHERE on the old value avoids racing a parallel change.

    public function rehash($table, $id, $old_stored, $plain, $column = 'password')
    {
        if ( ! $this->REHASH_ON_LOGIN || ! $this->column_fits($table, $column)) {
            return false;
        }
        $hash = $this->hash($plain);
        $ok = db()->update($table, [$column => $hash], '`id` = ' . (int) $id . ' AND `' . $column . '` = "' . db()->es($old_stored) . '"');
        return ($ok && db()->affected_rows() > 0) ? $hash : false;
    }

verify

public function verify($plain, $stored, $legacy = [])

Returns ['ok' => bool, 'rehash' => bool, 'via' => detected form or '']. $legacy['salts']: extra salt candidates for digests (yf_auth_user::$USER_PASSWORD_SALT), SALT is always tried; $legacy['md5_escaped']: also the digest of the mysql-escaped password (how yf_auth_admin hashed); $legacy['plain']: accept the plaintext itself (how yf_register stored). 'rehash' is true when the stored form is not ALGO (or the SALT differs), so a login can migrate the row.

  • $legacy array
    public function verify($plain, $stored, $legacy = [])
    {
        $plain = (string) $plain;
        $stored = (string) $stored;
        $no = ['ok' => false, 'rehash' => false, 'via' => ''];
        if ($plain === '' || $stored === '') {
            return $no;
        }
        $via = $this->detect($stored);
        $algo = $this->_algo();
        if ($via !== $algo && $via !== 'plain' && ! $this->ALLOW_LEGACY) {
            return $no;
        }
        if ($via === 'bcrypt' || $via === 'argon2id') {
            if ( ! password_verify($plain, $stored)) {
                return $no;
            }
            $rehash = ($via !== $algo) || password_needs_rehash($stored, $via === 'bcrypt' ? PASSWORD_BCRYPT : PASSWORD_ARGON2ID, $this->OPTIONS);
            return ['ok' => true, 'rehash' => $rehash, 'via' => $via];
        }
        if (in_array($via, ['md5', 'sha1', 'sha256'], true)) {
            // rows in the configured form were written by hash() with SALT; other digests came from the caller's
            // legacy code with its own salt, so the empty default SALT must not widen what those rows accept
            $salts = isset($legacy['salts']) ? (array) $legacy['salts'] : [$this->SALT];
            if ($via === $algo || $this->SALT !== '') {
                $salts[] = $this->SALT;
            }
            $salts = array_unique($salts);
            $candidates = [];
            foreach ($salts as $salt) {
                $candidates[$salt] = hash($via, $plain . $salt);
            }
            if ( ! empty($legacy['md5_escaped'])) {
                $candidates['__escaped'] = hash($via, db()->_mysql_escape_mimic($plain));
            }
            foreach ($candidates as $salt => $candidate) {
                if (hash_equals($stored, $candidate)) {
                    $rehash = ($via !== $algo) || ($salt !== $this->SALT);
                    return ['ok' => true, 'rehash' => $rehash, 'via' => $via];
                }
            }
        }
        if ( ! empty($legacy['plain']) && $this->ALLOW_LEGACY && hash_equals($stored, $plain)) {
            // a replayed digest matches "plain" too (old behaviour); re-hashing it would lock the real password out
            return ['ok' => true, 'rehash' => $via === 'plain', 'via' => 'plain'];
        }
        return $no;
    }

Methods starting with _ hooks and helpers; not routable as page actions

__call

public function __call($name, $args)
    public function __call($name, $args)
    {
        return main()->extend_call($this, $name, $args);
    }

_algo

public function _algo($algo = null)
  • $algo null|string
    public function _algo($algo = null)
    {
        $algo = strtolower((string) ($algo ?: $this->ALGO));
        $aliases = ['argon2' => 'argon2id', 'argon2i' => 'argon2id', '2y' => 'bcrypt', 'password_hash' => 'bcrypt', 'default' => 'bcrypt'];
        $algo = $aliases[$algo] ?? $algo;
        return in_array($algo, ['md5', 'sha1', 'sha256', 'bcrypt', 'argon2id'], true) ? $algo : 'sha256';
    }