DevLab / security / password-hash-utility-with-argon2-cffi
SECURITY PYTHON BEGINNER

Password Hash Utility with argon2-cffi

Storing passwords in plaintext—or even with fast hashes like MD5 or SHA-256—is a critical security vulnerability. Attackers who breach a database can crack

Python3.10+
OSLinux / macOS
Lines~159
Packages1 deps
Install Dependencies
$ pip install argon2-cffi
Usage
$ python3 hash_pass.py --password 'mysecret' --verify stored_hash
#security#python#beginner#argon2

Password Hash Utility with argon2-cffi

Problem Statement

Storing passwords in plaintext—or even with fast hashes like MD5 or SHA-256—is a critical security vulnerability. Attackers who breach a database can crack billions of fast-hash passwords per second with commodity GPUs. Modern password storage requires a memory-hard algorithm that is deliberately slow and resistant to GPU/ASIC parallelization. Argon2 is the winner of the 2015 Password Hashing Competition and is the current OWASP-recommended default.

This script provides a small, dependency-light CLI that wraps argon2-cffi to hash a password (producing a self-describing encoded string you can store in a database) and to verify a plaintext password against a previously stored hash. It is designed for DevOps scripts, CI pipelines, and admin tooling where you need to generate or check a hash without writing boilerplate.

How It Works

The script uses a two-command design driven by a mutually-exclusive argument group: --password triggers hashing, and --verify (combined with --password) triggers verification. Under the hood, argon2-cffi's PasswordHasher class handles salt generation, parameter selection, and encoding into the standard PHC string format ($argon2id$v=19$m=...,t=...,p=...$salt$hash). Because that encoded string already contains the salt and parameters, verification only needs the stored string and the candidate password—no separate salt column in your database.

Key design decisions:

  • Argon2id is used (the hybrid variant recommended by OWASP) rather than pure Argon2i or Argon2d.
  • Parameters are configurable via environment variables so deployments with different memory budgets can tune them without editing code.
  • Exit codes are meaningful (0 success, 1 verification mismatch, 2 usage/config error) so the tool composes cleanly in shell scripts and CI.
  • Passwords can be read from stdin to avoid leaking them into shell history or ps output.

Full Script

#!/usr/bin/env python3
"""
Password Hash Utility with argon2-cffi

Hash a password into a PHC-encoded Argon2id string, or verify a plaintext
password against a stored hash. Designed for CLI use in admin scripts and
CI pipelines.

Examples:
    python3 hash_pass.py --password 'mysecret'
    python3 hash_pass.py --password 'mysecret' --verify '$argon2id$v=19$...'
    echo -n 'mysecret' | python3 hash_pass.py --stdin
"""

from __future__ import annotations

import argparse
import os
import sys
from typing import Optional

from argon2 import PasswordHasher
from argon2.exceptions import (
    InvalidHashError,
    VerificationError,
    VerifyMismatchError,
)
from argon2.low_level import Type


# ---------------------------------------------------------------------------
# Configuration (overridable via environment variables)
# ---------------------------------------------------------------------------
# Defaults follow OWASP's recommended minimum for Argon2id (as of 2024):
#   m = 19456 KiB (~19 MiB), t = 2 iterations, p = 1 lane.
# Increase m/t for higher-value secrets; tune to your hardware's latency budget.
ARGON2_TIME_COST = int(os.environ.get("ARGON2_TIME_COST", "2"))
ARGON2_MEMORY_COST = int(os.environ.get("ARGON2_MEMORY_COST", str(19 * 1024)))
ARGON2_PARALLELISM = int(os.environ.get("ARGON2_PARALLELISM", "1"))
ARGON2_HASH_LEN = int(os.environ.get("ARGON2_HASH_LEN", "32"))
ARGON2_SALT_LEN = int(os.environ.get("ARGON2_SALT_LEN", "16"))
ARGON2_TYPE_NAME = os.environ.get("ARGON2_TYPE", "id").lower()

# Map the human-readable type name to the low-level enum.
_TYPE_MAP = {
    "id": Type.ID,
    "i": Type.I,
    "d": Type.D,
}


def _build_hasher() -> PasswordHasher:
    """Construct a PasswordHasher from the module-level configuration."""
    try:
        argon2_type = _TYPE_MAP[ARGON2_TYPE_NAME]
    except KeyError as exc:
        raise ValueError(
            f"Invalid ARGON2_TYPE={ARGON2_TYPE_NAME!r}; expected one of "
            f"{sorted(_TYPE_MAP)}"
        ) from exc

    return PasswordHasher(
        time_cost=ARGON2_TIME_COST,
        memory_cost=ARGON2_MEMORY_COST,
        parallelism=ARGON2_PARALLELISM,
        hash_len=ARGON2_HASH_LEN,
        salt_len=ARGON2_SALT_LEN,
        type=argon2_type,
    )


def hash_password(password: str) -> str:
    """Return a PHC-encoded Argon2 hash string for `password`."""
    hasher = _build_hasher()
    # The returned string embeds salt, params, and hash. Store this verbatim
    # in a VARCHAR(255) (or TEXT) column. It is safe to log — it is one-way.
    return hasher.hash(password)


