|
| 1 | +# Password strength policy |
| 2 | + |
| 3 | +This document is the single source of truth for the password strength policy that any implementation setting an account password must satisfy. |
| 4 | +It lives here, rather than in `vibetype` or `postgraphile`, because both services implement it independently (code sharing between them is not an option), and a policy shared across services belongs in `stack` rather than being duplicated per repo. |
| 5 | +When either service's implementation changes, check it against this document rather than against the other service's code. |
| 6 | + |
| 7 | +## Scope |
| 8 | + |
| 9 | +Applies to every operation that sets a password a user will authenticate with: |
| 10 | + |
| 11 | +| Operation | Field carrying the new password | Covered | |
| 12 | +| ------------------------ | -------------------------------- | ------- | |
| 13 | +| `accountRegistration` | `input.password` | yes | |
| 14 | +| `accountPasswordReset` | `input.password` | yes | |
| 15 | +| `accountPasswordChange` | `input.passwordNew` | yes | |
| 16 | + |
| 17 | +`accountPasswordChange`'s `input.passwordCurrent` is explicitly **out of scope**: it authenticates an existing password, which may predate this policy, and must never be strength-checked. |
| 18 | + |
| 19 | +## Requirements |
| 20 | + |
| 21 | +Both of the following must hold. |
| 22 | + |
| 23 | +1. **Minimum length**: 8 characters. |
| 24 | + This matches NIST SP 800-63B's own floor. |
| 25 | + It is a cheap sanity backstop, not the control doing the real work, see [Why keep a length floor](#why-keep-a-length-floor). |
| 26 | +2. **Minimum strength**: a [zxcvbn](https://github.com/zxcvbn-ts/zxcvbn) score of at least 3 ("safely unguessable", resists an offline, slow-hash attack; see the library's own scoring guidance). |
| 27 | + This is the control that actually determines whether a password is accepted in practice. |
| 28 | + |
| 29 | +### Why keep a length floor |
| 30 | + |
| 31 | +NIST SP 800-63B requires a minimum length (>= 8) plus screening against common or compromised passwords. |
| 32 | +It does not separately mandate a guessability-estimator score on top of that. |
| 33 | +zxcvbn's score already factors in length as one of its inputs, so once score >= 3 is required, an 8 character floor rarely does independent work: empirically, the shortest fully random password (e.g. `xK9#mL2qP`, drawn from a large character set) needed to reach score 3 is 9 characters, one above this floor. |
| 34 | +The floor is kept anyway as a structural backstop that does not depend on zxcvbn's heuristics being correct for a given input, and as a small margin against future improvements in offline hash-cracking speed, which erode a short password's safety margin fastest regardless of how patternless it is. |
| 35 | + |
| 36 | +## Algorithm and configuration |
| 37 | + |
| 38 | +Both implementations must use identical configuration, or they will disagree on borderline passwords (a password accepted by the client but rejected by the server, or vice versa). |
| 39 | + |
| 40 | +- **Library**: `@zxcvbn-ts/core`, via `new ZxcvbnFactory(options).check(password).score`. |
| 41 | +- **Dictionaries**: `@zxcvbn-ts/language-common` (common passwords plus adjacency graphs for keyboard-pattern detection) merged with `@zxcvbn-ts/language-de` and `@zxcvbn-ts/language-en` (both dictionaries only; German and English are the platform's supported locales). |
| 42 | +- **Translations**: `@zxcvbn-ts/language-en`. |
| 43 | + This only affects zxcvbn's internal feedback strings. |
| 44 | + Neither implementation surfaces them to the user, so the specific language here is not user-visible, but the `ZxcvbnFactory` constructor requires a non-empty value. |
| 45 | +- **Package versions**: pinned independently in each repo's `package.json`. |
| 46 | + Keep `@zxcvbn-ts/core`, `@zxcvbn-ts/language-common`, `@zxcvbn-ts/language-de`, and `@zxcvbn-ts/language-en` at the same version in both repos. |
| 47 | + A dictionary update can change which side of the score-3 boundary a given password falls on. |
| 48 | + |
| 49 | +## Current implementation status |
| 50 | + |
| 51 | +| Layer | Minimum length (8) | zxcvbn score (>= 3) | |
| 52 | +| ------------------------------- | -------------------- | --------------------- | |
| 53 | +| `vibetype` (client) | enforced, all 3 operations | enforced, all 3 operations | |
| 54 | +| `postgraphile` (server) | not this layer's job, see below | enforced, all 3 operations | |
| 55 | +| `sqitch` (database) | enforced, all 3 operations (`char_length(...) < 8` in each function) | not applicable, zxcvbn cannot run in SQL | |
| 56 | + |
| 57 | +`postgraphile` intentionally does not re-check length: since every underlying sqitch function already rejects anything shorter than 8 characters, and that is exactly this policy's floor, duplicating the check in `postgraphile` would add no protection. |
| 58 | + |
| 59 | +## Where each side implements this |
| 60 | + |
| 61 | +- `vibetype`: `src/app/utils/passwordStrength.ts` (scoring), `src/app/utils/validation.ts` (`SCHEMA_PASSWORD_V2`, length), `src/app/composables/useAuthPasswordValidation.ts` and `usePasswordPairValidation.ts` (live field validation wiring). |
| 62 | +- `postgraphile`: `src/presets/passwordStrength.ts` (`PasswordStrengthPlugin`, a Grafserv middleware that inspects `accountRegistration`, `accountPasswordReset`, and `accountPasswordChange` mutations before they execute). |
| 63 | +- `sqitch`: the `char_length(...) < 8` check in `function_account_registration.sql`, `function_account_password_reset.sql`, and `function_account_password_change.sql`. |
0 commit comments