xtr-password-hasher 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xtr_password_hasher-3.0.0/LICENSE +21 -0
- xtr_password_hasher-3.0.0/PKG-INFO +279 -0
- xtr_password_hasher-3.0.0/README.md +254 -0
- xtr_password_hasher-3.0.0/pyproject.toml +146 -0
- xtr_password_hasher-3.0.0/pyproject.toml.orig +117 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/__init__.py +53 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/command/__init__.py +15 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/command/user_password_hash_command.py +203 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/exception/__init__.py +15 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/exception/invalid_argument_error.py +29 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/exception/invalid_password_error.py +37 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/exception/password_hasher_error.py +14 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/exception/unknown_password_hasher_error.py +37 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/__init__.py +25 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/_password_length.py +33 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/create_auto_password_hasher.py +49 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/migrating_password_hasher.py +111 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/native_password_hasher.py +221 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/password_hasher_aware_interface.py +21 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/password_hasher_factory.py +94 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/password_hasher_factory_interface.py +40 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/pbkdf2_password_hasher.py +127 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/plaintext_password_hasher.py +77 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/user_password_hasher.py +88 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/user_password_hasher_interface.py +56 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/legacy_password_authenticated_user_interface.py +24 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/legacy_password_hasher_interface.py +56 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/password_authenticated_user_interface.py +22 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/password_hasher_interface.py +60 -0
- xtr_password_hasher-3.0.0/src/xtr_password_hasher/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-password-hasher
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Password hashing behind one interface: argon2id by default, legacy hashes verified and upgraded.
|
|
5
|
+
Keywords: password,hashing,argon2,bcrypt,security
|
|
6
|
+
Author: Xterr
|
|
7
|
+
Author-email: Xterr <me@xterr.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: pwdlib[argon2]>=0.3
|
|
18
|
+
Requires-Dist: typing-extensions>=4.12
|
|
19
|
+
Requires-Dist: pwdlib[bcrypt]>=0.3 ; extra == 'bcrypt'
|
|
20
|
+
Requires-Dist: xtr-console>=3.0,<4 ; extra == 'console'
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Provides-Extra: bcrypt
|
|
23
|
+
Provides-Extra: console
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
<div align="center">
|
|
27
|
+
|
|
28
|
+
# xtr-password-hasher
|
|
29
|
+
|
|
30
|
+
**Password hashing behind one interface: argon2id by default, legacy hashes verified and upgraded.**
|
|
31
|
+
|
|
32
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
33
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
34
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
35
|
+
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Why?
|
|
41
|
+
|
|
42
|
+
Storing a password is not "call a hash function". It is choosing an algorithm strong enough for
|
|
43
|
+
today, reading back hashes made by yesterday's choice, and moving each account to the current one
|
|
44
|
+
the next time its owner signs in â without the calling code knowing which era a hash came from.
|
|
45
|
+
|
|
46
|
+
This package puts all of that behind one small interface:
|
|
47
|
+
|
|
48
|
+
- ð **argon2id by default** â memory-hard, so a stolen hash is expensive to attack; bcrypt and
|
|
49
|
+
PBKDF2 are there to read hashes other stacks made.
|
|
50
|
+
- ð **Verify then upgrade** â a hash made by an older algorithm still verifies, and
|
|
51
|
+
`needs_rehash` tells you the moment to replace it (right after a successful check, the one time
|
|
52
|
+
the plaintext is in hand).
|
|
53
|
+
- ð§Đ **One hasher per user kind** â a factory picks the hasher by the user's class or a name it
|
|
54
|
+
declares, so people and machine accounts can hash differently.
|
|
55
|
+
- ðŠķ **One runtime dependency** â [pwdlib](https://frankie567.github.io/pwdlib/) with argon2.
|
|
56
|
+
|
|
57
|
+
> The `xtr-security` bundle configures this package â the factory, a user-facing hasher and the
|
|
58
|
+
> `security:hash-password` command â for an application on
|
|
59
|
+
> [xtr-dependency-injection](../xtr-dependency-injection). This package ships no bundle of its own.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
hasher = NativePasswordHasher()
|
|
63
|
+
stored = hasher.hash("correct horse battery staple")
|
|
64
|
+
hasher.verify(stored, "correct horse battery staple") # True
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
uv add xtr-password-hasher # argon2id, PBKDF2 and plaintext
|
|
71
|
+
uv add "xtr-password-hasher[bcrypt]" # + bcrypt, for hashes from other stacks
|
|
72
|
+
uv add "xtr-password-hasher[console]" # + the security:hash-password command
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Requires Python 3.11+.
|
|
76
|
+
|
|
77
|
+
## Quick start
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from xtr_password_hasher import NativePasswordHasher
|
|
81
|
+
|
|
82
|
+
hasher = NativePasswordHasher() # argon2id
|
|
83
|
+
|
|
84
|
+
stored = hasher.hash(plain) # keep this string; it describes its own algorithm
|
|
85
|
+
...
|
|
86
|
+
if hasher.verify(stored, attempt):
|
|
87
|
+
if hasher.needs_rehash(stored):
|
|
88
|
+
stored = hasher.hash(attempt) # re-store under the current settings
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Per-user hashing goes through a factory and the user hasher, which reads the stored hash off the
|
|
92
|
+
user itself:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from xtr_password_hasher import NativePasswordHasher, PasswordHasherFactory, UserPasswordHasher
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Account:
|
|
99
|
+
def __init__(self, password: str | None = None) -> None:
|
|
100
|
+
self._password = password
|
|
101
|
+
|
|
102
|
+
def get_password(self) -> str | None:
|
|
103
|
+
return self._password
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
hasher = UserPasswordHasher(PasswordHasherFactory({Account: NativePasswordHasher()}))
|
|
107
|
+
|
|
108
|
+
account = Account(hasher.hash_password(Account(), "s3cret"))
|
|
109
|
+
hasher.is_password_valid(account, "s3cret") # True
|
|
110
|
+
hasher.needs_rehash(account) # False, until the settings change
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A password that encodes to more than `MAX_PASSWORD_LENGTH` (4096) UTF-8 bytes is refused by `hash`
|
|
114
|
+
with `InvalidPasswordError` and can never match in `verify` â a guard against spending a hasher's
|
|
115
|
+
whole cost on one unbounded input, not a policy on how long a password may be. The bound is on
|
|
116
|
+
bytes, not characters, because that is the work a hasher reads: a multi-byte character costs its
|
|
117
|
+
bytes.
|
|
118
|
+
|
|
119
|
+
## Hashers
|
|
120
|
+
|
|
121
|
+
| Hasher | What it is |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `NativePasswordHasher` | argon2id (default) or bcrypt, on pwdlib; verifies hashes of either when the backend is installed |
|
|
124
|
+
| `Pbkdf2PasswordHasher` | PBKDF2-HMAC from the standard library, salted from outside â for legacy hashes |
|
|
125
|
+
| `PlaintextPasswordHasher` | stores the password as it is, `{salt}` appended â **tests only** |
|
|
126
|
+
| `MigratingPasswordHasher` | hashes with a preferred hasher, verifies older ones behind it |
|
|
127
|
+
|
|
128
|
+
Every hasher answers the same three methods â `hash(plain_password) -> str`,
|
|
129
|
+
`verify(hashed_password, plain_password) -> bool`, `needs_rehash(hashed_password) -> bool` â so
|
|
130
|
+
they compose. `MigratingPasswordHasher(best, *extras)` hashes new passwords with `best`, verifies a
|
|
131
|
+
hash `best` recognises with `best` alone, and offers a hash it does not to each extra in turn.
|
|
132
|
+
Never put a `PlaintextPasswordHasher` among the extras: a leaked hash would then be a working
|
|
133
|
+
password.
|
|
134
|
+
|
|
135
|
+
### Hashers salted from outside
|
|
136
|
+
|
|
137
|
+
argon2id and bcrypt mint a salt and keep it inside the hash. Older schemes stored the salt in a
|
|
138
|
+
column of its own and only the digest in the hash; a hasher for those implements
|
|
139
|
+
`LegacyPasswordHasherInterface`, whose `hash` and `verify` take a `salt` (`None` for none):
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from xtr_password_hasher import Pbkdf2PasswordHasher
|
|
143
|
+
|
|
144
|
+
hasher = Pbkdf2PasswordHasher() # sha512, base64, 1000 iterations, 40 bytes
|
|
145
|
+
hasher.verify(stored_digest, attempt, stored_salt)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`Pbkdf2PasswordHasher(algorithm="sha512", encode_hash_as_base64=True, iterations=1000,
|
|
149
|
+
length=40)` writes the derived key alone, base64 or hex; its defaults are the ones such hashes were
|
|
150
|
+
typically made with. The hash carries no parameters, so `needs_rehash` is always `False` â keep it
|
|
151
|
+
behind a self-salting hasher in a `MigratingPasswordHasher`, which passes the salt on to the
|
|
152
|
+
hashers that take one. `PlaintextPasswordHasher(ignore_password_case=False)` merges a salt as
|
|
153
|
+
`password{salt}` and refuses a salt holding `{` or `}`.
|
|
154
|
+
|
|
155
|
+
A user whose salt is stored beside its password implements
|
|
156
|
+
`LegacyPasswordAuthenticatedUserInterface` â `get_salt()` next to `get_password()` â and
|
|
157
|
+
`UserPasswordHasher` hands that salt to the hasher. Both interfaces share their method names with
|
|
158
|
+
the self-salting ones, so `isinstance` cannot tell them apart: ask
|
|
159
|
+
`is_legacy_password_hasher(hasher)`, which checks the declared interface.
|
|
160
|
+
|
|
161
|
+
## Migrating legacy hashes
|
|
162
|
+
|
|
163
|
+
argon2id is the default because it is memory-hard: an attacker needs both time and a lot of memory
|
|
164
|
+
per guess, which blunts the massively parallel hardware that makes bcrypt and PBKDF2 cheaper to
|
|
165
|
+
attack. New passwords should be argon2id.
|
|
166
|
+
|
|
167
|
+
But you rarely start clean. A `NativePasswordHasher` verifies a hash produced by another stack as
|
|
168
|
+
long as its backend is present â an `$argon2id$âĶ` string, or a bcrypt `$2a$` / `$2b$` / `$2y$`
|
|
169
|
+
string with the `bcrypt` extra installed â and reports it through `needs_rehash` so it is upgraded
|
|
170
|
+
on the next successful sign-in. The `auto` configuration builds exactly this: argon2id in front,
|
|
171
|
+
bcrypt (when installed) and PBKDF2 behind it.
|
|
172
|
+
|
|
173
|
+
**bcrypt's 72-byte limit.** bcrypt reads at most 72 bytes and refuses a longer input outright. A
|
|
174
|
+
longer password is folded first â the base64 of its SHA-512 digest, cut to the 72 bytes bcrypt
|
|
175
|
+
would itself have kept â on both hashing and verifying, so a long password stays stable and a hash
|
|
176
|
+
another stack made the same way still verifies.
|
|
177
|
+
|
|
178
|
+
## Hashing off the event loop
|
|
179
|
+
|
|
180
|
+
A single argon2id hash costs tens of milliseconds of CPU by design. That is fine in a script or a
|
|
181
|
+
command, but in an async server it would block the event loop and stall every other request for
|
|
182
|
+
that whole time. The hashers are synchronous and CPU-bound on purpose; call them through a worker
|
|
183
|
+
thread in async code:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
import anyio
|
|
187
|
+
|
|
188
|
+
stored = await anyio.to_thread.run_sync(hasher.hash, plain)
|
|
189
|
+
ok = await anyio.to_thread.run_sync(hasher.verify, stored, attempt)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## In an application
|
|
193
|
+
|
|
194
|
+
This package ships no bundle. The [`xtr-security`](../xtr-security#use-in-an-application) bundle
|
|
195
|
+
configures it for an application on
|
|
196
|
+
[xtr-dependency-injection](../xtr-dependency-injection): it registers a
|
|
197
|
+
`PasswordHasherFactoryInterface`, a `UserPasswordHasherInterface`, and the `security:hash-password`
|
|
198
|
+
command, and maps user classes to hashers through the security configuration. A service then asks
|
|
199
|
+
for either interface by its type:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from xtr_password_hasher import UserPasswordHasherInterface
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class SignUp:
|
|
206
|
+
def __init__(self, hasher: UserPasswordHasherInterface) -> None:
|
|
207
|
+
self._hasher = hasher
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`PasswordHasherFactory` takes a mapping of user class, `"module:Class"` string or declared name to
|
|
211
|
+
a **ready hasher instance** â the factory carries no configuration vocabulary, so it hashes the
|
|
212
|
+
same standalone as it does behind a container. Turning inert configuration (argon2 costs, migrating
|
|
213
|
+
chains, a hasher the container provides) into a live hasher is the security bundle's job: it reads
|
|
214
|
+
the `password_hashers` of the security configuration, builds each hasher, and hands the factory the
|
|
215
|
+
instances. `create_auto_password_hasher()` builds the one an application gets when it asks for no
|
|
216
|
+
particular hasher â argon2id, with bcrypt (when installed) and PBKDF2 verifying behind it â and is
|
|
217
|
+
what the bundle's `auto` configuration builds. A user chooses its hasher by class (walking its bases), or by a name it returns
|
|
218
|
+
from `PasswordHasherAwareInterface.get_password_hasher_name()`.
|
|
219
|
+
|
|
220
|
+
## Console command
|
|
221
|
+
|
|
222
|
+
With the `console` extra, once the `xtr-security` bundle registers it:
|
|
223
|
+
|
|
224
|
+
| Command | Does |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `security:hash-password [PASSWORD] [USER-CLASS] [--empty-salt]` | Hashes `PASSWORD` with the hasher configured for `USER-CLASS` and prints the hasher, the hash and any generated salt |
|
|
227
|
+
|
|
228
|
+
The command takes the factory and the configured user classes from its constructor â
|
|
229
|
+
`UserPasswordHashCommand(factory, user_classes)`; the security bundle supplies both. With no
|
|
230
|
+
`USER-CLASS` it hashes for the first configured user class, asking which when several are
|
|
231
|
+
configured and the run is interactive; `USER-CLASS` is a `module:Class` or the name of a
|
|
232
|
+
configured hasher.
|
|
233
|
+
|
|
234
|
+
The password is asked for, hidden, when left out, and must not be blank. A password given as an
|
|
235
|
+
argument lands in the shell history and the process list, so an interactive run warns about it;
|
|
236
|
+
`-` reads it from standard input instead (`echo "$PASSWORD" | âĶ security:hash-password -n -- -`).
|
|
237
|
+
A hasher salted from outside gets a generated salt â 30 random bytes, base64 â printed with the
|
|
238
|
+
hash for you to store beside it, after a confirmation when the run is interactive; `--empty-salt`
|
|
239
|
+
hashes without one.
|
|
240
|
+
|
|
241
|
+
## Errors
|
|
242
|
+
|
|
243
|
+
Every error derives from `PasswordHasherError` and carries what went wrong as typed attributes.
|
|
244
|
+
|
|
245
|
+
| Error | Raised when |
|
|
246
|
+
|---|---|
|
|
247
|
+
| `InvalidPasswordError` | A password encodes to more than `MAX_PASSWORD_LENGTH` UTF-8 bytes (also a `ValueError`) |
|
|
248
|
+
| `UnknownPasswordHasherError` | No hasher is configured for a user, class or name (also a `LookupError`) |
|
|
249
|
+
| `InvalidArgumentError` | A hasher or a config is given a value it cannot use (also a `ValueError`) |
|
|
250
|
+
|
|
251
|
+
## Layout
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
xtr_password_hasher/
|
|
255
|
+
âââ password_hasher_interface.py hash / verify / needs_rehash, and MAX_PASSWORD_LENGTH
|
|
256
|
+
âââ legacy_password_hasher_interface.py the same, salted from outside; is_legacy_password_hasher
|
|
257
|
+
âââ password_authenticated_user_interface.py
|
|
258
|
+
âââ legacy_password_authenticated_user_interface.py
|
|
259
|
+
âââ hasher/ the hashers, the factory, the user hasher,
|
|
260
|
+
â and create_auto_password_hasher
|
|
261
|
+
âââ command/ security:hash-password (UserPasswordHashCommand)
|
|
262
|
+
âââ exception/ one error per module, all a PasswordHasherError
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Development
|
|
266
|
+
|
|
267
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
268
|
+
`packages/xtr-password-hasher`; run the commands below from there. The
|
|
269
|
+
`python-xtr-password-hasher` repository is a read-only copy, so send issues and pull requests to
|
|
270
|
+
the monorepo.
|
|
271
|
+
|
|
272
|
+
```sh
|
|
273
|
+
uv sync --all-extras
|
|
274
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## License
|
|
278
|
+
|
|
279
|
+
MIT â see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# xtr-password-hasher
|
|
4
|
+
|
|
5
|
+
**Password hashing behind one interface: argon2id by default, legacy hashes verified and upgraded.**
|
|
6
|
+
|
|
7
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
8
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
9
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why?
|
|
16
|
+
|
|
17
|
+
Storing a password is not "call a hash function". It is choosing an algorithm strong enough for
|
|
18
|
+
today, reading back hashes made by yesterday's choice, and moving each account to the current one
|
|
19
|
+
the next time its owner signs in â without the calling code knowing which era a hash came from.
|
|
20
|
+
|
|
21
|
+
This package puts all of that behind one small interface:
|
|
22
|
+
|
|
23
|
+
- ð **argon2id by default** â memory-hard, so a stolen hash is expensive to attack; bcrypt and
|
|
24
|
+
PBKDF2 are there to read hashes other stacks made.
|
|
25
|
+
- ð **Verify then upgrade** â a hash made by an older algorithm still verifies, and
|
|
26
|
+
`needs_rehash` tells you the moment to replace it (right after a successful check, the one time
|
|
27
|
+
the plaintext is in hand).
|
|
28
|
+
- ð§Đ **One hasher per user kind** â a factory picks the hasher by the user's class or a name it
|
|
29
|
+
declares, so people and machine accounts can hash differently.
|
|
30
|
+
- ðŠķ **One runtime dependency** â [pwdlib](https://frankie567.github.io/pwdlib/) with argon2.
|
|
31
|
+
|
|
32
|
+
> The `xtr-security` bundle configures this package â the factory, a user-facing hasher and the
|
|
33
|
+
> `security:hash-password` command â for an application on
|
|
34
|
+
> [xtr-dependency-injection](../xtr-dependency-injection). This package ships no bundle of its own.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
hasher = NativePasswordHasher()
|
|
38
|
+
stored = hasher.hash("correct horse battery staple")
|
|
39
|
+
hasher.verify(stored, "correct horse battery staple") # True
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
uv add xtr-password-hasher # argon2id, PBKDF2 and plaintext
|
|
46
|
+
uv add "xtr-password-hasher[bcrypt]" # + bcrypt, for hashes from other stacks
|
|
47
|
+
uv add "xtr-password-hasher[console]" # + the security:hash-password command
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Requires Python 3.11+.
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from xtr_password_hasher import NativePasswordHasher
|
|
56
|
+
|
|
57
|
+
hasher = NativePasswordHasher() # argon2id
|
|
58
|
+
|
|
59
|
+
stored = hasher.hash(plain) # keep this string; it describes its own algorithm
|
|
60
|
+
...
|
|
61
|
+
if hasher.verify(stored, attempt):
|
|
62
|
+
if hasher.needs_rehash(stored):
|
|
63
|
+
stored = hasher.hash(attempt) # re-store under the current settings
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Per-user hashing goes through a factory and the user hasher, which reads the stored hash off the
|
|
67
|
+
user itself:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from xtr_password_hasher import NativePasswordHasher, PasswordHasherFactory, UserPasswordHasher
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Account:
|
|
74
|
+
def __init__(self, password: str | None = None) -> None:
|
|
75
|
+
self._password = password
|
|
76
|
+
|
|
77
|
+
def get_password(self) -> str | None:
|
|
78
|
+
return self._password
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
hasher = UserPasswordHasher(PasswordHasherFactory({Account: NativePasswordHasher()}))
|
|
82
|
+
|
|
83
|
+
account = Account(hasher.hash_password(Account(), "s3cret"))
|
|
84
|
+
hasher.is_password_valid(account, "s3cret") # True
|
|
85
|
+
hasher.needs_rehash(account) # False, until the settings change
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A password that encodes to more than `MAX_PASSWORD_LENGTH` (4096) UTF-8 bytes is refused by `hash`
|
|
89
|
+
with `InvalidPasswordError` and can never match in `verify` â a guard against spending a hasher's
|
|
90
|
+
whole cost on one unbounded input, not a policy on how long a password may be. The bound is on
|
|
91
|
+
bytes, not characters, because that is the work a hasher reads: a multi-byte character costs its
|
|
92
|
+
bytes.
|
|
93
|
+
|
|
94
|
+
## Hashers
|
|
95
|
+
|
|
96
|
+
| Hasher | What it is |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `NativePasswordHasher` | argon2id (default) or bcrypt, on pwdlib; verifies hashes of either when the backend is installed |
|
|
99
|
+
| `Pbkdf2PasswordHasher` | PBKDF2-HMAC from the standard library, salted from outside â for legacy hashes |
|
|
100
|
+
| `PlaintextPasswordHasher` | stores the password as it is, `{salt}` appended â **tests only** |
|
|
101
|
+
| `MigratingPasswordHasher` | hashes with a preferred hasher, verifies older ones behind it |
|
|
102
|
+
|
|
103
|
+
Every hasher answers the same three methods â `hash(plain_password) -> str`,
|
|
104
|
+
`verify(hashed_password, plain_password) -> bool`, `needs_rehash(hashed_password) -> bool` â so
|
|
105
|
+
they compose. `MigratingPasswordHasher(best, *extras)` hashes new passwords with `best`, verifies a
|
|
106
|
+
hash `best` recognises with `best` alone, and offers a hash it does not to each extra in turn.
|
|
107
|
+
Never put a `PlaintextPasswordHasher` among the extras: a leaked hash would then be a working
|
|
108
|
+
password.
|
|
109
|
+
|
|
110
|
+
### Hashers salted from outside
|
|
111
|
+
|
|
112
|
+
argon2id and bcrypt mint a salt and keep it inside the hash. Older schemes stored the salt in a
|
|
113
|
+
column of its own and only the digest in the hash; a hasher for those implements
|
|
114
|
+
`LegacyPasswordHasherInterface`, whose `hash` and `verify` take a `salt` (`None` for none):
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from xtr_password_hasher import Pbkdf2PasswordHasher
|
|
118
|
+
|
|
119
|
+
hasher = Pbkdf2PasswordHasher() # sha512, base64, 1000 iterations, 40 bytes
|
|
120
|
+
hasher.verify(stored_digest, attempt, stored_salt)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`Pbkdf2PasswordHasher(algorithm="sha512", encode_hash_as_base64=True, iterations=1000,
|
|
124
|
+
length=40)` writes the derived key alone, base64 or hex; its defaults are the ones such hashes were
|
|
125
|
+
typically made with. The hash carries no parameters, so `needs_rehash` is always `False` â keep it
|
|
126
|
+
behind a self-salting hasher in a `MigratingPasswordHasher`, which passes the salt on to the
|
|
127
|
+
hashers that take one. `PlaintextPasswordHasher(ignore_password_case=False)` merges a salt as
|
|
128
|
+
`password{salt}` and refuses a salt holding `{` or `}`.
|
|
129
|
+
|
|
130
|
+
A user whose salt is stored beside its password implements
|
|
131
|
+
`LegacyPasswordAuthenticatedUserInterface` â `get_salt()` next to `get_password()` â and
|
|
132
|
+
`UserPasswordHasher` hands that salt to the hasher. Both interfaces share their method names with
|
|
133
|
+
the self-salting ones, so `isinstance` cannot tell them apart: ask
|
|
134
|
+
`is_legacy_password_hasher(hasher)`, which checks the declared interface.
|
|
135
|
+
|
|
136
|
+
## Migrating legacy hashes
|
|
137
|
+
|
|
138
|
+
argon2id is the default because it is memory-hard: an attacker needs both time and a lot of memory
|
|
139
|
+
per guess, which blunts the massively parallel hardware that makes bcrypt and PBKDF2 cheaper to
|
|
140
|
+
attack. New passwords should be argon2id.
|
|
141
|
+
|
|
142
|
+
But you rarely start clean. A `NativePasswordHasher` verifies a hash produced by another stack as
|
|
143
|
+
long as its backend is present â an `$argon2id$âĶ` string, or a bcrypt `$2a$` / `$2b$` / `$2y$`
|
|
144
|
+
string with the `bcrypt` extra installed â and reports it through `needs_rehash` so it is upgraded
|
|
145
|
+
on the next successful sign-in. The `auto` configuration builds exactly this: argon2id in front,
|
|
146
|
+
bcrypt (when installed) and PBKDF2 behind it.
|
|
147
|
+
|
|
148
|
+
**bcrypt's 72-byte limit.** bcrypt reads at most 72 bytes and refuses a longer input outright. A
|
|
149
|
+
longer password is folded first â the base64 of its SHA-512 digest, cut to the 72 bytes bcrypt
|
|
150
|
+
would itself have kept â on both hashing and verifying, so a long password stays stable and a hash
|
|
151
|
+
another stack made the same way still verifies.
|
|
152
|
+
|
|
153
|
+
## Hashing off the event loop
|
|
154
|
+
|
|
155
|
+
A single argon2id hash costs tens of milliseconds of CPU by design. That is fine in a script or a
|
|
156
|
+
command, but in an async server it would block the event loop and stall every other request for
|
|
157
|
+
that whole time. The hashers are synchronous and CPU-bound on purpose; call them through a worker
|
|
158
|
+
thread in async code:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
import anyio
|
|
162
|
+
|
|
163
|
+
stored = await anyio.to_thread.run_sync(hasher.hash, plain)
|
|
164
|
+
ok = await anyio.to_thread.run_sync(hasher.verify, stored, attempt)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## In an application
|
|
168
|
+
|
|
169
|
+
This package ships no bundle. The [`xtr-security`](../xtr-security#use-in-an-application) bundle
|
|
170
|
+
configures it for an application on
|
|
171
|
+
[xtr-dependency-injection](../xtr-dependency-injection): it registers a
|
|
172
|
+
`PasswordHasherFactoryInterface`, a `UserPasswordHasherInterface`, and the `security:hash-password`
|
|
173
|
+
command, and maps user classes to hashers through the security configuration. A service then asks
|
|
174
|
+
for either interface by its type:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from xtr_password_hasher import UserPasswordHasherInterface
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class SignUp:
|
|
181
|
+
def __init__(self, hasher: UserPasswordHasherInterface) -> None:
|
|
182
|
+
self._hasher = hasher
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`PasswordHasherFactory` takes a mapping of user class, `"module:Class"` string or declared name to
|
|
186
|
+
a **ready hasher instance** â the factory carries no configuration vocabulary, so it hashes the
|
|
187
|
+
same standalone as it does behind a container. Turning inert configuration (argon2 costs, migrating
|
|
188
|
+
chains, a hasher the container provides) into a live hasher is the security bundle's job: it reads
|
|
189
|
+
the `password_hashers` of the security configuration, builds each hasher, and hands the factory the
|
|
190
|
+
instances. `create_auto_password_hasher()` builds the one an application gets when it asks for no
|
|
191
|
+
particular hasher â argon2id, with bcrypt (when installed) and PBKDF2 verifying behind it â and is
|
|
192
|
+
what the bundle's `auto` configuration builds. A user chooses its hasher by class (walking its bases), or by a name it returns
|
|
193
|
+
from `PasswordHasherAwareInterface.get_password_hasher_name()`.
|
|
194
|
+
|
|
195
|
+
## Console command
|
|
196
|
+
|
|
197
|
+
With the `console` extra, once the `xtr-security` bundle registers it:
|
|
198
|
+
|
|
199
|
+
| Command | Does |
|
|
200
|
+
|---|---|
|
|
201
|
+
| `security:hash-password [PASSWORD] [USER-CLASS] [--empty-salt]` | Hashes `PASSWORD` with the hasher configured for `USER-CLASS` and prints the hasher, the hash and any generated salt |
|
|
202
|
+
|
|
203
|
+
The command takes the factory and the configured user classes from its constructor â
|
|
204
|
+
`UserPasswordHashCommand(factory, user_classes)`; the security bundle supplies both. With no
|
|
205
|
+
`USER-CLASS` it hashes for the first configured user class, asking which when several are
|
|
206
|
+
configured and the run is interactive; `USER-CLASS` is a `module:Class` or the name of a
|
|
207
|
+
configured hasher.
|
|
208
|
+
|
|
209
|
+
The password is asked for, hidden, when left out, and must not be blank. A password given as an
|
|
210
|
+
argument lands in the shell history and the process list, so an interactive run warns about it;
|
|
211
|
+
`-` reads it from standard input instead (`echo "$PASSWORD" | âĶ security:hash-password -n -- -`).
|
|
212
|
+
A hasher salted from outside gets a generated salt â 30 random bytes, base64 â printed with the
|
|
213
|
+
hash for you to store beside it, after a confirmation when the run is interactive; `--empty-salt`
|
|
214
|
+
hashes without one.
|
|
215
|
+
|
|
216
|
+
## Errors
|
|
217
|
+
|
|
218
|
+
Every error derives from `PasswordHasherError` and carries what went wrong as typed attributes.
|
|
219
|
+
|
|
220
|
+
| Error | Raised when |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `InvalidPasswordError` | A password encodes to more than `MAX_PASSWORD_LENGTH` UTF-8 bytes (also a `ValueError`) |
|
|
223
|
+
| `UnknownPasswordHasherError` | No hasher is configured for a user, class or name (also a `LookupError`) |
|
|
224
|
+
| `InvalidArgumentError` | A hasher or a config is given a value it cannot use (also a `ValueError`) |
|
|
225
|
+
|
|
226
|
+
## Layout
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
xtr_password_hasher/
|
|
230
|
+
âââ password_hasher_interface.py hash / verify / needs_rehash, and MAX_PASSWORD_LENGTH
|
|
231
|
+
âââ legacy_password_hasher_interface.py the same, salted from outside; is_legacy_password_hasher
|
|
232
|
+
âââ password_authenticated_user_interface.py
|
|
233
|
+
âââ legacy_password_authenticated_user_interface.py
|
|
234
|
+
âââ hasher/ the hashers, the factory, the user hasher,
|
|
235
|
+
â and create_auto_password_hasher
|
|
236
|
+
âââ command/ security:hash-password (UserPasswordHashCommand)
|
|
237
|
+
âââ exception/ one error per module, all a PasswordHasherError
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Development
|
|
241
|
+
|
|
242
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
243
|
+
`packages/xtr-password-hasher`; run the commands below from there. The
|
|
244
|
+
`python-xtr-password-hasher` repository is a read-only copy, so send issues and pull requests to
|
|
245
|
+
the monorepo.
|
|
246
|
+
|
|
247
|
+
```sh
|
|
248
|
+
uv sync --all-extras
|
|
249
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
MIT â see [LICENSE](LICENSE).
|