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.
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ff3f3475bbaf6c434fe74f319a489df6bc9fe6e7aa1afe0fc08ab4d1a8c001db
|
|
| MD5 |
97579c8e84df23c0563d761ed1d5c578
|
|
| BLAKE2b-256 |
8e1b7d0cb5311838d06a534d8ae7348496683409afffddcd2bb8f154ea1243eb
|
Provenance
The following attestation bundles were made for credforge-0.1.0.tar.gz:
Publisher:
release.yml on trivikrama-madhusudhana/credforge
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
credforge-0.1.0.tar.gz -
Subject digest:
ff3f3475bbaf6c434fe74f319a489df6bc9fe6e7aa1afe0fc08ab4d1a8c001db - Sigstore transparency entry: 2194828634
- Sigstore integration time:
-
Permalink:
trivikrama-madhusudhana/credforge@ece86b54daf4de2bec2e6ce40e33bfd7f9c4d828 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/trivikrama-madhusudhana
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ece86b54daf4de2bec2e6ce40e33bfd7f9c4d828 -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bdf283c6bc51472ab35cd085d7671991f24d708ab79d7f5713d0e5b43229a5c7
|
|
| MD5 |
9c812fbe29261eebe7e1dbc3ba8023e3
|
|
| BLAKE2b-256 |
228cf160d3c85fecc1ef1e217745ad482c330aff9fbe1d26fca5c7dcce56663c
|
Provenance
The following attestation bundles were made for credforge-0.1.0-py3-none-any.whl:
Publisher:
release.yml on trivikrama-madhusudhana/credforge
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
credforge-0.1.0-py3-none-any.whl -
Subject digest:
bdf283c6bc51472ab35cd085d7671991f24d708ab79d7f5713d0e5b43229a5c7 - Sigstore transparency entry: 2194828636
- Sigstore integration time:
-
Permalink:
trivikrama-madhusudhana/credforge@ece86b54daf4de2bec2e6ce40e33bfd7f9c4d828 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/trivikrama-madhusudhana
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ece86b54daf4de2bec2e6ce40e33bfd7f9c4d828 -
Trigger Event:
release
-
Statement type: