Digestif.Hasher behaviour (digestif v0.4.1)

Copy Markdown

Behaviour and migration dispatcher for password hashers.

A hasher is configured as a {module, options} tuple. The module performs one algorithm; this dispatcher identifies the self-describing algorithm in a stored hash and routes verification only to the configured primary or legacy hashers.

Hashers may expose a rehash predicate, configuration validation, aliases for compatible stored prefixes, and a password byte limit. The bundled adapters also perform a small resource preflight before invoking their cryptographic backend.

Use the simpler Digestif facade in ordinary applications. This module is the extension and integration boundary for authentication libraries and custom hashers.

Summary

Callbacks

Returns the self-describing algorithm identifier this hasher emits and dispatches on, e.g. "argon2id".

Returns further stored identifiers this hasher also verifies, e.g. bcrypt's "2a" and "2y" for a "2b" hasher.

Hashes value under options, returning {:ok, encoded_hash}.

Returns whether encoded_hash is weaker than options and should be replaced.

Does the same work as verify/3 against a dummy hash, then returns false.

Returns the number of leading password bytes this algorithm distinguishes.

Returns :ok for a valid options list, or raises to reject it.

Returns whether value matches encoded_hash under options.

Functions

Returns whether a verified hash should be replaced by the primary hasher.

Returns a hasher's declared password byte limit, or nil when unlimited.

Validates one hasher tuple and its options.

Validates a primary hasher and its legacy migration set.

Verifies a value against the hasher selected by the stored algorithm.

Types

t()

@type t() :: {module(), keyword()}

Callbacks

algorithm()

(optional)
@callback algorithm() :: String.t()

Returns the self-describing algorithm identifier this hasher emits and dispatches on, e.g. "argon2id".

It must be the identifier segment of every hash hash/2 mints, and it must be unique across a configured set — dispatch selects a hasher by this value, never by list order. A hasher listed under :legacy_hashers must implement it; a primary-only hasher that never needs to be distinguished from others may omit it and receive every unrecognised encoding.

algorithm_aliases()

(optional)
@callback algorithm_aliases() :: [String.t()]

Returns further stored identifiers this hasher also verifies, e.g. bcrypt's "2a" and "2y" for a "2b" hasher.

Aliases share algorithm/0's uniqueness rule: within one configured set, no two hashers may claim the same identifier or alias, so dispatch stays unambiguous.

hash(value, options)

@callback hash(value :: String.t(), options :: keyword()) :: {:ok, String.t()}

Hashes value under options, returning {:ok, encoded_hash}.

The encoded hash must be self-describing: later verification routes on the algorithm identifier the hash carries, so what hash/2 emits and what algorithm/0 returns have to agree. Raise on invalid options rather than mint a hash the same configuration cannot verify.

needs_rehash?(encoded_hash, options)

(optional)
@callback needs_rehash?(encoded_hash :: String.t(), options :: keyword()) :: boolean()

Returns whether encoded_hash is weaker than options and should be replaced.

Consulted only for the primary hasher, and only after a successful verify; a legacy hash always needs rehashing without asking. The dispatcher treats a raise or a non-boolean result as false, but that is a safety net, not a contract to lean on — decide deliberately.

no_user_verify(value, options)

@callback no_user_verify(value :: String.t(), options :: keyword()) :: false

Does the same work as verify/3 against a dummy hash, then returns false.

This is the timing defence for a caller who names no stored hash — an unknown user. "No such account" and "wrong password" must take the same time, or the difference enumerates accounts. Returning anything truthy here would authenticate every such caller, so the return is false, always.

password_byte_limit()

(optional)
@callback password_byte_limit() :: pos_integer()

Returns the number of leading password bytes this algorithm distinguishes.

Implement it only for an algorithm that truncates — bcrypt at 72 bytes. A host that makes such a hasher primary must cap accepted passwords at this limit; otherwise two distinct passwords sharing a prefix verify interchangeably. Omitting the callback declares no limit.

validate_options!(options)

(optional)
@callback validate_options!(options :: keyword()) :: :ok

Returns :ok for a valid options list, or raises to reject it.

Runs at configuration time, before any password is hashed, so a misconfiguration fails at startup rather than at the first login. This is the boundary check that lets hash/2 and verify/3 trust the options they are given.

verify(value, encoded_hash, options)

@callback verify(value :: String.t(), encoded_hash :: String.t(), options :: keyword()) ::
  boolean()

Returns whether value matches encoded_hash under options.

The dispatcher sends an unrecognised algorithm identifier to the primary hasher, so a primary implementation is handed foreign and malformed encodings and must fail closed as false — after work indistinguishable in time from a real comparison, never a fast structural reject that leaks, by latency, whether the hash was even one of its own. Compare digests with :crypto.hash_equals/2, not ==.

Functions

needs_rehash?(encoded_hash, selected_hasher, primary_hasher)

@spec needs_rehash?(String.t(), t(), t()) :: boolean()

Returns whether a verified hash should be replaced by the primary hasher.

A hash verified by a legacy hasher always needs rehashing. For the primary hasher, its optional needs_rehash?/2 callback decides.

password_byte_limit(hasher)

@spec password_byte_limit(t()) :: pos_integer() | nil

Returns a hasher's declared password byte limit, or nil when unlimited.

validate!(arg1)

@spec validate!(t()) :: :ok

Validates one hasher tuple and its options.

validate_set!(primary, legacy_hashers)

@spec validate_set!(t(), [t()]) :: :ok

Validates a primary hasher and its legacy migration set.

Legacy hashers must declare self-describing algorithm identifiers. All identifiers and aliases must be unique, so dispatch cannot depend on list order.

verify_with_hashers(value, encoded_hash, primary, legacy_hashers)

@spec verify_with_hashers(String.t(), String.t(), t(), [t()]) :: {:ok, t()} | :error

Verifies a value against the hasher selected by the stored algorithm.

Only the primary hasher and explicitly listed legacy hashers are eligible. Unknown or malformed algorithm identifiers fall back to the primary hasher, which must fail closed for foreign encodings.