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.
Files changed (30) hide show
  1. xtr_password_hasher-3.0.0/LICENSE +21 -0
  2. xtr_password_hasher-3.0.0/PKG-INFO +279 -0
  3. xtr_password_hasher-3.0.0/README.md +254 -0
  4. xtr_password_hasher-3.0.0/pyproject.toml +146 -0
  5. xtr_password_hasher-3.0.0/pyproject.toml.orig +117 -0
  6. xtr_password_hasher-3.0.0/src/xtr_password_hasher/__init__.py +53 -0
  7. xtr_password_hasher-3.0.0/src/xtr_password_hasher/command/__init__.py +15 -0
  8. xtr_password_hasher-3.0.0/src/xtr_password_hasher/command/user_password_hash_command.py +203 -0
  9. xtr_password_hasher-3.0.0/src/xtr_password_hasher/exception/__init__.py +15 -0
  10. xtr_password_hasher-3.0.0/src/xtr_password_hasher/exception/invalid_argument_error.py +29 -0
  11. xtr_password_hasher-3.0.0/src/xtr_password_hasher/exception/invalid_password_error.py +37 -0
  12. xtr_password_hasher-3.0.0/src/xtr_password_hasher/exception/password_hasher_error.py +14 -0
  13. xtr_password_hasher-3.0.0/src/xtr_password_hasher/exception/unknown_password_hasher_error.py +37 -0
  14. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/__init__.py +25 -0
  15. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/_password_length.py +33 -0
  16. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/create_auto_password_hasher.py +49 -0
  17. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/migrating_password_hasher.py +111 -0
  18. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/native_password_hasher.py +221 -0
  19. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/password_hasher_aware_interface.py +21 -0
  20. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/password_hasher_factory.py +94 -0
  21. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/password_hasher_factory_interface.py +40 -0
  22. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/pbkdf2_password_hasher.py +127 -0
  23. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/plaintext_password_hasher.py +77 -0
  24. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/user_password_hasher.py +88 -0
  25. xtr_password_hasher-3.0.0/src/xtr_password_hasher/hasher/user_password_hasher_interface.py +56 -0
  26. xtr_password_hasher-3.0.0/src/xtr_password_hasher/legacy_password_authenticated_user_interface.py +24 -0
  27. xtr_password_hasher-3.0.0/src/xtr_password_hasher/legacy_password_hasher_interface.py +56 -0
  28. xtr_password_hasher-3.0.0/src/xtr_password_hasher/password_authenticated_user_interface.py +22 -0
  29. xtr_password_hasher-3.0.0/src/xtr_password_hasher/password_hasher_interface.py +60 -0
  30. 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).