Skip to main content

Password hashing and replayable-secret encryption behind one misuse-resistant API + CLI.

Project description

credforge

credforge grew out of my client work. Across a run of backends I kept needing the same two credential operations, and kept making the same call by hand at each site. One operation is one-way password hashing. The other is encrypting a secret I had to hand back verbatim later, like an API key for a client's ERP or a scraper's session cookie. Choosing between hashing and encrypting is easy to get subtly wrong, and a backend is where getting it wrong costs you, so I pulled the pattern into one small library with the safe option as the default and put it here. It's a thin layer over cryptography and argon2-cffi, not new crypto.

CI Python Typed License

Why it exists

The primitives are already good. What was missing, at least in my projects, was one place that makes the choice between them with defaults that are correct out of the box. Password libraries (passlib, pwdlib, argon2-cffi) hash and stop. cryptography encrypts and rotates keys but has no password story. So the "hash it or encrypt it" decision gets made ad hoc at every call site, which is exactly where it goes wrong. credforge makes that decision once, and picks argon2id, unique salts, and current OWASP work factors for you.

The tricky parts

These are the problems I kept running into, and the reasons the library earns its keep. The rest is plumbing.

A hash that survives its own work factor going up

Every hash carries its own algorithm, work factor, and salt inside the stored string, so verify reads those back from the value instead of a global constant. A hash written at 200k iterations still verifies after you raise the default to 600k. Skip this and the day you bump the cost, every existing user is locked out with a correct password and no error to explain why.

Upgrading weak hashes without ever weakening a strong one

On a correct login, if the stored hash is below today's target, credforge re-hashes at the target and hands you the new value to persist. The whole difficulty is the word "below". argon2's own check_needs_rehash returns true for any parameter mismatch, including a hash that is stronger than your target, so the obvious upgrade path quietly downgrades those hashes on the next login. credforge only rehashes when the stored parameters are at or below target on every axis, and never on a failed verify. That is the bug waiting in the obvious implementation, and it is why credforge compares the parameters itself instead of trusting check_needs_rehash to mean "weaker".

Key rotation you can interrupt

Rotation re-encrypts every secret under a new key using MultiFernet, which encrypts with the first key in its list and decrypts by trying each key in turn. The new key goes first, and the old key stays in the list until the last secret is migrated. So a rotation you kill halfway still decrypts everything, old and new. Put the keys in the wrong order and every "rotation" re-encrypts under the old key, which means retiring that key destroys the data you thought you had moved.

Not leaking which usernames exist

argon2 and pbkdf2 are deliberately slow, so a login handler that skips hashing for an unknown user answers faster, and that timing gap tells an attacker the account does not exist. Django shipped CVE-2025-13473 for exactly this in 2025. dummy_verify spends a real verify's worth of time and returns False. It takes an algorithm argument because a pbkdf2 verify costs far more than an argon2 one, about 80ms against 15ms on my machine, and calling the wrong dummy reopens the gap it exists to close.

One error taxonomy at the boundary

A wrong password is a normal answer, so verify returns False. A malformed hash or an undecryptable secret is a different kind of thing, and credforge raises HashFormatError, DecryptError, or KeyFormatError for it. No raw cryptography or argon2 exception reaches the caller, and a broken input never quietly becomes a True.

Install

pip install credforge

Passwords

from credforge import hash_password, verify_password, verify_and_upgrade

stored = hash_password("correct horse battery staple")   # argon2id by default
verify_password("correct horse battery staple", stored)  # True
verify_password("wrong", stored)                         # False

Lazy upgrade folds into the login path. On a correct login verify_and_upgrade returns a fresh hash when the stored one was weaker than today's target, and None otherwise:

verified, new_hash = verify_and_upgrade(password, stored)
if verified:
    login(user)
    if new_hash:
        db.update_password_hash(user, new_hash)

For the unknown-user branch, match the algorithm your real hashes use:

from credforge import verify_password, dummy_verify

row = db.get_user(username)
ok = verify_password(password, row.hash) if row else dummy_verify(password)

Secrets

For values you send back verbatim, encrypt them and keep the key somewhere other than your codebase:

