Skip to main content

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.

Metadata

Release files for credforge 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for credforge 0.1.0
File Size Uploaded
credforge-0.1.0.tar.gz 21.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for credforge 0.1.0
File Interpreter ABI Platform
credforge-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 37.1 kB

Release files / credforge-0.1.0.tar.gz

Download URL credforge-0.1.0.tar.gz
Size 21.6 kB
Tags Source
SHA-256 checksum
How to use checksums
ff3f3475bbaf6c434fe74f319a489df6bc9fe6e7aa1afe0fc08ab4d1a8c001db
BLAKE2b-256 checksum
How to use checksums
8e1b7d0cb5311838d06a534d8ae7348496683409afffddcd2bb8f154ea1243eb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 18, 2026.

Transparency log

Release files / credforge-0.1.0-py3-none-any.whl

Download URL credforge-0.1.0-py3-none-any.whl
Size 15.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
bdf283c6bc51472ab35cd085d7671991f24d708ab79d7f5713d0e5b43229a5c7
BLAKE2b-256 checksum
How to use checksums
228cf160d3c85fecc1ef1e217745ad482c330aff9fbe1d26fca5c7dcce56663c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 18, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page