def verify_password(stored_hash: str, candidate: str) -> bool:
    """
    Return True if `candidate` matches `stored_hash`.

    Raises InvalidHashError if `stored_hash` is not a valid Argon2 PHC string.
    """
    hasher = _build_hasher()
    try:
        hasher.verify(stored_hash, candidate)
        return True
    except VerifyMismatchError:
        return False
    except VerificationError as exc:
        # Covers malformed hashes and other low-level verification failures.
        raise InvalidHashError(str(exc)) from exc


def _read_password(args: argparse.Namespace) -> str:
    """Resolve the plaintext password from CLI args or stdin."""
    if args.password is not None:
        return args.password
    if args.stdin:
        # Read the entire stream; strip a single trailing newline so
        # `echo 'pw' | ...` works as expected without breaking passwords
        # that legitimately end in whitespace-only content.
        data = sys.stdin.read()
        if data.endswith("\n"):
            data = data[:-1]
        if not data:
            raise ValueError("No password received on stdin")
        return data
    raise ValueError("No password supplied (use --password or --stdin)")


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="hash_pass.py",
        description="Hash or verify a password using Argon2id.",
    )
    parser.add_argument(
        "--password",
        "-p",
        help="Plaintext password. WARNING: visible in shell history and ps.",
        default=None,
    )
    parser.add_argument(
        "--stdin",
        action="store_true",
        help="Read the plaintext password from stdin (safer than --password).",
    )
    parser.add_argument(
        "--verify",
        "-v",
        metavar="HASH",
        help="Verify --password/--stdin against this stored Argon2 hash.",
        default=None,
    )
    parser.add_argument(
        "--quiet",
        "-q",
        action="store_true",
        help="Suppress non-essential output (useful in scripts).",
    )
    return parser


def main(argv: Optional[list[str]] = None) -> int:
    parser = _build_parser()
    args = parser.parse_args(argv)

    # --- Resolve the plaintext password -----------------------------------
    try:
        password = _read_password(args)
    except ValueError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    # --- Verify mode -------------------------------------------------------
    if args.verify is not None:
        try:
            ok = verify_password(args.verify, password)
        except InvalidHashError as exc:
            print(f"error: invalid stored hash: {exc}", file=sys.stderr)
            return 2
        if ok:
            if not args.quiet:
                print("OK: password matches")
            return 0
        if not args.quiet:
            print("FAIL: password does not match", file=sys.stderr)
        return 1

    # --- Hash mode ---------------------------------------------------------
    try:
        encoded = hash_password(password)
    except ValueError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    print(encoded)
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        sys.exit(130)

Configuration

All tuning is done through environment variables, so you can adjust parameters per deployment without editing the script.

Variable Default Purpose
ARGON2_TIME_COST 2 Number of iterations. Higher = slower to hash and crack.
ARGON2_MEMORY_COST 19456 Memory in KiB. This is the primary GPU-resistance knob.
ARGON2_PARALLELISM 1 Lanes (threads). Match to CPU cores for server workloads.
ARGON2_HASH_LEN 32 Output hash length in bytes. 32 is standard.
ARGON2_SALT_LEN 16 Random salt length in bytes. Never lower than 16.
ARGON2_TYPE id One of id, i, d. Keep id unless you have a reason.

Example: raise the memory cost to 64 MiB on a beefy auth server:

ARGON2_MEMORY_COST=65536 ARGON2_TIME_COST=3 python3 hash_pass.py --stdin

Usage Examples

1. Hash a password (interactive, avoids shell history):

$ read -s -p "Password: " PW; echo
Password:
$ echo -n "$PW" | python3 hash_pass.py --stdin
$argon2id$v=19$m=19456,t=2,p=1$YjA1M2E...$3f9c1d...

2. Verify against a stored hash:

$ python3 hash_pass.py --password 'mysecret' --verify '$argon2id$v=19$m=19456,t=2,p=1$YjA1M2E...$3f9c1d...'
OK: password matches
$ echo $?
0

3. Wrong password (exit code 1 for scripting):

$ python3 hash_pass.py --password 'wrong' --verify '$argon2id$v=19$m=19456,t=2,p=1$YjA1M2E...$3f9c1d...'
FAIL: password does not match
$ echo $?
1

4. Use in a shell conditional:

if python3 hash_pass.py --quiet --password "$PW" --verify "$STORED"; then
    echo "Authenticated"
else
    echo "Access denied" >&2
    exit 1
fi

Error Handling

Situation Behavior
No --password and no --stdin Prints error: No password supplied... and exits 2.
Empty stdin Prints error: No password received on stdin and exits 2.
Malformed stored hash (verify mode) Prints error: invalid stored hash: ... and exits 2.
Password mismatch Prints FAIL: password does not match to stderr and exits 1.
Invalid ARGON2_TYPE value Raises ValueError, caught and reported, exit 2.
Ctrl-C Clean exit code 130 (standard for SIGINT).

The script never writes the plaintext password to stdout; only the hash is printed. Verification failures go to stderr so they don't pollute piped output.

Extending Ideas

  • Batch mode: Accept a CSV of username,password on stdin and emit username,hash so you can seed a user table in one pass.
  • Rehash-on-login: After a successful verify, call hasher.check_needs_rehash(stored_hash) and if True, emit a fresh hash so old parameters are upgraded transparently.
  • Keyring / secrets manager integration: Replace --password with a lookup against AWS Secrets Manager, HashiCorp Vault, or the OS keyring so plaintext never touches argv.
  • Structured output: Add a --json flag that emits {"algorithm": "argon2id", "hash": "...", "params": {...}} for consumption by other tools.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com