from credforge import generate_key, seal_secret, open_secret

key = generate_key()
token = seal_secret("sk_live_4eC39HqLy", key)
open_secret(token, key)                       # "sk_live_4eC39HqLy"

Rotating to a new key walks the whole store. Every token comes back readable under the new key alone, and the old key is safe to drop only once the last one is migrated:

from credforge import rotate_store

new_tokens = rotate_store(old_tokens, old_key, new_key)

Routing

If you would rather register credential kinds than remember which primitive each one takes, the router keeps that decision in one table:

from credforge import generate_key, routing

routing.register("stripe_key", "encrypt")

key = generate_key()
token = routing.store("stripe_key", "sk_live_4eC39HqLy", key=key)
routing.retrieve("stripe_key", token, key=key)     # "sk_live_4eC39HqLy"

pw_hash = routing.store("user_password", "hunter2")   # hash-case takes no key
routing.check("user_password", "hunter2", pw_hash)    # True

Hash-case kinds refuse a key, encrypt-case kinds require one, and asking to recover a hash-case credential (or verify an encrypt-case one) raises. The primitives can't be crossed by accident.

CLI

Secrets and passwords come from stdin, and Fernet keys come from an environment variable or a file. Nothing sensitive goes on argv, where it would show up in ps and shell history.

credforge genkey                                  # a fresh Fernet key
echo -n "s3cret" | credforge hash                 # argon2id hash from stdin
credforge verify '<stored-hash>'                  # exit 0 match, 1 mismatch

export CREDFORGE_KEY=$(credforge genkey)
echo -n "sk_live_4eC39HqLy" | credforge seal --key-env CREDFORGE_KEY
credforge open '<token>' --key-env CREDFORGE_KEY
cat tokens.txt | credforge rotate --old-key-env OLD --new-key-env NEW

Stored formats

Both hash formats are self-describing, so verify never needs external config to read an old value.

Kind Format
pbkdf2 pbkdf2-sha256$<iterations>$<b64 salt>$<b64 derived-key>
argon2 $argon2id$v=19$m=19456,t=2,p=1$<b64 salt>$<b64 hash>
sealed secret a Fernet token (AES-128-CBC + HMAC-SHA256, base64)

What it is not

credforge is an at-rest credential library. It does not store or custody your Fernet keys; that is the caller's job, whether that means an environment variable, a vault, or a KMS. It assumes the application host and its memory are trusted, so it is not a defense against a compromised runtime. The full threat model is in SECURITY.md.

Develop

python -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
ruff check . && ruff format --check . && mypy credforge && pytest

License

MIT, see LICENSE. Built with the help of Claude Code.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

credforge-0.1.0.tar.gz (21.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

credforge-0.1.0-py3-none-any.whl (15.5 kB view details)

Uploaded Python 3

File details

Details for the file credforge-0.1.0.tar.gz.

File metadata

  • Download URL: credforge-0.1.0.tar.gz
  • Upload date:
  • Size: 21.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for credforge-0.1.0.tar.gz
Algorithm Hash digest
SHA256 ff3f3475bbaf6c434fe74f319a489df6bc9fe6e7aa1afe0fc08ab4d1a8c001db
MD5 97579c8e84df23c0563d761ed1d5c578
BLAKE2b-256 8e1b7d0cb5311838d06a534d8ae7348496683409afffddcd2bb8f154ea1243eb

See more details on using hashes here.

Provenance

The following attestation bundles were made for credforge-0.1.0.tar.gz:

Publisher: release.yml on trivikrama-madhusudhana/credforge

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file credforge-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: credforge-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 15.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for credforge-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bdf283c6bc51472ab35cd085d7671991f24d708ab79d7f5713d0e5b43229a5c7
MD5 9c812fbe29261eebe7e1dbc3ba8023e3
BLAKE2b-256 228cf160d3c85fecc1ef1e217745ad482c330aff9fbe1d26fca5c7dcce56663c

See more details on using hashes here.

Provenance

The following attestation bundles were made for credforge-0.1.0-py3-none-any.whl:

Publisher: release.yml on trivikrama-madhusudhana/credforge

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page