Skip to content

Commit 20fa922

Browse files
authored
docs: add password-strength cross-service contract (#296)
1 parent e562881 commit 20fa922

2 files changed

Lines changed: 67 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@ applyTo: '**'
1414
**For contributing:**
1515
- [CONTRIBUTING.md](CONTRIBUTING.md): Development setup, dargstack guidelines, code style, git workflow
1616

17+
**Cross-service contracts** (policies two or more services must implement identically, with no code shared between them):
18+
- [docs/password-strength.md](docs/password-strength.md): Password strength requirements enforced by `vibetype` and `postgraphile`
19+
1720
## Code Style
1821

1922
- Do not use abbreviations in naming, except where omitting them would look unnatural
2023
- Use natural language in any non-code text instead of referring to code directly, e.g. "the database's password" instead of "the `postgres_password`", except when a code reference is needed
2124
- Use backticks in any non-code text to refer to code, e.g. "`postgres`" instead of "postgres"
25+
- In markdown prose, start each sentence on its own line (semantic line breaks); renders the same, but keeps diffs scoped to the sentence that changed
2226
- Sort YAML keys lexicographically except where order is semantically significant
2327
- Code formatting is done by the editor via `.editorconfig`
2428

docs/password-strength.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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

Comments
 (0)