From 3baff5fa05753e38dce57ec61076056a4bb03bb8 Mon Sep 17 00:00:00 2001 From: Jonas Thelemann Date: Sun, 16 Aug 2026 01:02:00 +0200 Subject: [PATCH 1/2] docs(password-strength): add cross-service contract --- AGENTS.md | 3 ++ docs/password-strength.md | 100 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 docs/password-strength.md diff --git a/AGENTS.md b/AGENTS.md index d1b2554a..2354c3c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,9 @@ applyTo: '**' **For contributing:** - [CONTRIBUTING.md](CONTRIBUTING.md): Development setup, dargstack guidelines, code style, git workflow +**Cross-service contracts** (policies two or more services must implement identically, with no code shared between them): +- [docs/password-strength.md](docs/password-strength.md): Password strength requirements enforced by `vibetype` and `postgraphile` + ## Code Style - Do not use abbreviations in naming, except where omitting them would look unnatural diff --git a/docs/password-strength.md b/docs/password-strength.md new file mode 100644 index 00000000..666dec1c --- /dev/null +++ b/docs/password-strength.md @@ -0,0 +1,100 @@ +# Password strength policy + +This document is the single source of truth for the password strength +policy that any implementation setting an account password must satisfy. 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. When either service's implementation changes, +check it against this document rather than against the other service's +code. + +## Scope + +Applies to every operation that sets a password a user will authenticate +with: + +| Operation | Field carrying the new password | Covered | +| ------------------------ | -------------------------------- | ------- | +| `accountRegistration` | `input.password` | yes | +| `accountPasswordReset` | `input.password` | yes | +| `accountPasswordChange` | `input.passwordNew` | yes | + +`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. + +## Requirements + +Both of the following must hold. + +1. **Minimum length**: 8 characters. This matches NIST SP 800-63B's own + floor. It is a cheap sanity backstop, not the control doing the real + work, see [Why keep a length floor](#why-keep-a-length-floor). +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). This is the control that + actually determines whether a password is accepted in practice. + +### Why keep a length floor + +NIST SP 800-63B requires a minimum length (>= 8) plus screening against +common or compromised passwords; it does not separately mandate a +guessability-estimator score on top of that. 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. 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. + +## Algorithm and configuration + +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). + +- **Library**: `@zxcvbn-ts/core`, via `new ZxcvbnFactory(options).check(password).score`. +- **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). +- **Translations**: `@zxcvbn-ts/language-en`. This only affects zxcvbn's + internal feedback strings; 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. +- **Package versions**: pinned independently in each repo's `package.json`. + Keep `@zxcvbn-ts/core`, `@zxcvbn-ts/language-common`, + `@zxcvbn-ts/language-de`, and `@zxcvbn-ts/language-en` at the same version + in both repos. A dictionary update can change which side of the score-3 + boundary a given password falls on. + +## Current implementation status + +| Layer | Minimum length (8) | zxcvbn score (>= 3) | +| ------------------------------- | -------------------- | --------------------- | +| `vibetype` (client) | enforced, all 3 operations | enforced, all 3 operations | +| `postgraphile` (server) | not this layer's job, see below | enforced, all 3 operations | +| `sqitch` (database) | enforced, all 3 operations (`char_length(...) < 8` in each function) | not applicable, zxcvbn cannot run in SQL | + +`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. + +## Where each side implements this + +- `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). +- `postgraphile`: `src/presets/passwordStrength.ts` (`PasswordStrengthPlugin`, + a Grafserv middleware that inspects `accountRegistration`, + `accountPasswordReset`, and `accountPasswordChange` mutations before they + execute). +- `sqitch`: the `char_length(...) < 8` check in + `function_account_registration.sql`, + `function_account_password_reset.sql`, and + `function_account_password_change.sql`. From 4921208d7e3790734591cb4ceb3361da96111a4e Mon Sep 17 00:00:00 2001 From: Jonas Thelemann Date: Sun, 16 Aug 2026 15:47:00 +0200 Subject: [PATCH 2/2] docs(password-strength): format prose as one sentence per line --- AGENTS.md | 1 + docs/password-strength.md | 87 +++++++++++---------------------------- 2 files changed, 26 insertions(+), 62 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2354c3c6..f00bcce3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ applyTo: '**' - Do not use abbreviations in naming, except where omitting them would look unnatural - 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 - Use backticks in any non-code text to refer to code, e.g. "`postgres`" instead of "postgres" +- 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 - Sort YAML keys lexicographically except where order is semantically significant - Code formatting is done by the editor via `.editorconfig` diff --git a/docs/password-strength.md b/docs/password-strength.md index 666dec1c..c3cac1fe 100644 --- a/docs/password-strength.md +++ b/docs/password-strength.md @@ -1,18 +1,12 @@ # Password strength policy -This document is the single source of truth for the password strength -policy that any implementation setting an account password must satisfy. 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. When either service's implementation changes, -check it against this document rather than against the other service's -code. +This document is the single source of truth for the password strength policy that any implementation setting an account password must satisfy. +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. +When either service's implementation changes, check it against this document rather than against the other service's code. ## Scope -Applies to every operation that sets a password a user will authenticate -with: +Applies to every operation that sets a password a user will authenticate with: | Operation | Field carrying the new password | Covered | | ------------------------ | -------------------------------- | ------- | @@ -20,56 +14,37 @@ with: | `accountPasswordReset` | `input.password` | yes | | `accountPasswordChange` | `input.passwordNew` | yes | -`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. +`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. ## Requirements Both of the following must hold. -1. **Minimum length**: 8 characters. This matches NIST SP 800-63B's own - floor. It is a cheap sanity backstop, not the control doing the real - work, see [Why keep a length floor](#why-keep-a-length-floor). -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). This is the control that - actually determines whether a password is accepted in practice. +1. **Minimum length**: 8 characters. + This matches NIST SP 800-63B's own floor. + It is a cheap sanity backstop, not the control doing the real work, see [Why keep a length floor](#why-keep-a-length-floor). +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). + This is the control that actually determines whether a password is accepted in practice. ### Why keep a length floor -NIST SP 800-63B requires a minimum length (>= 8) plus screening against -common or compromised passwords; it does not separately mandate a -guessability-estimator score on top of that. 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. 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. +NIST SP 800-63B requires a minimum length (>= 8) plus screening against common or compromised passwords. +It does not separately mandate a guessability-estimator score on top of that. +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. +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. ## Algorithm and configuration -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). +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). - **Library**: `@zxcvbn-ts/core`, via `new ZxcvbnFactory(options).check(password).score`. -- **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). -- **Translations**: `@zxcvbn-ts/language-en`. This only affects zxcvbn's - internal feedback strings; 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. +- **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). +- **Translations**: `@zxcvbn-ts/language-en`. + This only affects zxcvbn's internal feedback strings. + 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. - **Package versions**: pinned independently in each repo's `package.json`. - Keep `@zxcvbn-ts/core`, `@zxcvbn-ts/language-common`, - `@zxcvbn-ts/language-de`, and `@zxcvbn-ts/language-en` at the same version - in both repos. A dictionary update can change which side of the score-3 - boundary a given password falls on. + Keep `@zxcvbn-ts/core`, `@zxcvbn-ts/language-common`, `@zxcvbn-ts/language-de`, and `@zxcvbn-ts/language-en` at the same version in both repos. + A dictionary update can change which side of the score-3 boundary a given password falls on. ## Current implementation status @@ -79,22 +54,10 @@ the server, or vice versa). | `postgraphile` (server) | not this layer's job, see below | enforced, all 3 operations | | `sqitch` (database) | enforced, all 3 operations (`char_length(...) < 8` in each function) | not applicable, zxcvbn cannot run in SQL | -`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. +`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. ## Where each side implements this -- `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). -- `postgraphile`: `src/presets/passwordStrength.ts` (`PasswordStrengthPlugin`, - a Grafserv middleware that inspects `accountRegistration`, - `accountPasswordReset`, and `accountPasswordChange` mutations before they - execute). -- `sqitch`: the `char_length(...) < 8` check in - `function_account_registration.sql`, - `function_account_password_reset.sql`, and - `function_account_password_change.sql`. +- `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). +- `postgraphile`: `src/presets/passwordStrength.ts` (`PasswordStrengthPlugin`, a Grafserv middleware that inspects `accountRegistration`, `accountPasswordReset`, and `accountPasswordChange` mutations before they execute). +- `sqitch`: the `char_length(...) < 8` check in `function_account_registration.sql`, `function_account_password_reset.sql`, and `function_account_password_change.sql`.