Metadata-Version: 2.4
Name: credforge
Version: 0.1.0
Summary: Password hashing and replayable-secret encryption behind one misuse-resistant API + CLI.
Author: Trivikrama Madhusudhana
License-Expression: MIT
Project-URL: Homepage, https://github.com/trivikrama-madhusudhana/credforge
Project-URL: Source, https://github.com/trivikrama-madhusudhana/credforge
Project-URL: Issues, https://github.com/trivikrama-madhusudhana/credforge/issues
Project-URL: Changelog, https://github.com/trivikrama-madhusudhana/credforge/blob/main/CHANGELOG.md
Keywords: password-hashing,argon2,pbkdf2,fernet,key-rotation,secrets-management,security,cryptography,python
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security :: Cryptography
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography>=42
Requires-Dist: argon2-cffi>=23
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Dynamic: license-file

# 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`](https://cryptography.io) and
[`argon2-cffi`](https://github.com/hynek/argon2-cffi), not new crypto.

[![CI](https://github.com/trivikrama-madhusudhana/credforge/actions/workflows/ci.yml/badge.svg)](https://github.com/trivikrama-madhusudhana/credforge/actions/workflows/ci.yml)
![Python](https://img.shields.io/badge/python-3.10%E2%80%933.13-blue)
![Typed](https://img.shields.io/badge/typed-yes-brightgreen)
![License](https://img.shields.io/badge/license-MIT-green)

## 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

```bash
pip install credforge
```

## Passwords

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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.

```bash
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](SECURITY.md).

## Develop

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

## License

MIT, see [LICENSE](LICENSE). Built with the help of Claude Code.
