From 471677db1ff5a217fbf0c8280808a4eec805aad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 11:00:32 +0000 Subject: [PATCH 01/14] Add v3 package documentation and code review Detailed single-file reference covering the public API, internal generation flow, confirmed bugs (error-string returns, off-by-one character selection, modulo bias, non-uniform shuffle, static RNG), build/test results, packaging hygiene, feature gaps, and a prioritised v3 plan. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd --- docs/V3_REVIEW_AND_DOCUMENTATION.md | 326 ++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/V3_REVIEW_AND_DOCUMENTATION.md diff --git a/docs/V3_REVIEW_AND_DOCUMENTATION.md b/docs/V3_REVIEW_AND_DOCUMENTATION.md new file mode 100644 index 0000000..5a9987d --- /dev/null +++ b/docs/V3_REVIEW_AND_DOCUMENTATION.md @@ -0,0 +1,326 @@ +# PasswordGenerator — Full Package Documentation & v3 Review + +> Purpose: a single, self-contained reference for the `PasswordGenerator` NuGet package as it +> stands today (v2.1.0). Written so it can be pasted into a Claude chat to plan v3. It covers +> what the package is, every public API, the internal implementation, confirmed bugs, design +> smells, build/test output, and a prioritised list of v3 candidate features. +> +> Date: 2026-05-24 · Current published version: 2.1.0 · Target framework: `netstandard2.0` +> Repo: https://github.com/prjseal/PasswordGenerator · Author: Paul Seal · License: MIT + +--- + +## 1. What the package is + +`PasswordGenerator` is a small .NET Standard 2.0 class library that generates random passwords +(and short numeric codes) according to configurable rules: which character classes to include +(lowercase, uppercase, numeric, special), the length, custom special-character sets, and a cap on +generation attempts. It is marketed as helping meet "OWASP requirements" and is widely used in +the Umbraco / .NET community. + +- **Package id:** `PasswordGenerator` +- **Single dependency-free assembly** (no third-party runtime dependencies). +- **Distribution:** NuGet (`Install-Package PasswordGenerator`). +- **Randomness source:** `System.Security.Cryptography.RandomNumberGenerator` (CSPRNG). + +### Solution layout + +``` +PasswordGenerator.sln +├── PasswordGenerator/ (the library, packable) +│ ├── IPassword.cs public fluent interface +│ ├── Password.cs main implementation +│ ├── IPasswordSettings.cs settings interface +│ ├── PasswordSettings.cs settings implementation +│ ├── PasswordGenerator.cs [Obsolete] back-compat wrapper class +│ ├── PasswordGeneratorSettings.cs [Obsolete] back-compat settings subclass +│ ├── PasswordGenerator.csproj SDK-style, PackageVersion 2.1.0, netstandard2.0 +│ ├── PasswordGenerator.nuspec STALE legacy nuspec (says 2.0.5) — see §7 +│ └── readme.txt ASCII-art readme bundled in older package +├── PasswordGenerator.Tests/ (NUnit tests, netcoreapp2.2) +│ ├── BasicTests.cs 16 tests against Password +│ └── ObsoleteTests.cs 8 tests against the obsolete PasswordGenerator +├── Readme.md GitHub readme (has stale docs — see §6) +├── appveyor.yml CI: AppVeyor, VS2017 image, publish_nuget: true +├── License.md, *.png +``` + +--- + +## 2. Public API (what callers can do today) + +### 2.1 `IPassword` (the contract) + +```csharp +public interface IPassword +{ + IPassword IncludeLowercase(); + IPassword IncludeUppercase(); + IPassword IncludeNumeric(); + IPassword IncludeSpecial(); + IPassword IncludeSpecial(string specialCharactersToInclude); + IPassword LengthRequired(int passwordLength); + string Next(); + IEnumerable NextGroup(int numberOfPasswordsToGenerate); +} +``` + +### 2.2 `Password` constructors + +| Constructor | Behaviour | +|---|---| +| `Password()` | All four classes on, length 16, maxAttempts 10000, `usingDefaults = true` | +| `Password(IPasswordSettings settings)` | Caller-supplied settings | +| `Password(int passwordLength)` | All four classes on, given length | +| `Password(bool lower, bool upper, bool numeric, bool special)` | Explicit classes, length 16, `usingDefaults = false` | +| `Password(bool…, int passwordLength)` | + length | +| `Password(bool…, int passwordLength, int maximumAttempts)` | + attempts cap | + +Defaults: `DefaultPasswordLength = 16`, `DefaultMaxPasswordAttempts = 10000`, all `Include*` default `true`. + +### 2.3 Fluent builders + +`IncludeLowercase() / IncludeUppercase() / IncludeNumeric() / IncludeSpecial() / IncludeSpecial(string) / LengthRequired(int)` — each returns `this` for chaining. The first fluent +`Include*`/`Add*` call after a defaulted `Password()` flips `usingDefaults` off and **clears the +character set**, so `new Password().IncludeNumeric()` yields a numeric-only password (not +"defaults plus numeric"). + +### 2.4 Generation + +- `string Next()` — returns one password, OR a human-readable error **string** on failure (see §5.1). +- `IEnumerable NextGroup(int n)` — calls `Next()` n times; **does not de-duplicate**. + +### 2.5 Settings (`IPasswordSettings` / `PasswordSettings`) + +Character pools (constants in `PasswordSettings`): + +``` +Lowercase = "abcdefghijklmnopqrstuvwxyz" +Uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +Numeric = "0123456789" +Special = @"!#$%&*@\" // DEFAULT special set (8 chars only) +MinLength = 4 MaxLength = 256 +``` + +`Add*` methods mutate and return the same instance; `AddSpecial(string)` overrides the special set. + +### 2.6 Usage examples (from Readme) + +```csharp +var pwd = new Password(); var p = pwd.Next(); // 16 chars, all classes +var pwd = new Password(32); // length 32 +var pwd = new Password(true,true,false,false,21); // letters only, len 21 +var pwd = new Password().IncludeNumeric(); // numeric only, len 16 +var pwd = new Password().IncludeLowercase().IncludeUppercase().IncludeSpecial(); +var pwd = new Password(4).IncludeNumeric(); // 4-digit OTP +var pwd = new Password().IncludeLowercase().IncludeUppercase().IncludeNumeric().IncludeSpecial("[]{}^_="); +``` + +--- + +## 3. How generation actually works (internal flow) + +`Next()` → validate requested length is in `[Min,Max]` → loop up to `MaximumAttempts`: +`GenerateRandomPassword(settings)` then `PasswordIsValid(settings, pwd)` → return first valid, else `"Try again"`. + +`GenerateRandomPassword`: +1. Takes `settings.CharacterSet`, **shuffles** it via `OrderBy(Guid.NewGuid())`. +2. For each position, picks a char at `GetRandomNumberInRange(0, characterSetLength - 1)`. +3. Rejects a char that would make **3 identical in a row** (only checked from position > 2). + +`GetRandomNumberInRange(min, max)`: +```csharp +var data = new byte[sizeof(int)]; +_rng.GetBytes(data); +var randomNumber = BitConverter.ToInt32(data, 0); +return (int)Math.Floor((double)(min + Math.Abs(randomNumber % (max - min)))); +``` + +`PasswordIsValid`: regex-checks at least one lowercase/uppercase/numeric is present (when required), +checks at least one special char from the configured set is present (when required & set non-empty), +and re-checks length. It does **not** verify the password contains *only* allowed characters. + +--- + +## 4. Build & test results (verified in this environment) + +Built with the .NET 8 SDK (8.0.421). The library targets `netstandard2.0`; tests target +`netcoreapp2.2`. + +### 4.1 Library build — **succeeds, 5 warnings** + +All five are `CS0108` member-hiding warnings on the obsolete `PasswordGenerator` class, because its +`IncludeLowercase/Uppercase/Numeric/Special` and `LengthRequired` methods hide the inherited +`Password` members without the `new` keyword: + +``` +PasswordGenerator.cs(36,34): warning CS0108: 'PasswordGenerator.IncludeLowercase()' hides inherited member 'Password.IncludeLowercase()'. +… (same for IncludeUppercase, IncludeNumeric, IncludeSpecial, LengthRequired) +``` + +### 4.2 Test project build — **succeeds, with vulnerability + obsolete warnings** + +- `NU1903` (high) / `NU1902` (moderate): `Microsoft.NETCore.App` **2.2.0** has known + high/moderate-severity vulnerabilities. The `netcoreapp2.2` target is **out of support**. +- Many `CS0618`: the `ObsoleteTests` intentionally exercise the obsolete `PasswordGenerator` class. + +### 4.3 Tests — **24/24 pass** + +`netcoreapp2.2` runtime is not installable (EOL), so tests were re-run on `net8.0` (NUnit 3.14). +Result: `Failed: 0, Passed: 24, Skipped: 0, Total: 24`. (16 in `BasicTests`, 8 in `ObsoleteTests`.) + +> Note: the test names assert intent the code doesn't fully guarantee, e.g. +> `…10Passwords_ShouldReturn10DifferentPasswords` only asserts `Count() == 10`, never uniqueness. + +--- + +## 5. Confirmed bugs & correctness issues + +### 5.1 `Next()` returns error text *as if it were a password* (API design bug) +On invalid length it returns `"Password length invalid. Must be between 4 and 256 characters long"`; +if no valid password is produced within `MaximumAttempts` it returns `"Try again"`. A caller that +doesn't special-case these strings will happily store an error message as a user's password. There +is no exception, no `bool TryNext(out …)`, no `Result` type. **This is the single most important +correctness/safety issue.** + +### 5.2 Off-by-one: the last character of the shuffled set is never selected (verified) +`GetRandomNumberInRange(0, characterSetLength - 1)` computes `Math.Abs(r % (max - min))` = +`r % (characterSetLength - 1)`, which yields `0 … characterSetLength-2`. The highest index is +**never** reachable. Empirically confirmed: for a 10-element range, index 9 is never produced. +Because the set is reshuffled per `GenerateRandomPassword` call, no single character is permanently +excluded across passwords, but **within each password the effective alphabet is one char smaller** +and the distribution is skewed. + +### 5.3 Modulo bias (non-uniform distribution) +`r % n` over a full-range `Int32` is not uniform unless `n` divides 2^32. Character selection is +therefore slightly biased. For a security-focused generator that advertises a CSPRNG, the correct +approach is rejection sampling (e.g. `RandomNumberGenerator.GetInt32` on modern TFMs). + +### 5.4 "Max 2 identical in a row" rule is mis-guarded +The check is `characterPosition > maximumIdenticalConsecutiveChars` (i.e. `> 2`), so it only starts +at position 3. The first three characters can be identical (e.g. a password starting `aaa…`). The +rule itself also reduces entropy intentionally — debatable whether it belongs in a password +generator at all. + +### 5.5 Non-cryptographic, non-uniform shuffle +`Shuffle` uses `from item in items orderby Guid.NewGuid()`. `Guid.NewGuid()` is **not** a CSPRNG and +`OrderBy` over a random key is not a uniform (Fisher–Yates) shuffle. This undermines the +"cryptographically secure" positioning. (The per-character pick uses the CSPRNG, but the shuffle +layered on top adds weak, biased randomness.) + +### 5.6 `_rng` is `static`, reassigned per instance, never disposed +`private static RandomNumberGenerator _rng;` is reassigned inside **every** constructor. Constructing +many `Password` objects repeatedly replaces the shared static field and leaks `IDisposable` RNG +instances (never disposed). It's also a surprising shared-state design for a class that otherwise +looks instance-scoped. + +### 5.7 Dead code referencing the removed provider +`GetRngCryptoSeed(RNGCryptoServiceProvider rng)` is private, unused, and still references the legacy +`RNGCryptoServiceProvider` (the migration commit claimed to remove that usage). Should be deleted. + +### 5.8 `IncludeSpecial` with an empty/whitespace custom set silently never validates +If `IncludeSpecial` is true but `SpecialCharacters` is null/whitespace, `specialIsValid` stays +`false`, so every attempt fails and `Next()` returns `"Try again"`. No guard / no error explaining why. + +### 5.9 `Math.Abs(int.MinValue)` footgun (latent, NOT currently reachable) +Standalone `Math.Abs(int.MinValue)` throws `OverflowException` (verified). In this code it is **not** +reachable because `% (max - min)` is applied *before* `Math.Abs`, bounding the operand. Worth noting +so a v3 refactor doesn't accidentally expose it. + +### 5.10 `NextGroup` does not guarantee uniqueness +Despite the test name implying "different passwords", duplicates are possible (astronomically +unlikely at length 16, but real for short numeric OTPs like a 4-digit code). + +--- + +## 6. Documentation defects + +- **Readme length claims are wrong.** `Readme.md` repeatedly says length "Must be between 8 and 128", + but the code enforces **4 and 256** (`DefaultMinPasswordLength = 4`, `DefaultMaxPasswordLength = 256`). +- Code samples are fenced as ```javascript``` although they are C#. +- Readme logo points at branch `dev/v2`; compatibility image at `master`. Brittle. +- `IncludeSpecial(string)` exists on `Password`/`IPassword` but is **missing** from the obsolete + `PasswordGenerator` wrapper — minor inconsistency. + +--- + +## 7. Packaging / project hygiene + +- **Version drift:** `PasswordGenerator.csproj` declares `2.1.0` (and `Version`, `AssemblyVersion`, + `FileVersion` all 2.1.0). The legacy `PasswordGenerator.nuspec` still says **2.0.5** with 2019 + copyright and lists `RNGCryptoServiceProvider` in tags/notes. The nuspec appears stale/unused + (SDK-style `.csproj` packs the package) and is misleading — decide whether to delete it. +- `Copyright 2022` in csproj vs `Copyright 2019` in nuspec. +- **No `README` packed into the NuGet package** via the modern `` mechanism; only + the old `readme.txt` ASCII-art file is referenced by the nuspec. +- **No SourceLink, no deterministic build, no symbol package (`snupkg`), no ``** (uses + the deprecated ``). +- **CI is AppVeyor on the VS2017 image** (`appveyor.yml`) — very old; no GitHub Actions. +- Tests target EOL `netcoreapp2.2` and pull a vulnerable `Microsoft.NETCore.App 2.2.0`. + +--- + +## 8. What the package does NOT do (feature gaps) + +- No **passphrase / word-list** generation (e.g. diceware / xkcd-style). +- No **"exclude ambiguous characters"** option (e.g. `0/O`, `1/l/I`). +- No **per-class minimum counts** (e.g. "at least 2 digits and 1 special"). +- No **"require at least one of each included class"** guarantee — it relies on retry+validate, + which is probabilistic and can return `"Try again"`. +- No **entropy/strength estimate** for a generated password. +- No **pronounceable / memorable** password mode. +- No **`Span`/allocation-efficient** API; everything is `string`/`char[]`/LINQ. +- No **async** API (not really needed, but absent). +- No **dependency-injection helpers** (`AddPasswordGenerator()` / `IServiceCollection` extension). +- No **`TryNext`/`Result`** pattern — failures are encoded as magic strings. +- No **custom character pools** beyond special chars (can't, say, supply a full custom alphabet). +- No **uniqueness guarantee** in `NextGroup`. +- No **`net6/net8` target** — `netstandard2.0` only (works everywhere, but misses + `RandomNumberGenerator.GetInt32`, `GetItems`, etc.). +- No **nullable reference type** annotations. + +--- + +## 9. Suggested v3 direction (prioritised, for discussion) + +**Tier 1 — correctness & security (do these regardless):** +1. Replace the error-string returns with proper failure handling: throw `ArgumentException` for + invalid config and add `bool TryNext(out string password)` and/or a `PasswordResult` type. (§5.1) +2. Fix character selection: drop the Guid shuffle + biased modulo; use unbiased rejection sampling + (`RandomNumberGenerator.GetInt32` on modern TFMs, manual rejection on `netstandard2.0`). Fixes + §5.2, §5.3, §5.5 at once. +3. Guarantee included classes deterministically (seed one of each required class, then fill & shuffle) + instead of generate-and-retry; removes `"Try again"` and `MaximumAttempts` entirely. +4. Remove dead code (`GetRngCryptoSeed`) and fix the `static`/undisposed `_rng` design. (§5.6, §5.7) + +**Tier 2 — API & packaging modernisation:** +5. Multi-target `netstandard2.0;net8.0` (and maybe `net6.0`); add nullable annotations. +6. Add DI extension `services.AddPasswordGenerator()`. +7. Fix versioning/packaging: delete or regenerate the stale nuspec, add ``, + SourceLink, deterministic builds, `snupkg`, ``. Move CI to GitHub Actions. +8. Update tests to a supported TFM (`net8.0`) and NUnit 4, add uniqueness/entropy/edge-case tests. + +**Tier 3 — new features:** +9. "Exclude ambiguous characters" option and per-class minimum counts. +10. Passphrase/diceware mode with a bundled word list. +11. Entropy / strength estimate on the result. +12. `NextGroup` uniqueness option. + +**Breaking-change note:** items 1 and 3 change the failure contract and remove `MaximumAttempts` +semantics — appropriate for a major (v3) bump. Decide whether to keep the obsolete +`PasswordGenerator`/`PasswordGeneratorSettings` classes or finally drop them in v3. + +--- + +## 10. Quick reference — files & key line anchors + +- `Password.cs:114` `Next()` (error-string returns at lines 119, 131) +- `Password.cs:157` `GenerateRandomPassword` (shuffle at 163; 3-in-a-row guard at 172–177) +- `Password.cs:183` `GetRandomNumberInRange` (off-by-one + modulo bias) +- `Password.cs:195` `GetRngCryptoSeed` (dead code, references `RNGCryptoServiceProvider`) +- `Password.cs:208` `PasswordIsValid` (special-char empty-set edge case at 221–229) +- `Password.cs:247` `Shuffle` (Guid-based, non-uniform) +- `PasswordSettings.cs:10-15` character pools + min/max length (4/256) +- `PasswordSettings.cs:102` `StopUsingDefaults` (clears set on first fluent call) +- `PasswordGenerator.cs:5` `[Obsolete]` wrapper (source of the 5 CS0108 warnings) +- `PasswordGenerator.csproj:5,21` version 2.1.0 vs `PasswordGenerator.nuspec:5` version 2.0.5 From 21159c341dd0d4201515486f3a7ae1f171ae8d31 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 11:11:54 +0000 Subject: [PATCH 02/14] Fix AppVeyor build by using dotnet CLI for SDK-style projects The default MSBuild step on the old image used MSBuild 14, which cannot parse SDK-style csproj files (MSB4041). Switch to dotnet restore/build/ test/pack on the VS2019 image and publish the nupkg as a build artifact. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd --- appveyor.yml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index d503d01..63e913d 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,7 +1,13 @@ -version: 2.0.{build} -image: Visual Studio 2017 -before_build: - ps: nuget restore -build: - publish_nuget: true - verbosity: detailed \ No newline at end of file +version: 2.1.{build} +image: Visual Studio 2019 +before_build: + - cmd: dotnet restore PasswordGenerator.sln +build_script: + - cmd: dotnet build PasswordGenerator.sln -c Release --no-restore +test_script: + - cmd: dotnet test PasswordGenerator.Tests/PasswordGenerator.Tests.csproj -c Release --no-build --logger trx +after_build: + - cmd: dotnet pack PasswordGenerator/PasswordGenerator.csproj -c Release --no-build -o artifacts +artifacts: + - path: artifacts/*.nupkg + name: NuGet From 8d3152f4695fdcfb34f9d1ef84b9c42297206f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 11:12:31 +0000 Subject: [PATCH 03/14] Explicitly disable AppVeyor deployment Add 'deploy: off' so no NuGet (or other) deployment runs, regardless of any deploy provider configured in the AppVeyor project UI. CI only builds, tests, and packs the nupkg as a downloadable artifact. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd --- appveyor.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/appveyor.yml b/appveyor.yml index 63e913d..a692d66 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -11,3 +11,4 @@ after_build: artifacts: - path: artifacts/*.nupkg name: NuGet +deploy: off From d4911fbcde589cf724de90f6627be9e166ed41a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 11:54:37 +0000 Subject: [PATCH 04/14] Add v3 verification report against current source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-checks every original-review bug (§5) and feature gap (§8) against the current master source, marking each Confirmed / Already Fixed / Partially Fixed with code references. Corrects the addendum's premise that the Guid shuffle was removed (it still exists at Password.cs:247-249 on both master and dev/v2) and confirms master is ahead of the older dev/v2 line. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd --- docs/V3_VERIFICATION.md | 199 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/V3_VERIFICATION.md diff --git a/docs/V3_VERIFICATION.md b/docs/V3_VERIFICATION.md new file mode 100644 index 0000000..1127482 --- /dev/null +++ b/docs/V3_VERIFICATION.md @@ -0,0 +1,199 @@ +# PasswordGenerator v3 — Verification Report + +> Companion to `V3_REVIEW_AND_DOCUMENTATION.md` and the v3 Planning Addendum. +> This document does the verification the addendum asked for: every item from the original +> review's bug list (§5) and feature gaps (§8) was re-checked against the **current source**, +> and marked **Confirmed**, **Already Fixed**, or **Partially Fixed** with a code reference. +> +> Verification date: 2026-05-24. +> **Code state verified:** the source files on this branch are byte-identical to `origin/master` +> (`git diff origin/master -- PasswordGenerator/ PasswordGenerator.Tests/` is empty). The latest +> source commit is `36f2b58` *"removed usage of RNG Crypto Provider and replaced with +> RandomNumberGenerator"*. So the code reviewed here **is** the current default-branch state. + +--- + +## 0a. Which branch is current? (`master` vs `dev/v2`) + +`master` is the **most up-to-date** branch. `dev/v2` is the **older** v2.0.0 line — it has +diverged but is behind master on everything substantive: + +| Aspect | `dev/v2` (v2.0.0, `netstandard1.2`) | `master` (v2.1.0, `netstandard2.0`) | +|---|---|---| +| Randomness | `new Random()` — **insecure** (`Password.cs:148` on dev/v2) | `RandomNumberGenerator` CSPRNG (`Password.cs:189`) | +| Guid shuffle | present (`:204`) | **also present** (`:247-249`) | +| Length range | 8–128 | 4–256 | +| Custom special chars | absent | present (`IncludeSpecial(string)`) | +| Bug-fix tests | absent | present (`077b798`) | + +`dev/v2` carries a handful of commits master lacks, but they are all non-substantive +(readme/logo/nuspec/appveyor tweaks + an early "passwordservice" refactor). **Verify against +`master`** — which equals this branch's source. + +Key implication: the **Guid shuffle exists on BOTH branches and was never removed anywhere**, so the +"already fixed" recollection does not hold on any branch (see §0). What was actually fixed — only on +master — was `Random` → `RNGCryptoServiceProvider` → `RandomNumberGenerator` for *selection*. + +--- + +## 0. Headline correction (read this first) + +The addendum states the **Guid-based shuffle (original §5.5) is "ALREADY FIXED — remove from v3 +scope."** That is **not** what the code shows. + +- **What commit `36f2b58` actually changed:** the *character-selection* randomness. `GetRandomNumberInRange` + now draws from the CSPRNG — `_rng.GetBytes(data)` at `Password.cs:189`, where + `_rng = RandomNumberGenerator.Create()`. ✅ This part of the author's recollection is correct: the + **output's randomness now comes from a CSPRNG**, not from `Random` or from Guids. +- **What was NOT changed:** the `Shuffle` helper still uses `orderby Guid.NewGuid()` — + `Password.cs:247-249`, called at `Password.cs:163`. The Guid shuffle is **still in the code**. + +**Net verdict: Partially Fixed.** The Guid shuffle is no longer the source of the password's +randomness (so it is not a meaningful security hole anymore), but it is still present as +**redundant, non-uniform dead-weight** that reshuffles the pool before the CSPRNG indexes into it. +Recommendation: **keep a small cleanup task in v3** to delete `Shuffle` (and its call site) — do +**not** drop it from scope entirely. Selection via `GetRandomNumberInRange` alone already provides +the randomness; the shuffle adds nothing but a non-crypto code path. + +--- + +## 1. Bug list (§5) — verification + +| # | Original issue | Verdict | Evidence (current code) | +|---|---|---|---| +| 5.1 | `Next()` returns error text as a password (`"Try again"`, length message) | **Confirmed** | `Password.cs:119-120` (length message) and `Password.cs:131` (`… ? password : "Try again"`). No exception, no `TryNext`, no result type. | +| 5.2 | Off-by-one: top index of the pool never selected | **Confirmed** | `Password.cs:170` calls `GetRandomNumberInRange(0, characterSetLength - 1)`; `Password.cs:192` computes `… % (max - min)` = `% (characterSetLength - 1)` → range `0 … len-2`. Empirically reproduced earlier (index 9 never produced for a 10-element range). | +| 5.3 | Modulo bias (non-uniform selection) | **Confirmed** | `Password.cs:192` `randomNumber % (max - min)` over a full-range `Int32`. Should be rejection sampling / `RandomNumberGenerator.GetInt32`. | +| 5.4 | "Max 2 identical in a row" rule mis-guarded; first 3 chars can be identical | **Confirmed** | `Password.cs:173` guard is `characterPosition > maximumIdenticalConsecutiveChars` (i.e. `> 2`), so the check only starts at position 3. | +| 5.5 | Non-cryptographic, non-uniform Guid shuffle | **Partially Fixed** | Output randomness now from CSPRNG (`Password.cs:189`), but Guid shuffle still present at `Password.cs:247-249` (used at `:163`). See §0. Reclassify as **cleanup**, not security. | +| 5.6 | `_rng` is `static`, reassigned in every ctor, never disposed | **Confirmed** | `static` field `Password.cs:20`; reassigned in all six constructors (`:28, :35, :43, :51, :60, :69`); never disposed (it is `IDisposable`). | +| 5.7 | Dead code `GetRngCryptoSeed` referencing `RNGCryptoServiceProvider` | **Confirmed** | `Password.cs:195-200`. Unused; still references the legacy provider the commit message claimed to remove. | +| 5.8 | `IncludeSpecial` with empty/whitespace custom set silently never validates → `"Try again"` | **Confirmed** | `PasswordIsValid` `Password.cs:221-229`: `specialIsValid` only becomes `true` when `IncludeSpecial && !IsNullOrWhiteSpace(SpecialCharacters)` and a match is found; otherwise stays `false`. | +| 5.9 | `Math.Abs(int.MinValue)` overflow | **Confirmed (latent, NOT reachable)** | `Password.cs:192` applies `% (max - min)` *before* `Math.Abs`, bounding the operand. Standalone overflow verified earlier, but not reachable here. Keep in mind for the rewrite. | +| 5.10 | `NextGroup` does not de-duplicate | **Confirmed** | `Password.cs:138-149` simply loops `Next()` and adds to a `List`. Test `…ShouldReturn10DifferentPasswords` only asserts count. | + +### Documentation defects (§6) — still present +- Readme still says length "Must be between 8 and 128"; code enforces **4 and 256** + (`PasswordSettings.cs:14-15`). **Confirmed.** +- C# samples still fenced as ```javascript```. **Confirmed.** +- `IncludeSpecial(string)` still absent from the obsolete `PasswordGenerator` wrapper. **Confirmed.** + +### Packaging (§7) — still present +- `PasswordGenerator.csproj:5,21` = `2.1.0`; stale `PasswordGenerator.nuspec:5` = `2.0.5`. **Confirmed.** +- `PackageIconUrl` deprecation (`NU5048`) and missing `PackageReadmeFile` confirmed by `dotnet pack` + output during CI work. **Confirmed.** +- The 5 `CS0108` member-hiding warnings on the obsolete wrapper are still emitted. **Confirmed.** + +--- + +## 2. Feature gaps (§8) — verification + +All confirmed **absent** in current code (no hidden implementations found across the whole +`PasswordGenerator/` project — the only public surface is `IPassword`/`Password`/`IPasswordSettings`/ +`PasswordSettings` plus the two obsolete wrappers): + +| Gap | Verdict | Note | +|---|---|---| +| Passphrase / word-list (diceware) | **Confirmed gap** | No word list, no passphrase path. | +| Exclude ambiguous characters (`0/O`, `1/l/I`) | **Confirmed gap** | No such option. | +| Per-class minimum counts (e.g. "≥2 digits") | **Confirmed gap** | Only presence is checked, not counts. | +| Guarantee at least one of each included class | **Confirmed gap** | Probabilistic: relies on the generate-and-validate retry loop (`Password.cs:124-131`), hence `"Try again"`. | +| Entropy / strength estimate | **Confirmed gap** | None. | +| Pronounceable / memorable mode | **Confirmed gap** | None. | +| `Span` / low-allocation API | **Confirmed gap** | Uses `string`/`char[]`/LINQ throughout. | +| Async API | **Confirmed gap** | None. | +| DI helper (`AddPasswordGenerator()`) | **Confirmed gap** | None. | +| `TryNext` / `Result` pattern | **Confirmed gap** | Failures are magic strings (§5.1). | +| Custom full alphabet beyond special chars | **Partially achievable today** | No first-class API, but a caller *can* abuse `IncludeSpecial("…")` with the other classes off to supply an arbitrary pool (`PasswordSettings.cs:79-86`). v3 should add a clean `WithCharacters(...)`/`WithAllAscii()`. | +| `NextGroup` uniqueness | **Confirmed gap** | Dup of 5.10. | +| `net6`/`net8` target | **Confirmed gap** | `PasswordGenerator.csproj:4` is `netstandard2.0` only. | +| Nullable reference annotations | **Confirmed gap** | No `enable` in the csproj. | + +--- + +## 3. Adjusted v3 plan (reconciling the original review + the addendum + this verification) + +### Tier 1 — Correctness & security +1. **Replace error-string returns** with exceptions + a `TryNext`/`PasswordResult` pattern. (5.1) — *Confirmed, keep.* +2. **Fix selection: unbiased rejection sampling** (`RandomNumberGenerator.GetInt32` on modern TFMs; + manual rejection on `netstandard2.0`). Fixes 5.2 + 5.3 in one change. — *Confirmed, keep.* +3. **Delete the Guid `Shuffle`** (and its call site at `Password.cs:163`). — **Keep as a small + cleanup task** (the addendum's "remove from scope" is based on an inaccurate belief that the code + was already removed — it was not; see §0). Reclassified from "security" to "cleanup". +4. **Guarantee included classes deterministically** (seed one of each required class, then fill & + shuffle with the CSPRNG) → removes `"Try again"` and the `MaximumAttempts` retry loop. — *Keep.* + - Addendum nuance: retry behaviour, where it remains, must be **configurable** (fluent / + appSettings / library default) and must **throw** on exhaustion, never return a string. +5. **Remove dead code** `GetRngCryptoSeed` (5.7) and **fix the `static`/undisposed `_rng`** design + (5.6) — make the RNG an instance field (or use the static `RandomNumberGenerator.Fill`/`GetInt32` + static APIs and hold no field at all). — *Confirmed, keep.* +6. **Fix the empty-custom-special-set trap** (5.8) — validate the configuration up front and throw a + clear exception instead of silently failing. — *Confirmed, keep.* + +### Tier 2 — Modernisation +7. **Multi-target** `netstandard2.0;net8.0` (see open-question recommendation below); add nullable + annotations. +8. **Async API**: add `NextAsync()` / `GenerateAsync()`; mark sync methods `[Obsolete]` pointing to + async equivalents (gentle deprecation). *(addendum)* +9. **DI support**: `services.AddPasswordGenerator()` extension, opt-in (not auto-registered); wires up + the RNG; fluent API behaves identically whether `new`'d or resolved. *(addendum, confirmed gap)* +10. **BenchmarkDotNet** project alongside tests (sync vs async, batch sizes 1/100/1000/10000, + allocations); include comparative benchmark numbers in every release note going forward. *(addendum)* +11. **Packaging hygiene**: delete/regenerate the stale nuspec, add ``, replace + `PackageIconUrl` with `` (clears `NU5048`), add SourceLink + deterministic build + + `snupkg`. Fix the 5 `CS0108` warnings (or drop the obsolete wrappers — see open questions). +12. **Tests**: retarget to `net8.0` + NUnit 4 (current `netcoreapp2.2` is EOL and pulls vulnerable + `Microsoft.NETCore.App 2.2.0`); add uniqueness, entropy, and edge-case tests. This also makes the + AppVeyor `dotnet test` step reliable. + +### Tier 3 — New features *(all confirmed absent today)* +13. Fluent character-pool control incl. `.WithAllAscii()` / `WithCharacters(...)`; keep existing + `Include*` methods (library is also used for OTPs, env names, API keys). **Do not** impose a + global 12-char minimum. +14. Use-case / compliance presets: `.ForOwasp()`, `.ForNist()`, `.ForHipaa()`, `.ForPciDss()`, + `.ForOtp()`, `.ForPassphrase()`, `.ForApiKey()`, `.ForEnvironmentName()`. +15. `appSettings` configuration with resolution order fluent > appSettings > library default + (separate, opt-in step). +16. `.Generate()` / `.GenerateAsync()` batch API (count overloads + `.Count(n)` chaining + + appSettings default); keep `.Next()` for single (mirrors `Random.Next()`). +17. Exclude-ambiguous option, per-class minimum counts, entropy estimate, `NextGroup`/`Generate` + uniqueness option. + +### Tier 4 — Documentation *(addendum)* +18. v2→v3 migration guide (direct→DI, sync→async, error-string→exceptions, presets/appSettings). +19. Clarify broader purpose (OTPs, env names, API keys), document preset↔standard mapping with + OWASP/NIST links. + +--- + +## 4. Recommendations on the open questions + +1. **Drop the `[Obsolete] PasswordGenerator`/`PasswordGeneratorSettings` wrappers in v3?** + Recommend **dropping them**. They have carried `[Obsolete]` since v2, they are the sole source of + the 5 `CS0108` warnings, and v3 is a major version (the natural removal point). If you prefer + maximum caution, the fallback is to keep them for one more major but add the `new` keyword to + silence the warnings — but a clean removal is the better long-term call. +2. **Minimum target — `netstandard2.0` vs `net8.0;net10.0` only?** + Recommend **multi-targeting `netstandard2.0;net8.0`** (optionally add `net10.0`). Dropping + `netstandard2.0` would cut off .NET Framework / older consumers, which matters for this package's + Umbraco-heavy audience. Multi-targeting lets the modern TFM use `RandomNumberGenerator.GetInt32` + / `GetItems` (fixing the bias cleanly) while `netstandard2.0` keeps a manual rejection-sampling + fallback. +3. **DI overload taking an `IConfiguration` section for one-line appSettings binding?** + **Yes.** Provide both `AddPasswordGenerator(Action configure)` and + `AddPasswordGenerator(IConfiguration section)` so consumers can bind their policy from + `appSettings.json` in a single line, consistent with the fluent > appSettings > default order. + +--- + +## 5. Summary + +- **9 of 10** original §5 bugs are **Confirmed still present**; **§5.5 (Guid shuffle) is Partially + Fixed** — the addendum's premise that it was fully removed is inaccurate (`Password.cs:247-249`), + though it is now redundant rather than a security hole. +- **§5.9** remains a **latent, non-reachable** footgun. +- **All §8 feature gaps confirmed absent**, except a custom alphabet is *hackily* achievable via + `IncludeSpecial(string)` today. +- The adjusted plan keeps the Guid-shuffle removal as a **cleanup** task (not dropped), folds in all + addendum additions (async, DI, benchmarks, presets, appSettings, `.Generate()`, migration guide), + and answers the three open questions with recommendations. From 8538d5aa81c660f05682a559e41ce4639211a5f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 12:03:03 +0000 Subject: [PATCH 05/14] Add comprehensive docs section with mermaid diagrams New docs/ index plus paired current-state/ (as-is architecture, generation flow, API surface) and v3-target/ (target architecture, generation flow, API surface, config & DI, before/after, roadmap) sets. Diagrams show what the package does today, where v3 is headed, and why each change is better, tied back to verified issues. All 33 mermaid diagrams validated with the mermaid parser. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd --- docs/README.md | 46 +++++++++ docs/current-state/api-surface.md | 74 +++++++++++++++ docs/current-state/architecture.md | 97 +++++++++++++++++++ docs/current-state/generation-flow.md | 91 ++++++++++++++++++ docs/v3-target/api-surface.md | 83 +++++++++++++++++ docs/v3-target/architecture.md | 108 ++++++++++++++++++++++ docs/v3-target/before-after.md | 123 +++++++++++++++++++++++++ docs/v3-target/configuration-and-di.md | 76 +++++++++++++++ docs/v3-target/generation-flow.md | 82 +++++++++++++++++ docs/v3-target/roadmap.md | 84 +++++++++++++++++ 10 files changed, 864 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/current-state/api-surface.md create mode 100644 docs/current-state/architecture.md create mode 100644 docs/current-state/generation-flow.md create mode 100644 docs/v3-target/api-surface.md create mode 100644 docs/v3-target/architecture.md create mode 100644 docs/v3-target/before-after.md create mode 100644 docs/v3-target/configuration-and-di.md create mode 100644 docs/v3-target/generation-flow.md create mode 100644 docs/v3-target/roadmap.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2fb8848 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,46 @@ +# PasswordGenerator — Documentation + +This folder is the working reference for the package as it is **today** and the design we are +steering it toward in **v3**. Diagrams are written in [Mermaid](https://mermaid.js.org/) and render +directly on GitHub. + +## How the docs fit together + +```mermaid +flowchart LR + subgraph Review["Review & verification"] + A[V3_REVIEW_AND_DOCUMENTATION.md
full review of v2.1.0] + B[V3_VERIFICATION.md
every issue re-checked vs current source] + end + subgraph Current["current-state/ — what we have"] + C1[architecture.md] + C2[generation-flow.md] + C3[api-surface.md] + end + subgraph Target["v3-target/ — where we are going"] + T1[architecture.md] + T2[generation-flow.md] + T3[api-surface.md] + T4[configuration-and-di.md] + T5[before-after.md] + T6[roadmap.md] + end + A --> B --> Current + Current --> Target + T5 -. compares .-> Current +``` + +## Reading order + +1. **`V3_REVIEW_AND_DOCUMENTATION.md`** — the original full review (API, bugs, packaging, gaps). +2. **`V3_VERIFICATION.md`** — each issue re-checked against the current `master` source, with verdicts. +3. **`current-state/`** — diagrammed snapshot of the code as it runs today. +4. **`v3-target/`** — the proposed v3 design, diagrammed, with a before/after and a roadmap. + +## Conventions + +- **Current state** describes `master` @ v2.1.0, `netstandard2.0`. Code references use + `file:line` against that source. +- **v3 target** is a proposal for discussion, not yet implemented. Anything in `v3-target/` is + subject to change as we agree the plan. +- Each "target" doc ends with a **Why this is better** note tied back to a verified issue. diff --git a/docs/current-state/api-surface.md b/docs/current-state/api-surface.md new file mode 100644 index 0000000..55672e7 --- /dev/null +++ b/docs/current-state/api-surface.md @@ -0,0 +1,74 @@ +# Current State — Public API Surface (v2.1.0) + +What a caller can do today, and how configuration is resolved. + +## API map + +```mermaid +flowchart TD + subgraph Construct["Construction (6 constructors)"] + c0["Password()"] + c1["Password(int length)"] + c2["Password(IPasswordSettings)"] + c3["Password(bool l, u, n, s)"] + c4["Password(bool l,u,n,s, int length)"] + c5["Password(bool l,u,n,s, int length, int maxAttempts)"] + end + subgraph Fluent["Fluent builders (return this)"] + f1["IncludeLowercase()"] + f2["IncludeUppercase()"] + f3["IncludeNumeric()"] + f4["IncludeSpecial()"] + f5["IncludeSpecial(string)"] + f6["LengthRequired(int)"] + end + subgraph Generate["Generation"] + g1["Next() : string"] + g2["NextGroup(int) : IEnumerable~string~"] + end + Construct --> Fluent --> Generate +``` + +## Configuration resolution (today) + +```mermaid +flowchart LR + A["Constructor args
or defaults"] --> S[(PasswordSettings)] + B["Fluent Include*/LengthRequired"] --> S + S --> G["Next()"] + note1["First fluent call on a defaulted
Password() clears the pool
(StopUsingDefaults)"] + B -.-> note1 +``` + +There are only two sources: constructor arguments (or built-in defaults) and fluent calls. There is +**no** external configuration (`appSettings`), **no** DI, and **no** presets. + +Defaults: all four classes on, length 16, `MaximumAttempts` 10000, length bounds 4–256, default +special set `!#$%&*@\` (8 chars). + +## Key behaviour quirks (verified) + +```mermaid +flowchart TD + Q1["new Password().IncludeNumeric()"] --> R1["numeric ONLY, length 16
(first fluent call clears defaults)"] + Q2["Next() on bad config"] --> R2["returns an ERROR STRING (§5.1)"] + Q3["NextGroup(n)"] --> R3["n passwords, NOT de-duplicated (§5.10)"] + Q4["IncludeSpecial(empty string)"] --> R4["always 'Try again' (§5.8)"] + classDef bad fill:#ffe6e6,stroke:#cc0000; + class R2,R3,R4 bad; +``` + +## What the surface does NOT offer (verified gaps, §8) + +| Missing today | Confirmed | +|---|---| +| `TryNext` / result type (failures are strings) | ✅ | +| Async API | ✅ | +| DI registration helper | ✅ | +| Presets (OWASP/NIST/OTP/passphrase/API-key/env-name) | ✅ | +| `appSettings` configuration | ✅ | +| First-class custom alphabet (`WithAllAscii`/`WithCharacters`) | ✅ (only hackable via `IncludeSpecial(string)`) | +| Exclude-ambiguous, per-class minimums, entropy estimate | ✅ | +| `netstandard2.0` + `net8.0` multi-target / nullable | ✅ (netstandard2.0 only) | + +These gaps define the v3 surface in `../v3-target/api-surface.md`. diff --git a/docs/current-state/architecture.md b/docs/current-state/architecture.md new file mode 100644 index 0000000..c8304bd --- /dev/null +++ b/docs/current-state/architecture.md @@ -0,0 +1,97 @@ +# Current State — Architecture (v2.1.0) + +`master` @ v2.1.0 · target `netstandard2.0` · no third-party runtime dependencies. + +## Type relationships + +```mermaid +classDiagram + class IPassword { + <> + +IncludeLowercase() IPassword + +IncludeUppercase() IPassword + +IncludeNumeric() IPassword + +IncludeSpecial() IPassword + +IncludeSpecial(string) IPassword + +LengthRequired(int) IPassword + +Next() string + +NextGroup(int) IEnumerable~string~ + } + class IPasswordSettings { + <> + +bool IncludeLowercase + +bool IncludeUppercase + +bool IncludeNumeric + +bool IncludeSpecial + +int PasswordLength + +string CharacterSet + +int MaximumAttempts + +int MinimumLength + +int MaximumLength + +string SpecialCharacters + +AddLowercase() IPasswordSettings + +AddUppercase() IPasswordSettings + +AddNumeric() IPasswordSettings + +AddSpecial() IPasswordSettings + +AddSpecial(string) IPasswordSettings + } + class Password { + -static RandomNumberGenerator _rng + +IPasswordSettings Settings + +Next() string + +NextGroup(int) IEnumerable~string~ + -GenerateRandomPassword(settings)$ string + -GetRandomNumberInRange(min, max)$ int + -PasswordIsValid(settings, pwd)$ bool + -Shuffle(items)$ IEnumerable + -GetRngCryptoSeed(rng)$ int + } + class PasswordSettings { + +BuildCharacterSet(...) + -StopUsingDefaults() + } + class PasswordGenerator { + <> + } + class PasswordGeneratorSettings { + <> + } + + IPassword <|.. Password + IPasswordSettings <|.. PasswordSettings + Password o-- IPasswordSettings : Settings + Password <|-- PasswordGenerator : inherits + PasswordSettings <|-- PasswordGeneratorSettings : inherits +``` + +Notes: +- `PasswordGenerator` / `PasswordGeneratorSettings` are `[Obsolete]` back-compat wrappers. The five + `CS0108` build warnings come from `PasswordGenerator` hiding `Password` methods without `new`. +- `_rng` is a **`static`** field on `Password` (`Password.cs:20`), reassigned in **every** constructor + and never disposed (verified issue §5.6). +- `GetRngCryptoSeed` (`Password.cs:195`) is dead code still referencing the legacy + `RNGCryptoServiceProvider` (verified issue §5.7). + +## Runtime composition + +```mermaid +flowchart TD + Caller["Caller code"] -->|new Password / fluent| P[Password] + P --> S[PasswordSettings
character pools, length, attempts] + P -->|reads CharacterSet| S + P --> RNG["static RandomNumberGenerator (CSPRNG)"] + P -->|orderby Guid.NewGuid| SH["Shuffle helper (non-crypto)"] + classDef warn fill:#ffe6e6,stroke:#cc0000; + class SH warn; +``` + +The output's randomness comes from the CSPRNG via `GetRandomNumberInRange` (`Password.cs:189`). The +`Shuffle` helper (`Password.cs:247-249`) reshuffles the pool first using `Guid.NewGuid()` — a +non-crypto, non-uniform sort that is now **redundant** (verified issue §5.5, reclassified as cleanup). + +## Packaging / build snapshot + +- Single packable project `PasswordGenerator.csproj`, version `2.1.0`. +- Stale `PasswordGenerator.nuspec` declares `2.0.5` (verified issue §7). +- `dotnet pack` emits `NU5048` (deprecated `PackageIconUrl`) and "missing readme". +- Tests target EOL `netcoreapp2.2` (pulls vulnerable `Microsoft.NETCore.App 2.2.0`). diff --git a/docs/current-state/generation-flow.md b/docs/current-state/generation-flow.md new file mode 100644 index 0000000..e8ad7d2 --- /dev/null +++ b/docs/current-state/generation-flow.md @@ -0,0 +1,91 @@ +# Current State — Generation Flow (v2.1.0) + +How `Next()` produces a password today (`Password.cs:114-193`). + +## `Next()` control flow + +```mermaid +flowchart TD + Start([Next called]) --> LenOK{"length in
[Min, Max]?"} + LenOK -- no --> ErrStr["return ERROR STRING:
'Password length invalid...'"] + LenOK -- yes --> Gen["GenerateRandomPassword(settings)"] + Gen --> Valid{"PasswordIsValid?"} + Valid -- yes --> RetPwd([return password]) + Valid -- no --> Attempts{"attempts <
MaximumAttempts?"} + Attempts -- yes --> Gen + Attempts -- no --> TryAgain["return ERROR STRING:
'Try again'"] + + classDef bad fill:#ffe6e6,stroke:#cc0000; + class ErrStr,TryAgain bad; +``` + +**Verified problem (§5.1):** the two red nodes return human-readable **error strings in the same +`string` return slot as a real password**. A caller that does not special-case them will store an +error message as the user's password. There is no exception and no `TryNext`/result type. + +## Inside `GenerateRandomPassword` + +```mermaid +flowchart TD + A["pool = settings.CharacterSet"] --> B["pool = Shuffle(pool)
orderby Guid.NewGuid (non-crypto)"] + B --> C["for each position 0..length-1"] + C --> D["idx = GetRandomNumberInRange(0, len-1)
= rnd % (len-1) → range 0..len-2"] + D --> E["password[pos] = pool[idx]"] + E --> F{"pos > 2 AND
3 identical in a row?"} + F -- yes --> G["pos-- (redo this position)"] + F -- no --> H["next position"] + G --> C + H --> C + + classDef bad fill:#ffe6e6,stroke:#cc0000; + class B,D,F bad; +``` + +Verified problems in this loop: +- **§5.5** — `Shuffle` is a non-crypto `Guid.NewGuid()` sort (redundant; randomness really comes from + `GetRandomNumberInRange`). +- **§5.2 (off-by-one)** — `GetRandomNumberInRange(0, len-1)` computes `% (len-1)`, so the **top index + is never selected**; the effective alphabet is one char short per password. +- **§5.3 (modulo bias)** — `rnd % n` over a full-range `Int32` is not uniform. +- **§5.4** — the "no 3 identical in a row" guard only starts at position > 2, so the **first three + characters can be identical**. + +## How validity is decided (`PasswordIsValid`, `Password.cs:208`) + +```mermaid +flowchart LR + P[password] --> L{"lower required?
→ regex match"} + P --> U{"upper required?
→ regex match"} + P --> N{"numeric required?
→ regex match"} + P --> S{"special required?
→ any special char present"} + P --> Len{"length in range?"} + L & U & N & S & Len --> AND{{"all true?"}} + AND -- yes --> OK([valid]) + AND -- no --> NO([invalid → retry]) +``` + +**Verified problem (§5.8):** if `IncludeSpecial` is true but the custom special set is empty/whitespace, +`specialIsValid` stays `false` forever, so every attempt fails and `Next()` silently returns +`"Try again"`. + +## Why this design is fragile + +```mermaid +stateDiagram-v2 + [*] --> Configured + Configured --> Generating: Next() + Generating --> Generating: invalid (retry up to MaximumAttempts) + Generating --> Success: valid password + Generating --> FailureString: attempts exhausted + Configured --> FailureString: length invalid + note right of FailureString + Failure is a magic STRING, + not an exception. Caller may + not notice. (§5.1) + end note + Success --> [*] + FailureString --> [*] +``` + +The whole correctness contract hinges on probabilistic retry + string sentinels — the core thing v3 +replaces (see `../v3-target/generation-flow.md`). diff --git a/docs/v3-target/api-surface.md b/docs/v3-target/api-surface.md new file mode 100644 index 0000000..fc2beda --- /dev/null +++ b/docs/v3-target/api-surface.md @@ -0,0 +1,83 @@ +# v3 Target — Public API Surface (proposal) + +Keeps the familiar fluent feel; adds safety, presets, batch, async, and custom pools. + +## Target API map + +```mermaid +flowchart TD + subgraph Entry["Entry points"] + e1["new PasswordBuilder()"] + e2["inject IPasswordGenerator (DI)"] + end + subgraph Build["Fluent builder (IPasswordBuilder)"] + direction TB + b1["IncludeLowercase/Uppercase/Numeric"] + b2["IncludeSpecial(string)"] + b3["WithAllAscii() / WithCharacters(string)"] + b4["ExcludeAmbiguous()"] + b5["RequireAtLeast(class, count)"] + b6["LengthRequired(int)"] + b7["Presets: ForOwasp/ForNist/ForOtp/
ForPassphrase/ForApiKey/ForEnvironmentName"] + end + subgraph Gen["Generation (IPasswordGenerator)"] + g1["Next() : string (throws on bad config)"] + g2["TryNext(out string) : bool"] + g3["NextAsync(ct) : Task~string~"] + g4["Generate(count) / Generate().Count(n)"] + g5["GenerateAsync(count, ct)"] + end + Entry --> Build --> Gen + classDef good fill:#e6ffe6,stroke:#009900; + class g2,g3,g4,g5,b3,b4,b5,b7 good; +``` + +## Single vs batch (naming kept intentional) + +```mermaid +flowchart LR + N["Next() — ONE password
(mirrors Random.Next())"] + G["Generate(count) — MANY
Generate().Count(10)
count from appSettings if unset"] + N -. same options .- G +``` + +`.Next()` is retained because the original API was modelled on `Random.Next()`. `.Generate()` is the +new batch-oriented entry with count overloads, `.Count(n)` chaining, and an `appSettings` default. + +## Presets → standards mapping + +```mermaid +flowchart LR + ForOwasp --> O["all printable ASCII, no forced composition"] + ForNist --> Nn["NIST 800-63B aligned length/charset"] + ForOtp --> Ot["short numeric, e.g. 4-6 digits"] + ForPassphrase --> Pp["diceware word-list"] + ForApiKey --> Ak["long, URL-safe charset"] + ForEnvironmentName --> En["readable, memorable identifiers"] +``` + +Presets are sugar over `PasswordOptions`; any subsequent fluent call still overrides them +(resolution order is documented in `configuration-and-di.md`). + +## Surfacing the broader purpose + +The library is **not password-only**. The same surface generates OTPs, environment names, API keys, +and other identifiers — so v3 deliberately keeps the per-class `Include*` methods and adds +`WithCharacters`/`WithAllAscii` rather than forcing OWASP composition or a global 12-char minimum. + +## Deprecation / migration shape + +```mermaid +flowchart TD + Old["v2: new Password().Next() → string (maybe error)"] --> Mig["v3 migration"] + Mig --> A["sync Next()/Generate() kept but [Obsolete] → async"] + Mig --> B["error strings → exception / TryNext"] + Mig --> C["direct new → optional IPasswordGenerator via DI"] + Mig --> D["[Obsolete] PasswordGenerator/Settings REMOVED"] + classDef warn fill:#fff0e6,stroke:#cc6600; + class D warn; +``` + +**Why this is better:** every verified gap in `../current-state/api-surface.md` is closed +(`TryNext`/async/DI/presets/appSettings/custom pools), failures become explicit, and existing single +`.Next()` users still work (with an obsolete-hint nudge), giving a gentle upgrade path. diff --git a/docs/v3-target/architecture.md b/docs/v3-target/architecture.md new file mode 100644 index 0000000..ae4cd6d --- /dev/null +++ b/docs/v3-target/architecture.md @@ -0,0 +1,108 @@ +# v3 Target — Architecture (proposal) + +> Proposed design for discussion. Multi-target `netstandard2.0;net8.0` (optionally `net10.0`), +> nullable enabled. Aligns with the adjusted plan in `../V3_VERIFICATION.md` §3. + +## Target type relationships + +```mermaid +classDiagram + class IPasswordGenerator { + <> + +Next() string + +TryNext(out string) bool + +NextAsync(CancellationToken) Task + +Generate(int count) IReadOnlyList + +GenerateAsync(int count, CancellationToken) Task + } + class IPasswordBuilder { + <> + +IncludeLowercase() IPasswordBuilder + +IncludeUppercase() IPasswordBuilder + +IncludeNumeric() IPasswordBuilder + +IncludeSpecial(string) IPasswordBuilder + +WithAllAscii() IPasswordBuilder + +WithCharacters(string) IPasswordBuilder + +ExcludeAmbiguous() IPasswordBuilder + +RequireAtLeast(class, count) IPasswordBuilder + +LengthRequired(int) IPasswordBuilder + +ForOwasp() IPasswordBuilder + +ForOtp() IPasswordBuilder + +ForPassphrase() IPasswordBuilder + +Build() IPasswordGenerator + } + class PasswordOptions { + +pools, length, minCounts + +excludeAmbiguous + +maxAttempts + +bind from IConfiguration + } + class IRandomSource { + <> + +int NextInt(int maxExclusive) + +void Fill(Span~byte~) + } + class CryptoRandomSource { + uses RandomNumberGenerator.GetInt32 + } + class IEntropyEstimator { + <> + +double Bits(string password) + } + + IPasswordGenerator <|.. PasswordGenerator2 + IPasswordBuilder <|.. PasswordBuilder + PasswordBuilder --> PasswordOptions : produces + PasswordGenerator2 --> PasswordOptions : reads + PasswordGenerator2 --> IRandomSource : uses + IRandomSource <|.. CryptoRandomSource + PasswordGenerator2 ..> IEntropyEstimator : optional +``` + +Key shifts from today: +- **`IRandomSource` abstraction** wraps the CSPRNG (unbiased `RandomNumberGenerator.GetInt32` on + `net8.0`; rejection-sampling fallback on `netstandard2.0`). No `static`, disposable-aware, + injectable. Fixes verified §5.2/§5.3/§5.6 and lets the Guid `Shuffle` be deleted (§5.5). +- **`PasswordOptions`** is the single config object, bindable from `IConfiguration`. +- **Presets** are builder methods that pre-fill `PasswordOptions`. +- The `[Obsolete]` v2 wrappers are **removed** in v3 (recommended in `../V3_VERIFICATION.md` §4). + +## Target composition (with DI) + +```mermaid +flowchart TD + App["Consuming app"] -->|AddPasswordGenerator| DI["IServiceCollection"] + DI --> Reg["registers IPasswordGenerator,
IRandomSource, PasswordOptions"] + App -->|inject| IPG["IPasswordGenerator"] + App -->|or new directly| Builder["new PasswordBuilder()...Build()"] + IPG --> OPT[PasswordOptions] + Builder --> OPT + IPG --> RNG["IRandomSource → CryptoRandomSource"] + Builder --> RNG + classDef good fill:#e6ffe6,stroke:#009900; + class RNG,Reg good; +``` + +The fluent API behaves **identically** whether the instance is `new`'d or resolved from DI — the DI +registration is only responsible for wiring `IRandomSource` and default `PasswordOptions`. + +## Multi-targeting strategy + +```mermaid +flowchart LR + subgraph ns["netstandard2.0 (broad reach: .NET Framework, Umbraco)"] + a["manual rejection sampling"] + end + subgraph net8["net8.0 / net10.0 (modern)"] + b["RandomNumberGenerator.GetInt32 / GetItems"] + end + IRandomSource --> ns + IRandomSource --> net8 +``` + +`#if` inside `CryptoRandomSource` selects the optimal API per target while keeping one public surface. + +**Why this is better:** removes the `static`/undisposed RNG, makes randomness unbiased and testable +(inject a deterministic `IRandomSource` in unit tests), keeps .NET Framework users supported, and +gives modern consumers the fast built-in APIs — addressing verified issues §5.2, §5.3, §5.5, §5.6 and +gaps §8 (async/DI/multi-target) at the architecture level. diff --git a/docs/v3-target/before-after.md b/docs/v3-target/before-after.md new file mode 100644 index 0000000..b295888 --- /dev/null +++ b/docs/v3-target/before-after.md @@ -0,0 +1,123 @@ +# v3 Target — Before / After (proposal) + +Side-by-side of the things that change most, each tied to a verified issue. + +## 1. Failure handling + +```mermaid +flowchart LR + subgraph Before["v2.1.0 (§5.1)"] + b1["pwd.Next()"] --> b2["string — might be
'Try again' or
'Password length invalid...'"] + b2 --> b3["caller may store
an ERROR as a password"] + end + subgraph After["v3"] + a1["gen.Next()"] --> a2["valid password
OR throws ArgumentException"] + a1b["gen.TryNext(out pwd)"] --> a2b["bool + real password"] + end + classDef bad fill:#ffe6e6,stroke:#cc0000; + classDef good fill:#e6ffe6,stroke:#009900; + class b2,b3 bad; + class a2,a2b good; +``` + +```csharp +// Before — silent footgun +var pwd = new Password(3).Next(); // = "Password length invalid. Must be between 4 and 256..." + +// After — explicit +try { var pwd = gen.Next(); } // throws ArgumentException for length 3 +catch (ArgumentException ex) { /* handle */ } +if (gen.TryNext(out var p)) { /* use p */ } +``` + +## 2. Randomness & character selection + +```mermaid +flowchart LR + subgraph Before2["v2.1.0"] + x1["Guid.NewGuid() shuffle (§5.5)"] --> x2["pick rnd % (len-1)
top index never used (§5.2)
modulo bias (§5.3)"] + end + subgraph After2["v3"] + y1["IRandomSource (CSPRNG)"] --> y2["GetInt32 / rejection sampling
uniform, full range"] + y2 --> y3["crypto Fisher-Yates shuffle"] + end + classDef bad fill:#ffe6e6,stroke:#cc0000; + classDef good fill:#e6ffe6,stroke:#009900; + class x1,x2 bad; + class y1,y2,y3 good; +``` + +## 3. Guaranteeing required character classes + +```mermaid +flowchart LR + subgraph Before3["v2.1.0"] + g1["generate random"] --> g2["validate"] --> g3{"ok?"} + g3 -- no --> g1 + g3 -- "no, 10000x" --> g4["'Try again' (§5.1)"] + end + subgraph After3["v3"] + h1["seed one of each
required class"] --> h2["fill + crypto-shuffle"] --> h3["valid by construction"] + end + classDef bad fill:#ffe6e6,stroke:#cc0000; + classDef good fill:#e6ffe6,stroke:#009900; + class g4 bad; + class h3 good; +``` + +## 4. Configuration & wiring + +| Concern | v2.1.0 | v3 | +|---|---|---| +| Sources | constructor args + fluent only | fluent **>** appSettings **>** default | +| DI | none | opt-in `AddPasswordGenerator(...)` (+ `IConfiguration` overload) | +| RNG ownership | `static`, reassigned per ctor, never disposed (§5.6) | injected `IRandomSource`, disposable-aware | +| Presets | none | `ForOwasp/ForNist/ForOtp/ForPassphrase/ForApiKey/ForEnvironmentName` | + +## 5. Targets, tests, packaging + +```mermaid +flowchart LR + subgraph BeforeP["v2.1.0"] + p1["netstandard2.0 only"] + p2["tests on EOL netcoreapp2.2
(vulnerable 2.2.0)"] + p3["stale nuspec 2.0.5, NU5048,
no readme in package"] + p4["5x CS0108 from obsolete wrappers"] + end + subgraph AfterP["v3"] + q1["netstandard2.0 + net8.0 (+net10.0)"] + q2["tests on net8.0, NUnit 4
+ uniqueness/entropy/edge cases"] + q3["clean pack: PackageReadmeFile,
PackageIcon, SourceLink, snupkg"] + q4["obsolete wrappers removed → no CS0108"] + q5["BenchmarkDotNet numbers in release notes"] + end + classDef good fill:#e6ffe6,stroke:#009900; + class q1,q2,q3,q4,q5 good; +``` + +## Net effect + +```mermaid +mindmap + root((v3 better)) + Safety + exceptions not strings + TryNext + guaranteed classes + Correctness + unbiased CSPRNG + no off-by-one + no modulo bias + Reach + multi-target + nullable + DI + appSettings + Capability + presets + custom pools / WithAllAscii + batch Generate + async + Trust + modern tests + benchmarks in release notes + clean packaging +``` diff --git a/docs/v3-target/configuration-and-di.md b/docs/v3-target/configuration-and-di.md new file mode 100644 index 0000000..87cee82 --- /dev/null +++ b/docs/v3-target/configuration-and-di.md @@ -0,0 +1,76 @@ +# v3 Target — Configuration & Dependency Injection (proposal) + +## Settings resolution order + +```mermaid +flowchart TD + F["1. Fluent API call
(highest priority)"] --> Merge + A["2. appSettings.json value
(if configured)"] --> Merge + D["3. Library default
(lowest priority)"] --> Merge + Merge[(effective PasswordOptions)] --> Gen["generation"] + classDef hi fill:#e6ffe6,stroke:#009900; + class F hi; +``` + +A fluent call always wins; otherwise `appSettings` is used if present; otherwise the library default +applies. `appSettings` binding is an **opt-in, separate step** — it is never auto-applied. + +## Example `appSettings.json` + +```jsonc +{ + "PasswordGenerator": { + "Length": 20, + "IncludeLowercase": true, + "IncludeUppercase": true, + "IncludeNumeric": true, + "Special": "!#$%&*@", + "ExcludeAmbiguous": true, + "DefaultBatchCount": 5 + } +} +``` + +## DI registration (opt-in, not auto-registered on install) + +```mermaid +sequenceDiagram + participant Startup + participant SC as IServiceCollection + participant Cfg as IConfiguration + Startup->>SC: AddPasswordGenerator(cfg.GetSection("PasswordGenerator")) + SC->>SC: bind PasswordOptions + SC->>SC: register IRandomSource → CryptoRandomSource + SC->>SC: register IPasswordGenerator + Note over Startup,SC: later... + participant Svc as Your service + Svc->>SC: inject IPasswordGenerator + Svc->>Svc: generator.Generate(5) +``` + +Two overloads (answering open question #3 in `../V3_VERIFICATION.md`): + +```csharp +services.AddPasswordGenerator(options => { options.Length = 20; }); // code +services.AddPasswordGenerator(config.GetSection("PasswordGenerator")); // one-line appSettings bind +``` + +**Why opt-in, not auto-register:** auto-registering on package install is inflexible and risks +service-collection conflicts. Requiring an explicit `AddPasswordGenerator(...)` call keeps the +consumer in control and lets the registration wire up `IRandomSource` so callers never touch the RNG. + +## Identical behaviour: `new` vs DI + +```mermaid +flowchart LR + P1["new PasswordBuilder().ForOwasp().Build().Next()"] --> Same(("same result
semantics")) + P2["injected IPasswordGenerator.Next()"] --> Same +``` + +The fluent API must produce identical results whether the instance is constructed directly or +resolved from the container; DI only changes *how the dependencies are supplied*, not *what the +builder does*. + +**Why this is better:** teams can centralise password policy in `appSettings` (closing verified gap +§8) without forcing it on every call site, the RNG dependency is wired once, and unit tests can swap +`IRandomSource` for a deterministic stub. diff --git a/docs/v3-target/generation-flow.md b/docs/v3-target/generation-flow.md new file mode 100644 index 0000000..fb70c1c --- /dev/null +++ b/docs/v3-target/generation-flow.md @@ -0,0 +1,82 @@ +# v3 Target — Generation Flow (proposal) + +Replaces probabilistic retry + string sentinels with **deterministic construction + exceptions**. + +## Target `Next()` / `TryNext()` flow + +```mermaid +flowchart TD + Start([Next / TryNext]) --> Cfg{"options valid?
(pools non-empty,
length ≥ sum of minimums,
length in range)"} + Cfg -- no, Next() --> Throw["throw ArgumentException
(clear message)"] + Cfg -- no, TryNext() --> RetFalse([return false]) + Cfg -- yes --> Seed["Step 1: place one char from
each REQUIRED class
(satisfies minimum counts)"] + Seed --> Fill["Step 2: fill remaining positions
from the full pool"] + Fill --> ShuffleC["Step 3: crypto-shuffle (Fisher-Yates
via IRandomSource)"] + ShuffleC --> Done([return password]) + + classDef good fill:#e6ffe6,stroke:#009900; + classDef bad fill:#fff0e6,stroke:#cc6600; + class Seed,Fill,ShuffleC,Done good; + class Throw bad; +``` + +**What changed vs today (`../current-state/generation-flow.md`):** +- No retry loop, no `MaximumAttempts` gamble, **no `"Try again"` string**. Required classes are + *guaranteed* by construction (fixes the probabilistic guarantee gap, §8). +- Invalid configuration **throws** (`Next()`) or returns `false` (`TryNext`) — never a fake password + (fixes §5.1 and §5.8). +- Selection uses unbiased `IRandomSource.NextInt(maxExclusive)` — no `% (len-1)` off-by-one, no + modulo bias (fixes §5.2, §5.3). +- Shuffle is a real crypto Fisher–Yates, replacing `orderby Guid.NewGuid()` (fixes/cleans §5.5). + +## Deterministic class-seeding (the core idea) + +```mermaid +flowchart LR + R["RequireAtLeast: 1 lower, 1 upper, 2 digits, 1 special"] --> Place["place those 5 chars first"] + Place --> Rest["fill length-5 from full pool"] + Rest --> Shuf["crypto Fisher-Yates shuffle"] + Shuf --> Out["valid by construction —
no validate-and-retry needed"] + classDef good fill:#e6ffe6,stroke:#009900; + class Out good; +``` + +## Async path (`NextAsync` / `GenerateAsync`) + +```mermaid +sequenceDiagram + participant App + participant Gen as IPasswordGenerator + participant RNG as IRandomSource (CSPRNG) + App->>Gen: GenerateAsync(count: 1000, ct) + loop count + Gen->>RNG: NextInt(...) (sync, fast) + RNG-->>Gen: index + end + Gen-->>App: Task> + Note over App,Gen: sync Next()/Generate() still exist,
marked [Obsolete] pointing here +``` + +> Note: generation is CPU-bound, so async mainly helps large-batch ergonomics and cancellation, not +> raw throughput — the **BenchmarkDotNet** suite (plan §10/§12) exists to prove where async actually +> pays off, with numbers published in every release note. + +## Failure contract — before vs after + +```mermaid +stateDiagram-v2 + state "v2.1.0 (today)" as Old { + [*] --> RetryLoop + RetryLoop --> OKo: valid + RetryLoop --> StrFail: attempts exhausted → 'Try again' STRING + } + state "v3 (target)" as New { + [*] --> Validate + Validate --> BuildOK: build guarantees validity + Validate --> Throw: invalid config → exception / false + } +``` + +**Why this is better:** failure is impossible to ignore (exception or `bool`), output is always a +real password, randomness is unbiased and fully covered by deterministic-RNG unit tests, and the +slowest part of the old design (validate-and-retry) is gone. diff --git a/docs/v3-target/roadmap.md b/docs/v3-target/roadmap.md new file mode 100644 index 0000000..77e0f82 --- /dev/null +++ b/docs/v3-target/roadmap.md @@ -0,0 +1,84 @@ +# v3 Target — Roadmap (proposal) + +Tiered delivery from the adjusted plan in `../V3_VERIFICATION.md` §3. Sequencing only — not committed +dates. + +## Tiers as phases + +```mermaid +flowchart TD + T1["Tier 1 — Correctness & Security
exceptions+TryNext · unbiased CSPRNG · delete Guid shuffle
· guarantee classes · fix static RNG · empty-special guard"] + T2["Tier 2 — Modernisation
multi-target+nullable · async+[Obsolete] sync · opt-in DI
· BenchmarkDotNet · packaging hygiene · tests→net8/NUnit4"] + T3["Tier 3 — New Features
WithAllAscii/WithCharacters · presets · appSettings
· Generate batch · exclude-ambiguous · min-counts · entropy"] + T4["Tier 4 — Documentation
v2→v3 migration guide · broader-purpose docs · OWASP/NIST mapping"] + T1 --> T2 --> T3 --> T4 + classDef t1 fill:#ffe6e6,stroke:#cc0000; + classDef t2 fill:#fff5e6,stroke:#cc6600; + classDef t3 fill:#e6f0ff,stroke:#0066cc; + classDef t4 fill:#e6ffe6,stroke:#009900; + class T1 t1; + class T2 t2; + class T3 t3; + class T4 t4; +``` + +## Indicative sequencing + +```mermaid +gantt + title v3 indicative sequencing (relative, not dated) + dateFormat X + axisFormat %s + section Tier 1 Correctness + IRandomSource + unbiased selection :t1a, 0, 3 + Exceptions + TryNext :t1b, 0, 2 + Guarantee classes (seed+shuffle) :t1c, after t1a, 2 + Remove static RNG + dead code :t1d, after t1a, 1 + section Tier 2 Modernisation + Multi-target + nullable :t2a, after t1c, 2 + Async plus Obsolete sync :t2b, after t2a, 2 + Opt-in DI + appSettings bind :t2c, after t2a, 2 + Tests net8 + NUnit4 + BenchmarkDotNet :t2d, after t1c, 3 + Packaging hygiene :t2e, after t2a, 1 + section Tier 3 Features + Custom pools + WithAllAscii :t3a, after t2c, 2 + Presets :t3b, after t3a, 2 + Generate batch + uniqueness :t3c, after t2b, 2 + Exclude-ambiguous + min-counts + entropy :t3d, after t3a, 3 + section Tier 4 Docs + Migration guide + standards mapping :t4a, after t3b, 2 +``` + +## Dependency rationale + +```mermaid +flowchart LR + RNG["IRandomSource"] --> Classes["guarantee classes"] + RNG --> Multi["multi-target"] + Multi --> Async["async"] + Multi --> DI["DI + appSettings"] + DI --> Presets["presets"] + Async --> Batch["Generate batch"] + Presets --> Docs["migration guide"] +``` + +`IRandomSource` is the keystone: the correctness fixes, multi-targeting, and testability all build on +it, so it lands first. + +## Decision gates (resolve before/within the tier) + +```mermaid +flowchart TD + D1{"Drop [Obsolete] v2 wrappers?"} -->|recommended: yes| G1["clears 5x CS0108; Tier 2"] + D2{"Min target?"} -->|recommended: netstandard2.0 + net8.0| G2["Tier 2"] + D3{"IConfiguration DI overload?"} -->|recommended: yes| G3["Tier 2 DI"] + classDef q fill:#fff5e6,stroke:#cc6600; + class D1,D2,D3 q; +``` + +See `../V3_VERIFICATION.md` §4 for the reasoning behind each recommendation. + +## Release-note discipline + +Every v3.x release includes comparative **BenchmarkDotNet** numbers (sync vs async; batch sizes 1 / +100 / 1000 / 10000; allocations) so performance trends are visible across versions. From 0d4500920a140b731bee8970c3aaeced50d0489c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 12:47:42 +0000 Subject: [PATCH 06/14] Add phased v3 implementation plan Phase 0 (toolchain: install .NET SDK via bash + green baseline) through Phase 6 (docs/migration). Each phase has objective, tasks, files, and exit criteria, plus an issue-to-phase traceability table and a per-session checklist. Linked from the docs index. All 34 mermaid diagrams validated. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd --- docs/README.md | 5 +- docs/v3-target/implementation-plan.md | 251 ++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 docs/v3-target/implementation-plan.md diff --git a/docs/README.md b/docs/README.md index 2fb8848..92947ea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,10 +24,12 @@ flowchart LR T4[configuration-and-di.md] T5[before-after.md] T6[roadmap.md] + T7[implementation-plan.md] end A --> B --> Current Current --> Target T5 -. compares .-> Current + T6 --> T7 ``` ## Reading order @@ -35,7 +37,8 @@ flowchart LR 1. **`V3_REVIEW_AND_DOCUMENTATION.md`** — the original full review (API, bugs, packaging, gaps). 2. **`V3_VERIFICATION.md`** — each issue re-checked against the current `master` source, with verdicts. 3. **`current-state/`** — diagrammed snapshot of the code as it runs today. -4. **`v3-target/`** — the proposed v3 design, diagrammed, with a before/after and a roadmap. +4. **`v3-target/`** — the proposed v3 design, diagrammed, with a before/after, a roadmap, and a + phased **`implementation-plan.md`** (starts with installing the .NET SDK via bash). ## Conventions diff --git a/docs/v3-target/implementation-plan.md b/docs/v3-target/implementation-plan.md new file mode 100644 index 0000000..d9a9fa6 --- /dev/null +++ b/docs/v3-target/implementation-plan.md @@ -0,0 +1,251 @@ +# v3 Target — Implementation Plan (phased) + +> Actionable, phase-by-phase plan to deliver the v3 design in `architecture.md`, +> `generation-flow.md`, `api-surface.md`, `configuration-and-di.md` and `before-after.md`. +> Sequencing follows `roadmap.md`; issue numbers (§5.x / §8) reference `../V3_VERIFICATION.md`. + +## Working principles + +- **One phase = one PR** (or a small stack), each independently green and reviewable. +- **Keep `master` shippable.** Behaviour-breaking changes (exceptions, removed wrappers) land behind + the v3 major and are called out in the migration guide. +- **Test-first for correctness work** — write the failing test that encodes the bug, then fix it. +- **Verify every phase with the SDK** (see Phase 0) before opening the PR. + +## Phase map + +```mermaid +flowchart TD + P0["Phase 0 — Toolchain & baseline"] --> P1["Phase 1 — Correctness & security core"] + P1 --> P2["Phase 2 — Targets & test modernisation"] + P2 --> P3["Phase 3 — API: async, DI, builder split"] + P3 --> P4["Phase 4 — New features"] + P4 --> P5["Phase 5 — Packaging & release"] + P5 --> P6["Phase 6 — Documentation & migration"] + classDef setup fill:#eee,stroke:#666; + classDef core fill:#ffe6e6,stroke:#cc0000; + classDef mod fill:#fff5e6,stroke:#cc6600; + classDef feat fill:#e6f0ff,stroke:#0066cc; + classDef rel fill:#e6ffe6,stroke:#009900; + class P0 setup; + class P1 core; + class P2,P3 mod; + class P4 feat; + class P5,P6 rel; +``` + +--- + +## Phase 0 — Toolchain & baseline + +**Objective:** a reproducible build/test environment and a known-green starting point. The remote / +CI containers do **not** ship the .NET SDK, so installing it is the first task of any work session. + +**Tasks** +1. **Install dotnet via bash** (verified working in this environment): + ```bash + cd /tmp + curl -fsSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh + chmod +x dotnet-install.sh + ./dotnet-install.sh --channel 8.0 --install-dir /tmp/dotnet + export PATH="/tmp/dotnet:$PATH" + export DOTNET_CLI_TELEMETRY_OPTOUT=1 + dotnet --version # expect 8.0.4xx + ``` + (Add `--channel 10.0` as a second install once we multi-target to `net10.0`.) +2. Establish the baseline: + ```bash + dotnet build PasswordGenerator/PasswordGenerator.csproj -c Release # expect 5x CS0108 warnings + dotnet build PasswordGenerator.Tests/PasswordGenerator.Tests.csproj -c Release + ``` + Tests currently target EOL `netcoreapp2.2` and cannot run on a modern-only runtime; record this as + the reason Phase 2 retargets them. (Baseline behaviour: 24 tests, all passing when run on net8.) +3. Confirm CI is on the dotnet CLI (already done: `appveyor.yml` uses `dotnet restore/build/test/pack`, + `deploy: off`). + +**Verification / exit criteria** +- `dotnet --version` prints an 8.0.x SDK. +- Library builds (warnings only); CI build is green. +- A `docs/`-referenced note records the baseline warning set so later phases can show them clearing. + +**Closes:** nothing yet (setup). + +--- + +## Phase 1 — Correctness & security core (Tier 1) + +**Objective:** make generation correct, unbiased, and fail-loud — without changing target frameworks +yet (stay on `netstandard2.0`, use manual rejection sampling; the optimised `net8` path arrives in +Phase 2). + +**Tasks** +1. **Introduce `IRandomSource` + `CryptoRandomSource`** wrapping `RandomNumberGenerator`. Provide + `int NextInt(int maxExclusive)` using **rejection sampling** (uniform, no modulo bias, no + off-by-one). Remove the `static` RNG field. *(closes §5.2, §5.3, §5.6)* +2. **Delete the Guid `Shuffle`**; replace pool randomisation with a crypto Fisher–Yates using + `IRandomSource`. *(closes §5.5 cleanup)* +3. **Delete dead `GetRngCryptoSeed`** and the `RNGCryptoServiceProvider` reference. *(closes §5.7)* +4. **Deterministic class-seeding:** place one char per required class first, fill the rest, then + shuffle — so output is valid by construction. Remove the validate-and-retry loop and + `MaximumAttempts` gamble. *(closes the probabilistic-guarantee gap)* +5. **Fail-loud contract:** invalid configuration throws `ArgumentException`; add + `bool TryNext(out string)`. No method ever returns `"Try again"` / a length-error string. + *(closes §5.1)* +6. **Up-front config validation** including the empty/whitespace custom-special-set case. + *(closes §5.8)* +7. **Fix the consecutive-char rule** (or drop it deliberately) so it can't allow 3 identical leading + chars. *(closes §5.4)* + +**Files:** `Password.cs`, `PasswordSettings.cs`, new `IRandomSource.cs` / `CryptoRandomSource.cs`, +plus tests. + +**Verification / exit criteria** +- New unit tests with a **deterministic `IRandomSource` stub** prove uniform selection, the seeding + guarantee, and exception/`TryNext` behaviour. +- `dotnet test` green; statistical test confirms every pool index is reachable. + +**Closes:** §5.1, §5.2, §5.3, §5.4, §5.5, §5.6, §5.7, §5.8. + +--- + +## Phase 2 — Targets & test modernisation (Tier 2a) + +**Objective:** broaden reach and put correctness work under a modern, fast test+benchmark harness. + +**Tasks** +1. **Multi-target** `netstandard2.0;net8.0` (optionally `net10.0`); enable `enable`. +2. In `CryptoRandomSource`, add a `#if NET8_0_OR_GREATER` path using + `RandomNumberGenerator.GetInt32` / `GetItems`; keep rejection sampling for `netstandard2.0`. +3. **Retarget tests** to `net8.0`, upgrade to **NUnit 4** (update classic asserts), drop the + vulnerable `Microsoft.NETCore.App 2.2.0`. +4. Add edge-case + property tests: uniqueness, length bounds, per-class guarantees, custom pools. +5. **Add a BenchmarkDotNet project** covering sync vs async and batch sizes 1/100/1000/10000 + + allocations. + +**Verification / exit criteria** +- `dotnet test` runs on `net8.0` with **no `NU1903/NU1902`** warnings. +- `dotnet build` produces both TFMs; nullable warnings triaged to zero. +- Benchmarks run and emit a baseline report. + +**Closes:** §8 multi-target/nullable; unblocks reliable CI `dotnet test`. + +--- + +## Phase 3 — API: async, DI, builder split (Tier 2b) + +**Objective:** the modern surface from `api-surface.md` with a gentle deprecation path. + +**Tasks** +1. Introduce `IPasswordGenerator` (`Next`/`TryNext`/`NextAsync`/`Generate`/`GenerateAsync`) and + `IPasswordBuilder`; keep the fluent feel. +2. Add **async** methods; mark sync `Next()`/`Generate()` `[Obsolete]` pointing to async equivalents. +3. **DI**: `AddPasswordGenerator(Action)` **and** + `AddPasswordGenerator(IConfiguration section)` (opt-in; wires `IRandomSource`). Ensure `new` vs DI + produce identical results. +4. **Remove the `[Obsolete] PasswordGenerator` / `PasswordGeneratorSettings` wrappers** + (recommended in `../V3_VERIFICATION.md` §4) — clears the 5 `CS0108` warnings. + +**Verification / exit criteria** +- Build has **zero `CS0108`**; DI sample app resolves and generates. +- Tests cover async, `TryNext`, and DI-resolved equivalence. + +**Closes:** §8 async/DI; removes the obsolete-wrapper warnings. + +--- + +## Phase 4 — New features (Tier 3) + +**Objective:** the capability set that makes v3 worth the major bump. + +**Tasks** +1. **Custom pools:** `WithCharacters(string)` and `WithAllAscii()`; keep `Include*`. +2. **Presets:** `ForOwasp`, `ForNist`, `ForOtp`, `ForPassphrase`, `ForApiKey`, `ForEnvironmentName` + (sugar over `PasswordOptions`; later fluent calls still override). +3. **`appSettings` configuration** with resolution order **fluent > appSettings > default** + (opt-in, separate step). +4. **`Generate()` batch API:** count overloads, `.Count(n)` chaining, `appSettings` default; optional + uniqueness. *(closes §5.10)* +5. **Quality options:** `ExcludeAmbiguous()`, `RequireAtLeast(class, count)`, and an + `IEntropyEstimator` returning strength in bits. + +**Verification / exit criteria** +- Preset outputs match documented standards; tests for ambiguity exclusion, minimum counts, batch + uniqueness, and entropy bounds pass. + +**Closes:** §8 presets/appSettings/custom-pools/exclude-ambiguous/min-counts/entropy; §5.10. + +--- + +## Phase 5 — Packaging & release (Tier 2c) + +**Objective:** a clean, modern NuGet package and a disciplined release. + +**Tasks** +1. Delete or regenerate the stale `PasswordGenerator.nuspec` (2.0.5); single source of version truth + in the csproj, bumped to **3.0.0**. +2. Add ``, replace `PackageIconUrl` with `` (clears `NU5048`), add + **SourceLink**, deterministic build, and a `.snupkg` symbol package. +3. Confirm `dotnet pack` is warning-free; artifact still produced by CI (no auto-publish; keep + `deploy: off` until an intentional release). +4. Release notes include **comparative BenchmarkDotNet numbers** (discipline to repeat every release). + +**Verification / exit criteria** +- `dotnet pack -c Release` produces `PasswordGenerator.3.0.0.nupkg` + `.snupkg` with **no NU5048 / no + missing-readme** warnings. + +**Closes:** §7 packaging issues. + +--- + +## Phase 6 — Documentation & migration (Tier 4) + +**Objective:** make the upgrade obvious and the broader use cases discoverable. + +**Tasks** +1. **v2→v3 migration guide:** direct→DI, sync→async (with `[Obsolete]` still working), + error-string→exception/`TryNext`, preset/appSettings adoption — before/after snippets. +2. Document the **broader purpose** (OTPs, environment names, API keys, identifiers). +3. **OWASP/NIST mapping** for presets with links. +4. Fix the **stale Readme** length claim (8–128 → 4–256 is itself superseded by v3 docs) and the + ```javascript``` fences; link the root `Readme.md` into this `docs/` section. +5. Update `current-state/` notes to reflect that the documented issues are now resolved (or move them + to a CHANGELOG). + +**Verification / exit criteria** +- Docs build/render; all mermaid diagrams validated; migration snippets compile against the v3 API. + +**Closes:** §6 documentation defects; addendum Tier 4. + +--- + +## Issue → phase traceability + +| Issue / gap | Phase | +|---|---| +| §5.1 error strings → exceptions/`TryNext` | 1 | +| §5.2 off-by-one, §5.3 modulo bias | 1 | +| §5.4 consecutive-char guard | 1 | +| §5.5 Guid shuffle removal | 1 | +| §5.6 static/undisposed RNG | 1 | +| §5.7 dead code | 1 | +| §5.8 empty special set | 1 | +| §5.10 NextGroup/Generate uniqueness | 4 | +| §8 multi-target + nullable | 2 | +| §8 async, DI | 3 | +| §8 presets, appSettings, custom pools, exclude-ambiguous, min-counts, entropy | 4 | +| §7 packaging, CS0108 wrapper removal | 3 (warnings), 5 (package) | +| §6 docs defects | 6 | + +## Per-session checklist + +```bash +# 1. install SDK (Phase 0) +cd /tmp && curl -fsSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh \ + && chmod +x dotnet-install.sh && ./dotnet-install.sh --channel 8.0 --install-dir /tmp/dotnet +export PATH="/tmp/dotnet:$PATH"; export DOTNET_CLI_TELEMETRY_OPTOUT=1 +# 2. build + test before and after changes +dotnet build PasswordGenerator.sln -c Release +dotnet test PasswordGenerator.Tests/PasswordGenerator.Tests.csproj -c Release +# 3. pack check (Phase 5) +dotnet pack PasswordGenerator/PasswordGenerator.csproj -c Release -o artifacts +``` From 7170cf080be1e3ad4109e8cc9b21eda8ebdf7d53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 14:16:35 +0000 Subject: [PATCH 07/14] Require per-phase commit and green tests in v3 plan Add a "Definition of done" loop (implement -> test -> green -> commit/push) that applies to every phase, strengthen the working principles, and append an explicit tests-green gate plus a per-phase commit step to each phase's exit criteria and the per-session checklist. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd --- docs/v3-target/implementation-plan.md | 62 ++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/docs/v3-target/implementation-plan.md b/docs/v3-target/implementation-plan.md index d9a9fa6..4180170 100644 --- a/docs/v3-target/implementation-plan.md +++ b/docs/v3-target/implementation-plan.md @@ -7,10 +7,39 @@ ## Working principles - **One phase = one PR** (or a small stack), each independently green and reviewable. +- **Commit and push at the end of every phase** — no phase spans an uncommitted working tree. Each + phase ends with its own commit (suggested messages below) pushed to the working branch. +- **Tests must pass before each phase's commit.** A phase is not "done" until the appropriate test + suite is green (`dotnet test` exits 0). Never commit a phase with failing or skipped-for- + convenience tests. - **Keep `master` shippable.** Behaviour-breaking changes (exceptions, removed wrappers) land behind the v3 major and are called out in the migration guide. - **Test-first for correctness work** — write the failing test that encodes the bug, then fix it. -- **Verify every phase with the SDK** (see Phase 0) before opening the PR. +- **Verify every phase with the SDK** (see Phase 0) before committing. + +## Definition of done — applies to EVERY phase + +Each phase repeats the same loop and only advances once it closes: + +```mermaid +flowchart LR + A["implement phase tasks"] --> B["add/update tests
for this phase"] + B --> C{"dotnet build
+ dotnet test
green?"} + C -- no --> A + C -- yes --> D["commit + push
(one commit per phase)"] + D --> E["open / update PR"] + E --> F["next phase"] + classDef gate fill:#fff5e6,stroke:#cc6600; + classDef good fill:#e6ffe6,stroke:#009900; + class C gate; + class D good; +``` + +A phase's checklist is complete only when **all** of the following hold: +1. The phase's tasks are implemented. +2. Tests covering the phase's changes exist and **pass** (`dotnet test` returns 0). +3. The build is green at the warning level the phase targets (e.g. Phase 3 must show zero `CS0108`). +4. The work is **committed and pushed** as that phase's commit. ## Phase map @@ -66,7 +95,10 @@ CI containers do **not** ship the .NET SDK, so installing it is the first task o **Verification / exit criteria** - `dotnet --version` prints an 8.0.x SDK. - Library builds (warnings only); CI build is green. +- **Tests:** the existing suite (24 tests) runs and **passes** (run on net8 in this environment, since + the `netcoreapp2.2` runtime is EOL) — this is the green baseline every later phase is measured against. - A `docs/`-referenced note records the baseline warning set so later phases can show them clearing. +- **Commit & push** this phase, e.g. `chore: establish v3 toolchain and green baseline`. **Closes:** nothing yet (setup). @@ -102,7 +134,9 @@ plus tests. **Verification / exit criteria** - New unit tests with a **deterministic `IRandomSource` stub** prove uniform selection, the seeding guarantee, and exception/`TryNext` behaviour. -- `dotnet test` green; statistical test confirms every pool index is reachable. +- **Tests green:** `dotnet test` returns 0, including the new correctness tests and the existing + suite; a statistical test confirms every pool index is reachable. +- **Commit & push** this phase, e.g. `feat: unbiased CSPRNG selection, fail-loud contract (§5.1-5.8)`. **Closes:** §5.1, §5.2, §5.3, §5.4, §5.5, §5.6, §5.7, §5.8. @@ -123,9 +157,11 @@ plus tests. allocations. **Verification / exit criteria** -- `dotnet test` runs on `net8.0` with **no `NU1903/NU1902`** warnings. +- **Tests green on `net8.0`** with NUnit 4: `dotnet test` returns 0 with **no `NU1903/NU1902`** + warnings (the full migrated suite passes, not a subset). - `dotnet build` produces both TFMs; nullable warnings triaged to zero. - Benchmarks run and emit a baseline report. +- **Commit & push** this phase, e.g. `build: multi-target net8.0, migrate tests to NUnit4, add benchmarks`. **Closes:** §8 multi-target/nullable; unblocks reliable CI `dotnet test`. @@ -147,7 +183,9 @@ plus tests. **Verification / exit criteria** - Build has **zero `CS0108`**; DI sample app resolves and generates. -- Tests cover async, `TryNext`, and DI-resolved equivalence. +- **Tests green:** new tests cover async, `TryNext`, and DI-resolved equivalence, and `dotnet test` + returns 0 across all target frameworks. +- **Commit & push** this phase, e.g. `feat: async API, DI registration, remove obsolete v2 wrappers`. **Closes:** §8 async/DI; removes the obsolete-wrapper warnings. @@ -169,8 +207,10 @@ plus tests. `IEntropyEstimator` returning strength in bits. **Verification / exit criteria** -- Preset outputs match documented standards; tests for ambiguity exclusion, minimum counts, batch - uniqueness, and entropy bounds pass. +- Preset outputs match documented standards. +- **Tests green:** tests for ambiguity exclusion, minimum counts, batch uniqueness, appSettings + precedence, and entropy bounds **pass** (`dotnet test` returns 0). +- **Commit & push** this phase, e.g. `feat: presets, custom pools, appSettings, batch Generate, entropy`. **Closes:** §8 presets/appSettings/custom-pools/exclude-ambiguous/min-counts/entropy; §5.10. @@ -192,6 +232,9 @@ plus tests. **Verification / exit criteria** - `dotnet pack -c Release` produces `PasswordGenerator.3.0.0.nupkg` + `.snupkg` with **no NU5048 / no missing-readme** warnings. +- **Tests stay green:** `dotnet test` returns 0 after the packaging/version changes (a regression + check that retargeting/version bumps broke nothing). +- **Commit & push** this phase, e.g. `build: clean packaging, SourceLink, snupkg, bump to 3.0.0`. **Closes:** §7 packaging issues. @@ -212,7 +255,10 @@ plus tests. to a CHANGELOG). **Verification / exit criteria** -- Docs build/render; all mermaid diagrams validated; migration snippets compile against the v3 API. +- Docs build/render; all mermaid diagrams validated. +- **Tests green:** migration-guide snippets are backed by compiling sample/test code and the full + suite still passes (`dotnet test` returns 0) — docs changes must not land on a red tree. +- **Commit & push** this phase, e.g. `docs: v2->v3 migration guide, standards mapping, readme refresh`. **Closes:** §6 documentation defects; addendum Tier 4. @@ -248,4 +294,6 @@ dotnet build PasswordGenerator.sln -c Release dotnet test PasswordGenerator.Tests/PasswordGenerator.Tests.csproj -c Release # 3. pack check (Phase 5) dotnet pack PasswordGenerator/PasswordGenerator.csproj -c Release -o artifacts +# 4. only once tests are green, commit + push this phase (one commit per phase) +git add -A && git commit -m "" && git push -u origin ``` From 4c437e19de99ec3aaf1f238a84f5097c06ac0038 Mon Sep 17 00:00:00 2001 From: Paul Seal Date: Sun, 24 May 2026 21:08:04 +0100 Subject: [PATCH 08/14] V3 rewrite (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Phase 1: correctness & security core Replace biased/static randomness and error-string returns with a fail-loud, unbiased, instance-scoped design: - Add IRandomSource + CryptoRandomSource (rejection sampling, no modulo bias, no off-by-one; instance-based and disposable) (§5.2, §5.3, §5.6) - Delete the Guid shuffle and dead GetRngCryptoSeed; use crypto Fisher-Yates via IRandomSource (§5.5, §5.7) - Guarantee included classes by construction (seed one per class, fill, shuffle) and drop the validate-and-retry loop / "Try again" string - Next() throws ArgumentException on invalid config; add TryNext(out string); validate empty custom special set up front (§5.1, §5.8) - Expose IPasswordSettings.CharacterGroups; make Password IDisposable - Update tests to expect exceptions; add Phase1CorrectnessTests All 32 tests pass (run on net8 since netcoreapp2.2 is EOL); library and netcoreapp2.2 test build both succeed. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd * Phase 2: targets & test modernisation - Multi-target netstandard2.0;net8.0 with nullable enabled; net8 uses RandomNumberGenerator.GetInt32, netstandard2.0 keeps rejection sampling - Migrate tests to net8.0 + NUnit 4 (constraint-model asserts), dropping the EOL netcoreapp2.2 target and its vulnerable Microsoft.NETCore.App 2.2.0 - Add edge-case/property tests (uniqueness, custom-pool exclusivity, length boundaries, no-classes, null random source) - Add a BenchmarkDotNet project (single vs batch, sizes 1/100/1000/10000, MemoryDiagnoser) and wire it into the solution - Bump AppVeyor image to Visual Studio 2022 for the net8 SDK - Tighten .gitignore for bin/obj/artifacts and BenchmarkDotNet output 39/39 tests pass on net8; both library TFMs and the full solution build with no nullable warnings. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd * Phase 3: async API, DI registration, remove v2 wrappers - Add IPasswordGenerator (Next/TryNext/NextAsync/Generate/GenerateAsync); Password implements it alongside IPassword. Async honours CancellationToken - Keep sync methods fully supported (no [Obsolete]): generation is CPU-bound, so obsoleting sync in favour of async would be an anti-pattern - Add opt-in DI: AddPasswordGenerator(Action) and AddPasswordGenerator(IConfiguration) in the core package, taking Microsoft.Extensions.DependencyInjection.Abstractions and Configuration.Binder dependencies; new vs DI behave identically - Add PasswordOptions for code/appSettings configuration - Remove the [Obsolete] PasswordGenerator/PasswordGeneratorSettings wrappers and their tests, clearing all 5 CS0108 warnings - Enable nullable in the test project; add Phase3Tests (async, cancellation, batch, DI binding + equivalence) Library builds with 0 warnings/0 errors; 39/39 tests pass on net8; pack declares the new dependencies per target framework. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd * Phase 4: presets, custom pools, exclude-ambiguous, min-counts, entropy - Custom pools: WithCharacters(string) and WithAllAscii() alongside Include* - ExcludeAmbiguous() strips look-alike characters (CharacterFilter) from the pool and per-class groups - RequireAtLeast(CharacterClass, count): generalises the one-per-class guarantee; auto-enables the class; validated against length - IEntropyEstimator/PoolEntropyEstimator and Password.EstimateEntropyBits() (length * log2(effective pool size)) - Presets ForOwasp/ForNist/ForOtp/ForApiKey/ForEnvironmentName (static factories over the fluent builder) and ForPassphrase backed by a small built-in word list (PassphraseGenerator) - Batch: parameterless Generate()/GenerateAsync() driven by a configurable DefaultBatchCount; kept Generate(count) (no .Count(n) chaining) - appSettings precedence via AddPasswordGenerator(IConfiguration, Action): code-configure > appSettings > default; PasswordOptions gains ExcludeAmbiguous and DefaultBatchCount Library builds with 0 warnings/0 errors; 55/55 tests pass on net8 (16 new Phase 4 tests). https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd * build: clean packaging, SourceLink, snupkg, bump to 3.0.0 - Delete stale PasswordGenerator.nuspec (2.0.5); csproj is now the single source of version truth, bumped to 3.0.0 - Replace PackageIconUrl with PackageIcon (clears NU5048) and add PackageReadmeFile (root Readme.md packed as README.md) - Add SourceLink (Microsoft.SourceLink.GitHub), deterministic build, CI build flag, symbol package (.snupkg), embedded untracked sources - Refresh Description/ReleaseNotes/Tags/Copyright for v3 - appveyor: bump build version label to 3.0, capture .snupkg artifact, keep deploy: off dotnet pack -c Release produces PasswordGenerator.3.0.0.nupkg + .snupkg with no NU5048 / no missing-readme warnings; 55/55 tests still pass. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd * docs: v2->v3 migration guide, standards mapping, readme refresh - Add docs/v3-target/migration-v2-to-v3.md: error-string->exception/TryNext breaking change, opt-in DI/async/batch, OWASP/NIST preset mapping, and the broader use cases (OTP/API key/identifier/passphrase) - Add root CHANGELOG.md with the 3.0.0 entry - Refresh root Readme.md: fix the stale 8-128 length claim (actual 4-256), replace ```javascript fences with ```csharp, document presets, quality controls, error handling, async/batch and DI; link docs + changelog - Mark current-state/ docs as historical (issues resolved in v3) and link the migration guide from docs/README.md - Back Readme/migration snippets with DocumentationSnippetTests 59/59 tests pass. https://claude.ai/code/session_01NNRvLbK1UWy49i4Yg43QEd --------- Co-authored-by: Claude --- .gitignore | 8 + CHANGELOG.md | 47 +++ .../PasswordBenchmarks.cs | 29 ++ .../PasswordGenerator.Benchmarks.csproj | 19 + PasswordGenerator.Benchmarks/Program.cs | 10 + PasswordGenerator.Tests/BasicTests.cs | 51 ++- .../DocumentationSnippetTests.cs | 77 ++++ PasswordGenerator.Tests/ObsoleteTests.cs | 78 ---- .../PasswordGenerator.Tests.csproj | 14 +- .../Phase1CorrectnessTests.cs | 117 ++++++ PasswordGenerator.Tests/Phase2Tests.cs | 59 +++ PasswordGenerator.Tests/Phase3Tests.cs | 106 +++++ PasswordGenerator.Tests/Phase4Tests.cs | 182 ++++++++ PasswordGenerator.sln | 7 + PasswordGenerator/CharacterClass.cs | 14 + PasswordGenerator/CharacterFilter.cs | 48 +++ PasswordGenerator/CryptoRandomSource.cs | 61 +++ PasswordGenerator/IEntropyEstimator.cs | 15 + PasswordGenerator/IPassword.cs | 14 + PasswordGenerator/IPasswordGenerator.cs | 37 ++ PasswordGenerator/IPasswordSettings.cs | 30 ++ PasswordGenerator/IRandomSource.cs | 14 + PasswordGenerator/PassphraseGenerator.cs | 133 ++++++ PasswordGenerator/Password.cs | 388 +++++++++++++----- PasswordGenerator/PasswordGenerator.cs | 66 --- PasswordGenerator/PasswordGenerator.csproj | 53 ++- PasswordGenerator/PasswordGenerator.nuspec | 27 -- ...ordGeneratorServiceCollectionExtensions.cs | 68 +++ .../PasswordGeneratorSettings.cs | 12 - PasswordGenerator/PasswordOptions.cs | 25 ++ PasswordGenerator/PasswordSettings.cs | 81 +++- PasswordGenerator/PoolEntropyEstimator.cs | 21 + PasswordGenerator/WordList.cs | 53 +++ Readme.md | 133 ++++-- appveyor.yml | 6 +- docs/README.md | 8 +- docs/current-state/api-surface.md | 3 + docs/current-state/architecture.md | 3 + docs/current-state/generation-flow.md | 3 + docs/v3-target/implementation-plan.md | 77 +++- docs/v3-target/migration-v2-to-v3.md | 151 +++++++ 41 files changed, 1950 insertions(+), 398 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 PasswordGenerator.Benchmarks/PasswordBenchmarks.cs create mode 100644 PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj create mode 100644 PasswordGenerator.Benchmarks/Program.cs create mode 100644 PasswordGenerator.Tests/DocumentationSnippetTests.cs delete mode 100644 PasswordGenerator.Tests/ObsoleteTests.cs create mode 100644 PasswordGenerator.Tests/Phase1CorrectnessTests.cs create mode 100644 PasswordGenerator.Tests/Phase2Tests.cs create mode 100644 PasswordGenerator.Tests/Phase3Tests.cs create mode 100644 PasswordGenerator.Tests/Phase4Tests.cs create mode 100644 PasswordGenerator/CharacterClass.cs create mode 100644 PasswordGenerator/CharacterFilter.cs create mode 100644 PasswordGenerator/CryptoRandomSource.cs create mode 100644 PasswordGenerator/IEntropyEstimator.cs create mode 100644 PasswordGenerator/IPasswordGenerator.cs create mode 100644 PasswordGenerator/IRandomSource.cs create mode 100644 PasswordGenerator/PassphraseGenerator.cs delete mode 100644 PasswordGenerator/PasswordGenerator.cs delete mode 100644 PasswordGenerator/PasswordGenerator.nuspec create mode 100644 PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs delete mode 100644 PasswordGenerator/PasswordGeneratorSettings.cs create mode 100644 PasswordGenerator/PasswordOptions.cs create mode 100644 PasswordGenerator/PoolEntropyEstimator.cs create mode 100644 PasswordGenerator/WordList.cs create mode 100644 docs/v3-target/migration-v2-to-v3.md diff --git a/.gitignore b/.gitignore index b9b82b5..e3a2339 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,11 @@ PasswordGenerator/bin/Debug/$RANDOM_SEED$ /.idea /PasswordGenerator.2.0.0.nupkg *.nupkg + +# build output (all projects/configurations) +[Bb]in/ +[Oo]bj/ +/artifacts + +# BenchmarkDotNet output +BenchmarkDotNet.Artifacts/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b126ea --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to this project are documented here. This project adheres to +[Semantic Versioning](https://semver.org/). + +## 3.0.0 + +A major release focused on cryptographic correctness, a modern API, and broader use cases. +See the [v2 → v3 migration guide](docs/v3-target/migration-v2-to-v3.md). + +### Breaking changes +- **Invalid settings now throw** `ArgumentException` from `Next()` instead of returning an error + message as the "password". Use `TryNext(out var password)` for a non-throwing path. + +### Security / correctness fixes +- Cryptographically secure RNG (`CryptoRandomSource`) with **unbiased** integer sampling + (rejection sampling — removes modulo bias). +- Fixed an off-by-one in length handling and removed the GUID-based shuffle in favour of a + Fisher–Yates shuffle. +- Disposed/owned RNG lifecycle; removed dead code and the static RNG. +- Empty special-character sets are validated rather than silently producing weaker output. + +### Added +- **Async APIs:** `NextAsync`, `GenerateAsync`. +- **Dependency injection:** `AddPasswordGenerator(...)` with code and `appSettings.json` binding + (resolution order: code-configure > appSettings > default). +- **Presets:** `ForOwasp`, `ForNist`, `ForOtp`, `ForApiKey`, `ForEnvironmentName`, `ForPassphrase`. +- **Custom pools:** `WithCharacters(string)`, `WithAllAscii()`. +- **Quality controls:** `ExcludeAmbiguous()`, `RequireAtLeast(CharacterClass, count)`. +- **Entropy estimation:** `IEntropyEstimator` / `PoolEntropyEstimator` and `EstimateEntropyBits()`. +- **Batch API:** `Generate(count)` and a parameterless `Generate()` driven by + `PasswordOptions.DefaultBatchCount`. + +### Packaging +- Multi-targets `netstandard2.0` and `net8.0`; nullable reference types enabled. +- Single source of version truth in the csproj (removed the stale `.nuspec`). +- `PackageIcon` + `PackageReadmeFile` (clears `NU5048`), SourceLink, deterministic build, and a + `.snupkg` symbol package. + +### Compatibility +- The v2 surface (`Next`, `NextGroup`, constructors, `IncludeX`, `LengthRequired`) is unchanged and + continues to work, aside from the error-handling breaking change noted above. + +## 2.1.0 and earlier + +See the project history and the original review in +[`docs/V3_REVIEW_AND_DOCUMENTATION.md`](docs/V3_REVIEW_AND_DOCUMENTATION.md). diff --git a/PasswordGenerator.Benchmarks/PasswordBenchmarks.cs b/PasswordGenerator.Benchmarks/PasswordBenchmarks.cs new file mode 100644 index 0000000..2ee5553 --- /dev/null +++ b/PasswordGenerator.Benchmarks/PasswordBenchmarks.cs @@ -0,0 +1,29 @@ +using BenchmarkDotNet.Attributes; +using PasswordGenerator; + +namespace PasswordGenerator.Benchmarks +{ + [MemoryDiagnoser] + public class PasswordBenchmarks + { + [Params(1, 100, 1000, 10000)] + public int Count; + + [Benchmark] + public string SingleNext() + { + var pwd = new Password(); + return pwd.Next(); + } + + [Benchmark] + public int Batch() + { + var pwd = new Password(); + var generated = 0; + foreach (var _ in pwd.NextGroup(Count)) + generated++; + return generated; + } + } +} diff --git a/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj b/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj new file mode 100644 index 0000000..6600890 --- /dev/null +++ b/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + latest + false + + + + + + + + + + + diff --git a/PasswordGenerator.Benchmarks/Program.cs b/PasswordGenerator.Benchmarks/Program.cs new file mode 100644 index 0000000..cee61a4 --- /dev/null +++ b/PasswordGenerator.Benchmarks/Program.cs @@ -0,0 +1,10 @@ +using BenchmarkDotNet.Running; + +namespace PasswordGenerator.Benchmarks +{ + public static class Program + { + public static void Main(string[] args) => + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + } +} diff --git a/PasswordGenerator.Tests/BasicTests.cs b/PasswordGenerator.Tests/BasicTests.cs index 1af55ee..bbeba21 100644 --- a/PasswordGenerator.Tests/BasicTests.cs +++ b/PasswordGenerator.Tests/BasicTests.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Text.RegularExpressions; using NUnit.Framework; @@ -11,23 +12,21 @@ public void PasswordGenerator_GivenNoSettings_ShouldReturn16Length() { var pwd = new Password(); var result = pwd.Next(); - Assert.AreEqual(16, result.Length); + Assert.That(result.Length, Is.EqualTo(16)); } [Test] - public void PasswordGenerator_GivenLength3_ShouldReturnLengthErrorMessage() + public void PasswordGenerator_GivenLength3_ShouldThrowArgumentException() { var pwd = new Password(3); - var result = pwd.Next(); - Assert.AreEqual("Password length invalid. Must be between 4 and 256 characters long", result); + Assert.Throws(() => pwd.Next()); } [Test] - public void PasswordGenerator_GivenLength257_ShouldReturnLengthErrorMessage() + public void PasswordGenerator_GivenLength257_ShouldThrowArgumentException() { var pwd = new Password(257); - var result = pwd.Next(); - Assert.AreEqual("Password length invalid. Must be between 4 and 256 characters long", result); + Assert.Throws(() => pwd.Next()); } [Test] @@ -35,7 +34,7 @@ public void PasswordGenerator_GivenLength256_ShouldReturn128Length() { var pwd = new Password(256); var result = pwd.Next(); - Assert.AreEqual(256, result.Length); + Assert.That(result.Length, Is.EqualTo(256)); } [Test] @@ -43,7 +42,7 @@ public void PasswordGenerator_IncludeLowercase_ShouldReturn16Length() { var pwd = new Password().IncludeLowercase(); var result = pwd.Next(); - Assert.AreEqual(16, result.Length); + Assert.That(result.Length, Is.EqualTo(16)); } [Test] @@ -51,15 +50,15 @@ public void PasswordGenerator_LengthRequired50_ShouldReturn50Length() { var pwd = new Password().LengthRequired(50); var result = pwd.Next(); - Assert.AreEqual(50, result.Length); + Assert.That(result.Length, Is.EqualTo(50)); } - + [Test] public void PasswordGenerator_10Passwords_ShouldReturn10DifferentPasswords() { var pwd = new Password().LengthRequired(50); var result = pwd.NextGroup(10); - Assert.AreEqual(10, result.Count()); + Assert.That(result.Count(), Is.EqualTo(10)); } [Test] @@ -69,7 +68,7 @@ public void PasswordGenerator_16DigitNumeric_ShouldReturn16DigitNumericOnlyPassw var result = pwd.Next(); var pattern = @"^\d{16}$"; var m = Regex.Match(result, pattern, RegexOptions.IgnoreCase); - Assert.IsTrue(m.Success); + Assert.That(m.Success, Is.True); } [Test] @@ -79,7 +78,7 @@ public void PasswordGenerator_16DigitLowercase_ShouldReturn16DigitLowercaseOnlyP var result = pwd.Next(); var pattern = @"^[a-z]{16}$"; var m = Regex.Match(result, pattern, RegexOptions.IgnoreCase); - Assert.IsTrue(m.Success); + Assert.That(m.Success, Is.True); } [Test] @@ -89,7 +88,7 @@ public void PasswordGenerator_SpecificSpecialCharacters_ShouldReturnPasswordWith var result = pwd.Next(); var pattern = @"^[*|(|&|)|_|^]{16}$"; var m = Regex.Match(result, pattern, RegexOptions.IgnoreCase); - Assert.IsTrue(m.Success); + Assert.That(m.Success, Is.True); } [Test] @@ -99,7 +98,7 @@ public void PasswordGenerator_OneTimePasscode_ShouldReturn4DigitNumber() var result = pwd.Next(); var pattern = @"^\d{4}$"; var m = Regex.Match(result, pattern, RegexOptions.IgnoreCase); - Assert.IsTrue(m.Success); + Assert.That(m.Success, Is.True); } [Test] @@ -107,39 +106,39 @@ public void PasswordGenerator_SpecificSpecialCharacters_ShouldNotReturnTryAgain( { var pwd = new Password().IncludeLowercase().IncludeUppercase().IncludeNumeric().IncludeSpecial("[]{}^_="); var result = pwd.Next(); - Assert.AreNotEqual("Try again", result); + Assert.That(result, Is.Not.EqualTo("Try again")); } [Test] public void PasswordGenerator_LengthOnly_ShouldNotThrowAnError() { var pwd = new Password(passwordLength: 21); - string result = pwd.Next(); - Assert.AreEqual(21,result.Length); + var result = pwd.Next(); + Assert.That(result.Length, Is.EqualTo(21)); } [Test] public void PasswordGenerator_NoLengthTest_ShouldNotThrowAnError() { var pwd = new Password(includeLowercase: true, includeUppercase: true, includeNumeric: true, includeSpecial: false); - string result = pwd.Next(); - Assert.AreEqual(16, result.Length); + var result = pwd.Next(); + Assert.That(result.Length, Is.EqualTo(16)); } [Test] public void PasswordGenerator_ParametersWithLength_ShouldNotThrowAnError() { var pwd = new Password(includeLowercase: true, includeUppercase: true, includeNumeric: true, includeSpecial: false, passwordLength: 21); - string result = pwd.Next(); - Assert.AreEqual(21, result.Length); + var result = pwd.Next(); + Assert.That(result.Length, Is.EqualTo(21)); } [Test] public void PasswordGenerator_ParametersWithLengthAndMaxAttempts_ShouldNotThrowAnError() { var pwd = new Password(includeLowercase: true, includeUppercase: true, includeNumeric: true, includeSpecial: false, passwordLength: 24, maximumAttempts: 100); - string result = pwd.Next(); - Assert.AreEqual(24, result.Length); + var result = pwd.Next(); + Assert.That(result.Length, Is.EqualTo(24)); } } -} \ No newline at end of file +} diff --git a/PasswordGenerator.Tests/DocumentationSnippetTests.cs b/PasswordGenerator.Tests/DocumentationSnippetTests.cs new file mode 100644 index 0000000..d9a3140 --- /dev/null +++ b/PasswordGenerator.Tests/DocumentationSnippetTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace PasswordGenerator.Tests +{ + /// + /// Compile-and-run guards for the snippets in Readme.md and the v2->v3 migration guide, so the + /// documentation cannot drift from the public API. + /// + public class DocumentationSnippetTests + { + [Test] + public void Readme_NextThrows_TryNextDoesNot() + { + // Next() throws ArgumentException on invalid settings. + Assert.Throws(() => new Password(2).Next()); + + // TryNext never throws. + Assert.That(new Password(2).TryNext(out var bad), Is.False); + Assert.That(bad, Is.Null); + Assert.That(new Password(16).TryNext(out var ok), Is.True); + Assert.That(ok, Is.Not.Null); + } + + [Test] + public async Task Readme_AsyncAndBatch() + { + var pwd = new Password(16); + + var password = await pwd.NextAsync(CancellationToken.None); + Assert.That(password, Has.Length.EqualTo(16)); + + IReadOnlyList ten = pwd.Generate(10); + Assert.That(ten, Has.Count.EqualTo(10)); + + IReadOnlyList ten2 = await pwd.GenerateAsync(10, CancellationToken.None); + Assert.That(ten2, Has.Count.EqualTo(10)); + } + + [Test] + public void MigrationGuide_SectionBinding_CodeOverridesConfiguration() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["PasswordGenerator:Length"] = "16", + ["PasswordGenerator:IncludeSpecial"] = "true" + }) + .Build(); + + var services = new ServiceCollection(); + services.AddPasswordGenerator(configuration.GetSection("PasswordGenerator"), o => o.Length = 24); + + using var provider = services.BuildServiceProvider(); + var generator = provider.GetRequiredService(); + + Assert.That(generator.Next(), Has.Length.EqualTo(24)); + } + + [Test] + public void Readme_QualityControlsAndEntropy() + { + var custom = new Password().WithCharacters("ABCDEF0123456789").LengthRequired(24).Next(); + Assert.That(custom, Has.Length.EqualTo(24)); + + var ascii = new Password().WithAllAscii().LengthRequired(40).Next(); + Assert.That(ascii, Has.Length.EqualTo(40)); + + Assert.That(new Password(20).EstimateEntropyBits(), Is.GreaterThan(0)); + } + } +} diff --git a/PasswordGenerator.Tests/ObsoleteTests.cs b/PasswordGenerator.Tests/ObsoleteTests.cs deleted file mode 100644 index c365332..0000000 --- a/PasswordGenerator.Tests/ObsoleteTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Linq; -using System.Text.RegularExpressions; -using NUnit.Framework; - - -namespace PasswordGenerator.Tests -{ - public class ObsoleteTests - { - [Test] - public void PasswordGenerator_GivenNoSettings_ShouldReturn16Length() - { - PasswordGenerator pwdGen = new PasswordGenerator(); - string result = pwdGen.Next(); - Assert.AreEqual(16, result.Length); - } - - [Test] - public void PasswordGenerator_GivenLength3_ShouldReturnLengthErrorMessage() - { - PasswordGenerator pwdGen = new PasswordGenerator(3); - string result = pwdGen.Next(); - Assert.AreEqual("Password length invalid. Must be between 4 and 256 characters long", result); - } - - [Test] - public void PasswordGenerator_GivenLength257_ShouldReturnLengthErrorMessage() - { - PasswordGenerator pwdGen = new PasswordGenerator(257); - string result = pwdGen.Next(); - Assert.AreEqual("Password length invalid. Must be between 4 and 256 characters long", result); - } - - [Test] - public void PasswordGenerator_GivenLength256_ShouldReturn256Length() - { - PasswordGenerator pwdGen = new PasswordGenerator(256); - string result = pwdGen.Next(); - Assert.AreEqual(256, result.Length); - } - - [Test] - public void PasswordGenerator_IncludeLowercase_ShouldReturn16Length() - { - PasswordGenerator pwdGen = new PasswordGenerator().IncludeLowercase(); - string result = pwdGen.Next(); - Assert.AreEqual(16, result.Length); - } - - [Test] - public void PasswordGenerator_LengthRequired50_ShouldReturn50Length() - { - PasswordGenerator pwdGen = new PasswordGenerator().LengthRequired(50); - string result = pwdGen.Next(); - Assert.AreEqual(50, result.Length); - } - - [Test] - public void PasswordGenerator_16DigitNumeric_ShouldReturn16DigitNumericOnlyPassword() - { - PasswordGenerator pwdGen = new PasswordGenerator().IncludeNumeric(); - var result = pwdGen.Next(); - var pattern = @"^\d{16}$"; - var m = Regex.Match(result, pattern, RegexOptions.IgnoreCase); - Assert.IsTrue(m.Success); - } - - [Test] - public void PasswordGenerator_16DigitLowercase_ShouldReturn16DigitLowercaseOnlyPassword() - { - PasswordGenerator pwdGen = new PasswordGenerator().IncludeLowercase(); - var result = pwdGen.Next(); - var pattern = @"^[a-z]{16}$"; - var m = Regex.Match(result, pattern, RegexOptions.IgnoreCase); - Assert.IsTrue(m.Success); - } - } -} \ No newline at end of file diff --git a/PasswordGenerator.Tests/PasswordGenerator.Tests.csproj b/PasswordGenerator.Tests/PasswordGenerator.Tests.csproj index ef4ecf7..d2b0146 100644 --- a/PasswordGenerator.Tests/PasswordGenerator.Tests.csproj +++ b/PasswordGenerator.Tests/PasswordGenerator.Tests.csproj @@ -1,15 +1,19 @@ - + - netcoreapp2.2 + net8.0 + enable + latest false - - - + + + + + diff --git a/PasswordGenerator.Tests/Phase1CorrectnessTests.cs b/PasswordGenerator.Tests/Phase1CorrectnessTests.cs new file mode 100644 index 0000000..eb81ef9 --- /dev/null +++ b/PasswordGenerator.Tests/Phase1CorrectnessTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; + +namespace PasswordGenerator.Tests +{ + public class Phase1CorrectnessTests + { + // A deterministic random source that cycles a fixed sequence of values, so generation is + // fully reproducible in tests (no crypto RNG involved). + private class FixedRandomSource : IRandomSource + { + private readonly int[] _values; + private int _index; + + public FixedRandomSource(params int[] values) + { + _values = values; + } + + public int NextInt(int maxExclusive) + { + var value = _values[_index % _values.Length] % maxExclusive; + _index++; + return value; + } + } + + [Test] + public void Next_WithInvalidLength_ThrowsArgumentException() + { + var pwd = new Password(3); + Assert.Throws(() => pwd.Next()); + } + + [Test] + public void TryNext_WithInvalidLength_ReturnsFalseAndDoesNotThrow() + { + var pwd = new Password(3); + var ok = pwd.TryNext(out var result); + Assert.That(ok, Is.False); + Assert.That(result, Is.Null); + } + + [Test] + public void TryNext_WithValidSettings_ReturnsTrueAndPassword() + { + var pwd = new Password(16); + var ok = pwd.TryNext(out var result); + Assert.That(ok, Is.True); + Assert.That(result!.Length, Is.EqualTo(16)); + } + + [Test] + public void IncludeSpecial_WithEmptySet_ThrowsInsteadOfReturningTryAgain() + { + var pwd = new Password(); + pwd.IncludeLowercase().IncludeSpecial(" "); + Assert.Throws(() => pwd.Next()); + } + + [Test] + public void Next_WithAllClasses_AlwaysContainsOneOfEachClass() + { + // Deterministic guarantee: every generated password must contain each required class. + for (var i = 0; i < 200; i++) + { + var pwd = new Password(includeLowercase: true, includeUppercase: true, + includeNumeric: true, includeSpecial: true, passwordLength: 8); + var result = pwd.Next(); + + Assert.That(result.Any(char.IsLower), Is.True, $"missing lowercase: {result}"); + Assert.That(result.Any(char.IsUpper), Is.True, $"missing uppercase: {result}"); + Assert.That(result.Any(char.IsDigit), Is.True, $"missing digit: {result}"); + Assert.That(result.Any(c => !char.IsLetterOrDigit(c)), Is.True, $"missing special: {result}"); + } + } + + [Test] + public void CryptoRandomSource_NextInt_IsInRangeAndReachesTopValue() + { + var rng = new CryptoRandomSource(); + var seen = new HashSet(); + for (var i = 0; i < 20000; i++) + { + var v = rng.NextInt(10); + Assert.That(v, Is.GreaterThanOrEqualTo(0)); + Assert.That(v, Is.LessThan(10)); + seen.Add(v); + } + + // The old modulo implementation could never produce the top index; verify it now can. + Assert.That(seen, Does.Contain(9), "top value (9) was never produced"); + Assert.That(seen.Count, Is.EqualTo(10), "not every value in range was produced"); + } + + [Test] + public void CryptoRandomSource_NextInt_WithNonPositiveRange_Throws() + { + var rng = new CryptoRandomSource(); + Assert.Throws(() => rng.NextInt(0)); + } + + [Test] + public void Next_WithInjectedRandomSource_IsDeterministic() + { + var settingsA = new PasswordSettings(true, true, true, false, 12, 10000, false); + var settingsB = new PasswordSettings(true, true, true, false, 12, 10000, false); + + var a = new Password(settingsA, new FixedRandomSource(0, 1, 2, 3, 4, 5)); + var b = new Password(settingsB, new FixedRandomSource(0, 1, 2, 3, 4, 5)); + + Assert.That(a.Next(), Is.EqualTo(b.Next())); + } + } +} diff --git a/PasswordGenerator.Tests/Phase2Tests.cs b/PasswordGenerator.Tests/Phase2Tests.cs new file mode 100644 index 0000000..d848460 --- /dev/null +++ b/PasswordGenerator.Tests/Phase2Tests.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using NUnit.Framework; + +namespace PasswordGenerator.Tests +{ + public class Phase2Tests + { + [Test] + public void NextGroup_ReturnsRequestedCount() + { + var pwd = new Password().LengthRequired(20); + var result = pwd.NextGroup(50).ToList(); + Assert.That(result.Count, Is.EqualTo(50)); + } + + [Test] + public void NextGroup_WithLongPasswords_ProducesUniqueValues() + { + var pwd = new Password().LengthRequired(32); + var result = pwd.NextGroup(100).ToList(); + // At length 32 collisions are astronomically unlikely; all should be distinct. + Assert.That(result.Distinct().Count(), Is.EqualTo(result.Count)); + } + + [Test] + public void CustomSpecialPool_OnlyContainsTheGivenCharacters() + { + const string allowed = "!@#"; + var pwd = new Password().IncludeSpecial(allowed).LengthRequired(40); + var result = pwd.Next(); + Assert.That(result.All(c => allowed.Contains(c)), Is.True, result); + } + + [TestCase(4)] + [TestCase(256)] + public void Next_AtLengthBoundaries_ProducesPasswordOfThatLength(int length) + { + var pwd = new Password(length); + var result = pwd.Next(); + Assert.That(result.Length, Is.EqualTo(length)); + } + + [Test] + public void Next_WithNoCharacterClasses_Throws() + { + var settings = new PasswordSettings(false, false, false, false, 16, 10000, false); + var pwd = new Password(settings); + Assert.Throws(() => pwd.Next()); + } + + [Test] + public void Constructor_WithNullRandomSource_Throws() + { + var settings = new PasswordSettings(true, true, true, true, 16, 10000, false); + Assert.Throws(() => new Password(settings, null!)); + } + } +} diff --git a/PasswordGenerator.Tests/Phase3Tests.cs b/PasswordGenerator.Tests/Phase3Tests.cs new file mode 100644 index 0000000..095e3a0 --- /dev/null +++ b/PasswordGenerator.Tests/Phase3Tests.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace PasswordGenerator.Tests +{ + public class Phase3Tests + { + [Test] + public async Task NextAsync_ReturnsPasswordOfConfiguredLength() + { + var pwd = new Password(20); + var result = await pwd.NextAsync(); + Assert.That(result.Length, Is.EqualTo(20)); + } + + [Test] + public void NextAsync_WithAlreadyCancelledToken_Throws() + { + var pwd = new Password(20); + var cts = new CancellationTokenSource(); + cts.Cancel(); + Assert.That(async () => await pwd.NextAsync(cts.Token), + Throws.InstanceOf()); + } + + [Test] + public void Generate_ReturnsRequestedCount() + { + var pwd = new Password(16); + IReadOnlyList result = pwd.Generate(25); + Assert.That(result.Count, Is.EqualTo(25)); + } + + [Test] + public void Generate_WithNegativeCount_Throws() + { + var pwd = new Password(16); + Assert.Throws(() => pwd.Generate(-1)); + } + + [Test] + public async Task GenerateAsync_ReturnsRequestedCount() + { + var pwd = new Password(16); + var result = await pwd.GenerateAsync(10); + Assert.That(result.Count, Is.EqualTo(10)); + } + + [Test] + public void Di_AddPasswordGenerator_WithCodeConfig_ResolvesAndGenerates() + { + var services = new ServiceCollection(); + services.AddPasswordGenerator(o => + { + o.Length = 20; + o.IncludeSpecial = false; + }); + + using var provider = services.BuildServiceProvider(); + var generator = provider.GetRequiredService(); + + var result = generator.Next(); + Assert.That(result.Length, Is.EqualTo(20)); + Assert.That(result.All(char.IsLetterOrDigit), Is.True, result); + } + + [Test] + public void Di_AddPasswordGenerator_BindsFromConfiguration() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Length"] = "24", + ["IncludeSpecial"] = "false" + }) + .Build(); + + var services = new ServiceCollection(); + services.AddPasswordGenerator(configuration); + + using var provider = services.BuildServiceProvider(); + var generator = provider.GetRequiredService(); + + Assert.That(generator.Next().Length, Is.EqualTo(24)); + } + + [Test] + public void Di_ResolvedGenerator_BehavesLikeDirectConstruction() + { + var services = new ServiceCollection(); + services.AddPasswordGenerator(o => o.Length = 32); + using var provider = services.BuildServiceProvider(); + + var resolved = provider.GetRequiredService(); + var direct = new Password(true, true, true, true, 32); + + Assert.That(resolved.Next().Length, Is.EqualTo(direct.Next().Length)); + } + } +} diff --git a/PasswordGenerator.Tests/Phase4Tests.cs b/PasswordGenerator.Tests/Phase4Tests.cs new file mode 100644 index 0000000..96bc280 --- /dev/null +++ b/PasswordGenerator.Tests/Phase4Tests.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace PasswordGenerator.Tests +{ + public class Phase4Tests + { + [Test] + public void WithCharacters_RestrictsPoolToGivenCharacters() + { + var pwd = new Password().WithCharacters("ABC").LengthRequired(50); + var result = pwd.Next(); + Assert.That(result.All(c => "ABC".Contains(c)), Is.True, result); + } + + [Test] + public void WithAllAscii_OnlyProducesPrintableAscii() + { + var pwd = new Password().WithAllAscii().LengthRequired(100); + var result = pwd.Next(); + Assert.That(result.All(c => c >= 33 && c <= 126), Is.True, result); + } + + [Test] + public void ExcludeAmbiguous_RemovesLookAlikeCharacters() + { + var pwd = new Password(true, true, true, false, 200).ExcludeAmbiguous(); + for (var i = 0; i < 20; i++) + { + var result = pwd.Next(); + Assert.That(result.Any(c => CharacterFilter.AmbiguousCharacters.Contains(c)), Is.False, result); + } + } + + [Test] + public void RequireAtLeast_GuaranteesMinimumPerClass() + { + var pwd = new Password(true, true, true, true, 16); + pwd.RequireAtLeast(CharacterClass.Numeric, 4); + + for (var i = 0; i < 20; i++) + { + var result = pwd.Next(); + Assert.That(result.Count(char.IsDigit), Is.GreaterThanOrEqualTo(4), result); + } + } + + [Test] + public void RequireAtLeast_EnablesClassThatWasNotIncluded() + { + var pwd = new Password(true, false, false, false, 16); + pwd.RequireAtLeast(CharacterClass.Numeric, 3); + Assert.That(pwd.Next().Count(char.IsDigit), Is.GreaterThanOrEqualTo(3)); + } + + [Test] + public void RequireAtLeast_ExceedingLength_FailsValidation() + { + var pwd = new Password(true, true, true, true, 4); + pwd.RequireAtLeast(CharacterClass.Lowercase, 3); + pwd.RequireAtLeast(CharacterClass.Uppercase, 3); + + Assert.That(pwd.TryNext(out var password), Is.False); + Assert.That(password, Is.Null); + Assert.Throws(() => pwd.Next()); + } + + [Test] + public void RequireAtLeast_OnCustomPool_Throws() + { + var pwd = new Password().WithAllAscii(); + Assert.Throws(() => pwd.RequireAtLeast(CharacterClass.Numeric, 1)); + } + + [Test] + public void EstimateEntropyBits_MatchesLengthTimesLog2PoolSize() + { + var pwd = new Password(true, false, false, false, 16); // lowercase only -> pool of 26 + var expected = 16 * Math.Log(26, 2); + Assert.That(pwd.EstimateEntropyBits(), Is.EqualTo(expected).Within(1e-9)); + } + + [Test] + public void ParameterlessGenerate_UsesDefaultBatchCount() + { + var pwd = new Password(16) { DefaultBatchCount = 5 }; + Assert.That(pwd.Generate().Count, Is.EqualTo(5)); + } + + // ----- Presets ----- + + [Test] + public void ForOtp_ProducesNumericCodeOfRequestedLength() + { + var otp = Password.ForOtp(6).Next(); + Assert.That(otp.Length, Is.EqualTo(6)); + Assert.That(otp.All(char.IsDigit), Is.True, otp); + } + + [Test] + public void ForApiKey_ProducesUrlSafeSecret() + { + var key = Password.ForApiKey(40).Next(); + Assert.That(key.Length, Is.EqualTo(40)); + Assert.That(key.All(c => CharacterFilter.UrlSafeCharacters.Contains(c)), Is.True, key); + } + + [Test] + public void ForOwasp_UsesPrintableAsciiAtRequestedLength() + { + var result = Password.ForOwasp(20).Next(); + Assert.That(result.Length, Is.EqualTo(20)); + Assert.That(result.All(c => c >= 33 && c <= 126), Is.True, result); + } + + [Test] + public void ForEnvironmentName_IsLowercaseDigitsWithoutAmbiguous() + { + var name = Password.ForEnvironmentName(12).Next(); + Assert.That(name.Length, Is.EqualTo(12)); + Assert.That(name.All(c => (char.IsLower(c) || char.IsDigit(c)) + && !CharacterFilter.AmbiguousCharacters.Contains(c)), Is.True, name); + } + + [Test] + public void ForPassphrase_ProducesRequestedWordsPlusNumber() + { + var generator = Password.ForPassphrase(4, '-', capitalize: false, includeNumber: true); + var phrase = generator.Next(); + var parts = phrase.Split('-'); + + Assert.That(parts.Length, Is.EqualTo(5)); // 4 words + trailing number + Assert.That(int.TryParse(parts[4], out _), Is.True, phrase); + Assert.That(parts.Take(4).All(p => p.All(char.IsLetter)), Is.True, phrase); + } + + // ----- DI / appSettings precedence ----- + + [Test] + public void Di_CodeConfigOverridesConfiguration() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Length"] = "10" }) + .Build(); + + var services = new ServiceCollection(); + services.AddPasswordGenerator(configuration, o => o.Length = 20); + + using var provider = services.BuildServiceProvider(); + var generator = provider.GetRequiredService(); + + Assert.That(generator.Next().Length, Is.EqualTo(20)); + } + + [Test] + public void Di_BindsExcludeAmbiguousAndDefaultBatchCount() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Length"] = "60", + ["IncludeSpecial"] = "false", + ["ExcludeAmbiguous"] = "true", + ["DefaultBatchCount"] = "4" + }) + .Build(); + + var services = new ServiceCollection(); + services.AddPasswordGenerator(configuration); + + using var provider = services.BuildServiceProvider(); + var generator = provider.GetRequiredService(); + + Assert.That(generator.Generate().Count, Is.EqualTo(4)); + Assert.That(generator.Next().Any(c => CharacterFilter.AmbiguousCharacters.Contains(c)), Is.False); + } + } +} diff --git a/PasswordGenerator.sln b/PasswordGenerator.sln index 88b5239..cbb9c49 100644 --- a/PasswordGenerator.sln +++ b/PasswordGenerator.sln @@ -1,9 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 +# Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PasswordGenerator", "PasswordGenerator\PasswordGenerator.csproj", "{74E29546-7B25-470B-ABED-DEE07D8EA030}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PasswordGenerator.Tests", "PasswordGenerator.Tests\PasswordGenerator.Tests.csproj", "{B9B40182-C02F-4213-9AFB-3CC48D29835E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PasswordGenerator.Benchmarks", "PasswordGenerator.Benchmarks\PasswordGenerator.Benchmarks.csproj", "{BDDFF217-E17A-4CD4-9221-2F4DBD2F46C8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,5 +21,9 @@ Global {B9B40182-C02F-4213-9AFB-3CC48D29835E}.Debug|Any CPU.Build.0 = Debug|Any CPU {B9B40182-C02F-4213-9AFB-3CC48D29835E}.Release|Any CPU.ActiveCfg = Release|Any CPU {B9B40182-C02F-4213-9AFB-3CC48D29835E}.Release|Any CPU.Build.0 = Release|Any CPU + {BDDFF217-E17A-4CD4-9221-2F4DBD2F46C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BDDFF217-E17A-4CD4-9221-2F4DBD2F46C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BDDFF217-E17A-4CD4-9221-2F4DBD2F46C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BDDFF217-E17A-4CD4-9221-2F4DBD2F46C8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/PasswordGenerator/CharacterClass.cs b/PasswordGenerator/CharacterClass.cs new file mode 100644 index 0000000..ea525c7 --- /dev/null +++ b/PasswordGenerator/CharacterClass.cs @@ -0,0 +1,14 @@ +namespace PasswordGenerator +{ + /// + /// The character classes a password can be composed from. Used by + /// to guarantee a minimum number of characters per class. + /// + public enum CharacterClass + { + Lowercase, + Uppercase, + Numeric, + Special + } +} diff --git a/PasswordGenerator/CharacterFilter.cs b/PasswordGenerator/CharacterFilter.cs new file mode 100644 index 0000000..bc2c4a3 --- /dev/null +++ b/PasswordGenerator/CharacterFilter.cs @@ -0,0 +1,48 @@ +using System.Text; + +namespace PasswordGenerator +{ + /// + /// Shared character-pool helpers: well-known pools and ambiguous-character removal. + /// + public static class CharacterFilter + { + /// Look-alike characters removed by . + public const string AmbiguousCharacters = "Il1O0o"; + + /// URL-safe characters (RFC 4648 base64url alphabet), used by API-key style secrets. + public const string UrlSafeCharacters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + private static string? _allPrintableAscii; + + /// All printable ASCII characters (code points 33-126; excludes space). + public static string AllPrintableAscii + { + get + { + if (_allPrintableAscii != null) return _allPrintableAscii; + + var sb = new StringBuilder(126 - 33 + 1); + for (var c = 33; c <= 126; c++) + sb.Append((char)c); + _allPrintableAscii = sb.ToString(); + return _allPrintableAscii; + } + } + + /// Returns with ambiguous characters removed when is true. + public static string RemoveAmbiguous(string? input, bool exclude) + { + if (!exclude || string.IsNullOrEmpty(input)) + return input ?? string.Empty; + + var sb = new StringBuilder(input!.Length); + foreach (var ch in input) + if (AmbiguousCharacters.IndexOf(ch) < 0) + sb.Append(ch); + + return sb.ToString(); + } + } +} diff --git a/PasswordGenerator/CryptoRandomSource.cs b/PasswordGenerator/CryptoRandomSource.cs new file mode 100644 index 0000000..7118b26 --- /dev/null +++ b/PasswordGenerator/CryptoRandomSource.cs @@ -0,0 +1,61 @@ +using System; +using System.Security.Cryptography; + +namespace PasswordGenerator +{ + /// + /// backed by a cryptographic RNG. + /// On modern targets it uses ; on + /// netstandard2.0 it uses rejection sampling so the result is uniform across the whole + /// range with no modulo bias and no off-by-one. + /// + public sealed class CryptoRandomSource : IRandomSource, IDisposable + { +#if !NET8_0_OR_GREATER + private readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); +#endif + + public int NextInt(int maxExclusive) + { + if (maxExclusive <= 0) + throw new ArgumentOutOfRangeException(nameof(maxExclusive), "maxExclusive must be positive."); + +#if NET8_0_OR_GREATER + return RandomNumberGenerator.GetInt32(maxExclusive); +#else + if (maxExclusive == 1) return 0; + + var range = (uint)maxExclusive; + + // Largest multiple of range that is <= 2^32. Values at or above this are rejected so the + // accepted region is a whole number of buckets, giving an unbiased result mod range. + const ulong fullSpace = 1UL << 32; + var limit = fullSpace - fullSpace % range; + + uint value; + do + { + value = NextUInt32(); + } while (value >= limit); + + return (int)(value % range); +#endif + } + +#if !NET8_0_OR_GREATER + private uint NextUInt32() + { + var buffer = new byte[4]; + _rng.GetBytes(buffer); + return BitConverter.ToUInt32(buffer, 0); + } +#endif + + public void Dispose() + { +#if !NET8_0_OR_GREATER + _rng.Dispose(); +#endif + } + } +} diff --git a/PasswordGenerator/IEntropyEstimator.cs b/PasswordGenerator/IEntropyEstimator.cs new file mode 100644 index 0000000..987b319 --- /dev/null +++ b/PasswordGenerator/IEntropyEstimator.cs @@ -0,0 +1,15 @@ +namespace PasswordGenerator +{ + /// + /// Estimates the strength, in bits, of passwords produced from a given set of settings. + /// + public interface IEntropyEstimator + { + /// + /// Estimates entropy in bits as length * log2(poolSize), using the effective character + /// pool (after any ambiguous-character exclusion). This is an upper-bound estimate that ignores + /// forced-composition minimums. + /// + double EstimateBits(IPasswordSettings settings); + } +} diff --git a/PasswordGenerator/IPassword.cs b/PasswordGenerator/IPassword.cs index 9cf4160..70fe6ee 100644 --- a/PasswordGenerator/IPassword.cs +++ b/PasswordGenerator/IPassword.cs @@ -9,8 +9,22 @@ public interface IPassword IPassword IncludeNumeric(); IPassword IncludeSpecial(); IPassword IncludeSpecial(string specialCharactersToInclude); + + /// Replaces the pool with an explicit set of characters (no forced composition). + IPassword WithCharacters(string characters); + + /// Uses every printable ASCII character as the pool (no forced composition). + IPassword WithAllAscii(); + + /// Removes look-alike characters from the pool (e.g. I l 1 O 0 o). + IPassword ExcludeAmbiguous(); + + /// Requires at least characters from the given class. + IPassword RequireAtLeast(CharacterClass characterClass, int count); + IPassword LengthRequired(int passwordLength); string Next(); + bool TryNext(out string? password); IEnumerable NextGroup(int numberOfPasswordsToGenerate); } } \ No newline at end of file diff --git a/PasswordGenerator/IPasswordGenerator.cs b/PasswordGenerator/IPasswordGenerator.cs new file mode 100644 index 0000000..b842dc4 --- /dev/null +++ b/PasswordGenerator/IPasswordGenerator.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PasswordGenerator +{ + /// + /// The generation contract for a configured password generator. This is the type to depend on + /// when a generator is resolved from dependency injection. + /// + public interface IPasswordGenerator + { + /// Generates a single password. Throws if the settings are invalid. + string Next(); + + /// Tries to generate a single password, returning false (instead of throwing) for invalid settings. + bool TryNext(out string? password); + + /// + /// Generates a single password. Generation is CPU-bound; this overload exists for ergonomics + /// and to honour cancellation, not to offload work to another thread. + /// + Task NextAsync(CancellationToken cancellationToken = default); + + /// Generates the default number of passwords (configurable; one unless overridden). + IReadOnlyList Generate(); + + /// Generates passwords. + IReadOnlyList Generate(int count); + + /// Generates the default number of passwords, observing . + Task> GenerateAsync(CancellationToken cancellationToken = default); + + /// Generates passwords, observing . + Task> GenerateAsync(int count, CancellationToken cancellationToken = default); + } +} diff --git a/PasswordGenerator/IPasswordSettings.cs b/PasswordGenerator/IPasswordSettings.cs index 7ea2d9f..8dbebd3 100644 --- a/PasswordGenerator/IPasswordSettings.cs +++ b/PasswordGenerator/IPasswordSettings.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace PasswordGenerator { /// @@ -11,6 +13,21 @@ public interface IPasswordSettings bool IncludeSpecial { get; } int PasswordLength { get; set; } string CharacterSet { get; } + + /// True when a custom pool (e.g. ) replaces the per-class sets. + bool IsCustomPool { get; } + + /// When true, look-alike characters are removed from the pool before generating. + bool ExcludeAmbiguous { get; } + + /// The minimum number of characters required from each class (empty when none are forced). + IReadOnlyDictionary MinimumCounts { get; } + + /// + /// The individual character groups that are included (one per enabled class). Used to + /// guarantee at least one character from each required class is present in the output. + /// + IReadOnlyList CharacterGroups { get; } int MaximumAttempts { get; } int MinimumLength { get; } int MaximumLength { get; } @@ -19,6 +36,19 @@ public interface IPasswordSettings IPasswordSettings AddNumeric(); IPasswordSettings AddSpecial(); IPasswordSettings AddSpecial(string specialCharactersToAdd); + + /// Replaces the entire pool with an explicit set of characters (no forced composition). + IPasswordSettings UseCharacters(string characters); + + /// Uses every printable ASCII character as the pool (no forced composition). + IPasswordSettings UseAllAscii(); + + /// Removes look-alike characters (see ) from the pool. + IPasswordSettings ExcludeAmbiguousCharacters(); + + /// Requires at least characters from the given class, enabling it if needed. + IPasswordSettings RequireAtLeast(CharacterClass characterClass, int count); + string SpecialCharacters { get; set; } } } \ No newline at end of file diff --git a/PasswordGenerator/IRandomSource.cs b/PasswordGenerator/IRandomSource.cs new file mode 100644 index 0000000..9e35a80 --- /dev/null +++ b/PasswordGenerator/IRandomSource.cs @@ -0,0 +1,14 @@ +namespace PasswordGenerator +{ + /// + /// Source of random integers used to build passwords. Abstracted so the crypto RNG can be + /// swapped for a deterministic source in tests and wired through DI. + /// + public interface IRandomSource + { + /// + /// Returns a uniformly distributed integer in the range [0, maxExclusive). + /// + int NextInt(int maxExclusive); + } +} diff --git a/PasswordGenerator/PassphraseGenerator.cs b/PasswordGenerator/PassphraseGenerator.cs new file mode 100644 index 0000000..e4ea7b7 --- /dev/null +++ b/PasswordGenerator/PassphraseGenerator.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace PasswordGenerator +{ + /// + /// Generates diceware-style passphrases from a built-in word list. Created via + /// . + /// + public class PassphraseGenerator : IPasswordGenerator, IDisposable + { + private readonly IRandomSource _random; + private readonly bool _ownsRandom; + + public PassphraseGenerator(int wordCount = 4, char separator = '-', bool capitalize = false, + bool includeNumber = true, IRandomSource? randomSource = null) + { + if (wordCount < 1) + throw new ArgumentOutOfRangeException(nameof(wordCount), "A passphrase needs at least one word."); + + WordCount = wordCount; + Separator = separator; + Capitalize = capitalize; + IncludeNumber = includeNumber; + _random = randomSource ?? new CryptoRandomSource(); + _ownsRandom = randomSource == null; + } + + public int WordCount { get; } + public char Separator { get; } + public bool Capitalize { get; } + public bool IncludeNumber { get; } + + /// The number of passphrases produced by the parameterless overload. + public int DefaultBatchCount { get; set; } = 1; + + public string Next() + { + var sb = new StringBuilder(); + + for (var i = 0; i < WordCount; i++) + { + if (i > 0) sb.Append(Separator); + + var word = WordList.Words[_random.NextInt(WordList.Words.Length)]; + if (Capitalize && word.Length > 0) + { + sb.Append(char.ToUpper(word[0], CultureInfo.InvariantCulture)); + sb.Append(word, 1, word.Length - 1); + } + else + { + sb.Append(word); + } + } + + if (IncludeNumber) + { + sb.Append(Separator); + sb.Append((_random.NextInt(90) + 10).ToString(CultureInfo.InvariantCulture)); + } + + return sb.ToString(); + } + + public bool TryNext(out string? password) + { + password = Next(); + return true; + } + + public Task NextAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Next()); + } + + public IReadOnlyList Generate() + { + return Generate(DefaultBatchCount); + } + + public IReadOnlyList Generate(int count) + { + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), "count cannot be negative."); + + var passphrases = new List(count); + for (var i = 0; i < count; i++) + passphrases.Add(Next()); + + return passphrases; + } + + public Task> GenerateAsync(CancellationToken cancellationToken = default) + { + return GenerateAsync(DefaultBatchCount, cancellationToken); + } + + public Task> GenerateAsync(int count, CancellationToken cancellationToken = default) + { + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), "count cannot be negative."); + + var passphrases = new List(count); + for (var i = 0; i < count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + passphrases.Add(Next()); + } + + return Task.FromResult>(passphrases); + } + + /// Estimates passphrase entropy in bits from the word-list size, word count, and trailing number. + public double EstimateEntropyBits() + { + var bits = WordCount * Math.Log(WordList.Words.Length, 2); + if (IncludeNumber) bits += Math.Log(90, 2); + return bits; + } + + public void Dispose() + { + if (_ownsRandom && _random is IDisposable disposable) + disposable.Dispose(); + } + } +} diff --git a/PasswordGenerator/Password.cs b/PasswordGenerator/Password.cs index ca9138a..4e44763 100644 --- a/PasswordGenerator/Password.cs +++ b/PasswordGenerator/Password.cs @@ -1,15 +1,14 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; namespace PasswordGenerator { -/// - /// Generates random passwords and validates that they meet the rules passed in + /// + /// Generates random passwords that satisfy the configured rules. /// - public class Password : IPassword + public class Password : IPassword, IPasswordGenerator, IDisposable { private const int DefaultPasswordLength = 16; private const int DefaultMaxPasswordAttempts = 10000; @@ -17,38 +16,40 @@ public class Password : IPassword private const bool DefaultIncludeUppercase = true; private const bool DefaultIncludeNumeric = true; private const bool DefaultIncludeSpecial = true; - private static RandomNumberGenerator _rng; + + private readonly IRandomSource _random; + private readonly bool _ownsRandom; public Password() { Settings = new PasswordSettings(DefaultIncludeLowercase, DefaultIncludeUppercase, DefaultIncludeNumeric, DefaultIncludeSpecial, DefaultPasswordLength, DefaultMaxPasswordAttempts, true); - - _rng = RandomNumberGenerator.Create(); + _random = new CryptoRandomSource(); + _ownsRandom = true; } public Password(IPasswordSettings settings) { Settings = settings; - - _rng = RandomNumberGenerator.Create(); + _random = new CryptoRandomSource(); + _ownsRandom = true; } public Password(int passwordLength) { Settings = new PasswordSettings(DefaultIncludeLowercase, DefaultIncludeUppercase, DefaultIncludeNumeric, DefaultIncludeSpecial, passwordLength, DefaultMaxPasswordAttempts, true); - - _rng = RandomNumberGenerator.Create(); + _random = new CryptoRandomSource(); + _ownsRandom = true; } public Password(bool includeLowercase, bool includeUppercase, bool includeNumeric, bool includeSpecial) { Settings = new PasswordSettings(includeLowercase, includeUppercase, includeNumeric, includeSpecial, DefaultPasswordLength, DefaultMaxPasswordAttempts, false); - - _rng = RandomNumberGenerator.Create(); + _random = new CryptoRandomSource(); + _ownsRandom = true; } public Password(bool includeLowercase, bool includeUppercase, bool includeNumeric, bool includeSpecial, @@ -56,8 +57,8 @@ public Password(bool includeLowercase, bool includeUppercase, bool includeNumeri { Settings = new PasswordSettings(includeLowercase, includeUppercase, includeNumeric, includeSpecial, passwordLength, DefaultMaxPasswordAttempts, false); - - _rng = RandomNumberGenerator.Create(); + _random = new CryptoRandomSource(); + _ownsRandom = true; } public Password(bool includeLowercase, bool includeUppercase, bool includeNumeric, bool includeSpecial, @@ -65,8 +66,19 @@ public Password(bool includeLowercase, bool includeUppercase, bool includeNumeri { Settings = new PasswordSettings(includeLowercase, includeUppercase, includeNumeric, includeSpecial, passwordLength, maximumAttempts, false); + _random = new CryptoRandomSource(); + _ownsRandom = true; + } - _rng = RandomNumberGenerator.Create(); + /// + /// Creates a password generator with an explicit random source. The caller owns the + /// supplied and is responsible for disposing it. + /// + public Password(IPasswordSettings settings, IRandomSource randomSource) + { + Settings = settings; + _random = randomSource ?? throw new ArgumentNullException(nameof(randomSource)); + _ownsRandom = false; } public IPasswordSettings Settings { get; set; } @@ -101,6 +113,30 @@ public IPassword IncludeSpecial(string specialCharactersToInclude) return this; } + public IPassword WithCharacters(string characters) + { + Settings = Settings.UseCharacters(characters); + return this; + } + + public IPassword WithAllAscii() + { + Settings = Settings.UseAllAscii(); + return this; + } + + public IPassword ExcludeAmbiguous() + { + Settings = Settings.ExcludeAmbiguousCharacters(); + return this; + } + + public IPassword RequireAtLeast(CharacterClass characterClass, int count) + { + Settings = Settings.RequireAtLeast(characterClass, count); + return this; + } + public IPassword LengthRequired(int passwordLength) { Settings.PasswordLength = passwordLength; @@ -108,145 +144,277 @@ public IPassword LengthRequired(int passwordLength) } /// - /// Gets the next random password which meets the requirements + /// The number of passwords produced by the parameterless overload. /// - /// A password as a string + public int DefaultBatchCount { get; set; } = 1; + + /// Estimates the strength, in bits, of passwords produced from the current settings. + public double EstimateEntropyBits() + { + return new PoolEntropyEstimator().EstimateBits(Settings); + } + + /// + /// Generates a password that meets the configured requirements. + /// + /// A password as a string. + /// Thrown when the configured settings cannot produce a valid password. public string Next() { - string password; - if (!LengthIsValid(Settings.PasswordLength, Settings.MinimumLength, Settings.MaximumLength)) + if (!TryValidateSettings(out var error)) + throw new ArgumentException(error); + + return GenerateRandomPassword(Settings); + } + + /// + /// Tries to generate a password. Returns false (instead of throwing) when the settings are invalid. + /// + public bool TryNext(out string? password) + { + if (!TryValidateSettings(out _)) { - password = - $"Password length invalid. Must be between {Settings.MinimumLength} and {Settings.MaximumLength} characters long"; + password = null; + return false; } - else - { - var passwordAttempts = 0; - do - { - password = GenerateRandomPassword(Settings); - passwordAttempts++; - } while (passwordAttempts < Settings.MaximumAttempts && !PasswordIsValid(Settings, password)); - password = PasswordIsValid(Settings, password) ? password : "Try again"; + password = GenerateRandomPassword(Settings); + return true; + } + + public IEnumerable NextGroup(int numberOfPasswordsToGenerate) + { + return Generate(numberOfPasswordsToGenerate); + } + + public Task NextAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Next()); + } + + public IReadOnlyList Generate() + { + return Generate(DefaultBatchCount); + } + + public IReadOnlyList Generate(int count) + { + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), "count cannot be negative."); + + var passwords = new List(count); + for (var i = 0; i < count; i++) + passwords.Add(Next()); + + return passwords; + } + + public Task> GenerateAsync(CancellationToken cancellationToken = default) + { + return GenerateAsync(DefaultBatchCount, cancellationToken); + } + + public Task> GenerateAsync(int count, CancellationToken cancellationToken = default) + { + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), "count cannot be negative."); + + var passwords = new List(count); + for (var i = 0; i < count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + passwords.Add(Next()); } - return password; + return Task.FromResult>(passwords); } + private static readonly CharacterClass[] OrderedClasses = + { + CharacterClass.Lowercase, CharacterClass.Uppercase, CharacterClass.Numeric, CharacterClass.Special + }; - public IEnumerable NextGroup(int numberOfPasswordsToGenerate) + private bool TryValidateSettings(out string? error) { - var passwords = new List(); + if (!LengthIsValid(Settings.PasswordLength, Settings.MinimumLength, Settings.MaximumLength)) + { + error = + $"Password length invalid. Must be between {Settings.MinimumLength} and {Settings.MaximumLength} characters long"; + return false; + } - for (var i = 0; i < numberOfPasswordsToGenerate; i++) + if (Settings.IncludeSpecial && string.IsNullOrWhiteSpace(Settings.SpecialCharacters)) { - var pwd = this.Next(); - passwords.Add(pwd); + error = "Special characters are required but no special characters have been provided."; + return false; } - - return passwords; + + var pool = CharacterFilter.RemoveAmbiguous(Settings.CharacterSet, Settings.ExcludeAmbiguous); + if (string.IsNullOrEmpty(pool)) + { + error = "No characters are available to generate a password from."; + return false; + } + + if (!Settings.IsCustomPool) + { + var totalRequired = 0; + foreach (var characterClass in OrderedClasses) + { + var minimum = EffectiveMinimum(Settings, characterClass); + if (minimum <= 0) continue; + + var group = CharacterFilter.RemoveAmbiguous(ClassCharacters(Settings, characterClass), + Settings.ExcludeAmbiguous); + if (group.Length == 0) + { + error = + $"At least {minimum} {characterClass} character(s) are required but none are available."; + return false; + } + + totalRequired += minimum; + } + + if (totalRequired > Settings.PasswordLength) + { + error = + $"The required minimum characters ({totalRequired}) exceed the password length ({Settings.PasswordLength})."; + return false; + } + } + + error = null; + return true; } /// - /// Generates a random password based on the rules passed in the settings parameter - /// This does not do any validation + /// Builds a password that is valid by construction: the required minimum characters are taken + /// from each class first, the remainder is filled from the full pool, and the result is shuffled. /// - /// Password generator settings object - /// a random password - private static string GenerateRandomPassword(IPasswordSettings settings) + private string GenerateRandomPassword(IPasswordSettings settings) { - const int maximumIdenticalConsecutiveChars = 2; - var password = new char[settings.PasswordLength]; + var length = settings.PasswordLength; + var pool = CharacterFilter.RemoveAmbiguous(settings.CharacterSet, settings.ExcludeAmbiguous); - var characters = settings.CharacterSet.ToCharArray(); - var shuffledChars = Shuffle(characters.Select(x => x)).ToArray(); + var password = new char[length]; + var position = 0; - var shuffledCharacterSet = string.Join(null, shuffledChars); - var characterSetLength = shuffledCharacterSet.Length; + if (!settings.IsCustomPool) + foreach (var characterClass in OrderedClasses) + { + var minimum = EffectiveMinimum(settings, characterClass); + if (minimum <= 0) continue; - for (var characterPosition = 0; characterPosition < settings.PasswordLength; characterPosition++) - { - password[characterPosition] = shuffledCharacterSet[GetRandomNumberInRange(0,characterSetLength - 1)]; + var group = CharacterFilter.RemoveAmbiguous(ClassCharacters(settings, characterClass), + settings.ExcludeAmbiguous); - var moreThanTwoIdenticalInARow = - characterPosition > maximumIdenticalConsecutiveChars - && password[characterPosition] == password[characterPosition - 1] - && password[characterPosition - 1] == password[characterPosition - 2]; + for (var k = 0; k < minimum && position < length; k++, position++) + password[position] = group[_random.NextInt(group.Length)]; + } - if (moreThanTwoIdenticalInARow) characterPosition--; - } + for (; position < length; position++) + password[position] = pool[_random.NextInt(pool.Length)]; - return string.Join(null, password); + ShuffleInPlace(password); + + return new string(password); } - private static int GetRandomNumberInRange(int min, int max) + private static int EffectiveMinimum(IPasswordSettings settings, CharacterClass characterClass) { - if (min > max) - throw new ArgumentOutOfRangeException(); + if (!ClassEnabled(settings, characterClass)) return 0; + // Each enabled class defaults to one guaranteed character unless overridden. + return settings.MinimumCounts.TryGetValue(characterClass, out var minimum) ? minimum : 1; + } - var data = new byte[sizeof(int)]; - _rng.GetBytes(data); - var randomNumber = BitConverter.ToInt32(data, 0); + private static bool ClassEnabled(IPasswordSettings settings, CharacterClass characterClass) + { + switch (characterClass) + { + case CharacterClass.Lowercase: return settings.IncludeLowercase; + case CharacterClass.Uppercase: return settings.IncludeUppercase; + case CharacterClass.Numeric: return settings.IncludeNumeric; + case CharacterClass.Special: return settings.IncludeSpecial; + default: return false; + } + } - return (int)Math.Floor((double)(min + Math.Abs(randomNumber % (max - min)))); + private static string ClassCharacters(IPasswordSettings settings, CharacterClass characterClass) + { + switch (characterClass) + { + case CharacterClass.Lowercase: return PasswordSettings.LowercaseCharacters; + case CharacterClass.Uppercase: return PasswordSettings.UppercaseCharacters; + case CharacterClass.Numeric: return PasswordSettings.NumericCharacters; + case CharacterClass.Special: return settings.SpecialCharacters ?? string.Empty; + default: return string.Empty; + } } - private static int GetRngCryptoSeed(RNGCryptoServiceProvider rng) + private void ShuffleInPlace(char[] items) { - var rngByteArray = new byte[4]; - rng.GetBytes(rngByteArray); - return BitConverter.ToInt32(rngByteArray, 0); + for (var i = items.Length - 1; i > 0; i--) + { + var j = _random.NextInt(i + 1); + var temp = items[i]; + items[i] = items[j]; + items[j] = temp; + } } - /// - /// When you give it a password and some _settings, it validates the password against the _settings. - /// - /// Password settings - /// Password to test - /// True or False to say if the password is valid or not - private static bool PasswordIsValid(IPasswordSettings settings, string password) + private static bool LengthIsValid(int passwordLength, int minLength, int maxLength) { - const string regexLowercase = @"[a-z]"; - const string regexUppercase = @"[A-Z]"; - const string regexNumeric = @"[\d]"; + return passwordLength >= minLength && passwordLength <= maxLength; + } - var lowerCaseIsValid = !settings.IncludeLowercase || - settings.IncludeLowercase && Regex.IsMatch(password, regexLowercase); - var upperCaseIsValid = !settings.IncludeUppercase || - settings.IncludeUppercase && Regex.IsMatch(password, regexUppercase); - var numericIsValid = !settings.IncludeNumeric || - settings.IncludeNumeric && Regex.IsMatch(password, regexNumeric); + public void Dispose() + { + if (_ownsRandom && _random is IDisposable disposable) + disposable.Dispose(); + } - var specialIsValid = !settings.IncludeSpecial; + // ----- Presets (sugar over the fluent builder; later fluent calls still override) ----- - if (settings.IncludeSpecial && !string.IsNullOrWhiteSpace(settings.SpecialCharacters)) - { - var listA = settings.SpecialCharacters.ToCharArray(); - var listB = password.ToCharArray(); + /// OWASP-style: the full printable-ASCII pool with no forced composition. + public static IPassword ForOwasp(int length = 16) + { + return new Password().WithAllAscii().LengthRequired(length); + } - specialIsValid = listA.Any(x => listB.Contains(x)); - } + /// NIST 800-63B aligned: a long passphrase-friendly length over the full ASCII pool, no composition rules. + public static IPassword ForNist(int length = 12) + { + return new Password().WithAllAscii().LengthRequired(length); + } + + /// One-time-password style: a short numeric code. + public static IPassword ForOtp(int digits = 6) + { + return new Password().WithCharacters(PasswordSettings.NumericCharacters).LengthRequired(digits); + } - return lowerCaseIsValid && upperCaseIsValid && numericIsValid && specialIsValid && - LengthIsValid(password.Length, settings.MinimumLength, settings.MaximumLength); + /// API-key style: a long URL-safe secret. + public static IPassword ForApiKey(int length = 32) + { + return new Password().WithCharacters(CharacterFilter.UrlSafeCharacters).LengthRequired(length); } - /// - /// Checks that the password is within the valid length range - /// - /// The length of the password - /// The minimum allowed length - /// The maximum allowed length - /// A bool to say if it is valid or not - private static bool LengthIsValid(int passwordLength, int minLength, int maxLength) + /// Readable identifier: lowercase letters and digits with look-alikes removed. + public static IPassword ForEnvironmentName(int length = 12) { - return passwordLength >= minLength && passwordLength <= maxLength; + return new Password() + .WithCharacters(PasswordSettings.LowercaseCharacters + PasswordSettings.NumericCharacters) + .ExcludeAmbiguous() + .LengthRequired(length); } - private static IEnumerable Shuffle(IEnumerable items) + /// Diceware-style passphrase built from a built-in word list. + public static IPasswordGenerator ForPassphrase(int words = 4, char separator = '-', + bool capitalize = false, bool includeNumber = true) { - return from item in items orderby Guid.NewGuid() select item; + return new PassphraseGenerator(words, separator, capitalize, includeNumber); } } -} \ No newline at end of file +} diff --git a/PasswordGenerator/PasswordGenerator.cs b/PasswordGenerator/PasswordGenerator.cs deleted file mode 100644 index 0f802b1..0000000 --- a/PasswordGenerator/PasswordGenerator.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; - -namespace PasswordGenerator -{ - [ObsoleteAttribute("The class 'PasswordGenerator' is obsolete. Use 'Password' instead.")] - public class PasswordGenerator : Password - { - public PasswordGenerator() - { - - } - - public PasswordGenerator(PasswordGeneratorSettings settings) : base (settings) - { - } - - public PasswordGenerator(int passwordLength) : base(passwordLength) - { - } - - public PasswordGenerator(bool includeLowercase, bool includeUppercase, bool includeNumeric, bool includeSpecial) : base(includeLowercase, includeUppercase, includeNumeric, includeSpecial) - { - } - - public PasswordGenerator(bool includeLowercase, bool includeUppercase, bool includeNumeric, bool includeSpecial, - int passwordLength) : base(includeLowercase, includeUppercase, includeNumeric, includeSpecial, passwordLength) - { - } - - public PasswordGenerator(bool includeLowercase, bool includeUppercase, bool includeNumeric, bool includeSpecial, - int passwordLength, int maximumAttempts) : base(includeLowercase, includeUppercase, includeNumeric, includeSpecial, - passwordLength, maximumAttempts) - { - } - - public PasswordGenerator IncludeLowercase() - { - base.Settings = base.Settings.AddLowercase(); - return this; - } - - public PasswordGenerator IncludeUppercase() - { - base.Settings = base.Settings.AddUppercase(); - return this; - } - - public PasswordGenerator IncludeNumeric() - { - base.Settings = base.Settings.AddNumeric(); - return this; - } - - public PasswordGenerator IncludeSpecial() - { - base.Settings = base.Settings.AddSpecial(); - return this; - } - - public PasswordGenerator LengthRequired(int passwordLength) - { - base.Settings.PasswordLength = passwordLength; - return this; - } - } -} diff --git a/PasswordGenerator/PasswordGenerator.csproj b/PasswordGenerator/PasswordGenerator.csproj index 78f3c8c..b2f9420 100644 --- a/PasswordGenerator/PasswordGenerator.csproj +++ b/PasswordGenerator/PasswordGenerator.csproj @@ -1,28 +1,47 @@ - + - netstandard2.0 - 2.1.0 + netstandard2.0;net8.0 + enable + latest + + + 3.0.0 + 3.0.0.0 + 3.0.0.0 + PasswordGenerator Paul Seal - - Removed usage of RNG Crypto Provider and used Random Number Generator -- Fixed bug with methods that use bool and int parameters for the Password class -- Removed usage of Random and replace it with a method which uses RngCrytopServiceProvider - Copyright 2022 + A cross-platform .NET library that generates cryptographically secure random passwords, passphrases, OTPs, API keys and readable identifiers. Configurable via a fluent API, presets (OWASP/NIST) and dependency injection, with async support and entropy estimation. + Copyright 2026 https://github.com/prjseal/PasswordGenerator/ - https://github.com/prjseal/PasswordGenerator/blob/master/passwordgeneratorlogo.png?raw=true https://github.com/prjseal/PasswordGenerator/ Git - Password,Generator,OWASP,Security,Random,Special,Characters,.net,framework,standard,core,dotnet,dotnetcore,Rng,Random,Number,Generator - Removed usage of RNG Crypto Provider - Compatible with .NET Core, .NET Framework and .NET Standard -Added ability to get a group of passwords in one call + MIT + passwordgeneratorlogo.png + README.md + Password,Passphrase,Generator,OWASP,NIST,Security,Random,Crypto,OTP,ApiKey,Entropy,dotnet,netstandard,DependencyInjection + 3.0.0 is a major release: cryptographically secure RNG with unbiased sampling, exception/TryNext error handling, async APIs, dependency-injection support, presets (OWASP/NIST/OTP/API key/passphrase), custom pools, exclude-ambiguous, per-class minimums and entropy estimation. See the migration guide for upgrading from 2.x. false - 2.1.0 - 2.1.0.0 - 2.1.0.0 - Git - MIT + + + true + true + true + true + true + snupkg + + + + + + + + + + + diff --git a/PasswordGenerator/PasswordGenerator.nuspec b/PasswordGenerator/PasswordGenerator.nuspec deleted file mode 100644 index fce1692..0000000 --- a/PasswordGenerator/PasswordGenerator.nuspec +++ /dev/null @@ -1,27 +0,0 @@ - - - - PasswordGenerator - 2.0.5 - Paul Seal - Paul Seal - MIT - https://github.com/prjseal/PasswordGenerator/ - https://github.com/prjseal/PasswordGenerator/blob/master/passwordgeneratorlogo.png?raw=true - false - A .NET Standard library which generates random passwords with different settings to meet the OWASP requirements - - - Fixed bug with methods that use bool and int parameters for the Password class - - Removed usage of Random and replace it with a method which uses RngCrytopServiceProvider - - Copyright 2019 - Password,Generator,OWASP,Security,Random,Special,Characters,.net,framework,standard,core,dotnet,dotnetcore,Rng,Crypto,RNGCryptoServiceProvider - - - - - - - - - \ No newline at end of file diff --git a/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs b/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs new file mode 100644 index 0000000..fa3e13e --- /dev/null +++ b/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace PasswordGenerator +{ + /// + /// Opt-in dependency-injection registration for the password generator. Call this from the + /// consuming application's startup; the package does not auto-register anything. + /// + public static class PasswordGeneratorServiceCollectionExtensions + { + /// Registers the generator, optionally configuring it in code. + public static IServiceCollection AddPasswordGenerator(this IServiceCollection services, + Action? configure = null) + { + var options = new PasswordOptions(); + configure?.Invoke(options); + return AddCore(services, options); + } + + /// + /// Registers the generator, binding options from configuration (e.g. appSettings.json) and then + /// applying an optional code override. Resolution order is configure (code) > configuration > default. + /// + public static IServiceCollection AddPasswordGenerator(this IServiceCollection services, + IConfiguration configuration, Action? configure = null) + { + if (configuration == null) throw new ArgumentNullException(nameof(configuration)); + + var options = new PasswordOptions(); + configuration.Bind(options); + configure?.Invoke(options); + return AddCore(services, options); + } + + private static IServiceCollection AddCore(IServiceCollection services, PasswordOptions options) + { + services.AddSingleton(); + services.AddSingleton(sp => + CreateGenerator(options, sp.GetRequiredService())); + return services; + } + + private static Password CreateGenerator(PasswordOptions options, IRandomSource randomSource) + { + // Build with the non-special classes first, then layer special characters on (default or + // custom) so the combined character set is assembled correctly. + var settings = new PasswordSettings(options.IncludeLowercase, options.IncludeUppercase, + options.IncludeNumeric, false, options.Length, 10000, usingDefaults: false); + + var password = new Password(settings, randomSource) { DefaultBatchCount = options.DefaultBatchCount }; + + if (options.IncludeSpecial) + { + if (!string.IsNullOrEmpty(options.SpecialCharacters)) + password.IncludeSpecial(options.SpecialCharacters!); + else + password.IncludeSpecial(); + } + + if (options.ExcludeAmbiguous) + password.ExcludeAmbiguous(); + + return password; + } + } +} diff --git a/PasswordGenerator/PasswordGeneratorSettings.cs b/PasswordGenerator/PasswordGeneratorSettings.cs deleted file mode 100644 index 67dcaa9..0000000 --- a/PasswordGenerator/PasswordGeneratorSettings.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace PasswordGenerator -{ - public class PasswordGeneratorSettings : PasswordSettings - { - public PasswordGeneratorSettings(bool includeLowercase, bool includeUppercase, bool includeNumeric, - bool includeSpecial, int passwordLength, int maximumAttempts, bool usingDefaults) - : base(includeLowercase, includeUppercase, includeNumeric, - includeSpecial, passwordLength, maximumAttempts, usingDefaults) - { - } - } -} diff --git a/PasswordGenerator/PasswordOptions.cs b/PasswordGenerator/PasswordOptions.cs new file mode 100644 index 0000000..b9eb066 --- /dev/null +++ b/PasswordGenerator/PasswordOptions.cs @@ -0,0 +1,25 @@ +namespace PasswordGenerator +{ + /// + /// Configuration for a password generator, used when registering via dependency injection or + /// binding from configuration (e.g. appSettings.json). + /// + public class PasswordOptions + { + public bool IncludeLowercase { get; set; } = true; + public bool IncludeUppercase { get; set; } = true; + public bool IncludeNumeric { get; set; } = true; + public bool IncludeSpecial { get; set; } = true; + + /// Custom special characters. When null/empty the library default special set is used. + public string? SpecialCharacters { get; set; } + + public int Length { get; set; } = 16; + + /// Removes look-alike characters from the pool when true. + public bool ExcludeAmbiguous { get; set; } + + /// The number of passwords produced by the parameterless Generate() overload. + public int DefaultBatchCount { get; set; } = 1; + } +} diff --git a/PasswordGenerator/PasswordSettings.cs b/PasswordGenerator/PasswordSettings.cs index 9af29ab..26b6431 100644 --- a/PasswordGenerator/PasswordSettings.cs +++ b/PasswordGenerator/PasswordSettings.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Text; namespace PasswordGenerator @@ -7,9 +9,9 @@ namespace PasswordGenerator /// public class PasswordSettings : IPasswordSettings { - private const string LowercaseCharacters = "abcdefghijklmnopqrstuvwxyz"; - private const string UppercaseCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - private const string NumericCharacters = "0123456789"; + public const string LowercaseCharacters = "abcdefghijklmnopqrstuvwxyz"; + public const string UppercaseCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + public const string NumericCharacters = "0123456789"; private const string DefaultSpecialCharacters = @"!#$%&*@\"; private const int DefaultMinPasswordLength = 4; private const int DefaultMaxPasswordLength = 256; @@ -32,6 +34,7 @@ public PasswordSettings(bool includeLowercase, bool includeUppercase, bool inclu } private bool UsingDefaults { get; set; } + private readonly Dictionary _minimumCounts = new Dictionary(); public bool IncludeLowercase { get; private set; } public bool IncludeUppercase { get; private set; } @@ -39,10 +42,26 @@ public PasswordSettings(bool includeLowercase, bool includeUppercase, bool inclu public bool IncludeSpecial { get; private set; } public int PasswordLength { get; set; } public string CharacterSet { get; private set; } + public bool IsCustomPool { get; private set; } + public bool ExcludeAmbiguous { get; private set; } + public IReadOnlyDictionary MinimumCounts => _minimumCounts; public int MaximumAttempts { get; } public int MinimumLength { get; } public int MaximumLength { get; } + public IReadOnlyList CharacterGroups + { + get + { + var groups = new List(); + if (IncludeLowercase) groups.Add(LowercaseCharacters); + if (IncludeUppercase) groups.Add(UppercaseCharacters); + if (IncludeNumeric) groups.Add(NumericCharacters); + if (IncludeSpecial && !string.IsNullOrEmpty(SpecialCharacters)) groups.Add(SpecialCharacters); + return groups; + } + } + public IPasswordSettings AddLowercase() { StopUsingDefaults(); @@ -85,6 +104,62 @@ public IPasswordSettings AddSpecial(string specialCharactersToAdd) return this; } + public IPasswordSettings UseCharacters(string characters) + { + if (characters == null) throw new ArgumentNullException(nameof(characters)); + + StopUsingDefaults(); + IncludeLowercase = false; + IncludeUppercase = false; + IncludeNumeric = false; + IncludeSpecial = false; + _minimumCounts.Clear(); + IsCustomPool = true; + CharacterSet = characters; + return this; + } + + public IPasswordSettings UseAllAscii() + { + return UseCharacters(CharacterFilter.AllPrintableAscii); + } + + public IPasswordSettings ExcludeAmbiguousCharacters() + { + ExcludeAmbiguous = true; + return this; + } + + public IPasswordSettings RequireAtLeast(CharacterClass characterClass, int count) + { + if (count < 0) throw new ArgumentOutOfRangeException(nameof(count), "count cannot be negative."); + + if (IsCustomPool) + throw new InvalidOperationException( + "Per-class minimums cannot be combined with a custom character pool."); + + // Requiring a class implies it is part of the pool. + if (count > 0) + switch (characterClass) + { + case CharacterClass.Lowercase: + if (!IncludeLowercase) AddLowercase(); + break; + case CharacterClass.Uppercase: + if (!IncludeUppercase) AddUppercase(); + break; + case CharacterClass.Numeric: + if (!IncludeNumeric) AddNumeric(); + break; + case CharacterClass.Special: + if (!IncludeSpecial) AddSpecial(); + break; + } + + _minimumCounts[characterClass] = count; + return this; + } + private string BuildCharacterSet(bool includeLowercase, bool includeUppercase, bool includeNumeric, bool includeSpecial) { diff --git a/PasswordGenerator/PoolEntropyEstimator.cs b/PasswordGenerator/PoolEntropyEstimator.cs new file mode 100644 index 0000000..0bd8753 --- /dev/null +++ b/PasswordGenerator/PoolEntropyEstimator.cs @@ -0,0 +1,21 @@ +using System; + +namespace PasswordGenerator +{ + /// + /// Default : entropy = length * log2(effective pool size). + /// + public class PoolEntropyEstimator : IEntropyEstimator + { + public double EstimateBits(IPasswordSettings settings) + { + if (settings == null) throw new ArgumentNullException(nameof(settings)); + + var pool = CharacterFilter.RemoveAmbiguous(settings.CharacterSet, settings.ExcludeAmbiguous); + if (string.IsNullOrEmpty(pool) || settings.PasswordLength <= 0) + return 0d; + + return settings.PasswordLength * Math.Log(pool.Length, 2); + } + } +} diff --git a/PasswordGenerator/WordList.cs b/PasswordGenerator/WordList.cs new file mode 100644 index 0000000..1d03b3f --- /dev/null +++ b/PasswordGenerator/WordList.cs @@ -0,0 +1,53 @@ +namespace PasswordGenerator +{ + /// + /// A small built-in list of common, readable words used by . + /// This is intentionally compact (not a full diceware list); each word contributes + /// log2(.Length) bits of entropy. + /// + internal static class WordList + { + public static readonly string[] Words = + { + "able", "acid", "acorn", "actor", "agile", "alarm", "album", "alert", "alley", "amber", + "amend", "angle", "ankle", "apple", "april", "arena", "armor", "arrow", "aside", "asset", + "atlas", "audio", "aunt", "avoid", "awake", "award", "bacon", "badge", "baker", "banjo", + "barge", "basil", "basin", "beach", "beard", "beast", "begin", "berry", "birch", "bison", + "blade", "blaze", "blend", "blink", "block", "bloom", "board", "boost", "booth", "brace", + "brain", "brand", "brave", "bread", "brick", "brief", "broad", "brook", "brush", "buddy", + "bunch", "cabin", "cable", "cacao", "camel", "candy", "canoe", "cargo", "carol", "carve", + "catch", "cedar", "chalk", "charm", "chase", "cheek", "chess", "chief", "chili", "chime", + "civic", "claim", "clamp", "clay", "clean", "clear", "cliff", "climb", "clock", "cloud", + "clove", "coast", "cobra", "cocoa", "comet", "coral", "couch", "cover", "crane", "crate", + "crisp", "crown", "crumb", "curve", "daisy", "dance", "delta", "diner", "ditch", "diver", + "dodge", "donor", "draft", "drama", "dream", "dress", "drift", "drink", "drive", "eagle", + "early", "earth", "ember", "emery", "enjoy", "equal", "ethos", "every", "fable", "fancy", + "feast", "fiber", "field", "final", "flame", "flank", "flash", "fleet", "flint", "float", + "flock", "flora", "flute", "focus", "forge", "frame", "frost", "fruit", "gauge", "ghost", + "giant", "glade", "glass", "glide", "globe", "glory", "grace", "grain", "grand", "grape", + "grasp", "grass", "green", "grove", "guide", "habit", "happy", "harbor", "haven", "hazel", + "heart", "hedge", "honey", "horse", "hotel", "house", "human", "humor", "ideal", "image", + "index", "inlet", "ivory", "jelly", "jewel", "jolly", "joust", "judge", "juice", "karma", + "kayak", "ketch", "kitten", "knack", "knife", "koala", "label", "lance", "laser", "latch", + "layer", "leaf", "ledge", "lemon", "lever", "light", "lilac", "linen", "llama", "lodge", + "lotus", "lunar", "lyric", "magic", "maize", "mango", "maple", "march", "marsh", "match", + "medal", "melon", "mercy", "metro", "manor", "mocha", "model", "money", "month", "motor", + "mound", "mount", "mouse", "music", "noble", "north", "novel", "ocean", "olive", "onion", + "opera", "orbit", "otter", "owl", "paint", "panda", "paper", "party", "pasta", "patch", + "peach", "pearl", "pedal", "perch", "piano", "pilot", "pixel", "pizza", "plank", "plant", + "plaza", "plume", "polar", "porch", "prism", "prize", "proud", "pulse", "punch", "quail", + "quartz", "queen", "quest", "quiet", "quill", "quilt", "radar", "rapid", "raven", "reach", + "realm", "rebel", "relay", "rhino", "ridge", "river", "roast", "robin", "rocky", "rover", + "royal", "ruby", "salad", "salsa", "sandy", "scarf", "scout", "shade", "shard", "shark", + "sheep", "shelf", "shell", "shine", "shore", "siren", "skate", "skiff", "slate", "sleek", + "slope", "smile", "smoke", "snail", "solar", "sonic", "spade", "spark", "spice", "spine", + "spire", "spoon", "sport", "spray", "spruce", "stack", "stage", "stalk", "stamp", "stark", + "steam", "steel", "stork", "storm", "story", "stove", "straw", "strip", "sugar", "swift", + "table", "tango", "teal", "thorn", "tidal", "tiger", "toast", "topaz", "torch", "totem", + "tower", "trail", "treat", "trend", "tribe", "trout", "tulip", "tundra", "ultra", "umber", + "unity", "urban", "valley", "value", "vapor", "vault", "venus", "vigor", "villa", "vinyl", + "viola", "vivid", "vocal", "wafer", "wagon", "waltz", "water", "wheat", "whale", "wharf", + "wheel", "whisk", "willow", "windy", "woven", "yacht", "yearn", "yield", "zebra", "zesty" + }; + } +} diff --git a/Readme.md b/Readme.md index 3c97912..9cbd20d 100644 --- a/Readme.md +++ b/Readme.md @@ -2,7 +2,9 @@ ![Password Logo](https://github.com/prjseal/PasswordGenerator/blob/dev/v2/passwordgeneratorlogo.png "Password Logo") -A .NET Standard library which generates random passwords with different settings to meet the OWASP requirements +A cross-platform .NET library that generates cryptographically secure random passwords, passphrases, +OTPs, API keys and readable identifiers. Configure it with a fluent API, ready-made presets +(OWASP/NIST) or dependency injection — with async support and entropy estimation. ## NuGet @@ -12,73 +14,142 @@ Install via NuGet: ``` Install-Package PasswordGenerator ``` [Or click here to go to the package landing page](https://www.nuget.org/packages/PasswordGenerator) -It is Compatible with .NET Core, .NET Framework and more. See the below chart: +It targets `netstandard2.0` and `net8.0`, so it runs on .NET Framework, .NET Core and modern .NET. +See the chart below: ![Compatibility Chart](https://github.com/prjseal/PasswordGenerator/blob/master/compatibility.png "Compatibility Chart") +> **Upgrading from 2.x?** See the [v2 → v3 migration guide](docs/v3-target/migration-v2-to-v3.md). +> The v2 API still works; the one behavioural change is that invalid settings now **throw** (or use +> `TryNext`) instead of returning an error string as the "password". ## Basic usage -See examples below or try them out now in your browser using [Dotnetfiddle](https://dotnetfiddle.net/Q0hMlU) - -```javascript -// By default, all characters available for use and a length of 16 -// Will return a random password with the default settings +```csharp +// By default, all character types are available and the length is 16. +// Returns a random password with the default settings. var pwd = new Password(); var password = pwd.Next(); ``` -```javascript -// Same as above but you can set the length. Must be between 8 and 128 -// Will return a password which is 32 characters long +```csharp +// Set the length. Must be between 4 and 256. +// Returns a password that is 32 characters long. var pwd = new Password(32); var password = pwd.Next(); ``` -```javascript -// Same as above but you can set the length. Must be between 8 and 128 -// Will return a password which only contains lowercase and uppercase characters and is 21 characters long. +```csharp +// Choose which character types to include. +// Returns a 21-character password of lowercase and uppercase letters only. var pwd = new Password(includeLowercase: true, includeUppercase: true, includeNumeric: false, includeSpecial: false, passwordLength: 21); var password = pwd.Next(); ``` ## Fluent usage -```javascript -// You can build up your reqirements by adding things to the end, like .IncludeNumeric() -// This will return a password which is just numbers and has a default length of 16 +```csharp +// Build up your requirements by chaining, e.g. .IncludeNumeric() +// Returns a numbers-only password with the default length of 16. var pwd = new Password().IncludeNumeric(); var password = pwd.Next(); ``` -```javascript -// As above, here is how to get lower, upper and special characters using this approach +```csharp +// Combine lower, upper and special characters the same way. var pwd = new Password().IncludeLowercase().IncludeUppercase().IncludeSpecial(); var password = pwd.Next(); ``` -```javascript -// This is the same as the above, but with a length of 128 +```csharp +// As above, but with a length of 128. var pwd = new Password(128).IncludeLowercase().IncludeUppercase().IncludeSpecial(); var password = pwd.Next(); ``` -```javascript -// This is the same as the above, but with passes the length in using the method LengthRequired() +```csharp +// As above, but passing the length via LengthRequired(). var pwd = new Password().IncludeLowercase().IncludeUppercase().IncludeSpecial().LengthRequired(128); var password = pwd.Next(); ``` -```javascript -// One Time Passwords -// If you want to return a 4 digit number you can use this: -var pwd = new Password(4).IncludeNumeric(); +```csharp +// Specify your own special characters. +var pwd = new Password().IncludeLowercase().IncludeUppercase().IncludeNumeric().IncludeSpecial("[]{}^_="); var password = pwd.Next(); ``` -```javascript -// Specify your own special characters -// You can now specify your own special characters -var pwd = new Password().IncludeLowercase().IncludeUppercase().IncludeNumeric().IncludeSpecial("[]{}^_="); -var password = pwd.Next(); +## Presets + +Ready-made starting points; later fluent calls still override them. See the +[standards mapping](docs/v3-target/migration-v2-to-v3.md#6-standards-mapping-for-the-presets) for the +OWASP/NIST rationale. + +```csharp +string strong = Password.ForOwasp().Next(); // full printable-ASCII pool, length 16 +string nist = Password.ForNist().Next(); // NIST-aligned, length 12 +string otp = Password.ForOtp(6).Next(); // 6-digit one-time code +string apiKey = Password.ForApiKey(32).Next(); // URL-safe token +string envName = Password.ForEnvironmentName(12).Next();// readable id, no look-alike characters +string phrase = Password.ForPassphrase(4).Next(); // e.g. "maple-river-quartz-bloom-42" +``` + +## Quality controls + +```csharp +// Remove look-alike characters (I l 1 O 0 o) +var readable = new Password(20).ExcludeAmbiguous().Next(); + +// Guarantee at least N characters from a class +var pwd = new Password(16).RequireAtLeast(CharacterClass.Numeric, 2).Next(); + +// Use a custom pool, or every printable ASCII character +var custom = new Password().WithCharacters("ABCDEF0123456789").LengthRequired(24).Next(); +var ascii = new Password().WithAllAscii().LengthRequired(40).Next(); + +// Estimate strength in bits +double bits = new Password(20).EstimateEntropyBits(); ``` + +## Error handling + +```csharp +// Next() throws ArgumentException when the settings can't produce a valid password. +var password = new Password(16).Next(); + +// TryNext() never throws; it returns false on invalid settings. +if (new Password(16).TryNext(out var result)) + Console.WriteLine(result); +``` + +## Async and batches + +```csharp +string password = await pwd.NextAsync(cancellationToken); +IReadOnlyList ten = pwd.Generate(10); +IReadOnlyList ten2 = await pwd.GenerateAsync(10, cancellationToken); +``` + +## Dependency injection + +```csharp +// Register once (optionally bind from appSettings.json) +services.AddPasswordGenerator(o => +{ + o.Length = 20; + o.IncludeSpecial = true; + o.ExcludeAmbiguous = true; +}); + +// Inject IPasswordGenerator wherever you need it +public class SignupService(IPasswordGenerator generator) +{ + public string NewTempPassword() => generator.Next(); +} +``` + +## Documentation + +- [v2 → v3 migration guide](docs/v3-target/migration-v2-to-v3.md) +- [Changelog](CHANGELOG.md) +- [Design & architecture docs](docs/README.md) diff --git a/appveyor.yml b/appveyor.yml index a692d66..43c8a6e 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,5 +1,5 @@ -version: 2.1.{build} -image: Visual Studio 2019 +version: 3.0.{build} +image: Visual Studio 2022 before_build: - cmd: dotnet restore PasswordGenerator.sln build_script: @@ -11,4 +11,6 @@ after_build: artifacts: - path: artifacts/*.nupkg name: NuGet + - path: artifacts/*.snupkg + name: NuGetSymbols deploy: off diff --git a/docs/README.md b/docs/README.md index 92947ea..04cca96 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ flowchart LR T5[before-after.md] T6[roadmap.md] T7[implementation-plan.md] + T8[migration-v2-to-v3.md] end A --> B --> Current Current --> Target @@ -36,9 +37,10 @@ flowchart LR 1. **`V3_REVIEW_AND_DOCUMENTATION.md`** — the original full review (API, bugs, packaging, gaps). 2. **`V3_VERIFICATION.md`** — each issue re-checked against the current `master` source, with verdicts. -3. **`current-state/`** — diagrammed snapshot of the code as it runs today. -4. **`v3-target/`** — the proposed v3 design, diagrammed, with a before/after, a roadmap, and a - phased **`implementation-plan.md`** (starts with installing the .NET SDK via bash). +3. **`current-state/`** — diagrammed snapshot of the v2.1.0 code (now **historical**; the issues it + documents are resolved in v3 — see the root [`CHANGELOG.md`](../CHANGELOG.md)). +4. **`v3-target/`** — the v3 design, diagrammed, with a before/after, a roadmap, a phased + **`implementation-plan.md`**, and the user-facing **`migration-v2-to-v3.md`**. ## Conventions diff --git a/docs/current-state/api-surface.md b/docs/current-state/api-surface.md index 55672e7..7f0f6b5 100644 --- a/docs/current-state/api-surface.md +++ b/docs/current-state/api-surface.md @@ -1,5 +1,8 @@ # Current State — Public API Surface (v2.1.0) +> **Historical.** This describes v2.1.0. The issues noted here are resolved in v3 — see the root +> [`CHANGELOG.md`](../../CHANGELOG.md) and the [migration guide](../v3-target/migration-v2-to-v3.md). + What a caller can do today, and how configuration is resolved. ## API map diff --git a/docs/current-state/architecture.md b/docs/current-state/architecture.md index c8304bd..b903995 100644 --- a/docs/current-state/architecture.md +++ b/docs/current-state/architecture.md @@ -1,5 +1,8 @@ # Current State — Architecture (v2.1.0) +> **Historical.** This describes v2.1.0. The issues noted here are resolved in v3 — see the root +> [`CHANGELOG.md`](../../CHANGELOG.md) and the [migration guide](../v3-target/migration-v2-to-v3.md). + `master` @ v2.1.0 · target `netstandard2.0` · no third-party runtime dependencies. ## Type relationships diff --git a/docs/current-state/generation-flow.md b/docs/current-state/generation-flow.md index e8ad7d2..7f2afa0 100644 --- a/docs/current-state/generation-flow.md +++ b/docs/current-state/generation-flow.md @@ -1,5 +1,8 @@ # Current State — Generation Flow (v2.1.0) +> **Historical.** This describes v2.1.0. The issues noted here are resolved in v3 — see the root +> [`CHANGELOG.md`](../../CHANGELOG.md) and the [migration guide](../v3-target/migration-v2-to-v3.md). + How `Next()` produces a password today (`Password.cs:114-193`). ## `Next()` control flow diff --git a/docs/v3-target/implementation-plan.md b/docs/v3-target/implementation-plan.md index 4180170..efa2f98 100644 --- a/docs/v3-target/implementation-plan.md +++ b/docs/v3-target/implementation-plan.md @@ -167,24 +167,36 @@ plus tests. --- -## Phase 3 — API: async, DI, builder split (Tier 2b) +## Phase 3 — API: async, DI, remove v2 wrappers (Tier 2b) -**Objective:** the modern surface from `api-surface.md` with a gentle deprecation path. +**Objective:** the modern generation surface from `api-surface.md`, additively (no churn for existing +callers). + +**Decisions taken during implementation** (differ from the earlier draft): +- **Async is added but sync is NOT marked `[Obsolete]`.** Generation is CPU-bound, so obsoleting sync + in favour of async would be an anti-pattern and would spam every consumer with build warnings. + Async methods exist for ergonomics/cancellation only. +- **DI lives in the core package** (chosen over a separate `PasswordGenerator.DependencyInjection` + package), adding `Microsoft.Extensions.DependencyInjection.Abstractions` and + `Microsoft.Extensions.Configuration.Binder` dependencies. +- **The full `IPasswordBuilder` split is deferred.** The existing `IPassword` remains the fluent + builder; `IPasswordGenerator` is added as the generation contract and is what DI hands out. **Tasks** -1. Introduce `IPasswordGenerator` (`Next`/`TryNext`/`NextAsync`/`Generate`/`GenerateAsync`) and - `IPasswordBuilder`; keep the fluent feel. -2. Add **async** methods; mark sync `Next()`/`Generate()` `[Obsolete]` pointing to async equivalents. +1. Introduce `IPasswordGenerator` (`Next`/`TryNext`/`NextAsync`/`Generate`/`GenerateAsync`); + `Password` implements it alongside `IPassword`. +2. Add **async** methods (`NextAsync`/`GenerateAsync`) that honour `CancellationToken`; keep sync fully + supported. 3. **DI**: `AddPasswordGenerator(Action)` **and** - `AddPasswordGenerator(IConfiguration section)` (opt-in; wires `IRandomSource`). Ensure `new` vs DI - produce identical results. -4. **Remove the `[Obsolete] PasswordGenerator` / `PasswordGeneratorSettings` wrappers** - (recommended in `../V3_VERIFICATION.md` §4) — clears the 5 `CS0108` warnings. + `AddPasswordGenerator(IConfiguration section)` (opt-in; wires `IRandomSource`). `new` vs DI produce + identical results. +4. **Remove the `[Obsolete] PasswordGenerator` / `PasswordGeneratorSettings` wrappers** (and their + tests) — clears the 5 `CS0108` warnings. **Verification / exit criteria** -- Build has **zero `CS0108`**; DI sample app resolves and generates. -- **Tests green:** new tests cover async, `TryNext`, and DI-resolved equivalence, and `dotnet test` - returns 0 across all target frameworks. +- Build has **zero `CS0108`** (and zero warnings overall); DI resolves and generates. +- **Tests green:** new tests cover async, cancellation, batch `Generate`, and DI-resolved equivalence; + `dotnet test` returns 0. - **Commit & push** this phase, e.g. `feat: async API, DI registration, remove obsolete v2 wrappers`. **Closes:** §8 async/DI; removes the obsolete-wrapper warnings. @@ -195,16 +207,26 @@ plus tests. **Objective:** the capability set that makes v3 worth the major bump. +**Decisions taken during implementation:** +- **`ForPassphrase` uses a small built-in word list** (`WordList`, ~280 common words), not a full + EFF/diceware list — avoids bundling ~70KB and an external attribution. Entropy is reported honestly + by `PassphraseGenerator.EstimateEntropyBits()`. +- **Batch API is `Generate(count)` plus a parameterless `Generate()`** that uses a configurable + `DefaultBatchCount` (bindable from appSettings). The `.Count(n)` fluent-chaining shape from the + design doc was **not** added (no new return type); optional batch uniqueness was not implemented. +- The existing fluent `IPassword` remains the builder (no separate `IPasswordBuilder`); the new + methods/presets hang off it. Passphrases return an `IPasswordGenerator` (they have no char classes). + **Tasks** 1. **Custom pools:** `WithCharacters(string)` and `WithAllAscii()`; keep `Include*`. 2. **Presets:** `ForOwasp`, `ForNist`, `ForOtp`, `ForPassphrase`, `ForApiKey`, `ForEnvironmentName` - (sugar over `PasswordOptions`; later fluent calls still override). -3. **`appSettings` configuration** with resolution order **fluent > appSettings > default** - (opt-in, separate step). -4. **`Generate()` batch API:** count overloads, `.Count(n)` chaining, `appSettings` default; optional - uniqueness. *(closes §5.10)* + (static factories; later fluent calls still override). +3. **`appSettings` configuration** with resolution order **code-configure > appSettings > default**, + realised by the `AddPasswordGenerator(IConfiguration, Action)` overload. +4. **`Generate()` batch API:** `Generate(count)` + parameterless `Generate()` using `DefaultBatchCount` + from appSettings. *(closes §5.10)* 5. **Quality options:** `ExcludeAmbiguous()`, `RequireAtLeast(class, count)`, and an - `IEntropyEstimator` returning strength in bits. + `IEntropyEstimator` (`PoolEntropyEstimator`) returning strength in bits. **Verification / exit criteria** - Preset outputs match documented standards. @@ -220,6 +242,15 @@ plus tests. **Objective:** a clean, modern NuGet package and a disciplined release. +**Decisions taken during implementation:** +- The stale `PasswordGenerator.nuspec` was **deleted** (not regenerated) — SDK-style `dotnet pack` + derives the nuspec from the csproj, which is now the single source of version truth (`Version`, + `AssemblyVersion`, `FileVersion` only; the duplicate `PackageVersion` was removed). +- README is the repo root `Readme.md`, packed to the package root as `README.md`. +- **SourceLink emits one warning in the web sandbox only** ("Source control information is not + available") because the sandbox clone's `origin` is a local HTTP proxy, not `github.com`. Packing + against a `github.com` remote is fully warning-free, so the config is correct for real CI. + **Tasks** 1. Delete or regenerate the stale `PasswordGenerator.nuspec` (2.0.5); single source of version truth in the csproj, bumped to **3.0.0**. @@ -244,6 +275,16 @@ plus tests. **Objective:** make the upgrade obvious and the broader use cases discoverable. +**Decisions taken during implementation:** +- The migration guide drops the "`[Obsolete]` still working" framing: the **entire v2 surface is + intact** (no members were obsoleted), so the only behavioural change to flag is error-string → + exception/`TryNext`. Async/DI/presets are presented as **opt-in additions**. +- Standards mapping and the "beyond passwords" use cases live in + [`migration-v2-to-v3.md`](migration-v2-to-v3.md); the root `Readme.md` links to them. +- A root [`CHANGELOG.md`](../../CHANGELOG.md) captures the v3 changes; `current-state/` docs get a + "historical / resolved in v3" banner rather than being deleted. +- Readme/migration snippets are backed by `DocumentationSnippetTests` so docs can't drift from the API. + **Tasks** 1. **v2→v3 migration guide:** direct→DI, sync→async (with `[Obsolete]` still working), error-string→exception/`TryNext`, preset/appSettings adoption — before/after snippets. diff --git a/docs/v3-target/migration-v2-to-v3.md b/docs/v3-target/migration-v2-to-v3.md new file mode 100644 index 0000000..67816c7 --- /dev/null +++ b/docs/v3-target/migration-v2-to-v3.md @@ -0,0 +1,151 @@ +# Migrating from v2.x to v3.0 + +v3 is a major release, but the **v2 public surface still compiles and runs** — constructors, +`IncludeLowercase()/…`, `LengthRequired()`, `Next()` and `NextGroup()` are all intact. Most projects +upgrade by just bumping the package. The sections below cover the one behavioural change you must know +about, plus the new capabilities you can adopt at your own pace. + +> **Length range:** valid password lengths are **4–256** characters (the old "8–128" Readme claim was +> never the actual limit). + +--- + +## 1. The one breaking change: error strings → exceptions / `TryNext` + +In v2, invalid settings caused `Next()` to **return an error message as if it were a password** (e.g. +`"Try again"` or a "Password length invalid…" string). In v3 `Next()` **throws**, and a non-throwing +`TryNext` is provided. + +```csharp +// v2 — the "password" might actually be an error string +var pwd = new Password(passwordLength: 2); // below the minimum +var password = pwd.Next(); // returns "Password length invalid. Must be between …" + +// v3 — fail loudly… +var password = new Password(2).Next(); // throws ArgumentException + +// …or fail softly +if (new Password(2).TryNext(out var password)) + Use(password); +else + // settings were invalid; password is null +``` + +**Action:** if you relied on the returned string to detect failure, switch to `TryNext` or wrap +`Next()` in a `try/catch (ArgumentException)`. + +--- + +## 2. Direct construction → dependency injection (optional) + +Direct `new Password(...)` still works. If you use `Microsoft.Extensions.DependencyInjection`, you can +now register the generator instead. + +```csharp +// v2 / still valid in v3 +var pwd = new Password().IncludeLowercase().IncludeUppercase().IncludeNumeric(); +var password = pwd.Next(); + +// v3 — register once… +services.AddPasswordGenerator(o => +{ + o.Length = 20; + o.IncludeSpecial = true; +}); + +// …then inject IPasswordGenerator anywhere +public class SignupService(IPasswordGenerator generator) +{ + public string NewTempPassword() => generator.Next(); +} +``` + +Bind from `appSettings.json`, with **code overrides taking precedence over configuration**: + +```csharp +// resolution order: code-configure > appSettings > default +services.AddPasswordGenerator(configuration.GetSection("PasswordGenerator"), o => o.Length = 24); +``` + +```json +{ + "PasswordGenerator": { + "Length": 16, + "IncludeSpecial": true, + "ExcludeAmbiguous": true, + "DefaultBatchCount": 1 + } +} +``` + +--- + +## 3. Synchronous → async (optional) + +Sync methods are unchanged. v3 adds `async` overloads for call sites that want them (they complete +synchronously but honour cancellation): + +```csharp +var password = await generator.NextAsync(cancellationToken); +var passwords = await generator.GenerateAsync(count: 10, cancellationToken); +``` + +--- + +## 4. Batch generation + +```csharp +// v2 — still works +IEnumerable many = new Password().NextGroup(10); + +// v3 — explicit count… +IReadOnlyList ten = generator.Generate(10); + +// …or the parameterless overload, driven by PasswordOptions.DefaultBatchCount +IReadOnlyList defaultBatch = generator.Generate(); +``` + +> Batch results are **not de-duplicated** — collisions are astronomically unlikely at realistic +> lengths, and forcing uniqueness would bias the distribution. + +--- + +## 5. New capabilities to adopt + +| Need | v3 API | +|---|---| +| Custom character pool | `new Password().WithCharacters("ABC123")` | +| Every printable ASCII char | `new Password().WithAllAscii()` | +| Drop look-alikes (`I l 1 O 0 o`) | `…ExcludeAmbiguous()` | +| Guarantee N of a class | `…RequireAtLeast(CharacterClass.Numeric, 2)` | +| Strength estimate (bits) | `new Password(20).EstimateEntropyBits()` | +| Presets | `Password.ForOwasp()`, `ForNist()`, `ForOtp()`, `ForApiKey()`, `ForEnvironmentName()`, `ForPassphrase()` | + +--- + +## 6. Standards mapping for the presets + +The presets are convenience starting points; later fluent calls still override them. + +| Preset | Intent | Reference | +|---|---|---| +| `Password.ForOwasp(length = 16)` | Long secret over the full printable-ASCII pool, no forced composition rules. | [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#implement-proper-password-strength-controls) | +| `Password.ForNist(length = 12)` | Length-first, no composition-rule penalties, broad character support. | [NIST SP 800-63B §5.1.1](https://pages.nist.gov/800-63-3/sp800-63b.html#memsecret) | +| `Password.ForPassphrase(words = 4)` | Diceware-style multi-word secret (memorable, high entropy per length). | [NIST SP 800-63B (memorized secrets)](https://pages.nist.gov/800-63-3/sp800-63b.html#memsecret) | + +> OWASP and NIST both **discourage composition rules** (forcing symbol/number mixes) in favour of +> length and screening, which is why `ForOwasp`/`ForNist` use the full pool without per-class minimums. +> When a downstream system *requires* composition, layer it on explicitly with `RequireAtLeast`. + +--- + +## 7. Beyond passwords — what else v3 generates + +PasswordGenerator is a general cryptographically-secure secret generator: + +```csharp +string otp = Password.ForOtp(6).Next(); // "418207" +string apiKey = Password.ForApiKey(32).Next(); // URL-safe token +string envName = Password.ForEnvironmentName(12).Next(); // readable, no look-alikes +string phrase = Password.ForPassphrase(4).Next(); // "maple-river-quartz-bloom-42" +``` From d7df5dd3312400b9068dff41a7e657306b33ab19 Mon Sep 17 00:00:00 2001 From: Paul Seal Date: Sun, 24 May 2026 21:40:05 +0100 Subject: [PATCH 09/14] Add v3 package documentation and code review (#32) * docs: reconcile v3-target design docs with shipped v3 API The v3-target docs were written as a pre-implementation proposal and described API that diverged from what shipped (IPasswordBuilder/Build(), Generate().Count(n), [Obsolete] sync methods, IRandomSource.Fill, net10.0, and a "Special" appSettings key). Update them to match the released v3.0.0 surface: IPassword as the fluent builder, static Password presets, parameterless Generate(), sync kept and not obsoleted, and the correct "SpecialCharacters" option key. Also drop the "not yet implemented" framing now that v3 has shipped. https://claude.ai/code/session_01FshNNNTHNHupkfMJVLmotu * ci: migrate from AppVeyor to GitHub Actions Add a combined CI workflow (build + test + pack on push to master and on every pull request, uploading test results and the .nupkg/.snupkg) and a Release workflow that publishes to NuGet.org when a GitHub Release is published. The release tag drives the published package version, and the .snupkg symbol package is pushed alongside the main package. Remove the AppVeyor configuration it replaces. Requires a NUGET_API_KEY repository secret for the publish step. https://claude.ai/code/session_01FshNNNTHNHupkfMJVLmotu --------- Co-authored-by: Claude --- .github/workflows/ci.yml | 54 ++++++++++++++++++ .github/workflows/release.yml | 46 +++++++++++++++ .gitignore | 3 + appveyor.yml | 16 ------ docs/README.md | 16 +++--- docs/v3-target/api-surface.md | 41 ++++++++----- docs/v3-target/architecture.md | 79 ++++++++++++++------------ docs/v3-target/before-after.md | 4 +- docs/v3-target/configuration-and-di.md | 7 ++- docs/v3-target/generation-flow.md | 4 +- docs/v3-target/implementation-plan.md | 2 +- docs/v3-target/roadmap.md | 8 +-- 12 files changed, 195 insertions(+), 85 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml delete mode 100644 appveyor.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fe64105 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history so SourceLink can resolve the commit + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore PasswordGenerator.sln + + - name: Build + run: dotnet build PasswordGenerator.sln -c Release --no-restore + + - name: Test + run: > + dotnet test PasswordGenerator.Tests/PasswordGenerator.Tests.csproj + -c Release --no-build + --logger "trx;LogFileName=test-results.trx" + --results-directory ./test-results + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: ./test-results/*.trx + + - name: Pack + run: dotnet pack PasswordGenerator/PasswordGenerator.csproj -c Release --no-build -o artifacts + + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: nuget + path: | + artifacts/*.nupkg + artifacts/*.snupkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b6fc743 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,46 @@ +name: Release + +on: + release: + types: [ published ] + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history so SourceLink can resolve the commit + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + # The release tag drives the published package version (e.g. tag "v3.0.1" -> 3.0.1). + - name: Derive version from release tag + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Restore + run: dotnet restore PasswordGenerator.sln + + - name: Build + run: dotnet build PasswordGenerator.sln -c Release --no-restore -p:Version=${{ steps.version.outputs.version }} + + - name: Test + run: dotnet test PasswordGenerator.Tests/PasswordGenerator.Tests.csproj -c Release --no-build + + - name: Pack + run: dotnet pack PasswordGenerator/PasswordGenerator.csproj -c Release --no-build -o artifacts -p:Version=${{ steps.version.outputs.version }} + + - name: Push to NuGet.org + run: > + dotnet nuget push "artifacts/*.nupkg" + --api-key "${{ secrets.NUGET_API_KEY }}" + --source https://api.nuget.org/v3/index.json + --skip-duplicate diff --git a/.gitignore b/.gitignore index e3a2339..2c810e8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,8 @@ PasswordGenerator/bin/Debug/$RANDOM_SEED$ [Oo]bj/ /artifacts +# test output +/test-results + # BenchmarkDotNet output BenchmarkDotNet.Artifacts/ diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 43c8a6e..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 3.0.{build} -image: Visual Studio 2022 -before_build: - - cmd: dotnet restore PasswordGenerator.sln -build_script: - - cmd: dotnet build PasswordGenerator.sln -c Release --no-restore -test_script: - - cmd: dotnet test PasswordGenerator.Tests/PasswordGenerator.Tests.csproj -c Release --no-build --logger trx -after_build: - - cmd: dotnet pack PasswordGenerator/PasswordGenerator.csproj -c Release --no-build -o artifacts -artifacts: - - path: artifacts/*.nupkg - name: NuGet - - path: artifacts/*.snupkg - name: NuGetSymbols -deploy: off diff --git a/docs/README.md b/docs/README.md index 04cca96..04b8357 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,8 @@ # PasswordGenerator — Documentation -This folder is the working reference for the package as it is **today** and the design we are -steering it toward in **v3**. Diagrams are written in [Mermaid](https://mermaid.js.org/) and render -directly on GitHub. +This folder is the working reference for the package: the **shipped v3 design** and the historical +review of the v2.1.0 code it replaced. Diagrams are written in [Mermaid](https://mermaid.js.org/) and +render directly on GitHub. ## How the docs fit together @@ -39,13 +39,15 @@ flowchart LR 2. **`V3_VERIFICATION.md`** — each issue re-checked against the current `master` source, with verdicts. 3. **`current-state/`** — diagrammed snapshot of the v2.1.0 code (now **historical**; the issues it documents are resolved in v3 — see the root [`CHANGELOG.md`](../CHANGELOG.md)). -4. **`v3-target/`** — the v3 design, diagrammed, with a before/after, a roadmap, a phased - **`implementation-plan.md`**, and the user-facing **`migration-v2-to-v3.md`**. +4. **`v3-target/`** — the v3 design (now **shipped**), diagrammed, with a before/after, a roadmap, a + phased **`implementation-plan.md`**, and the user-facing **`migration-v2-to-v3.md`**. ## Conventions - **Current state** describes `master` @ v2.1.0, `netstandard2.0`. Code references use `file:line` against that source. -- **v3 target** is a proposal for discussion, not yet implemented. Anything in `v3-target/` is - subject to change as we agree the plan. +- **v3 target** describes the design that shipped in v3.0.0 (`netstandard2.0;net8.0`, nullable + enabled). The `implementation-plan.md` records where the shipped code intentionally diverged from + the earlier proposal (e.g. the `IPasswordBuilder` split was deferred and sync methods were not + obsoleted). - Each "target" doc ends with a **Why this is better** note tied back to a verified issue. diff --git a/docs/v3-target/api-surface.md b/docs/v3-target/api-surface.md index fc2beda..19e18fe 100644 --- a/docs/v3-target/api-surface.md +++ b/docs/v3-target/api-surface.md @@ -1,16 +1,22 @@ -# v3 Target — Public API Surface (proposal) +# v3 Target — Public API Surface Keeps the familiar fluent feel; adds safety, presets, batch, async, and custom pools. -## Target API map +> **As shipped:** the fluent builder is the existing `IPassword` (the full `IPasswordBuilder`/`Build()` +> split from the early proposal was deferred). `Password` implements both `IPassword` and the new +> generation contract `IPasswordGenerator`. Passphrases return an `IPasswordGenerator` +> (`PassphraseGenerator`). See `implementation-plan.md` for the deviations. + +## API map ```mermaid flowchart TD subgraph Entry["Entry points"] - e1["new PasswordBuilder()"] - e2["inject IPasswordGenerator (DI)"] + e1["new Password()"] + e2["Password.ForOwasp()/ForOtp()/... (static presets)"] + e3["inject IPasswordGenerator (DI)"] end - subgraph Build["Fluent builder (IPasswordBuilder)"] + subgraph Build["Fluent builder (IPassword)"] direction TB b1["IncludeLowercase/Uppercase/Numeric"] b2["IncludeSpecial(string)"] @@ -18,18 +24,17 @@ flowchart TD b4["ExcludeAmbiguous()"] b5["RequireAtLeast(class, count)"] b6["LengthRequired(int)"] - b7["Presets: ForOwasp/ForNist/ForOtp/
ForPassphrase/ForApiKey/ForEnvironmentName"] end subgraph Gen["Generation (IPasswordGenerator)"] g1["Next() : string (throws on bad config)"] g2["TryNext(out string) : bool"] g3["NextAsync(ct) : Task~string~"] - g4["Generate(count) / Generate().Count(n)"] - g5["GenerateAsync(count, ct)"] + g4["Generate() / Generate(count)"] + g5["GenerateAsync() / GenerateAsync(count, ct)"] end Entry --> Build --> Gen classDef good fill:#e6ffe6,stroke:#009900; - class g2,g3,g4,g5,b3,b4,b5,b7 good; + class g2,g3,g4,g5,b3,b4,b5 good; ``` ## Single vs batch (naming kept intentional) @@ -37,12 +42,14 @@ flowchart TD ```mermaid flowchart LR N["Next() — ONE password
(mirrors Random.Next())"] - G["Generate(count) — MANY
Generate().Count(10)
count from appSettings if unset"] + G["Generate(count) — MANY
Generate() — DefaultBatchCount
(bindable from appSettings)"] N -. same options .- G ``` `.Next()` is retained because the original API was modelled on `Random.Next()`. `.Generate()` is the -new batch-oriented entry with count overloads, `.Count(n)` chaining, and an `appSettings` default. +new batch-oriented entry: `Generate(count)` plus a parameterless `Generate()` that uses the +configurable `DefaultBatchCount` (bindable from appSettings). The `.Count(n)` chaining shape from the +early proposal was not added — there is no new return type. ## Presets → standards mapping @@ -56,8 +63,8 @@ flowchart LR ForEnvironmentName --> En["readable, memorable identifiers"] ``` -Presets are sugar over `PasswordOptions`; any subsequent fluent call still overrides them -(resolution order is documented in `configuration-and-di.md`). +Presets are static factory methods on `Password` (sugar over the fluent builder); any subsequent +fluent call still overrides them (resolution order is documented in `configuration-and-di.md`). ## Surfacing the broader purpose @@ -70,7 +77,7 @@ and other identifiers — so v3 deliberately keeps the per-class `Include*` meth ```mermaid flowchart TD Old["v2: new Password().Next() → string (maybe error)"] --> Mig["v3 migration"] - Mig --> A["sync Next()/Generate() kept but [Obsolete] → async"] + Mig --> A["sync Next()/Generate() kept and fully supported
(NOT obsoleted); async added alongside"] Mig --> B["error strings → exception / TryNext"] Mig --> C["direct new → optional IPasswordGenerator via DI"] Mig --> D["[Obsolete] PasswordGenerator/Settings REMOVED"] @@ -78,6 +85,10 @@ flowchart TD class D warn; ``` +> Sync methods are **not** marked `[Obsolete]`: generation is CPU-bound, so obsoleting sync in favour +> of async would be an anti-pattern and would spam every consumer with build warnings. Async exists +> for ergonomics and cancellation only. + **Why this is better:** every verified gap in `../current-state/api-surface.md` is closed (`TryNext`/async/DI/presets/appSettings/custom pools), failures become explicit, and existing single -`.Next()` users still work (with an obsolete-hint nudge), giving a gentle upgrade path. +`.Next()` users still work unchanged, giving a gentle upgrade path. diff --git a/docs/v3-target/architecture.md b/docs/v3-target/architecture.md index ae4cd6d..6345600 100644 --- a/docs/v3-target/architecture.md +++ b/docs/v3-target/architecture.md @@ -1,9 +1,11 @@ -# v3 Target — Architecture (proposal) +# v3 Target — Architecture -> Proposed design for discussion. Multi-target `netstandard2.0;net8.0` (optionally `net10.0`), -> nullable enabled. Aligns with the adjusted plan in `../V3_VERIFICATION.md` §3. +> Multi-target `netstandard2.0;net8.0`, nullable enabled. Aligns with the adjusted plan in +> `../V3_VERIFICATION.md` §3. As shipped, the existing `IPassword` remains the fluent builder (no +> separate `IPasswordBuilder`/`Build()`); `Password` implements both `IPassword` and the generation +> contract `IPasswordGenerator`. -## Target type relationships +## Type relationships ```mermaid classDiagram @@ -12,59 +14,66 @@ classDiagram +Next() string +TryNext(out string) bool +NextAsync(CancellationToken) Task + +Generate() IReadOnlyList +Generate(int count) IReadOnlyList + +GenerateAsync(CancellationToken) Task +GenerateAsync(int count, CancellationToken) Task } - class IPasswordBuilder { + class IPassword { <> - +IncludeLowercase() IPasswordBuilder - +IncludeUppercase() IPasswordBuilder - +IncludeNumeric() IPasswordBuilder - +IncludeSpecial(string) IPasswordBuilder - +WithAllAscii() IPasswordBuilder - +WithCharacters(string) IPasswordBuilder - +ExcludeAmbiguous() IPasswordBuilder - +RequireAtLeast(class, count) IPasswordBuilder - +LengthRequired(int) IPasswordBuilder - +ForOwasp() IPasswordBuilder - +ForOtp() IPasswordBuilder - +ForPassphrase() IPasswordBuilder - +Build() IPasswordGenerator + +IncludeLowercase() IPassword + +IncludeUppercase() IPassword + +IncludeNumeric() IPassword + +IncludeSpecial(string) IPassword + +WithAllAscii() IPassword + +WithCharacters(string) IPassword + +ExcludeAmbiguous() IPassword + +RequireAtLeast(class, count) IPassword + +LengthRequired(int) IPassword + +Next() string + +TryNext(out string) bool + +NextGroup(int) IEnumerable + } + class Password { + +static ForOwasp/ForNist/ForOtp() IPassword + +static ForApiKey/ForEnvironmentName() IPassword + +static ForPassphrase() IPasswordGenerator + +EstimateEntropyBits() double } class PasswordOptions { - +pools, length, minCounts - +excludeAmbiguous - +maxAttempts + +IncludeLowercase/Uppercase/Numeric/Special + +SpecialCharacters, Length + +ExcludeAmbiguous, DefaultBatchCount +bind from IConfiguration } class IRandomSource { <> +int NextInt(int maxExclusive) - +void Fill(Span~byte~) } class CryptoRandomSource { - uses RandomNumberGenerator.GetInt32 + GetInt32 on net8, rejection sampling on netstandard2.0 } class IEntropyEstimator { <> - +double Bits(string password) + +double EstimateBits(IPasswordSettings) } - IPasswordGenerator <|.. PasswordGenerator2 - IPasswordBuilder <|.. PasswordBuilder - PasswordBuilder --> PasswordOptions : produces - PasswordGenerator2 --> PasswordOptions : reads - PasswordGenerator2 --> IRandomSource : uses + IPassword <|.. Password + IPasswordGenerator <|.. Password + IPasswordGenerator <|.. PassphraseGenerator + Password --> IRandomSource : uses + PasswordOptions ..> Password : configures (DI) IRandomSource <|.. CryptoRandomSource - PasswordGenerator2 ..> IEntropyEstimator : optional + IEntropyEstimator <|.. PoolEntropyEstimator + Password ..> PoolEntropyEstimator : EstimateEntropyBits ``` Key shifts from today: - **`IRandomSource` abstraction** wraps the CSPRNG (unbiased `RandomNumberGenerator.GetInt32` on `net8.0`; rejection-sampling fallback on `netstandard2.0`). No `static`, disposable-aware, injectable. Fixes verified §5.2/§5.3/§5.6 and lets the Guid `Shuffle` be deleted (§5.5). -- **`PasswordOptions`** is the single config object, bindable from `IConfiguration`. -- **Presets** are builder methods that pre-fill `PasswordOptions`. +- **`PasswordOptions`** is the DI config object, bindable from `IConfiguration`. +- **Presets** are static factory methods on `Password` that pre-fill the fluent builder. - The `[Obsolete]` v2 wrappers are **removed** in v3 (recommended in `../V3_VERIFICATION.md` §4). ## Target composition (with DI) @@ -74,7 +83,7 @@ flowchart TD App["Consuming app"] -->|AddPasswordGenerator| DI["IServiceCollection"] DI --> Reg["registers IPasswordGenerator,
IRandomSource, PasswordOptions"] App -->|inject| IPG["IPasswordGenerator"] - App -->|or new directly| Builder["new PasswordBuilder()...Build()"] + App -->|or new directly| Builder["new Password()..."] IPG --> OPT[PasswordOptions] Builder --> OPT IPG --> RNG["IRandomSource → CryptoRandomSource"] @@ -93,8 +102,8 @@ flowchart LR subgraph ns["netstandard2.0 (broad reach: .NET Framework, Umbraco)"] a["manual rejection sampling"] end - subgraph net8["net8.0 / net10.0 (modern)"] - b["RandomNumberGenerator.GetInt32 / GetItems"] + subgraph net8["net8.0 (modern)"] + b["RandomNumberGenerator.GetInt32"] end IRandomSource --> ns IRandomSource --> net8 diff --git a/docs/v3-target/before-after.md b/docs/v3-target/before-after.md index b295888..acb93b9 100644 --- a/docs/v3-target/before-after.md +++ b/docs/v3-target/before-after.md @@ -1,4 +1,4 @@ -# v3 Target — Before / After (proposal) +# v3 Target — Before / After Side-by-side of the things that change most, each tied to a verified issue. @@ -85,7 +85,7 @@ flowchart LR p4["5x CS0108 from obsolete wrappers"] end subgraph AfterP["v3"] - q1["netstandard2.0 + net8.0 (+net10.0)"] + q1["netstandard2.0 + net8.0"] q2["tests on net8.0, NUnit 4
+ uniqueness/entropy/edge cases"] q3["clean pack: PackageReadmeFile,
PackageIcon, SourceLink, snupkg"] q4["obsolete wrappers removed → no CS0108"] diff --git a/docs/v3-target/configuration-and-di.md b/docs/v3-target/configuration-and-di.md index 87cee82..2645040 100644 --- a/docs/v3-target/configuration-and-di.md +++ b/docs/v3-target/configuration-and-di.md @@ -1,4 +1,4 @@ -# v3 Target — Configuration & Dependency Injection (proposal) +# v3 Target — Configuration & Dependency Injection ## Settings resolution order @@ -24,7 +24,8 @@ applies. `appSettings` binding is an **opt-in, separate step** — it is never a "IncludeLowercase": true, "IncludeUppercase": true, "IncludeNumeric": true, - "Special": "!#$%&*@", + "IncludeSpecial": true, + "SpecialCharacters": "!#$%&*@", "ExcludeAmbiguous": true, "DefaultBatchCount": 5 } @@ -63,7 +64,7 @@ consumer in control and lets the registration wire up `IRandomSource` so callers ```mermaid flowchart LR - P1["new PasswordBuilder().ForOwasp().Build().Next()"] --> Same(("same result
semantics")) + P1["Password.ForOwasp().Next()"] --> Same(("same result
semantics")) P2["injected IPasswordGenerator.Next()"] --> Same ``` diff --git a/docs/v3-target/generation-flow.md b/docs/v3-target/generation-flow.md index fb70c1c..6bd1aed 100644 --- a/docs/v3-target/generation-flow.md +++ b/docs/v3-target/generation-flow.md @@ -1,4 +1,4 @@ -# v3 Target — Generation Flow (proposal) +# v3 Target — Generation Flow Replaces probabilistic retry + string sentinels with **deterministic construction + exceptions**. @@ -54,7 +54,7 @@ sequenceDiagram RNG-->>Gen: index end Gen-->>App: Task> - Note over App,Gen: sync Next()/Generate() still exist,
marked [Obsolete] pointing here + Note over App,Gen: sync Next()/Generate() still exist
and are fully supported (NOT obsoleted) ``` > Note: generation is CPU-bound, so async mainly helps large-batch ergonomics and cancellation, not diff --git a/docs/v3-target/implementation-plan.md b/docs/v3-target/implementation-plan.md index efa2f98..7c6f8e3 100644 --- a/docs/v3-target/implementation-plan.md +++ b/docs/v3-target/implementation-plan.md @@ -286,7 +286,7 @@ callers). - Readme/migration snippets are backed by `DocumentationSnippetTests` so docs can't drift from the API. **Tasks** -1. **v2→v3 migration guide:** direct→DI, sync→async (with `[Obsolete]` still working), +1. **v2→v3 migration guide:** direct→DI, sync→async (sync kept, not obsoleted), error-string→exception/`TryNext`, preset/appSettings adoption — before/after snippets. 2. Document the **broader purpose** (OTPs, environment names, API keys, identifiers). 3. **OWASP/NIST mapping** for presets with links. diff --git a/docs/v3-target/roadmap.md b/docs/v3-target/roadmap.md index 77e0f82..58fa049 100644 --- a/docs/v3-target/roadmap.md +++ b/docs/v3-target/roadmap.md @@ -1,14 +1,14 @@ -# v3 Target — Roadmap (proposal) +# v3 Target — Roadmap Tiered delivery from the adjusted plan in `../V3_VERIFICATION.md` §3. Sequencing only — not committed -dates. +dates. (Delivered in v3.0.0; see `implementation-plan.md` for where the shipped code diverged.) ## Tiers as phases ```mermaid flowchart TD T1["Tier 1 — Correctness & Security
exceptions+TryNext · unbiased CSPRNG · delete Guid shuffle
· guarantee classes · fix static RNG · empty-special guard"] - T2["Tier 2 — Modernisation
multi-target+nullable · async+[Obsolete] sync · opt-in DI
· BenchmarkDotNet · packaging hygiene · tests→net8/NUnit4"] + T2["Tier 2 — Modernisation
multi-target+nullable · async (sync kept, not obsoleted) · opt-in DI
· BenchmarkDotNet · packaging hygiene · tests→net8/NUnit4"] T3["Tier 3 — New Features
WithAllAscii/WithCharacters · presets · appSettings
· Generate batch · exclude-ambiguous · min-counts · entropy"] T4["Tier 4 — Documentation
v2→v3 migration guide · broader-purpose docs · OWASP/NIST mapping"] T1 --> T2 --> T3 --> T4 @@ -36,7 +36,7 @@ gantt Remove static RNG + dead code :t1d, after t1a, 1 section Tier 2 Modernisation Multi-target + nullable :t2a, after t1c, 2 - Async plus Obsolete sync :t2b, after t2a, 2 + Async (sync kept, not obsoleted) :t2b, after t2a, 2 Opt-in DI + appSettings bind :t2c, after t2a, 2 Tests net8 + NUnit4 + BenchmarkDotNet :t2d, after t1c, 3 Packaging hygiene :t2e, after t2a, 1 From 2202a196bc7eeacfd51d4ab8581852e558fb7929 Mon Sep 17 00:00:00 2001 From: Paul Seal Date: Sun, 24 May 2026 22:35:48 +0100 Subject: [PATCH 10/14] Add v3.0.0 local NuGet package test report (#34) * Add v3.0.0 local NuGet package test report Documents an end-to-end consumer test of the PasswordGenerator 3.0.0 package built from dev/v3: pack, local feed registration, install into a fresh console app, and 12 usage scenarios with the code added, the actual output, and whether each result was expected. https://claude.ai/code/session_01Hk3he4x9TKXfuopS3caYKE * Ignore PasswordGenerator/bin build output Broaden the bin ignore from bin/Debug to the whole bin folder so Release pack artifacts are not tracked. https://claude.ai/code/session_01Hk3he4x9TKXfuopS3caYKE --------- Co-authored-by: Claude --- .gitignore | 2 +- docs/v3-local-nuget-test.md | 397 ++++++++++++++++++++++++++++++++++++ 2 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 docs/v3-local-nuget-test.md diff --git a/.gitignore b/.gitignore index 2c810e8..3d74f1b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ PasswordGenerator/bin/Debug/$RANDOM_SEED$ *.suo /.vs /PasswordGenerator/obj -/PasswordGenerator/bin/Debug +/PasswordGenerator/bin /UnitTests/obj /UnitTests/bin/Debug /packages diff --git a/docs/v3-local-nuget-test.md b/docs/v3-local-nuget-test.md new file mode 100644 index 0000000..3c70469 --- /dev/null +++ b/docs/v3-local-nuget-test.md @@ -0,0 +1,397 @@ +# PasswordGenerator v3.0.0 — local NuGet package test report + +This document records an end-to-end test of the **PasswordGenerator 3.0.0** package +(built from the `dev/v3` branch) as a real consumer would experience it: the package was +packed locally, served from a local NuGet feed, installed into a fresh console app, and +exercised across every documented scenario. For each scenario you get the exact code that +was added, the real output it produced, and a note on whether that output was **expected**. + +## How the package was built and consumed + +These are the exact steps a user would follow to reproduce this report. + +### 1. Pack the library from `dev/v3` + +```bash +git checkout dev/v3 +git pull origin dev/v3 +dotnet pack PasswordGenerator/PasswordGenerator.csproj -c Release -o /tmp/localnuget +``` + +Output (trimmed): + +``` +PasswordGenerator -> .../bin/Release/netstandard2.0/PasswordGenerator.dll +PasswordGenerator -> .../bin/Release/net8.0/PasswordGenerator.dll +Successfully created package '/tmp/localnuget/PasswordGenerator.3.0.0.nupkg'. +Successfully created package '/tmp/localnuget/PasswordGenerator.3.0.0.snupkg'. +``` + +The package multi-targets `netstandard2.0` and `net8.0`, and a `.snupkg` symbol package is +produced alongside it. **Expected** — this matches the packaging notes in `CHANGELOG.md`. + +> The only build warnings were `SourceLink` notices that the source-control information is +> empty. That is expected when packing from a plain working tree (no CI commit metadata) and +> does not affect the produced assemblies. + +### 2. Create a test project and register the local feed + +```bash +dotnet new console -n PgTestApp -o . +dotnet nuget add source /tmp/localnuget --name LocalPgSource +``` + +`dotnet nuget list source` then shows the local feed registered alongside nuget.org: + +``` +1. nuget.org [Enabled] https://api.nuget.org/v3/index.json +2. LocalPgSource [Enabled] /tmp/localnuget +``` + +### 3. Install the package from the local feed + +```bash +dotnet add package PasswordGenerator --version 3.0.0 --source /tmp/localnuget +``` + +``` +info : Installed PasswordGenerator 3.0.0 from /tmp/localnuget ... +info : Package 'PasswordGenerator' is compatible with all the specified frameworks ... +``` + +**Expected** — the package resolves from the local source and is compatible with the +`net8.0` test project. + +For the dependency-injection / `appsettings.json` scenarios two more packages were added: + +```bash +dotnet add package Microsoft.Extensions.DependencyInjection --version 8.0.0 +dotnet add package Microsoft.Extensions.Configuration.Json --version 8.0.0 +``` + +### Resulting `PgTestApp.csproj` + +```xml + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + +``` + +### `appsettings.json` (used by scenario 12) + +```json +{ + "PasswordGenerator": { + "Length": 24, + "IncludeLowercase": true, + "IncludeUppercase": true, + "IncludeNumeric": true, + "IncludeSpecial": false, + "ExcludeAmbiguous": true, + "DefaultBatchCount": 3 + } +} +``` + +--- + +## Scenarios + +> Passwords are random, so the exact characters differ on every run. The notes focus on the +> properties that should hold (length, character classes, behaviour), not the literal value. + +### 1. Basic default + +```csharp +var pwd = new Password(); +var password = pwd.Next(); +``` + +Output: + +``` +value='RHX%0Bzgz@f8R4rI' length=16 +``` + +**Expected.** Default length is 16 and all four character classes are available. + +### 2. Set the length + +```csharp +var password = new Password(32).Next(); +``` + +Output: + +``` +value='Ck4JOCY!8uNCQR0o4qn7jKx\Q%cAM0%3' length=32 +``` + +**Expected.** Length honoured exactly. + +### 3. Choose which character types to include + +```csharp +var password = new Password( + includeLowercase: true, includeUppercase: true, + includeNumeric: false, includeSpecial: false, + passwordLength: 21).Next(); +``` + +Output: + +``` +value='KDTwPmSwycfVDTKuEwRXQ' length=21 +``` + +**Expected.** Letters only — no digits or specials — at the requested length of 21. + +### 4. Fluent: numeric only + +```csharp +var password = new Password().IncludeNumeric().Next(); +``` + +Output: + +``` +value='1542580664200162' length=16 +``` + +**Expected.** A digits-only password at the default length of 16. The fluent +`IncludeNumeric()` resets the pool to the single requested class. + +### 5. Fluent: lower + upper + special, length 128 + +```csharp +var password = new Password(128) + .IncludeLowercase().IncludeUppercase().IncludeSpecial().Next(); +``` + +Output: + +``` +value='Xq#$PLzbuSFdBSkvwMbKoPYxlE@BQJmp\um%g&\n*qCQz...' length=128 +``` + +**Expected.** Length 128 produced. (The `\n` in the value is a literal backslash followed by +`n` — `\` is part of the special-character set — not a newline.) + +### 6. Custom special characters + +```csharp +var password = new Password() + .IncludeLowercase().IncludeUppercase().IncludeNumeric() + .IncludeSpecial("[]{}^_=").Next(); +``` + +Output: + +``` +value='1hAJT5uB6p]swPrI' length=16 +``` + +**Expected.** Any special character present comes only from the supplied set (`]` here). + +### 7. Presets + +```csharp +Console.WriteLine(Password.ForOwasp().Next()); +Console.WriteLine(Password.ForNist().Next()); +Console.WriteLine(Password.ForOtp(6).Next()); +Console.WriteLine(Password.ForApiKey(32).Next()); +Console.WriteLine(Password.ForEnvironmentName(12).Next()); +Console.WriteLine(Password.ForPassphrase(4).Next()); +``` + +Output: + +``` +ForOwasp() = 'ma4n'q^g"GH=X8$t' (length 16, full printable ASCII) +ForNist() = 'EPsw/Ib9S!'|' (length 12, full ASCII) +ForOtp(6) = '481386' (6 numeric digits) +ForApiKey(32) = 'oCj6ppytpoqdzFvivPaIOiYbBjsChu4g' (URL-safe, length 32) +ForEnvironmentName() = 'b9w8wxvbt35f' (lowercase + digits, no look-alikes) +ForPassphrase(4) = 'umber-acid-shine-salad-16' (4 words + number, '-' separated) +``` + +**Expected.** Each preset matches its documented shape: OWASP = full ASCII/length 16, +NIST = full ASCII/length 12, OTP = numeric code, API key = URL-safe token, environment name = +readable id with ambiguous characters removed, passphrase = diceware words plus a number. + +### 8. Quality controls + +```csharp +var readable = new Password(20).ExcludeAmbiguous().Next(); +var req = new Password(16).RequireAtLeast(CharacterClass.Numeric, 2).Next(); +var custom = new Password().WithCharacters("ABCDEF0123456789").LengthRequired(24).Next(); +var ascii = new Password().WithAllAscii().LengthRequired(40).Next(); +double bits = new Password(20).EstimateEntropyBits(); +``` + +Output: + +``` +ExcludeAmbiguous(20) = '%C\CAiS63wsz**nrLx6!' +RequireAtLeast(Numeric,2) = '624eWC#w%Sb8U8Zh' +WithCharacters(hex) len 24 = '4D1B017D148873963C0475A2' +WithAllAscii() len 40 = ']Ef;:(:d*{jJZh'vVa.!Vj.Gb!6Bys9to>m>q}Ja' +EstimateEntropyBits(20) = 122.58566033889934 +``` + +**Expected.** +- `ExcludeAmbiguous` output contains none of `I l 1 O 0 o`. +- `RequireAtLeast(Numeric, 2)` contains at least two digits (`6 2 4 8 8`). +- `WithCharacters` restricts output to the supplied hex alphabet only, at length 24. +- `WithAllAscii` uses the full printable-ASCII pool at length 40. +- `EstimateEntropyBits(20)` returns a positive bit estimate (~122.6 bits) consistent with a + 20-character password over the default multi-class pool. + +### 9. Error handling + +```csharp +// Valid settings via TryNext +if (new Password(16).TryNext(out var result)) + Console.WriteLine(result); + +// Special required but empty special set +new Password().IncludeSpecial("").Next(); // expected to throw + +// Length below the minimum +new Password(2).Next(); // expected to throw + +// TryNext never throws +var ok = new Password(2).TryNext(out var r2); +``` + +Output: + +``` +TryNext valid -> true, '4WsV&4z\$&Ksix\F' +Next() with empty special -> threw ArgumentException: Special characters are required but no special characters have been provided. +Next() length 2 -> threw ArgumentException: Password length invalid. Must be between 4 and 256 characters long +TryNext length 2 -> False, result is null: True +``` + +**Expected.** This is the headline v3 breaking change: invalid settings now **throw** +`ArgumentException` from `Next()` (rather than returning an error string as the "password"), +while `TryNext` returns `false` and a `null` password instead of throwing. + +### 10. Async and batches + +```csharp +var pwd = new Password(); +string asyncPwd = await pwd.NextAsync(CancellationToken.None); +IReadOnlyList five = pwd.Generate(5); +IReadOnlyList three = await pwd.GenerateAsync(3, CancellationToken.None); +``` + +Output: + +``` +NextAsync() = 'j%FCN%i4Q&#bvUgR' +Generate(5) = 5 items + y3nbjyLgRKkrJ$T# + 3#&G$#vXU$3*mq!9 + CeN0FRM#RP9y@ryN + WlZ%IxX@kIrem8oc + Ysi@i*#qjA9SL0g8 +GenerateAsync(3) = 3 items +``` + +**Expected.** `NextAsync` returns a single password; `Generate(n)` / `GenerateAsync(n)` +return exactly `n` distinct passwords. + +### 11. Dependency injection (configured in code) + +```csharp +var services = new ServiceCollection(); +services.AddPasswordGenerator(o => +{ + o.Length = 20; + o.IncludeSpecial = true; + o.ExcludeAmbiguous = true; +}); +using var sp = services.BuildServiceProvider(); +var gen = sp.GetRequiredService(); +var password = gen.Next(); +``` + +Output: + +``` +DI(code) Next() = 'b#3$%zQ6j4xGtYBjYt&K' length=20 +``` + +**Expected.** `AddPasswordGenerator` registers `IPasswordGenerator`; the resolved generator +honours the code-configured options (length 20, specials on, ambiguous removed). + +### 12. Dependency injection (bound from `appsettings.json`) + +```csharp +var config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + +var services = new ServiceCollection(); +services.AddPasswordGenerator(config.GetSection("PasswordGenerator")); +using var sp = services.BuildServiceProvider(); +var gen = sp.GetRequiredService(); + +var password = gen.Next(); +var batch = gen.Generate(); // uses DefaultBatchCount from config +``` + +Output: + +``` +DI(config) Next() = '54LWWrw8F4fMHfQDSjPWjawP' length=24 (config Length=24, ExcludeAmbiguous, no special) +DI(config) Generate() default batch count = 3 (config DefaultBatchCount=3) +``` + +**Expected.** Options bind from the `PasswordGenerator` configuration section: length 24, no +special characters, ambiguous characters removed, and `Generate()` (parameterless) returns 3 +passwords matching `DefaultBatchCount`. + +--- + +## Summary + +| # | Scenario | Result | Expected? | +|---|----------|--------|-----------| +| – | `dotnet pack` (netstandard2.0 + net8.0, .snupkg) | Built | Yes | +| – | Register local feed + install package | Installed, framework-compatible | Yes | +| 1 | Basic default | length 16, all classes | Yes | +| 2 | Explicit length 32 | length 32 | Yes | +| 3 | Letters only, length 21 | letters only, length 21 | Yes | +| 4 | Fluent numeric only | digits only, length 16 | Yes | +| 5 | Fluent length 128 | length 128 | Yes | +| 6 | Custom special chars | specials from supplied set only | Yes | +| 7 | Presets (OWASP/NIST/OTP/API/Env/Passphrase) | each matches documented shape | Yes | +| 8 | Quality controls + entropy | ambiguous removed, minimums met, custom pool, entropy estimate | Yes | +| 9 | Error handling (throw / TryNext) | invalid settings throw; TryNext returns false/null | Yes | +| 10 | Async + batches | correct counts | Yes | +| 11 | DI (code configured) | options honoured | Yes | +| 12 | DI (appsettings.json) | options + DefaultBatchCount bound | Yes | + +**Every scenario behaved as expected.** The package packs cleanly, installs from a local +NuGet feed, and the public API — fluent builder, presets, quality controls, error handling, +async, batch generation, and both dependency-injection registration paths — all behave as +documented in the Readme and CHANGELOG. The only non-fatal note during the whole run was the +empty-SourceLink build warning, which is expected when packing outside CI. From 57c0f007b0569c0e656e724306d5639f934f4844 Mon Sep 17 00:00:00 2001 From: Paul Seal Date: Mon, 25 May 2026 08:52:55 +0100 Subject: [PATCH 11/14] Add v3 package documentation and code review (#35) * Add BenchmarkDotNet pipeline with GitHub Actions summary Expand the benchmark project into structured scenarios (single, batch, async, presets, DI-vs-direct, and a v3 batch-vs-loop comparison), all with memory diagnostics. Multi-target net8.0/net10.0 and emit GitHub markdown reports via the default exporter. Add a Benchmarks workflow that runs on push/dispatch, publishes the report tables to the job summary, and uploads the raw results. The planned v2-vs-v3 comparison via a second PasswordGenerator package reference is omitted: v2 and v3 both produce PasswordGenerator.dll and collide in one bin folder (extern alias fails with CS0430), so the comparison instead measures v3 usage patterns. https://claude.ai/code/session_01NZJPrT8QFtXsMG6JJ5AJfv * Target net10.0 and benchmark across both runtimes Add net10.0 to the package's target frameworks (the existing NET8_0_OR_GREATER conditionals cover it) and to the CI/release SDK setup so the new target builds and packs. Upgrade BenchmarkDotNet to 0.15.8 (0.14.0 has no .NET 10 moniker) and add .NET 8 and .NET 10 runtime jobs so every benchmark runs on both, with a Runtime column comparing them side by side in one report. https://claude.ai/code/session_01NZJPrT8QFtXsMG6JJ5AJfv * Add v3.0.0 benchmark results for .NET 8 and .NET 10 Record a sample BenchmarkDotNet run (both runtimes) under benchmarks/ as a readable historical reference. Reduced-sampling numbers; the workflow publishes full-precision results to the run summary. https://claude.ai/code/session_01NZJPrT8QFtXsMG6JJ5AJfv --------- Co-authored-by: Claude --- .github/workflows/benchmarks.yml | 65 ++++++++ .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 4 +- .../Benchmarks/AsyncBenchmarks.cs | 40 +++++ .../Benchmarks/BatchGenerationBenchmarks.cs | 33 ++++ .../Benchmarks/InstantiationBenchmarks.cs | 40 +++++ .../Benchmarks/PresetBenchmarks.cs | 45 ++++++ .../Benchmarks/SingleGenerationBenchmarks.cs | 32 ++++ .../Benchmarks/VersionComparisonBenchmarks.cs | 54 +++++++ .../PasswordBenchmarks.cs | 29 ---- .../PasswordGenerator.Benchmarks.csproj | 5 +- PasswordGenerator.Benchmarks/Program.cs | 21 ++- PasswordGenerator/PasswordGenerator.csproj | 2 +- benchmarks/v3.0.0.md | 143 ++++++++++++++++++ 14 files changed, 481 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/benchmarks.yml create mode 100644 PasswordGenerator.Benchmarks/Benchmarks/AsyncBenchmarks.cs create mode 100644 PasswordGenerator.Benchmarks/Benchmarks/BatchGenerationBenchmarks.cs create mode 100644 PasswordGenerator.Benchmarks/Benchmarks/InstantiationBenchmarks.cs create mode 100644 PasswordGenerator.Benchmarks/Benchmarks/PresetBenchmarks.cs create mode 100644 PasswordGenerator.Benchmarks/Benchmarks/SingleGenerationBenchmarks.cs create mode 100644 PasswordGenerator.Benchmarks/Benchmarks/VersionComparisonBenchmarks.cs delete mode 100644 PasswordGenerator.Benchmarks/PasswordBenchmarks.cs create mode 100644 benchmarks/v3.0.0.md diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..527e5b2 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,65 @@ +name: Benchmarks + +on: + push: + branches: [main, dev/v3] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + benchmark: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Restore + run: dotnet restore PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj + + - name: Build (Release) + run: dotnet build PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj -c Release --no-restore + + - name: Run benchmarks + run: | + cd PasswordGenerator.Benchmarks + dotnet run -c Release --no-build -f net8.0 -- --filter '*' + + - name: Publish results to workflow summary + if: always() + run: | + echo "# 📊 PasswordGenerator Benchmark Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "**Run date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC")" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + shopt -s nullglob + reports=(PasswordGenerator.Benchmarks/BenchmarkDotNet.Artifacts/results/*-report-github.md) + if [ ${#reports[@]} -eq 0 ]; then + echo "_No benchmark reports were produced._" >> $GITHUB_STEP_SUMMARY + fi + for f in "${reports[@]}"; do + name=$(basename "$f" -report-github.md) + echo "## $name" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + cat "$f" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + done + + - name: Upload raw artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: benchmark-results-${{ github.run_number }} + path: PasswordGenerator.Benchmarks/BenchmarkDotNet.Artifacts/results/ + retention-days: 90 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe64105..0e291c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: | + 8.0.x + 10.0.x - name: Restore run: dotnet restore PasswordGenerator.sln diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6fc743..e392bd6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: | + 8.0.x + 10.0.x # The release tag drives the published package version (e.g. tag "v3.0.1" -> 3.0.1). - name: Derive version from release tag diff --git a/PasswordGenerator.Benchmarks/Benchmarks/AsyncBenchmarks.cs b/PasswordGenerator.Benchmarks/Benchmarks/AsyncBenchmarks.cs new file mode 100644 index 0000000..a0ab1ef --- /dev/null +++ b/PasswordGenerator.Benchmarks/Benchmarks/AsyncBenchmarks.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; + +namespace PasswordGenerator.Benchmarks +{ + /// Cost of the async generation APIs relative to their synchronous counterparts. + [MemoryDiagnoser] + public class AsyncBenchmarks + { + private Password _password = null!; + + [Params(1, 10, 100, 1000, 10000)] + public int Count { get; set; } + + [GlobalSetup] + public void Setup() + { + _password = new Password(); + } + + [GlobalCleanup] + public void Cleanup() + { + _password.Dispose(); + } + + [Benchmark] + public Task NextAsync() + { + return _password.NextAsync(); + } + + [Benchmark] + public Task> GenerateAsync() + { + return _password.GenerateAsync(Count); + } + } +} diff --git a/PasswordGenerator.Benchmarks/Benchmarks/BatchGenerationBenchmarks.cs b/PasswordGenerator.Benchmarks/Benchmarks/BatchGenerationBenchmarks.cs new file mode 100644 index 0000000..33c90b8 --- /dev/null +++ b/PasswordGenerator.Benchmarks/Benchmarks/BatchGenerationBenchmarks.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; + +namespace PasswordGenerator.Benchmarks +{ + /// Cost of generating a batch of passwords in one call. + [MemoryDiagnoser] + public class BatchGenerationBenchmarks + { + private Password _password = null!; + + [Params(1, 10, 100, 1000, 10000)] + public int Count { get; set; } + + [GlobalSetup] + public void Setup() + { + _password = new Password(); + } + + [GlobalCleanup] + public void Cleanup() + { + _password.Dispose(); + } + + [Benchmark] + public IReadOnlyList Generate() + { + return _password.Generate(Count); + } + } +} diff --git a/PasswordGenerator.Benchmarks/Benchmarks/InstantiationBenchmarks.cs b/PasswordGenerator.Benchmarks/Benchmarks/InstantiationBenchmarks.cs new file mode 100644 index 0000000..988c6eb --- /dev/null +++ b/PasswordGenerator.Benchmarks/Benchmarks/InstantiationBenchmarks.cs @@ -0,0 +1,40 @@ +using System; +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace PasswordGenerator.Benchmarks +{ + /// Overhead of resolving a generator from the DI container versus constructing one directly. + [MemoryDiagnoser] + public class InstantiationBenchmarks + { + private ServiceProvider _provider = null!; + + [GlobalSetup] + public void Setup() + { + _provider = new ServiceCollection() + .AddPasswordGenerator() + .BuildServiceProvider(); + } + + [GlobalCleanup] + public void Cleanup() + { + _provider.Dispose(); + } + + [Benchmark(Baseline = true)] + public string DirectInstantiation() + { + using var password = new Password(); + return password.Next(); + } + + [Benchmark] + public string ResolveFromContainer() + { + return _provider.GetRequiredService().Next(); + } + } +} diff --git a/PasswordGenerator.Benchmarks/Benchmarks/PresetBenchmarks.cs b/PasswordGenerator.Benchmarks/Benchmarks/PresetBenchmarks.cs new file mode 100644 index 0000000..93fc35f --- /dev/null +++ b/PasswordGenerator.Benchmarks/Benchmarks/PresetBenchmarks.cs @@ -0,0 +1,45 @@ +using BenchmarkDotNet.Attributes; + +namespace PasswordGenerator.Benchmarks +{ + /// Cost of generating from each built-in preset. + [MemoryDiagnoser] + public class PresetBenchmarks + { + private IPassword _owasp = null!; + private IPassword _nist = null!; + private IPassword _otp = null!; + private IPassword _apiKey = null!; + private IPassword _environmentName = null!; + private IPasswordGenerator _passphrase = null!; + + [GlobalSetup] + public void Setup() + { + _owasp = Password.ForOwasp(); + _nist = Password.ForNist(); + _otp = Password.ForOtp(); + _apiKey = Password.ForApiKey(); + _environmentName = Password.ForEnvironmentName(); + _passphrase = Password.ForPassphrase(); + } + + [Benchmark] + public string ForOwasp() => _owasp.Next(); + + [Benchmark] + public string ForNist() => _nist.Next(); + + [Benchmark] + public string ForOtp() => _otp.Next(); + + [Benchmark] + public string ForApiKey() => _apiKey.Next(); + + [Benchmark] + public string ForEnvironmentName() => _environmentName.Next(); + + [Benchmark] + public string ForPassphrase() => _passphrase.Next(); + } +} diff --git a/PasswordGenerator.Benchmarks/Benchmarks/SingleGenerationBenchmarks.cs b/PasswordGenerator.Benchmarks/Benchmarks/SingleGenerationBenchmarks.cs new file mode 100644 index 0000000..0d9b76f --- /dev/null +++ b/PasswordGenerator.Benchmarks/Benchmarks/SingleGenerationBenchmarks.cs @@ -0,0 +1,32 @@ +using BenchmarkDotNet.Attributes; + +namespace PasswordGenerator.Benchmarks +{ + /// Cost of generating a single password across a range of lengths. + [MemoryDiagnoser] + public class SingleGenerationBenchmarks + { + private Password _password = null!; + + [Params(8, 16, 32, 64, 128)] + public int Length { get; set; } + + [GlobalSetup] + public void Setup() + { + _password = new Password(Length); + } + + [GlobalCleanup] + public void Cleanup() + { + _password.Dispose(); + } + + [Benchmark] + public string Next() + { + return _password.Next(); + } + } +} diff --git a/PasswordGenerator.Benchmarks/Benchmarks/VersionComparisonBenchmarks.cs b/PasswordGenerator.Benchmarks/Benchmarks/VersionComparisonBenchmarks.cs new file mode 100644 index 0000000..958bcfd --- /dev/null +++ b/PasswordGenerator.Benchmarks/Benchmarks/VersionComparisonBenchmarks.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; + +namespace PasswordGenerator.Benchmarks +{ + /// + /// Compares ways of producing N passwords in v3. The naive loop-over- + /// pattern (how v2 callers typically batched) is the baseline; the v3 batch + /// API is measured against it. + /// + /// + /// A true v2-vs-v3 comparison cannot run in one assembly: the published v2 package and the v3 + /// project both produce PasswordGenerator.dll, so they collide in a single bin folder. + /// + [MemoryDiagnoser] + public class VersionComparisonBenchmarks + { + private Password _password = null!; + + [Params(1, 10, 100, 1000, 10000)] + public int Count { get; set; } + + [GlobalSetup] + public void Setup() + { + _password = new Password(); + } + + [GlobalCleanup] + public void Cleanup() + { + _password.Dispose(); + } + + [Benchmark(Baseline = true)] + public int LoopNext() + { + var generated = 0; + for (var i = 0; i < Count; i++) + { + _ = _password.Next(); + generated++; + } + + return generated; + } + + [Benchmark] + public IReadOnlyList BatchGenerate() + { + return _password.Generate(Count); + } + } +} diff --git a/PasswordGenerator.Benchmarks/PasswordBenchmarks.cs b/PasswordGenerator.Benchmarks/PasswordBenchmarks.cs deleted file mode 100644 index 2ee5553..0000000 --- a/PasswordGenerator.Benchmarks/PasswordBenchmarks.cs +++ /dev/null @@ -1,29 +0,0 @@ -using BenchmarkDotNet.Attributes; -using PasswordGenerator; - -namespace PasswordGenerator.Benchmarks -{ - [MemoryDiagnoser] - public class PasswordBenchmarks - { - [Params(1, 100, 1000, 10000)] - public int Count; - - [Benchmark] - public string SingleNext() - { - var pwd = new Password(); - return pwd.Next(); - } - - [Benchmark] - public int Batch() - { - var pwd = new Password(); - var generated = 0; - foreach (var _ in pwd.NextGroup(Count)) - generated++; - return generated; - } - } -} diff --git a/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj b/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj index 6600890..f445b44 100644 --- a/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj +++ b/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj @@ -2,14 +2,15 @@ Exe - net8.0 + net8.0;net10.0 enable latest false - + + diff --git a/PasswordGenerator.Benchmarks/Program.cs b/PasswordGenerator.Benchmarks/Program.cs index cee61a4..d28536e 100644 --- a/PasswordGenerator.Benchmarks/Program.cs +++ b/PasswordGenerator.Benchmarks/Program.cs @@ -1,10 +1,27 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Environments; +using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Running; namespace PasswordGenerator.Benchmarks { public static class Program { - public static void Main(string[] args) => - BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + public static void Main(string[] args) + { + // DefaultConfig already supplies the GitHub markdown exporter (MarkdownExporter-github), + // which produces the *-report-github.md files the workflow drops into the step summary. + // Every benchmark is run on both runtimes so the reports compare .NET 8 against .NET 10 + // side by side (BenchmarkDotNet adds a "Runtime" column). + var config = DefaultConfig.Instance + .AddDiagnoser(MemoryDiagnoser.Default) + .AddJob(Job.Default.WithRuntime(CoreRuntime.Core80)) + .AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0)); + + BenchmarkSwitcher + .FromAssembly(typeof(Program).Assembly) + .Run(args, config); + } } } diff --git a/PasswordGenerator/PasswordGenerator.csproj b/PasswordGenerator/PasswordGenerator.csproj index b2f9420..933fdf5 100644 --- a/PasswordGenerator/PasswordGenerator.csproj +++ b/PasswordGenerator/PasswordGenerator.csproj @@ -1,7 +1,7 @@ - netstandard2.0;net8.0 + netstandard2.0;net8.0;net10.0 enable latest diff --git a/benchmarks/v3.0.0.md b/benchmarks/v3.0.0.md new file mode 100644 index 0000000..135c43d --- /dev/null +++ b/benchmarks/v3.0.0.md @@ -0,0 +1,143 @@ +# PasswordGenerator v3.0.0 — Benchmark Results + +Generated with [BenchmarkDotNet](https://benchmarkdotnet.org/) from +`PasswordGenerator.Benchmarks`, running every scenario on both **.NET 8.0** and +**.NET 10.0** in a single process. + +## Environment + +``` +BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat) +Intel Xeon Processor 2.80GHz, 1 CPU, 4 logical and 4 physical cores +[Host] : .NET 8.0.27, X64 RyuJIT x86-64-v4 +.NET 8 : .NET 8.0.27 (8.0.2726.22922) +.NET 10 : .NET 10.0.8 (10.0.826.23019) +``` + +> **Note on precision:** this run used reduced sampling +> (`WarmupCount=1 IterationCount=3 LaunchCount=1`) to keep wall-time short, so +> the error margins are wide — treat the means as indicative rather than +> publication-grade. The `Benchmarks` GitHub Actions workflow runs the full +> default job for higher-precision numbers and publishes them to the run summary. + +## SingleGenerationBenchmarks — `Next()` by password length + +| Method | Runtime | Length | Mean | Allocated | +|--------|---------|-------:|-----:|----------:| +| Next | .NET 10.0 | 8 | 25.64 μs | 80 B | +| Next | .NET 8.0 | 8 | 25.81 μs | 80 B | +| Next | .NET 10.0 | 16 | 57.56 μs | 112 B | +| Next | .NET 8.0 | 16 | 56.08 μs | 112 B | +| Next | .NET 10.0 | 32 | 119.35 μs | 176 B | +| Next | .NET 8.0 | 32 | 121.63 μs | 176 B | +| Next | .NET 10.0 | 64 | 252.17 μs | 304 B | +| Next | .NET 8.0 | 64 | 250.51 μs | 304 B | +| Next | .NET 10.0 | 128 | 494.68 μs | 560 B | +| Next | .NET 8.0 | 128 | 486.25 μs | 560 B | + +## BatchGenerationBenchmarks — `Generate(n)` + +| Method | Runtime | Count | Mean | Allocated | +|--------|---------|------:|-----:|----------:| +| Generate | .NET 10.0 | 1 | 56.11 μs | 176 B | +| Generate | .NET 8.0 | 1 | 56.63 μs | 176 B | +| Generate | .NET 10.0 | 10 | 559.63 μs | 1256 B | +| Generate | .NET 8.0 | 10 | 559.66 μs | 1256 B | +| Generate | .NET 10.0 | 100 | 5,623 μs | 12056 B | +| Generate | .NET 8.0 | 100 | 5,732 μs | 12056 B | +| Generate | .NET 10.0 | 1000 | 55,664 μs | 120056 B | +| Generate | .NET 8.0 | 1000 | 55,856 μs | 120056 B | +| Generate | .NET 10.0 | 10000 | 568,403 μs | 1200056 B | +| Generate | .NET 8.0 | 10000 | 554,511 μs | 1200056 B | + +## AsyncBenchmarks — `NextAsync()` / `GenerateAsync(n)` + +`NextAsync` ignores `Count` (it generates one password), so it stays flat ~56 μs. + +| Method | Runtime | Count | Mean | Allocated | +|--------|---------|------:|-----:|----------:| +| NextAsync | .NET 10.0 | 1 | 56.42 μs | 184 B | +| GenerateAsync | .NET 10.0 | 1 | 58.68 μs | 248 B | +| NextAsync | .NET 8.0 | 1 | 56.57 μs | 184 B | +| GenerateAsync | .NET 8.0 | 1 | 57.43 μs | 248 B | +| NextAsync | .NET 10.0 | 10 | 56.58 μs | 184 B | +| GenerateAsync | .NET 10.0 | 10 | 559.01 μs | 1328 B | +| NextAsync | .NET 8.0 | 10 | 56.72 μs | 184 B | +| GenerateAsync | .NET 8.0 | 10 | 569.33 μs | 1328 B | +| NextAsync | .NET 10.0 | 100 | 57.71 μs | 184 B | +| GenerateAsync | .NET 10.0 | 100 | 5,591 μs | 12128 B | +| NextAsync | .NET 8.0 | 100 | 55.70 μs | 184 B | +| GenerateAsync | .NET 8.0 | 100 | 5,707 μs | 12128 B | +| NextAsync | .NET 10.0 | 1000 | 55.90 μs | 184 B | +| GenerateAsync | .NET 10.0 | 1000 | 55,939 μs | 120128 B | +| NextAsync | .NET 8.0 | 1000 | 56.24 μs | 184 B | +| GenerateAsync | .NET 8.0 | 1000 | 56,175 μs | 120128 B | +| NextAsync | .NET 10.0 | 10000 | 55.76 μs | 184 B | +| GenerateAsync | .NET 10.0 | 10000 | 563,644 μs | 1200128 B | +| NextAsync | .NET 8.0 | 10000 | 56.03 μs | 184 B | +| GenerateAsync | .NET 8.0 | 10000 | 560,427 μs | 1200128 B | + +## PresetBenchmarks + +| Method | Runtime | Mean | Allocated | +|--------|---------|-----:|----------:| +| ForOwasp | .NET 10.0 | 50.58 μs | 112 B | +| ForNist | .NET 10.0 | 38.46 μs | 96 B | +| ForOtp | .NET 10.0 | 19.24 μs | 80 B | +| ForApiKey | .NET 10.0 | 88.12 μs | 176 B | +| ForEnvironmentName | .NET 10.0 | 33.24 μs | 560 B | +| ForPassphrase | .NET 10.0 | 8.29 μs | 287 B | +| ForOwasp | .NET 8.0 | 50.19 μs | 112 B | +| ForNist | .NET 8.0 | 38.61 μs | 96 B | +| ForOtp | .NET 8.0 | 19.39 μs | 80 B | +| ForApiKey | .NET 8.0 | 87.03 μs | 176 B | +| ForEnvironmentName | .NET 8.0 | 33.72 μs | 560 B | +| ForPassphrase | .NET 8.0 | 8.54 μs | 287 B | + +## InstantiationBenchmarks — DI vs direct (baseline = `new Password()`) + +DI resolution is essentially free on time and allocates ~9× less, because the +registered generator is a singleton and is reused on each resolve. + +| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio | +|--------|---------|-----:|------:|----------:|------------:| +| DirectInstantiation | .NET 10.0 | 56.78 μs | 1.00 | 1032 B | 1.00 | +| ResolveFromContainer | .NET 10.0 | 55.92 μs | 0.98 | 112 B | 0.11 | +| DirectInstantiation | .NET 8.0 | 56.38 μs | 1.00 | 1032 B | 1.00 | +| ResolveFromContainer | .NET 8.0 | 56.04 μs | 0.99 | 112 B | 0.11 | + +## VersionComparisonBenchmarks — loop `Next()` (baseline) vs `Generate(n)` + +| Method | Runtime | Count | Mean | Ratio | Allocated | Alloc Ratio | +|--------|---------|------:|-----:|------:|----------:|------------:| +| LoopNext | .NET 10.0 | 1 | 55.73 μs | 1.00 | 112 B | 1.00 | +| BatchGenerate | .NET 10.0 | 1 | 56.48 μs | 1.01 | 176 B | 1.57 | +| LoopNext | .NET 8.0 | 1 | 56.26 μs | 1.00 | 112 B | 1.00 | +| BatchGenerate | .NET 8.0 | 1 | 56.28 μs | 1.00 | 176 B | 1.57 | +| LoopNext | .NET 10.0 | 10 | 566.68 μs | 1.00 | 1120 B | 1.00 | +| BatchGenerate | .NET 10.0 | 10 | 562.27 μs | 0.99 | 1256 B | 1.12 | +| LoopNext | .NET 8.0 | 10 | 561.85 μs | 1.00 | 1120 B | 1.00 | +| BatchGenerate | .NET 8.0 | 10 | 562.08 μs | 1.00 | 1256 B | 1.12 | +| LoopNext | .NET 10.0 | 100 | 5,498 μs | 1.00 | 11200 B | 1.00 | +| BatchGenerate | .NET 10.0 | 100 | 5,566 μs | 1.01 | 12056 B | 1.08 | +| LoopNext | .NET 8.0 | 100 | 5,543 μs | 1.00 | 11200 B | 1.00 | +| BatchGenerate | .NET 8.0 | 100 | 5,576 μs | 1.01 | 12056 B | 1.08 | +| LoopNext | .NET 10.0 | 1000 | 56,380 μs | 1.00 | 112000 B | 1.00 | +| BatchGenerate | .NET 10.0 | 1000 | 56,404 μs | 1.00 | 120056 B | 1.07 | +| LoopNext | .NET 8.0 | 1000 | 56,727 μs | 1.00 | 112000 B | 1.00 | +| BatchGenerate | .NET 8.0 | 1000 | 56,752 μs | 1.00 | 120056 B | 1.07 | +| LoopNext | .NET 10.0 | 10000 | 555,818 μs | 1.00 | 1120000 B | 1.00 | +| BatchGenerate | .NET 10.0 | 10000 | 551,998 μs | 0.99 | 1200056 B | 1.07 | +| LoopNext | .NET 8.0 | 10000 | 555,037 μs | 1.00 | 1120000 B | 1.00 | +| BatchGenerate | .NET 8.0 | 10000 | 561,637 μs | 1.01 | 1200056 B | 1.07 | + +## Takeaways + +- **.NET 8 vs .NET 10:** within noise across every scenario — runtime is + dominated by `RandomNumberGenerator` crypto calls, not managed code, so the + newer JIT makes little difference here. +- **`Generate(n)` vs a manual `Next()` loop:** identical timing; the batch path + only adds the result `List` allocation (~7% more memory). +- **Cost scales linearly** with password length and batch count, as expected. +- **DI has no measurable overhead** and allocates far less per call than + constructing a new `Password` each time. From cb6f656937b02fc9b51e75fd8f6c344a419cd810 Mon Sep 17 00:00:00 2001 From: Paul Seal Date: Mon, 25 May 2026 11:14:39 +0100 Subject: [PATCH 12/14] Add v3 package documentation and code review (#36) * Update non-shipped test/benchmark packages and SourceLink to latest Bump test tooling (NUnit, NUnit3TestAdapter, Test.Sdk) and the Microsoft.Extensions.* packages used only by the test and benchmark projects to their latest stable versions, plus SourceLink in the library (build-only, PrivateAssets=All). The shipped library's Microsoft.Extensions.* dependencies stay pinned at 8.0.0 on purpose: for a multi-target library those versions are a minimum floor imposed on consumers, so keeping the LTS floor maximizes compatibility across netstandard2.0/net8.0/net10.0. https://claude.ai/code/session_01WVsnjDXjACmcLWJLGNMKVL * Correct SourceLink.GitHub to latest stable 10.0.300 11.0.100 is preview-only and does not exist as a stable release, so restore failed. Pin to the actual latest stable, 10.0.300. https://claude.ai/code/session_01WVsnjDXjACmcLWJLGNMKVL * Drop netstandard2.0 from v3; make v3 docs the current docs Target net8.0;net10.0 only and remove the netstandard2.0 fallback in CryptoRandomSource (the #if rejection-sampling path, the RNG field, and IDisposable), which are no longer needed now that GetInt32 is available on every target. Restructure docs so v3 is the current state: flatten the former v3-target/* into docs/ (present tense, no "target/future" framing) and move the v2.1.0 snapshot, review/verification docs, and v3 planning docs into docs/archive/ with historical banners noting netstandard2.0 was dropped. Update Readme, CHANGELOG, migration guide, and cross-links. https://claude.ai/code/session_01WVsnjDXjACmcLWJLGNMKVL * Run tests against both net8.0 and net10.0 Multi-target the test project so the suite exercises the library on both shipped target frameworks rather than net8.0 only. https://claude.ai/code/session_01WVsnjDXjACmcLWJLGNMKVL --------- Co-authored-by: Claude --- CHANGELOG.md | 10 ++- .../PasswordGenerator.Benchmarks.csproj | 2 +- .../PasswordGenerator.Tests.csproj | 12 +-- PasswordGenerator/CryptoRandomSource.cs | 48 +----------- PasswordGenerator/PasswordGenerator.csproj | 6 +- Readme.md | 12 ++- docs/README.md | 74 +++++++++---------- docs/{v3-target => }/api-surface.md | 14 ++-- docs/{v3-target => }/architecture.md | 36 ++++----- .../V3_REVIEW_AND_DOCUMENTATION.md | 4 + docs/{ => archive}/V3_VERIFICATION.md | 5 ++ docs/{v3-target => archive}/before-after.md | 4 + .../current-state/api-surface.md | 4 +- .../current-state/architecture.md | 2 +- .../current-state/generation-flow.md | 4 +- .../implementation-plan.md | 10 ++- docs/{v3-target => archive}/roadmap.md | 8 +- docs/{v3-target => }/configuration-and-di.md | 8 +- docs/{v3-target => }/generation-flow.md | 27 ++++--- docs/{v3-target => }/migration-v2-to-v3.md | 3 + docs/v3-local-nuget-test.md | 6 +- 21 files changed, 141 insertions(+), 158 deletions(-) rename docs/{v3-target => }/api-surface.md (85%) rename docs/{v3-target => }/architecture.md (73%) rename docs/{ => archive}/V3_REVIEW_AND_DOCUMENTATION.md (98%) rename docs/{ => archive}/V3_VERIFICATION.md (97%) rename docs/{v3-target => archive}/before-after.md (93%) rename docs/{ => archive}/current-state/api-surface.md (94%) rename docs/{ => archive}/current-state/architecture.md (97%) rename docs/{ => archive}/current-state/generation-flow.md (95%) rename docs/{v3-target => archive}/implementation-plan.md (97%) rename docs/{v3-target => archive}/roadmap.md (87%) rename docs/{v3-target => }/configuration-and-di.md (90%) rename docs/{v3-target => }/generation-flow.md (75%) rename docs/{v3-target => }/migration-v2-to-v3.md (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b126ea..0181e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,17 @@ All notable changes to this project are documented here. This project adheres to ## 3.0.0 A major release focused on cryptographic correctness, a modern API, and broader use cases. -See the [v2 → v3 migration guide](docs/v3-target/migration-v2-to-v3.md). +See the [v2 → v3 migration guide](docs/migration-v2-to-v3.md). ### Breaking changes - **Invalid settings now throw** `ArgumentException` from `Next()` instead of returning an error message as the "password". Use `TryNext(out var password)` for a non-throwing path. +- **Minimum runtime is now .NET 8.** The package targets `net8.0` and `net10.0`; `netstandard2.0` + has been dropped. Consumers on .NET Framework or other older runtimes should stay on the 2.x line. ### Security / correctness fixes - Cryptographically secure RNG (`CryptoRandomSource`) with **unbiased** integer sampling - (rejection sampling — removes modulo bias). + (via `RandomNumberGenerator.GetInt32` — removes modulo bias). - Fixed an off-by-one in length handling and removed the GUID-based shuffle in favour of a Fisher–Yates shuffle. - Disposed/owned RNG lifecycle; removed dead code and the static RNG. @@ -32,7 +34,7 @@ See the [v2 → v3 migration guide](docs/v3-target/migration-v2-to-v3.md). `PasswordOptions.DefaultBatchCount`. ### Packaging -- Multi-targets `netstandard2.0` and `net8.0`; nullable reference types enabled. +- Multi-targets `net8.0` and `net10.0`; nullable reference types enabled. - Single source of version truth in the csproj (removed the stale `.nuspec`). - `PackageIcon` + `PackageReadmeFile` (clears `NU5048`), SourceLink, deterministic build, and a `.snupkg` symbol package. @@ -44,4 +46,4 @@ See the [v2 → v3 migration guide](docs/v3-target/migration-v2-to-v3.md). ## 2.1.0 and earlier See the project history and the original review in -[`docs/V3_REVIEW_AND_DOCUMENTATION.md`](docs/V3_REVIEW_AND_DOCUMENTATION.md). +[`docs/archive/V3_REVIEW_AND_DOCUMENTATION.md`](docs/archive/V3_REVIEW_AND_DOCUMENTATION.md). diff --git a/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj b/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj index f445b44..f9c107c 100644 --- a/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj +++ b/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj @@ -10,7 +10,7 @@ - + diff --git a/PasswordGenerator.Tests/PasswordGenerator.Tests.csproj b/PasswordGenerator.Tests/PasswordGenerator.Tests.csproj index d2b0146..40a0cae 100644 --- a/PasswordGenerator.Tests/PasswordGenerator.Tests.csproj +++ b/PasswordGenerator.Tests/PasswordGenerator.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0;net10.0 enable latest @@ -9,11 +9,11 @@ - - - - - + + + + + diff --git a/PasswordGenerator/CryptoRandomSource.cs b/PasswordGenerator/CryptoRandomSource.cs index 7118b26..93a220e 100644 --- a/PasswordGenerator/CryptoRandomSource.cs +++ b/PasswordGenerator/CryptoRandomSource.cs @@ -4,58 +4,18 @@ namespace PasswordGenerator { /// - /// backed by a cryptographic RNG. - /// On modern targets it uses ; on - /// netstandard2.0 it uses rejection sampling so the result is uniform across the whole - /// range with no modulo bias and no off-by-one. + /// backed by a cryptographic RNG. Uses + /// , which samples uniformly across the whole + /// range with no modulo bias. /// - public sealed class CryptoRandomSource : IRandomSource, IDisposable + public sealed class CryptoRandomSource : IRandomSource { -#if !NET8_0_OR_GREATER - private readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); -#endif - public int NextInt(int maxExclusive) { if (maxExclusive <= 0) throw new ArgumentOutOfRangeException(nameof(maxExclusive), "maxExclusive must be positive."); -#if NET8_0_OR_GREATER return RandomNumberGenerator.GetInt32(maxExclusive); -#else - if (maxExclusive == 1) return 0; - - var range = (uint)maxExclusive; - - // Largest multiple of range that is <= 2^32. Values at or above this are rejected so the - // accepted region is a whole number of buckets, giving an unbiased result mod range. - const ulong fullSpace = 1UL << 32; - var limit = fullSpace - fullSpace % range; - - uint value; - do - { - value = NextUInt32(); - } while (value >= limit); - - return (int)(value % range); -#endif - } - -#if !NET8_0_OR_GREATER - private uint NextUInt32() - { - var buffer = new byte[4]; - _rng.GetBytes(buffer); - return BitConverter.ToUInt32(buffer, 0); - } -#endif - - public void Dispose() - { -#if !NET8_0_OR_GREATER - _rng.Dispose(); -#endif } } } diff --git a/PasswordGenerator/PasswordGenerator.csproj b/PasswordGenerator/PasswordGenerator.csproj index 933fdf5..ae8ce7a 100644 --- a/PasswordGenerator/PasswordGenerator.csproj +++ b/PasswordGenerator/PasswordGenerator.csproj @@ -1,7 +1,7 @@ - netstandard2.0;net8.0;net10.0 + net8.0;net10.0 enable latest @@ -20,7 +20,7 @@ MIT passwordgeneratorlogo.png README.md - Password,Passphrase,Generator,OWASP,NIST,Security,Random,Crypto,OTP,ApiKey,Entropy,dotnet,netstandard,DependencyInjection + Password,Passphrase,Generator,OWASP,NIST,Security,Random,Crypto,OTP,ApiKey,Entropy,dotnet,DependencyInjection 3.0.0 is a major release: cryptographically secure RNG with unbiased sampling, exception/TryNext error handling, async APIs, dependency-injection support, presets (OWASP/NIST/OTP/API key/passphrase), custom pools, exclude-ambiguous, per-class minimums and entropy estimation. See the migration guide for upgrading from 2.x. false @@ -41,7 +41,7 @@ - + diff --git a/Readme.md b/Readme.md index 9cbd20d..e3fa558 100644 --- a/Readme.md +++ b/Readme.md @@ -14,12 +14,10 @@ Install via NuGet: ``` Install-Package PasswordGenerator ``` [Or click here to go to the package landing page](https://www.nuget.org/packages/PasswordGenerator) -It targets `netstandard2.0` and `net8.0`, so it runs on .NET Framework, .NET Core and modern .NET. -See the chart below: +It targets `net8.0` and `net10.0`, so it requires .NET 8 or later. If you need to run on .NET +Framework or other older runtimes, use the 2.x line (which targets `netstandard2.0`). -![Compatibility Chart](https://github.com/prjseal/PasswordGenerator/blob/master/compatibility.png "Compatibility Chart") - -> **Upgrading from 2.x?** See the [v2 → v3 migration guide](docs/v3-target/migration-v2-to-v3.md). +> **Upgrading from 2.x?** See the [v2 → v3 migration guide](docs/migration-v2-to-v3.md). > The v2 API still works; the one behavioural change is that invalid settings now **throw** (or use > `TryNext`) instead of returning an error string as the "password". @@ -82,7 +80,7 @@ var password = pwd.Next(); ## Presets Ready-made starting points; later fluent calls still override them. See the -[standards mapping](docs/v3-target/migration-v2-to-v3.md#6-standards-mapping-for-the-presets) for the +[standards mapping](docs/migration-v2-to-v3.md#6-standards-mapping-for-the-presets) for the OWASP/NIST rationale. ```csharp @@ -150,6 +148,6 @@ public class SignupService(IPasswordGenerator generator) ## Documentation -- [v2 → v3 migration guide](docs/v3-target/migration-v2-to-v3.md) +- [v2 → v3 migration guide](docs/migration-v2-to-v3.md) - [Changelog](CHANGELOG.md) - [Design & architecture docs](docs/README.md) diff --git a/docs/README.md b/docs/README.md index 04b8357..33d9e0a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,53 +1,53 @@ # PasswordGenerator — Documentation -This folder is the working reference for the package: the **shipped v3 design** and the historical -review of the v2.1.0 code it replaced. Diagrams are written in [Mermaid](https://mermaid.js.org/) and -render directly on GitHub. +This folder is the working reference for the package as it ships today (**v3**). Diagrams are written +in [Mermaid](https://mermaid.js.org/) and render directly on GitHub. ## How the docs fit together ```mermaid flowchart LR - subgraph Review["Review & verification"] - A[V3_REVIEW_AND_DOCUMENTATION.md
full review of v2.1.0] - B[V3_VERIFICATION.md
every issue re-checked vs current source] + subgraph Docs["the docs — current (v3)"] + D1[architecture.md] + D2[generation-flow.md] + D3[api-surface.md] + D4[configuration-and-di.md] + D5[migration-v2-to-v3.md] + D6[v3-local-nuget-test.md] end - subgraph Current["current-state/ — what we have"] - C1[architecture.md] - C2[generation-flow.md] - C3[api-surface.md] + subgraph Archive["archive/ — historical"] + A1[V3_REVIEW_AND_DOCUMENTATION.md] + A2[V3_VERIFICATION.md] + A3[current-state/ v2.1.0 snapshot] + A4[before-after.md] + A5[roadmap.md] + A6[implementation-plan.md] end - subgraph Target["v3-target/ — where we are going"] - T1[architecture.md] - T2[generation-flow.md] - T3[api-surface.md] - T4[configuration-and-di.md] - T5[before-after.md] - T6[roadmap.md] - T7[implementation-plan.md] - T8[migration-v2-to-v3.md] - end - A --> B --> Current - Current --> Target - T5 -. compares .-> Current - T6 --> T7 + Docs -. superseded by .-> Archive ``` ## Reading order -1. **`V3_REVIEW_AND_DOCUMENTATION.md`** — the original full review (API, bugs, packaging, gaps). -2. **`V3_VERIFICATION.md`** — each issue re-checked against the current `master` source, with verdicts. -3. **`current-state/`** — diagrammed snapshot of the v2.1.0 code (now **historical**; the issues it - documents are resolved in v3 — see the root [`CHANGELOG.md`](../CHANGELOG.md)). -4. **`v3-target/`** — the v3 design (now **shipped**), diagrammed, with a before/after, a roadmap, a - phased **`implementation-plan.md`**, and the user-facing **`migration-v2-to-v3.md`**. +1. **`architecture.md`** — type relationships, the random source, and the multi-targeting strategy. +2. **`generation-flow.md`** — how `Next()`/`Generate()` build a password, plus the async path. +3. **`api-surface.md`** — the public fluent surface, presets, and batch/async APIs. +4. **`configuration-and-di.md`** — `PasswordOptions`, settings resolution, and DI registration. +5. **`migration-v2-to-v3.md`** — the user-facing upgrade guide from 2.x. +6. **`v3-local-nuget-test.md`** — local `dotnet pack` / install verification procedure. ## Conventions -- **Current state** describes `master` @ v2.1.0, `netstandard2.0`. Code references use - `file:line` against that source. -- **v3 target** describes the design that shipped in v3.0.0 (`netstandard2.0;net8.0`, nullable - enabled). The `implementation-plan.md` records where the shipped code intentionally diverged from - the earlier proposal (e.g. the `IPasswordBuilder` split was deferred and sync methods were not - obsoleted). -- Each "target" doc ends with a **Why this is better** note tied back to a verified issue. +- These docs describe **v3.0.0** as shipped: targets `net8.0;net10.0`, nullable enabled. +- Each doc ends with a **Why this is better** note. + +## Archive + +`archive/` keeps the material that led to v3 but no longer describes the current state: + +- **`V3_REVIEW_AND_DOCUMENTATION.md`** / **`V3_VERIFICATION.md`** — the original review and + issue-by-issue verification of the v2.1.0 code. +- **`current-state/`** — the diagrammed snapshot of the v2.1.0 (`netstandard2.0`) code that v3 replaced. +- **`roadmap.md`** / **`implementation-plan.md`** / **`before-after.md`** — the v3 planning documents. + +These are point-in-time records; where they recommend or describe `netstandard2.0` support, note that +v3 dropped it (see the root [`CHANGELOG.md`](../CHANGELOG.md)). diff --git a/docs/v3-target/api-surface.md b/docs/api-surface.md similarity index 85% rename from docs/v3-target/api-surface.md rename to docs/api-surface.md index 19e18fe..61723f4 100644 --- a/docs/v3-target/api-surface.md +++ b/docs/api-surface.md @@ -1,11 +1,11 @@ -# v3 Target — Public API Surface +# Public API Surface Keeps the familiar fluent feel; adds safety, presets, batch, async, and custom pools. -> **As shipped:** the fluent builder is the existing `IPassword` (the full `IPasswordBuilder`/`Build()` -> split from the early proposal was deferred). `Password` implements both `IPassword` and the new -> generation contract `IPasswordGenerator`. Passphrases return an `IPasswordGenerator` -> (`PassphraseGenerator`). See `implementation-plan.md` for the deviations. +> The fluent builder is `IPassword` (there is no separate `IPasswordBuilder`/`Build()` split). +> `Password` implements both `IPassword` and the generation contract `IPasswordGenerator`. +> Passphrases return an `IPasswordGenerator` (`PassphraseGenerator`). See +> `archive/implementation-plan.md` for how the shipped surface diverged from the early proposal. ## API map @@ -69,7 +69,7 @@ fluent call still overrides them (resolution order is documented in `configurati ## Surfacing the broader purpose The library is **not password-only**. The same surface generates OTPs, environment names, API keys, -and other identifiers — so v3 deliberately keeps the per-class `Include*` methods and adds +and other identifiers — so the library deliberately keeps the per-class `Include*` methods and adds `WithCharacters`/`WithAllAscii` rather than forcing OWASP composition or a global 12-char minimum. ## Deprecation / migration shape @@ -89,6 +89,6 @@ flowchart TD > of async would be an anti-pattern and would spam every consumer with build warnings. Async exists > for ergonomics and cancellation only. -**Why this is better:** every verified gap in `../current-state/api-surface.md` is closed +**Why this is better:** every gap noted in the v2.1.0 review (`archive/current-state/api-surface.md`) is closed (`TryNext`/async/DI/presets/appSettings/custom pools), failures become explicit, and existing single `.Next()` users still work unchanged, giving a gentle upgrade path. diff --git a/docs/v3-target/architecture.md b/docs/architecture.md similarity index 73% rename from docs/v3-target/architecture.md rename to docs/architecture.md index 6345600..c594d81 100644 --- a/docs/v3-target/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,6 @@ -# v3 Target — Architecture +# Architecture -> Multi-target `netstandard2.0;net8.0`, nullable enabled. Aligns with the adjusted plan in -> `../V3_VERIFICATION.md` §3. As shipped, the existing `IPassword` remains the fluent builder (no +> Multi-targets `net8.0;net10.0`, nullable enabled. `IPassword` is the fluent builder (no > separate `IPasswordBuilder`/`Build()`); `Password` implements both `IPassword` and the generation > contract `IPasswordGenerator`. @@ -51,7 +50,7 @@ classDiagram +int NextInt(int maxExclusive) } class CryptoRandomSource { - GetInt32 on net8, rejection sampling on netstandard2.0 + RandomNumberGenerator.GetInt32 } class IEntropyEstimator { <> @@ -68,13 +67,13 @@ classDiagram Password ..> PoolEntropyEstimator : EstimateEntropyBits ``` -Key shifts from today: -- **`IRandomSource` abstraction** wraps the CSPRNG (unbiased `RandomNumberGenerator.GetInt32` on - `net8.0`; rejection-sampling fallback on `netstandard2.0`). No `static`, disposable-aware, - injectable. Fixes verified §5.2/§5.3/§5.6 and lets the Guid `Shuffle` be deleted (§5.5). +Key points: +- **`IRandomSource` abstraction** wraps the CSPRNG (unbiased `RandomNumberGenerator.GetInt32`). No + `static`, injectable — a deterministic `IRandomSource` can be injected in unit tests — and the + Guid-based `Shuffle` is gone in favour of Fisher–Yates. - **`PasswordOptions`** is the DI config object, bindable from `IConfiguration`. - **Presets** are static factory methods on `Password` that pre-fill the fluent builder. -- The `[Obsolete]` v2 wrappers are **removed** in v3 (recommended in `../V3_VERIFICATION.md` §4). +- The `[Obsolete]` v2 wrappers from earlier proposals are not present. ## Target composition (with DI) @@ -99,19 +98,20 @@ registration is only responsible for wiring `IRandomSource` and default `Passwor ```mermaid flowchart LR - subgraph ns["netstandard2.0 (broad reach: .NET Framework, Umbraco)"] - a["manual rejection sampling"] - end - subgraph net8["net8.0 (modern)"] + subgraph net8["net8.0"] b["RandomNumberGenerator.GetInt32"] end - IRandomSource --> ns + subgraph net10["net10.0"] + c["RandomNumberGenerator.GetInt32"] + end IRandomSource --> net8 + IRandomSource --> net10 ``` -`#if` inside `CryptoRandomSource` selects the optimal API per target while keeping one public surface. +Both targets use the same built-in `RandomNumberGenerator.GetInt32`, so `CryptoRandomSource` needs no +`#if` and exposes one uniform public surface. (`netstandard2.0`, which required a manual +rejection-sampling fallback, was dropped in v3 — see the [changelog](../CHANGELOG.md).) **Why this is better:** removes the `static`/undisposed RNG, makes randomness unbiased and testable -(inject a deterministic `IRandomSource` in unit tests), keeps .NET Framework users supported, and -gives modern consumers the fast built-in APIs — addressing verified issues §5.2, §5.3, §5.5, §5.6 and -gaps §8 (async/DI/multi-target) at the architecture level. +(inject a deterministic `IRandomSource` in unit tests), and uses the fast, allocation-free built-in +crypto API on every supported runtime. diff --git a/docs/V3_REVIEW_AND_DOCUMENTATION.md b/docs/archive/V3_REVIEW_AND_DOCUMENTATION.md similarity index 98% rename from docs/V3_REVIEW_AND_DOCUMENTATION.md rename to docs/archive/V3_REVIEW_AND_DOCUMENTATION.md index 5a9987d..1c56c1c 100644 --- a/docs/V3_REVIEW_AND_DOCUMENTATION.md +++ b/docs/archive/V3_REVIEW_AND_DOCUMENTATION.md @@ -1,5 +1,9 @@ # PasswordGenerator — Full Package Documentation & v3 Review +> **Archived / historical.** Review of the v2.1.0 source written to plan v3, kept for reference. +> Where it recommends multi-targeting `netstandard2.0`, note that v3 dropped `netstandard2.0` and +> targets `net8.0;net10.0`. See the root [`CHANGELOG.md`](../../CHANGELOG.md). + > Purpose: a single, self-contained reference for the `PasswordGenerator` NuGet package as it > stands today (v2.1.0). Written so it can be pasted into a Claude chat to plan v3. It covers > what the package is, every public API, the internal implementation, confirmed bugs, design diff --git a/docs/V3_VERIFICATION.md b/docs/archive/V3_VERIFICATION.md similarity index 97% rename from docs/V3_VERIFICATION.md rename to docs/archive/V3_VERIFICATION.md index 1127482..35f8da2 100644 --- a/docs/V3_VERIFICATION.md +++ b/docs/archive/V3_VERIFICATION.md @@ -1,5 +1,10 @@ # PasswordGenerator v3 — Verification Report +> **Archived / historical (2026-05-24).** Point-in-time analysis of the v2.1.0 source, kept for +> reference. Its target-framework recommendation (multi-target `netstandard2.0;net8.0`) was **not** +> followed: v3 dropped `netstandard2.0` and targets `net8.0;net10.0`. See the root +> [`CHANGELOG.md`](../../CHANGELOG.md). + > Companion to `V3_REVIEW_AND_DOCUMENTATION.md` and the v3 Planning Addendum. > This document does the verification the addendum asked for: every item from the original > review's bug list (§5) and feature gaps (§8) was re-checked against the **current source**, diff --git a/docs/v3-target/before-after.md b/docs/archive/before-after.md similarity index 93% rename from docs/v3-target/before-after.md rename to docs/archive/before-after.md index acb93b9..e3ece99 100644 --- a/docs/v3-target/before-after.md +++ b/docs/archive/before-after.md @@ -1,5 +1,9 @@ # v3 Target — Before / After +> **Archived / historical.** A v3 planning document, kept for reference and superseded by the shipped +> v3 docs in [`../`](../README.md). The "after" column reflects the early plan; note that v3 ultimately +> **dropped `netstandard2.0`** (targets `net8.0;net10.0`). + Side-by-side of the things that change most, each tied to a verified issue. ## 1. Failure handling diff --git a/docs/current-state/api-surface.md b/docs/archive/current-state/api-surface.md similarity index 94% rename from docs/current-state/api-surface.md rename to docs/archive/current-state/api-surface.md index 7f0f6b5..84b1089 100644 --- a/docs/current-state/api-surface.md +++ b/docs/archive/current-state/api-surface.md @@ -1,7 +1,7 @@ # Current State — Public API Surface (v2.1.0) > **Historical.** This describes v2.1.0. The issues noted here are resolved in v3 — see the root -> [`CHANGELOG.md`](../../CHANGELOG.md) and the [migration guide](../v3-target/migration-v2-to-v3.md). +> [`CHANGELOG.md`](../../../CHANGELOG.md) and the [migration guide](../../migration-v2-to-v3.md). What a caller can do today, and how configuration is resolved. @@ -74,4 +74,4 @@ flowchart TD | Exclude-ambiguous, per-class minimums, entropy estimate | ✅ | | `netstandard2.0` + `net8.0` multi-target / nullable | ✅ (netstandard2.0 only) | -These gaps define the v3 surface in `../v3-target/api-surface.md`. +These gaps define the v3 surface in `../../api-surface.md`. diff --git a/docs/current-state/architecture.md b/docs/archive/current-state/architecture.md similarity index 97% rename from docs/current-state/architecture.md rename to docs/archive/current-state/architecture.md index b903995..3de1bcf 100644 --- a/docs/current-state/architecture.md +++ b/docs/archive/current-state/architecture.md @@ -1,7 +1,7 @@ # Current State — Architecture (v2.1.0) > **Historical.** This describes v2.1.0. The issues noted here are resolved in v3 — see the root -> [`CHANGELOG.md`](../../CHANGELOG.md) and the [migration guide](../v3-target/migration-v2-to-v3.md). +> [`CHANGELOG.md`](../../../CHANGELOG.md) and the [migration guide](../../migration-v2-to-v3.md). `master` @ v2.1.0 · target `netstandard2.0` · no third-party runtime dependencies. diff --git a/docs/current-state/generation-flow.md b/docs/archive/current-state/generation-flow.md similarity index 95% rename from docs/current-state/generation-flow.md rename to docs/archive/current-state/generation-flow.md index 7f2afa0..ac54286 100644 --- a/docs/current-state/generation-flow.md +++ b/docs/archive/current-state/generation-flow.md @@ -1,7 +1,7 @@ # Current State — Generation Flow (v2.1.0) > **Historical.** This describes v2.1.0. The issues noted here are resolved in v3 — see the root -> [`CHANGELOG.md`](../../CHANGELOG.md) and the [migration guide](../v3-target/migration-v2-to-v3.md). +> [`CHANGELOG.md`](../../../CHANGELOG.md) and the [migration guide](../../migration-v2-to-v3.md). How `Next()` produces a password today (`Password.cs:114-193`). @@ -91,4 +91,4 @@ stateDiagram-v2 ``` The whole correctness contract hinges on probabilistic retry + string sentinels — the core thing v3 -replaces (see `../v3-target/generation-flow.md`). +replaces (see `../../generation-flow.md`). diff --git a/docs/v3-target/implementation-plan.md b/docs/archive/implementation-plan.md similarity index 97% rename from docs/v3-target/implementation-plan.md rename to docs/archive/implementation-plan.md index 7c6f8e3..56d0299 100644 --- a/docs/v3-target/implementation-plan.md +++ b/docs/archive/implementation-plan.md @@ -1,8 +1,12 @@ # v3 Target — Implementation Plan (phased) -> Actionable, phase-by-phase plan to deliver the v3 design in `architecture.md`, -> `generation-flow.md`, `api-surface.md`, `configuration-and-di.md` and `before-after.md`. -> Sequencing follows `roadmap.md`; issue numbers (§5.x / §8) reference `../V3_VERIFICATION.md`. +> **Archived / historical.** This is a v3 planning document, kept for reference and superseded by the +> shipped v3 docs in [`../`](../README.md). Note that v3 ultimately **dropped `netstandard2.0`** +> (targets `net8.0;net10.0`), contrary to the multi-target plan described here. + +> Actionable, phase-by-phase plan to deliver the v3 design in `../architecture.md`, +> `../generation-flow.md`, `../api-surface.md`, `../configuration-and-di.md` and `before-after.md`. +> Sequencing follows `roadmap.md`; issue numbers (§5.x / §8) reference `V3_VERIFICATION.md`. ## Working principles diff --git a/docs/v3-target/roadmap.md b/docs/archive/roadmap.md similarity index 87% rename from docs/v3-target/roadmap.md rename to docs/archive/roadmap.md index 58fa049..5a95746 100644 --- a/docs/v3-target/roadmap.md +++ b/docs/archive/roadmap.md @@ -1,6 +1,10 @@ # v3 Target — Roadmap -Tiered delivery from the adjusted plan in `../V3_VERIFICATION.md` §3. Sequencing only — not committed +> **Archived / historical.** This is a v3 planning document, kept for reference. It is superseded by +> the shipped v3 docs in [`../`](../README.md). Note that v3 ultimately **dropped `netstandard2.0`** +> (targets `net8.0;net10.0`), contrary to the multi-target recommendation below. + +Tiered delivery from the adjusted plan in `V3_VERIFICATION.md` §3. Sequencing only — not committed dates. (Delivered in v3.0.0; see `implementation-plan.md` for where the shipped code diverged.) ## Tiers as phases @@ -76,7 +80,7 @@ flowchart TD class D1,D2,D3 q; ``` -See `../V3_VERIFICATION.md` §4 for the reasoning behind each recommendation. +See `V3_VERIFICATION.md` §4 for the reasoning behind each recommendation. ## Release-note discipline diff --git a/docs/v3-target/configuration-and-di.md b/docs/configuration-and-di.md similarity index 90% rename from docs/v3-target/configuration-and-di.md rename to docs/configuration-and-di.md index 2645040..31ed2fc 100644 --- a/docs/v3-target/configuration-and-di.md +++ b/docs/configuration-and-di.md @@ -1,4 +1,4 @@ -# v3 Target — Configuration & Dependency Injection +# Configuration & Dependency Injection ## Settings resolution order @@ -49,7 +49,7 @@ sequenceDiagram Svc->>Svc: generator.Generate(5) ``` -Two overloads (answering open question #3 in `../V3_VERIFICATION.md`): +Two overloads: ```csharp services.AddPasswordGenerator(options => { options.Length = 20; }); // code @@ -72,6 +72,6 @@ The fluent API must produce identical results whether the instance is constructe resolved from the container; DI only changes *how the dependencies are supplied*, not *what the builder does*. -**Why this is better:** teams can centralise password policy in `appSettings` (closing verified gap -§8) without forcing it on every call site, the RNG dependency is wired once, and unit tests can swap +**Why this is better:** teams can centralise password policy in `appSettings` +without forcing it on every call site, the RNG dependency is wired once, and unit tests can swap `IRandomSource` for a deterministic stub. diff --git a/docs/v3-target/generation-flow.md b/docs/generation-flow.md similarity index 75% rename from docs/v3-target/generation-flow.md rename to docs/generation-flow.md index 6bd1aed..90d9f96 100644 --- a/docs/v3-target/generation-flow.md +++ b/docs/generation-flow.md @@ -1,8 +1,8 @@ -# v3 Target — Generation Flow +# Generation Flow -Replaces probabilistic retry + string sentinels with **deterministic construction + exceptions**. +Uses **deterministic construction + exceptions** rather than probabilistic retry + string sentinels. -## Target `Next()` / `TryNext()` flow +## `Next()` / `TryNext()` flow ```mermaid flowchart TD @@ -20,14 +20,13 @@ flowchart TD class Throw bad; ``` -**What changed vs today (`../current-state/generation-flow.md`):** +**Design properties:** - No retry loop, no `MaximumAttempts` gamble, **no `"Try again"` string**. Required classes are - *guaranteed* by construction (fixes the probabilistic guarantee gap, §8). -- Invalid configuration **throws** (`Next()`) or returns `false` (`TryNext`) — never a fake password - (fixes §5.1 and §5.8). + *guaranteed* by construction. +- Invalid configuration **throws** (`Next()`) or returns `false` (`TryNext`) — never a fake password. - Selection uses unbiased `IRandomSource.NextInt(maxExclusive)` — no `% (len-1)` off-by-one, no - modulo bias (fixes §5.2, §5.3). -- Shuffle is a real crypto Fisher–Yates, replacing `orderby Guid.NewGuid()` (fixes/cleans §5.5). + modulo bias. +- Shuffle is a real crypto Fisher–Yates, not `orderby Guid.NewGuid()`. ## Deterministic class-seeding (the core idea) @@ -54,23 +53,23 @@ sequenceDiagram RNG-->>Gen: index end Gen-->>App: Task> - Note over App,Gen: sync Next()/Generate() still exist
and are fully supported (NOT obsoleted) + Note over App,Gen: sync Next()/Generate() also exist
and are fully supported (NOT obsoleted) ``` > Note: generation is CPU-bound, so async mainly helps large-batch ergonomics and cancellation, not -> raw throughput — the **BenchmarkDotNet** suite (plan §10/§12) exists to prove where async actually +> raw throughput — the **BenchmarkDotNet** suite exists to prove where async actually > pays off, with numbers published in every release note. -## Failure contract — before vs after +## Failure contract — v2.1.0 vs v3 ```mermaid stateDiagram-v2 - state "v2.1.0 (today)" as Old { + state "v2.1.0 (previous)" as Old { [*] --> RetryLoop RetryLoop --> OKo: valid RetryLoop --> StrFail: attempts exhausted → 'Try again' STRING } - state "v3 (target)" as New { + state "v3 (current)" as New { [*] --> Validate Validate --> BuildOK: build guarantees validity Validate --> Throw: invalid config → exception / false diff --git a/docs/v3-target/migration-v2-to-v3.md b/docs/migration-v2-to-v3.md similarity index 96% rename from docs/v3-target/migration-v2-to-v3.md rename to docs/migration-v2-to-v3.md index 67816c7..809e4b9 100644 --- a/docs/v3-target/migration-v2-to-v3.md +++ b/docs/migration-v2-to-v3.md @@ -8,6 +8,9 @@ about, plus the new capabilities you can adopt at your own pace. > **Length range:** valid password lengths are **4–256** characters (the old "8–128" Readme claim was > never the actual limit). +> **Runtime requirement:** v3 targets `net8.0` and `net10.0` and **drops `netstandard2.0`**. You need +> .NET 8 or later. Projects on .NET Framework or other older runtimes should stay on the 2.x line. + --- ## 1. The one breaking change: error strings → exceptions / `TryNext` diff --git a/docs/v3-local-nuget-test.md b/docs/v3-local-nuget-test.md index 3c70469..9f6b377 100644 --- a/docs/v3-local-nuget-test.md +++ b/docs/v3-local-nuget-test.md @@ -21,13 +21,13 @@ dotnet pack PasswordGenerator/PasswordGenerator.csproj -c Release -o /tmp/localn Output (trimmed): ``` -PasswordGenerator -> .../bin/Release/netstandard2.0/PasswordGenerator.dll PasswordGenerator -> .../bin/Release/net8.0/PasswordGenerator.dll +PasswordGenerator -> .../bin/Release/net10.0/PasswordGenerator.dll Successfully created package '/tmp/localnuget/PasswordGenerator.3.0.0.nupkg'. Successfully created package '/tmp/localnuget/PasswordGenerator.3.0.0.snupkg'. ``` -The package multi-targets `netstandard2.0` and `net8.0`, and a `.snupkg` symbol package is +The package multi-targets `net8.0` and `net10.0`, and a `.snupkg` symbol package is produced alongside it. **Expected** — this matches the packaging notes in `CHANGELOG.md`. > The only build warnings were `SourceLink` notices that the source-control information is @@ -375,7 +375,7 @@ passwords matching `DefaultBatchCount`. | # | Scenario | Result | Expected? | |---|----------|--------|-----------| -| – | `dotnet pack` (netstandard2.0 + net8.0, .snupkg) | Built | Yes | +| – | `dotnet pack` (net8.0 + net10.0, .snupkg) | Built | Yes | | – | Register local feed + install package | Installed, framework-compatible | Yes | | 1 | Basic default | length 16, all classes | Yes | | 2 | Explicit length 32 | length 32 | Yes | From ffa2d21207af452b5bf72802d3b24ad537be1880 Mon Sep 17 00:00:00 2001 From: Paul Seal Date: Mon, 25 May 2026 16:08:42 +0100 Subject: [PATCH 13/14] Add v3 package documentation and code review (#37) * Ship XML documentation file in NuGet package Enable GenerateDocumentationFile so the heavily-documented public API provides IntelliSense to consumers of the package. https://claude.ai/code/session_01AikHWpKd7zQCfPDJcE2ZkX * Use ValueTask and cancelled-task semantics for async APIs Switch NextAsync/GenerateAsync to ValueTask so the synchronous completion path allocates no Task, and surface cancellation as a cancelled task (ValueTask.FromCanceled) instead of throwing synchronously, so the result composes correctly when not awaited immediately. Argument validation still throws synchronously, matching BCL conventions. https://claude.ai/code/session_01AikHWpKd7zQCfPDJcE2ZkX * Fix benchmark build against ValueTask async APIs and silence CS1591 The async APIs now return ValueTask, so the AsyncBenchmarks methods must return ValueTask too (the Task return types no longer compile on either target framework). Add CS1591 to NoWarn so enabling the XML doc file does not warn on intentionally-undocumented public members. https://claude.ai/code/session_01WVsnjDXjACmcLWJLGNMKVL * Document the public API instead of suppressing CS1591 With the XML doc file enabled, add XML comments to every public type and member so the shipped documentation is complete and CS1591 no longer warns. Interface implementations use ; the NoWarn CS1591 suppression added earlier is removed. https://claude.ai/code/session_01WVsnjDXjACmcLWJLGNMKVL * Bump GitHub Actions to Node 24 runtimes The v4 majors of checkout, setup-dotnet, and upload-artifact run on the deprecated Node.js 20 runner. Bump to checkout@v6, setup-dotnet@v5, and upload-artifact@v7, which run on Node.js 24. Inputs used here are unchanged across these majors. https://claude.ai/code/session_01WVsnjDXjACmcLWJLGNMKVL --------- Co-authored-by: Claude --- .github/workflows/benchmarks.yml | 6 +- .github/workflows/ci.yml | 8 +-- .github/workflows/release.yml | 4 +- .../Benchmarks/AsyncBenchmarks.cs | 4 +- PasswordGenerator.Tests/Phase3Tests.cs | 24 ++++++++ PasswordGenerator/CharacterClass.cs | 7 +++ PasswordGenerator/CryptoRandomSource.cs | 9 +++ PasswordGenerator/IPassword.cs | 35 +++++++++++ PasswordGenerator/IPasswordGenerator.cs | 12 ++-- PasswordGenerator/IPasswordSettings.cs | 34 +++++++++++ PasswordGenerator/PassphraseGenerator.cs | 44 +++++++++++--- PasswordGenerator/Password.cs | 60 ++++++++++++++++--- PasswordGenerator/PasswordGenerator.csproj | 3 + PasswordGenerator/PasswordOptions.cs | 8 +++ PasswordGenerator/PasswordSettings.cs | 52 ++++++++++++++++ PasswordGenerator/PoolEntropyEstimator.cs | 9 +++ docs/api-surface.md | 2 +- 17 files changed, 290 insertions(+), 31 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 527e5b2..256759e 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -14,10 +14,10 @@ jobs: benchmark: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 8.0.x @@ -58,7 +58,7 @@ jobs: - name: Upload raw artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: benchmark-results-${{ github.run_number }} path: PasswordGenerator.Benchmarks/BenchmarkDotNet.Artifacts/results/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e291c2..d04e877 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # full history so SourceLink can resolve the commit - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 8.0.x @@ -39,7 +39,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: test-results path: ./test-results/*.trx @@ -48,7 +48,7 @@ jobs: run: dotnet pack PasswordGenerator/PasswordGenerator.csproj -c Release --no-build -o artifacts - name: Upload package - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: nuget path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e392bd6..07e75c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,12 +12,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # full history so SourceLink can resolve the commit - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 8.0.x diff --git a/PasswordGenerator.Benchmarks/Benchmarks/AsyncBenchmarks.cs b/PasswordGenerator.Benchmarks/Benchmarks/AsyncBenchmarks.cs index a0ab1ef..05b597d 100644 --- a/PasswordGenerator.Benchmarks/Benchmarks/AsyncBenchmarks.cs +++ b/PasswordGenerator.Benchmarks/Benchmarks/AsyncBenchmarks.cs @@ -26,13 +26,13 @@ public void Cleanup() } [Benchmark] - public Task NextAsync() + public ValueTask NextAsync() { return _password.NextAsync(); } [Benchmark] - public Task> GenerateAsync() + public ValueTask> GenerateAsync() { return _password.GenerateAsync(Count); } diff --git a/PasswordGenerator.Tests/Phase3Tests.cs b/PasswordGenerator.Tests/Phase3Tests.cs index 095e3a0..2758096 100644 --- a/PasswordGenerator.Tests/Phase3Tests.cs +++ b/PasswordGenerator.Tests/Phase3Tests.cs @@ -29,6 +29,30 @@ public void NextAsync_WithAlreadyCancelledToken_Throws() Throws.InstanceOf()); } + [Test] + public void NextAsync_WithCancelledToken_SurfacesCancellationThroughTask() + { + var pwd = new Password(20); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Cancellation must come back through the returned task, not as a synchronous throw, + // so the result composes correctly when not awaited immediately. + var task = pwd.NextAsync(cts.Token); + Assert.That(task.IsCanceled, Is.True); + } + + [Test] + public void GenerateAsync_WithCancelledToken_SurfacesCancellationThroughTask() + { + var pwd = new Password(16); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var task = pwd.GenerateAsync(10, cts.Token); + Assert.That(task.IsCanceled, Is.True); + } + [Test] public void Generate_ReturnsRequestedCount() { diff --git a/PasswordGenerator/CharacterClass.cs b/PasswordGenerator/CharacterClass.cs index ea525c7..e5947f3 100644 --- a/PasswordGenerator/CharacterClass.cs +++ b/PasswordGenerator/CharacterClass.cs @@ -6,9 +6,16 @@ namespace PasswordGenerator ///
public enum CharacterClass { + /// Lowercase letters (a–z). Lowercase, + + /// Uppercase letters (A–Z). Uppercase, + + /// Digits (0–9). Numeric, + + /// Special / symbol characters. Special } } diff --git a/PasswordGenerator/CryptoRandomSource.cs b/PasswordGenerator/CryptoRandomSource.cs index 93a220e..d3f7ace 100644 --- a/PasswordGenerator/CryptoRandomSource.cs +++ b/PasswordGenerator/CryptoRandomSource.cs @@ -10,6 +10,15 @@ namespace PasswordGenerator /// public sealed class CryptoRandomSource : IRandomSource { + /// + /// Returns a uniformly distributed, non-negative random integer that is less than + /// . + /// + /// The exclusive upper bound; must be positive. + /// A random integer in the range [0, maxExclusive). + /// + /// is zero or negative. + /// public int NextInt(int maxExclusive) { if (maxExclusive <= 0) diff --git a/PasswordGenerator/IPassword.cs b/PasswordGenerator/IPassword.cs index 70fe6ee..a778230 100644 --- a/PasswordGenerator/IPassword.cs +++ b/PasswordGenerator/IPassword.cs @@ -2,12 +2,31 @@ namespace PasswordGenerator { + /// + /// Fluent builder for configuring and generating passwords. Each configuration method returns the + /// same instance so calls can be chained. + /// public interface IPassword { + /// Includes lowercase letters in the pool. + /// The same builder, for chaining. IPassword IncludeLowercase(); + + /// Includes uppercase letters in the pool. + /// The same builder, for chaining. IPassword IncludeUppercase(); + + /// Includes digits in the pool. + /// The same builder, for chaining. IPassword IncludeNumeric(); + + /// Includes the default special characters in the pool. + /// The same builder, for chaining. IPassword IncludeSpecial(); + + /// Includes the given special characters in the pool. + /// The special characters to add to the pool. + /// The same builder, for chaining. IPassword IncludeSpecial(string specialCharactersToInclude); /// Replaces the pool with an explicit set of characters (no forced composition). @@ -22,9 +41,25 @@ public interface IPassword /// Requires at least characters from the given class. IPassword RequireAtLeast(CharacterClass characterClass, int count); + /// Sets the required password length. + /// The number of characters the generated password should contain. + /// The same builder, for chaining. IPassword LengthRequired(int passwordLength); + + /// Generates a single password using the current settings. + /// The generated password. string Next(); + + /// Attempts to generate a single password without throwing on invalid settings. + /// + /// When this method returns , the generated password; otherwise . + /// + /// if a password was generated; otherwise . bool TryNext(out string? password); + + /// Generates a sequence of passwords using the current settings. + /// How many passwords to generate. + /// The generated passwords. IEnumerable NextGroup(int numberOfPasswordsToGenerate); } } \ No newline at end of file diff --git a/PasswordGenerator/IPasswordGenerator.cs b/PasswordGenerator/IPasswordGenerator.cs index b842dc4..e3651c9 100644 --- a/PasswordGenerator/IPasswordGenerator.cs +++ b/PasswordGenerator/IPasswordGenerator.cs @@ -17,10 +17,12 @@ public interface IPasswordGenerator bool TryNext(out string? password); /// - /// Generates a single password. Generation is CPU-bound; this overload exists for ergonomics - /// and to honour cancellation, not to offload work to another thread. + /// Generates a single password. Generation is CPU-bound and completes synchronously; this overload + /// exists for ergonomics and to honour cancellation, not to offload work to another thread. A + /// is used because the result is always available synchronously. + /// If is already cancelled, the returned task is cancelled. /// - Task NextAsync(CancellationToken cancellationToken = default); + ValueTask NextAsync(CancellationToken cancellationToken = default); /// Generates the default number of passwords (configurable; one unless overridden). IReadOnlyList Generate(); @@ -29,9 +31,9 @@ public interface IPasswordGenerator IReadOnlyList Generate(int count); /// Generates the default number of passwords, observing . - Task> GenerateAsync(CancellationToken cancellationToken = default); + ValueTask> GenerateAsync(CancellationToken cancellationToken = default); /// Generates passwords, observing . - Task> GenerateAsync(int count, CancellationToken cancellationToken = default); + ValueTask> GenerateAsync(int count, CancellationToken cancellationToken = default); } } diff --git a/PasswordGenerator/IPasswordSettings.cs b/PasswordGenerator/IPasswordSettings.cs index 8dbebd3..ddd2b97 100644 --- a/PasswordGenerator/IPasswordSettings.cs +++ b/PasswordGenerator/IPasswordSettings.cs @@ -7,11 +7,22 @@ namespace PasswordGenerator /// public interface IPasswordSettings { + /// Whether lowercase letters are included in the pool. bool IncludeLowercase { get; } + + /// Whether uppercase letters are included in the pool. bool IncludeUppercase { get; } + + /// Whether digits are included in the pool. bool IncludeNumeric { get; } + + /// Whether special characters are included in the pool. bool IncludeSpecial { get; } + + /// The number of characters the generated password should contain. int PasswordLength { get; set; } + + /// The full set of characters the password is drawn from, after applying all settings. string CharacterSet { get; } /// True when a custom pool (e.g. ) replaces the per-class sets. @@ -28,13 +39,35 @@ public interface IPasswordSettings /// guarantee at least one character from each required class is present in the output. /// IReadOnlyList CharacterGroups { get; } + + /// The maximum number of attempts allowed when generating a valid password. int MaximumAttempts { get; } + + /// The smallest allowed password length. int MinimumLength { get; } + + /// The largest allowed password length. int MaximumLength { get; } + + /// Enables lowercase letters in the pool. + /// The same settings, for chaining. IPasswordSettings AddLowercase(); + + /// Enables uppercase letters in the pool. + /// The same settings, for chaining. IPasswordSettings AddUppercase(); + + /// Enables digits in the pool. + /// The same settings, for chaining. IPasswordSettings AddNumeric(); + + /// Enables the default special characters in the pool. + /// The same settings, for chaining. IPasswordSettings AddSpecial(); + + /// Enables the given special characters in the pool. + /// The special characters to add to the pool. + /// The same settings, for chaining. IPasswordSettings AddSpecial(string specialCharactersToAdd); /// Replaces the entire pool with an explicit set of characters (no forced composition). @@ -49,6 +82,7 @@ public interface IPasswordSettings /// Requires at least characters from the given class, enabling it if needed. IPasswordSettings RequireAtLeast(CharacterClass characterClass, int count); + /// The special characters used when special characters are included. string SpecialCharacters { get; set; } } } \ No newline at end of file diff --git a/PasswordGenerator/PassphraseGenerator.cs b/PasswordGenerator/PassphraseGenerator.cs index e4ea7b7..00549e1 100644 --- a/PasswordGenerator/PassphraseGenerator.cs +++ b/PasswordGenerator/PassphraseGenerator.cs @@ -16,6 +16,16 @@ public class PassphraseGenerator : IPasswordGenerator, IDisposable private readonly IRandomSource _random; private readonly bool _ownsRandom; + /// Creates a passphrase generator. + /// The number of words in each passphrase; must be at least one. + /// The character placed between words (and before the trailing number). + /// Whether to capitalize the first letter of each word. + /// Whether to append a random two-digit number. + /// + /// An optional random source. When supplied, the caller owns it; otherwise a + /// is created and owned by this instance. + /// + /// is less than one. public PassphraseGenerator(int wordCount = 4, char separator = '-', bool capitalize = false, bool includeNumber = true, IRandomSource? randomSource = null) { @@ -30,14 +40,22 @@ public PassphraseGenerator(int wordCount = 4, char separator = '-', bool capital _ownsRandom = randomSource == null; } + /// The number of words in each passphrase. public int WordCount { get; } + + /// The character placed between words. public char Separator { get; } + + /// Whether the first letter of each word is capitalized. public bool Capitalize { get; } + + /// Whether a random two-digit number is appended. public bool IncludeNumber { get; } /// The number of passphrases produced by the parameterless overload. public int DefaultBatchCount { get; set; } = 1; + /// public string Next() { var sb = new StringBuilder(); @@ -67,23 +85,28 @@ public string Next() return sb.ToString(); } + /// public bool TryNext(out string? password) { password = Next(); return true; } - public Task NextAsync(CancellationToken cancellationToken = default) + /// + public ValueTask NextAsync(CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(Next()); + return cancellationToken.IsCancellationRequested + ? ValueTask.FromCanceled(cancellationToken) + : new ValueTask(Next()); } + /// public IReadOnlyList Generate() { return Generate(DefaultBatchCount); } + /// public IReadOnlyList Generate(int count) { if (count < 0) @@ -96,24 +119,30 @@ public IReadOnlyList Generate(int count) return passphrases; } - public Task> GenerateAsync(CancellationToken cancellationToken = default) + /// + public ValueTask> GenerateAsync(CancellationToken cancellationToken = default) { return GenerateAsync(DefaultBatchCount, cancellationToken); } - public Task> GenerateAsync(int count, CancellationToken cancellationToken = default) + /// + public ValueTask> GenerateAsync(int count, CancellationToken cancellationToken = default) { if (count < 0) throw new ArgumentOutOfRangeException(nameof(count), "count cannot be negative."); + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled>(cancellationToken); + var passphrases = new List(count); for (var i = 0; i < count; i++) { - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled>(cancellationToken); passphrases.Add(Next()); } - return Task.FromResult>(passphrases); + return new ValueTask>(passphrases); } /// Estimates passphrase entropy in bits from the word-list size, word count, and trailing number. @@ -124,6 +153,7 @@ public double EstimateEntropyBits() return bits; } + /// Disposes the random source when this instance owns it (i.e. it was not supplied by the caller). public void Dispose() { if (_ownsRandom && _random is IDisposable disposable) diff --git a/PasswordGenerator/Password.cs b/PasswordGenerator/Password.cs index 4e44763..8cc48f5 100644 --- a/PasswordGenerator/Password.cs +++ b/PasswordGenerator/Password.cs @@ -20,6 +20,7 @@ public class Password : IPassword, IPasswordGenerator, IDisposable private readonly IRandomSource _random; private readonly bool _ownsRandom; + /// Creates a generator with the default settings (all character classes, length 16). public Password() { Settings = new PasswordSettings(DefaultIncludeLowercase, DefaultIncludeUppercase, @@ -29,6 +30,8 @@ public Password() _ownsRandom = true; } + /// Creates a generator from the supplied settings. + /// The settings to use. public Password(IPasswordSettings settings) { Settings = settings; @@ -36,6 +39,8 @@ public Password(IPasswordSettings settings) _ownsRandom = true; } + /// Creates a generator with the default character classes and the given length. + /// The required password length. public Password(int passwordLength) { Settings = new PasswordSettings(DefaultIncludeLowercase, DefaultIncludeUppercase, @@ -44,6 +49,11 @@ public Password(int passwordLength) _ownsRandom = true; } + /// Creates a generator with the given character classes enabled and the default length. + /// Whether to include lowercase letters. + /// Whether to include uppercase letters. + /// Whether to include digits. + /// Whether to include special characters. public Password(bool includeLowercase, bool includeUppercase, bool includeNumeric, bool includeSpecial) { Settings = new PasswordSettings(includeLowercase, includeUppercase, includeNumeric, @@ -52,6 +62,12 @@ public Password(bool includeLowercase, bool includeUppercase, bool includeNumeri _ownsRandom = true; } + /// Creates a generator with the given character classes enabled and a specific length. + /// Whether to include lowercase letters. + /// Whether to include uppercase letters. + /// Whether to include digits. + /// Whether to include special characters. + /// The required password length. public Password(bool includeLowercase, bool includeUppercase, bool includeNumeric, bool includeSpecial, int passwordLength) { @@ -61,6 +77,13 @@ public Password(bool includeLowercase, bool includeUppercase, bool includeNumeri _ownsRandom = true; } + /// Creates a generator with the given character classes, length, and attempt limit. + /// Whether to include lowercase letters. + /// Whether to include uppercase letters. + /// Whether to include digits. + /// Whether to include special characters. + /// The required password length. + /// The maximum number of generation attempts. public Password(bool includeLowercase, bool includeUppercase, bool includeNumeric, bool includeSpecial, int passwordLength, int maximumAttempts) { @@ -81,62 +104,73 @@ public Password(IPasswordSettings settings, IRandomSource randomSource) _ownsRandom = false; } + /// The settings that drive generation. Replaced in place by the fluent configuration methods. public IPasswordSettings Settings { get; set; } + /// public IPassword IncludeLowercase() { Settings = Settings.AddLowercase(); return this; } + /// public IPassword IncludeUppercase() { Settings = Settings.AddUppercase(); return this; } + /// public IPassword IncludeNumeric() { Settings = Settings.AddNumeric(); return this; } + /// public IPassword IncludeSpecial() { Settings = Settings.AddSpecial(); return this; } + /// public IPassword IncludeSpecial(string specialCharactersToInclude) { Settings = Settings.AddSpecial(specialCharactersToInclude); return this; } + /// public IPassword WithCharacters(string characters) { Settings = Settings.UseCharacters(characters); return this; } + /// public IPassword WithAllAscii() { Settings = Settings.UseAllAscii(); return this; } + /// public IPassword ExcludeAmbiguous() { Settings = Settings.ExcludeAmbiguousCharacters(); return this; } + /// public IPassword RequireAtLeast(CharacterClass characterClass, int count) { Settings = Settings.RequireAtLeast(characterClass, count); return this; } + /// public IPassword LengthRequired(int passwordLength) { Settings.PasswordLength = passwordLength; @@ -182,22 +216,27 @@ public bool TryNext(out string? password) return true; } + /// public IEnumerable NextGroup(int numberOfPasswordsToGenerate) { return Generate(numberOfPasswordsToGenerate); } - public Task NextAsync(CancellationToken cancellationToken = default) + /// + public ValueTask NextAsync(CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(Next()); + return cancellationToken.IsCancellationRequested + ? ValueTask.FromCanceled(cancellationToken) + : new ValueTask(Next()); } + /// public IReadOnlyList Generate() { return Generate(DefaultBatchCount); } + /// public IReadOnlyList Generate(int count) { if (count < 0) @@ -210,24 +249,30 @@ public IReadOnlyList Generate(int count) return passwords; } - public Task> GenerateAsync(CancellationToken cancellationToken = default) + /// + public ValueTask> GenerateAsync(CancellationToken cancellationToken = default) { return GenerateAsync(DefaultBatchCount, cancellationToken); } - public Task> GenerateAsync(int count, CancellationToken cancellationToken = default) + /// + public ValueTask> GenerateAsync(int count, CancellationToken cancellationToken = default) { if (count < 0) throw new ArgumentOutOfRangeException(nameof(count), "count cannot be negative."); + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled>(cancellationToken); + var passwords = new List(count); for (var i = 0; i < count; i++) { - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled>(cancellationToken); passwords.Add(Next()); } - return Task.FromResult>(passwords); + return new ValueTask>(passwords); } private static readonly CharacterClass[] OrderedClasses = @@ -369,6 +414,7 @@ private static bool LengthIsValid(int passwordLength, int minLength, int maxLeng return passwordLength >= minLength && passwordLength <= maxLength; } + /// Disposes the random source when this instance owns it (i.e. it was not supplied by the caller). public void Dispose() { if (_ownsRandom && _random is IDisposable disposable) diff --git a/PasswordGenerator/PasswordGenerator.csproj b/PasswordGenerator/PasswordGenerator.csproj index ae8ce7a..a4c08df 100644 --- a/PasswordGenerator/PasswordGenerator.csproj +++ b/PasswordGenerator/PasswordGenerator.csproj @@ -5,6 +5,9 @@ enable latest + + true + 3.0.0 3.0.0.0 diff --git a/PasswordGenerator/PasswordOptions.cs b/PasswordGenerator/PasswordOptions.cs index b9eb066..a025bbe 100644 --- a/PasswordGenerator/PasswordOptions.cs +++ b/PasswordGenerator/PasswordOptions.cs @@ -6,14 +6,22 @@ namespace PasswordGenerator /// public class PasswordOptions { + /// Whether lowercase letters are included in the pool. Defaults to . public bool IncludeLowercase { get; set; } = true; + + /// Whether uppercase letters are included in the pool. Defaults to . public bool IncludeUppercase { get; set; } = true; + + /// Whether digits are included in the pool. Defaults to . public bool IncludeNumeric { get; set; } = true; + + /// Whether special characters are included in the pool. Defaults to . public bool IncludeSpecial { get; set; } = true; /// Custom special characters. When null/empty the library default special set is used. public string? SpecialCharacters { get; set; } + /// The password length. Defaults to 16. public int Length { get; set; } = 16; /// Removes look-alike characters from the pool when true. diff --git a/PasswordGenerator/PasswordSettings.cs b/PasswordGenerator/PasswordSettings.cs index 26b6431..5f9c947 100644 --- a/PasswordGenerator/PasswordSettings.cs +++ b/PasswordGenerator/PasswordSettings.cs @@ -9,14 +9,33 @@ namespace PasswordGenerator /// public class PasswordSettings : IPasswordSettings { + /// The lowercase letters (a–z) used when lowercase is enabled. public const string LowercaseCharacters = "abcdefghijklmnopqrstuvwxyz"; + + /// The uppercase letters (A–Z) used when uppercase is enabled. public const string UppercaseCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + /// The digits (0–9) used when numeric is enabled. public const string NumericCharacters = "0123456789"; + private const string DefaultSpecialCharacters = @"!#$%&*@\"; private const int DefaultMinPasswordLength = 4; private const int DefaultMaxPasswordLength = 256; + + /// public string SpecialCharacters { get; set; } + /// Creates a settings instance. + /// Whether to include lowercase letters. + /// Whether to include uppercase letters. + /// Whether to include digits. + /// Whether to include special characters. + /// The required password length. + /// The maximum number of generation attempts. + /// + /// Whether these are the library defaults; when , the first fluent + /// configuration call clears the default pool before applying changes. + /// public PasswordSettings(bool includeLowercase, bool includeUppercase, bool includeNumeric, bool includeSpecial, int passwordLength, int maximumAttempts, bool usingDefaults) { @@ -36,19 +55,43 @@ public PasswordSettings(bool includeLowercase, bool includeUppercase, bool inclu private bool UsingDefaults { get; set; } private readonly Dictionary _minimumCounts = new Dictionary(); + /// public bool IncludeLowercase { get; private set; } + + /// public bool IncludeUppercase { get; private set; } + + /// public bool IncludeNumeric { get; private set; } + + /// public bool IncludeSpecial { get; private set; } + + /// public int PasswordLength { get; set; } + + /// public string CharacterSet { get; private set; } + + /// public bool IsCustomPool { get; private set; } + + /// public bool ExcludeAmbiguous { get; private set; } + + /// public IReadOnlyDictionary MinimumCounts => _minimumCounts; + + /// public int MaximumAttempts { get; } + + /// public int MinimumLength { get; } + + /// public int MaximumLength { get; } + /// public IReadOnlyList CharacterGroups { get @@ -62,6 +105,7 @@ public IReadOnlyList CharacterGroups } } + /// public IPasswordSettings AddLowercase() { StopUsingDefaults(); @@ -70,6 +114,7 @@ public IPasswordSettings AddLowercase() return this; } + /// public IPasswordSettings AddUppercase() { StopUsingDefaults(); @@ -78,6 +123,7 @@ public IPasswordSettings AddUppercase() return this; } + /// public IPasswordSettings AddNumeric() { StopUsingDefaults(); @@ -86,6 +132,7 @@ public IPasswordSettings AddNumeric() return this; } + /// public IPasswordSettings AddSpecial() { StopUsingDefaults(); @@ -95,6 +142,7 @@ public IPasswordSettings AddSpecial() return this; } + /// public IPasswordSettings AddSpecial(string specialCharactersToAdd) { StopUsingDefaults(); @@ -104,6 +152,7 @@ public IPasswordSettings AddSpecial(string specialCharactersToAdd) return this; } + /// public IPasswordSettings UseCharacters(string characters) { if (characters == null) throw new ArgumentNullException(nameof(characters)); @@ -119,17 +168,20 @@ public IPasswordSettings UseCharacters(string characters) return this; } + /// public IPasswordSettings UseAllAscii() { return UseCharacters(CharacterFilter.AllPrintableAscii); } + /// public IPasswordSettings ExcludeAmbiguousCharacters() { ExcludeAmbiguous = true; return this; } + /// public IPasswordSettings RequireAtLeast(CharacterClass characterClass, int count) { if (count < 0) throw new ArgumentOutOfRangeException(nameof(count), "count cannot be negative."); diff --git a/PasswordGenerator/PoolEntropyEstimator.cs b/PasswordGenerator/PoolEntropyEstimator.cs index 0bd8753..b8a2308 100644 --- a/PasswordGenerator/PoolEntropyEstimator.cs +++ b/PasswordGenerator/PoolEntropyEstimator.cs @@ -7,6 +7,15 @@ namespace PasswordGenerator /// public class PoolEntropyEstimator : IEntropyEstimator { + /// + /// Estimates the entropy, in bits, of passwords produced with the given + /// as length × log2(effective pool size). + /// + /// The settings describing the character pool and length. + /// + /// The estimated entropy in bits, or 0 when the pool is empty or the length is not positive. + /// + /// is . public double EstimateBits(IPasswordSettings settings) { if (settings == null) throw new ArgumentNullException(nameof(settings)); diff --git a/docs/api-surface.md b/docs/api-surface.md index 61723f4..7e3fffc 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -28,7 +28,7 @@ flowchart TD subgraph Gen["Generation (IPasswordGenerator)"] g1["Next() : string (throws on bad config)"] g2["TryNext(out string) : bool"] - g3["NextAsync(ct) : Task~string~"] + g3["NextAsync(ct) : ValueTask~string~"] g4["Generate() / Generate(count)"] g5["GenerateAsync() / GenerateAsync(count, ct)"] end From 0fcca79ac12865140b7be95d7e9337cd2b928351 Mon Sep 17 00:00:00 2001 From: Paul Seal Date: Mon, 25 May 2026 17:38:28 +0100 Subject: [PATCH 14/14] Add v3 package documentation and code review (#38) * Use EFF Large Wordlist for passphrases Replace the 390-word built-in list with the EFF Large Wordlist (7,776 words, ~12.9 bits/word), raising a 6-word passphrase to ~77 bits. Add CC BY 3.0 attribution (WordList.cs header, THIRD-PARTY-NOTICES.md, Readme, CHANGELOG), an InternalsVisibleTo for the test project, and word-list integrity tests. Switch the passphrase smoke test to a '.' separator since a few EFF words are hyphenated. https://claude.ai/code/session_01Y5PCRCxt2rJFBLnbaQdQRB * Add entropy-targeted passphrases and an entropy floor Add ForPassphraseWithEntropy(targetBits) which derives the word count needed to reach a target and enforces it, plus an optional minimumEntropyBits floor on ForPassphrase that rejects weak configs. Expose PassphraseGenerator.WordCountForEntropy and surface EstimateEntropyBits on the IPasswordGenerator interface. https://claude.ai/code/session_01Y5PCRCxt2rJFBLnbaQdQRB * Add optional symbol injection for composition rules Add includeSymbol to the passphrase generator and factories: a random symbol is attached to one randomly chosen word so phrases satisfy "needs a number and a symbol" validators while staying memorable. Entropy estimation now accounts for the symbol's value and position. Document that some EFF words are hyphenated, so callers tokenizing the output should use a separator that does not appear in any word. https://claude.ai/code/session_01Y5PCRCxt2rJFBLnbaQdQRB * Wire passphrases through DI and add ForMemorable preset Add PassphraseOptions and a PasswordOptions.Passphrase property so the DI registration can resolve a passphrase IPasswordGenerator, in code or bound from a "Passphrase" configuration section. Add a ForMemorable() preset (capitalized words sized to at least 80 bits). https://claude.ai/code/session_01Y5PCRCxt2rJFBLnbaQdQRB * Add deterministic passphrase tests and update design docs Add seeded-RNG tests covering word selection by index, capitalization, symbol placement, plus a broad-sampling distribution check. Update api-surface.md and architecture.md to reflect the EFF Large Wordlist, entropy targeting/floor, symbol injection, EstimateEntropyBits on the interface, and passphrase configuration through DI. https://claude.ai/code/session_01Y5PCRCxt2rJFBLnbaQdQRB --------- Co-authored-by: Claude --- CHANGELOG.md | 13 + PasswordGenerator.Tests/PassphraseTests.cs | 216 ++++ PasswordGenerator.Tests/Phase4Tests.cs | 9 +- PasswordGenerator.Tests/WordListTests.cs | 47 + PasswordGenerator/IPasswordGenerator.cs | 3 + PasswordGenerator/PassphraseGenerator.cs | 70 +- PasswordGenerator/PassphraseOptions.cs | 28 + PasswordGenerator/Password.cs | 42 +- PasswordGenerator/PasswordGenerator.csproj | 4 + ...ordGeneratorServiceCollectionExtensions.cs | 14 +- PasswordGenerator/PasswordOptions.cs | 6 + PasswordGenerator/WordList.cs | 1050 ++++++++++++++++- Readme.md | 27 + THIRD-PARTY-NOTICES.md | 21 + docs/api-surface.md | 18 +- docs/architecture.md | 29 +- 16 files changed, 1542 insertions(+), 55 deletions(-) create mode 100644 PasswordGenerator.Tests/PassphraseTests.cs create mode 100644 PasswordGenerator.Tests/WordListTests.cs create mode 100644 PasswordGenerator/PassphraseOptions.cs create mode 100644 THIRD-PARTY-NOTICES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0181e40..991b5c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,19 @@ See the [v2 → v3 migration guide](docs/migration-v2-to-v3.md). - **Dependency injection:** `AddPasswordGenerator(...)` with code and `appSettings.json` binding (resolution order: code-configure > appSettings > default). - **Presets:** `ForOwasp`, `ForNist`, `ForOtp`, `ForApiKey`, `ForEnvironmentName`, `ForPassphrase`. +- **Passphrases use the EFF Large Wordlist** (7,776 words, ~12.9 bits/word), replacing the small + built-in list — a 6-word phrase is now ~77 bits. The list is © EFF under CC BY 3.0; see + [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md). +- **Entropy-targeted passphrases:** `ForPassphraseWithEntropy(targetBits)` derives the word count + to meet a target, and `ForPassphrase(..., minimumEntropyBits)` enforces an entropy floor. +- **Symbol injection:** `ForPassphrase(..., includeSymbol: true)` attaches a random symbol to one + randomly chosen word, so passphrases satisfy "needs a number and a symbol" rules while staying + memorable. Entropy estimation now accounts for both the trailing number and the symbol. +- **`ForMemorable()` preset:** capitalized words sized to at least 80 bits of entropy. +- **Passphrases via dependency injection:** set `PasswordOptions.Passphrase` (a `PassphraseOptions`) + in code or bind a `Passphrase` section from configuration to resolve a passphrase + `IPasswordGenerator`. +- `EstimateEntropyBits()` is now part of the `IPasswordGenerator` interface. - **Custom pools:** `WithCharacters(string)`, `WithAllAscii()`. - **Quality controls:** `ExcludeAmbiguous()`, `RequireAtLeast(CharacterClass, count)`. - **Entropy estimation:** `IEntropyEstimator` / `PoolEntropyEstimator` and `EstimateEntropyBits()`. diff --git a/PasswordGenerator.Tests/PassphraseTests.cs b/PasswordGenerator.Tests/PassphraseTests.cs new file mode 100644 index 0000000..5465ce3 --- /dev/null +++ b/PasswordGenerator.Tests/PassphraseTests.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace PasswordGenerator.Tests +{ + public class PassphraseTests + { + // A deterministic random source that cycles a fixed sequence, so generation is reproducible. + private class FixedRandomSource : IRandomSource + { + private readonly int[] _values; + private int _index; + + public FixedRandomSource(params int[] values) => _values = values; + + public int NextInt(int maxExclusive) + { + var value = _values[_index % _values.Length] % maxExclusive; + _index++; + return value; + } + } + + [Test] + public void WordCountForEntropy_DerivesEnoughWordsToMeetTarget() + { + // 7,776-word list => ~12.925 bits/word; with the trailing number 6 words clears 80 bits, + // without it 7 are needed. + Assert.That(PassphraseGenerator.WordCountForEntropy(80, includeNumber: true), Is.EqualTo(6)); + Assert.That(PassphraseGenerator.WordCountForEntropy(80, includeNumber: false), Is.EqualTo(7)); + } + + [Test] + public void WordCountForEntropy_NeverReturnsLessThanOne() + { + Assert.That(PassphraseGenerator.WordCountForEntropy(0), Is.EqualTo(1)); + Assert.That(PassphraseGenerator.WordCountForEntropy(-50), Is.EqualTo(1)); + } + + [Test] + public void ForPassphraseWithEntropy_MeetsOrExceedsTarget() + { + var generator = Password.ForPassphraseWithEntropy(80); + Assert.That(generator.EstimateEntropyBits(), Is.GreaterThanOrEqualTo(80)); + } + + [Test] + public void ForPassphraseWithEntropy_ProducesDerivedWordCount() + { + var generator = Password.ForPassphraseWithEntropy(80, separator: '.', includeNumber: true); + var parts = generator.Next().Split('.'); + Assert.That(parts.Length, Is.EqualTo(7)); // 6 words + trailing number + } + + [Test] + public void EntropyFloor_RejectsWeakConfiguration() + { + Assert.Throws(() => Password.ForPassphrase(words: 2, minimumEntropyBits: 80)); + } + + [Test] + public void EntropyFloor_AllowsStrongConfiguration() + { + Assert.DoesNotThrow(() => Password.ForPassphrase(words: 8, minimumEntropyBits: 80)); + } + + [Test] + public void EntropyFloor_ZeroMeansNoEnforcement() + { + Assert.DoesNotThrow(() => Password.ForPassphrase(words: 1, minimumEntropyBits: 0)); + } + + [Test] + public void EstimateEntropyBits_IsAvailableThroughInterface() + { + IPasswordGenerator generator = Password.ForPassphrase(6); + Assert.That(generator.EstimateEntropyBits(), Is.GreaterThan(0)); + } + + private static readonly char[] SymbolChars = "!@#$%&*?".ToCharArray(); + + [Test] + public void IncludeSymbol_InjectsASymbol() + { + var generator = Password.ForPassphrase(4, separator: '.', includeNumber: false, + includeSymbol: true); + var phrase = generator.Next(); + Assert.That(phrase.IndexOfAny(SymbolChars), Is.GreaterThanOrEqualTo(0), phrase); + } + + [Test] + public void NumberAndSymbol_SatisfyCompositionRules() + { + var generator = Password.ForPassphrase(4, separator: '.', includeNumber: true, + includeSymbol: true); + var phrase = generator.Next(); + Assert.That(phrase.Any(char.IsDigit), Is.True, phrase); + Assert.That(phrase.IndexOfAny(SymbolChars), Is.GreaterThanOrEqualTo(0), phrase); + } + + [Test] + public void IncludeSymbol_IsOffByDefault() + { + var generator = Password.ForPassphrase(4, separator: '.', includeNumber: false); + var phrase = generator.Next(); + Assert.That(phrase.IndexOfAny(SymbolChars), Is.EqualTo(-1), phrase); + } + + [Test] + public void IncludeSymbol_AddsEntropy() + { + var withoutSymbol = Password.ForPassphrase(6, includeSymbol: false).EstimateEntropyBits(); + var withSymbol = Password.ForPassphrase(6, includeSymbol: true).EstimateEntropyBits(); + Assert.That(withSymbol, Is.GreaterThan(withoutSymbol)); + } + + [Test] + public void ForMemorable_IsCapitalizedAndStrong() + { + var generator = Password.ForMemorable(); + Assert.That(generator.EstimateEntropyBits(), Is.GreaterThanOrEqualTo(80)); + var phrase = generator.Next(); + Assert.That(char.IsUpper(phrase[0]), Is.True, phrase); + } + + [Test] + public void Di_CodeConfiguresPassphrase() + { + var services = new ServiceCollection(); + services.AddPasswordGenerator(o => + o.Passphrase = new PassphraseOptions { WordCount = 6, Separator = '.' }); + + using var provider = services.BuildServiceProvider(); + var generator = provider.GetRequiredService(); + + var parts = generator.Next().Split('.'); + Assert.That(parts.Length, Is.EqualTo(7)); // 6 words + trailing number (on by default) + } + + [Test] + public void Next_SelectsWordsByRandomIndex() + { + var rng = new FixedRandomSource(0, 1, 2, 3); + var generator = new PassphraseGenerator(4, '.', capitalize: false, includeNumber: false, + includeSymbol: false, minimumEntropyBits: 0, randomSource: rng); + + var expected = string.Join('.', + WordList.Words[0], WordList.Words[1], WordList.Words[2], WordList.Words[3]); + Assert.That(generator.Next(), Is.EqualTo(expected)); + } + + [Test] + public void Next_CapitalizesEachWordsFirstLetter() + { + var rng = new FixedRandomSource(0); + var generator = new PassphraseGenerator(1, '.', capitalize: true, includeNumber: false, + includeSymbol: false, minimumEntropyBits: 0, randomSource: rng); + + var word = WordList.Words[0]; + var expected = char.ToUpperInvariant(word[0]) + word.Substring(1); + Assert.That(generator.Next(), Is.EqualTo(expected)); + } + + [Test] + public void Next_PlacesSymbolOnTheChosenWord() + { + // Draw order: symbol-word index, symbol char index, then one index per word. + var rng = new FixedRandomSource(0, 0, 0, 1); + var generator = new PassphraseGenerator(2, '.', capitalize: false, includeNumber: false, + includeSymbol: true, minimumEntropyBits: 0, randomSource: rng); + + // Symbol index 0 maps to '!' (first of "!@#$%&*?"). + var expected = WordList.Words[0] + "!" + "." + WordList.Words[1]; + Assert.That(generator.Next(), Is.EqualTo(expected)); + } + + [Test] + public void Next_SamplesBroadlyAcrossTheWordList() + { + var generator = new PassphraseGenerator(1, '.', capitalize: false, includeNumber: false); + + var seen = new HashSet(); + for (var i = 0; i < 2000; i++) seen.Add(generator.Next()); + + // With 7,776 words and 2,000 draws the distinct count is ~1,700; >500 confirms broad, + // non-degenerate sampling without being flaky. + Assert.That(seen.Count, Is.GreaterThan(500)); + } + + [Test] + public void Di_BindsPassphraseFromConfiguration() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Passphrase:WordCount"] = "5", + ["Passphrase:Separator"] = ".", + ["Passphrase:IncludeNumber"] = "false" + }) + .Build(); + + var services = new ServiceCollection(); + services.AddPasswordGenerator(configuration); + + using var provider = services.BuildServiceProvider(); + var generator = provider.GetRequiredService(); + + var parts = generator.Next().Split('.'); + Assert.That(parts.Length, Is.EqualTo(5)); // 5 words, no trailing number + } + } +} diff --git a/PasswordGenerator.Tests/Phase4Tests.cs b/PasswordGenerator.Tests/Phase4Tests.cs index 96bc280..579f0db 100644 --- a/PasswordGenerator.Tests/Phase4Tests.cs +++ b/PasswordGenerator.Tests/Phase4Tests.cs @@ -129,13 +129,16 @@ public void ForEnvironmentName_IsLowercaseDigitsWithoutAmbiguous() [Test] public void ForPassphrase_ProducesRequestedWordsPlusNumber() { - var generator = Password.ForPassphrase(4, '-', capitalize: false, includeNumber: true); + // Use '.' as the separator: a handful of EFF words are themselves hyphenated + // (e.g. "t-shirt"), so splitting on '-' would over-split the phrase. + var generator = Password.ForPassphrase(4, '.', capitalize: false, includeNumber: true); var phrase = generator.Next(); - var parts = phrase.Split('-'); + var parts = phrase.Split('.'); Assert.That(parts.Length, Is.EqualTo(5)); // 4 words + trailing number Assert.That(int.TryParse(parts[4], out _), Is.True, phrase); - Assert.That(parts.Take(4).All(p => p.All(char.IsLetter)), Is.True, phrase); + Assert.That(parts.Take(4).All(p => p.Length > 0 && p.All(c => char.IsLetter(c) || c == '-')), + Is.True, phrase); } // ----- DI / appSettings precedence ----- diff --git a/PasswordGenerator.Tests/WordListTests.cs b/PasswordGenerator.Tests/WordListTests.cs new file mode 100644 index 0000000..e7fa0c4 --- /dev/null +++ b/PasswordGenerator.Tests/WordListTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq; +using NUnit.Framework; + +namespace PasswordGenerator.Tests +{ + public class WordListTests + { + // The EFF Large Wordlist is exactly 7,776 words; the entropy claims in + // PassphraseGenerator depend on this count, so guard it explicitly. + [Test] + public void WordList_HasExactlyEffLargeCount() + { + Assert.That(WordList.Words.Length, Is.EqualTo(7776)); + } + + [Test] + public void WordList_HasNoDuplicates() + { + var distinct = WordList.Words.Distinct(StringComparer.Ordinal).Count(); + Assert.That(distinct, Is.EqualTo(WordList.Words.Length)); + } + + [Test] + public void WordList_WordsAreLowercaseLettersOrHyphen() + { + // The EFF list contains four legitimately hyphenated entries + // (drop-down, felt-tip, t-shirt, yo-yo); everything else is a-z. + foreach (var word in WordList.Words) + Assert.That(word.All(c => (c >= 'a' && c <= 'z') || c == '-'), Is.True, word); + } + + [Test] + public void WordList_WordLengthsAreWithinEffBounds() + { + foreach (var word in WordList.Words) + Assert.That(word.Length, Is.InRange(3, 9), word); + } + + [Test] + public void WordList_PerWordEntropyIsAboutTwelveNineBits() + { + var bitsPerWord = Math.Log(WordList.Words.Length, 2); + Assert.That(bitsPerWord, Is.EqualTo(12.925).Within(0.001)); + } + } +} diff --git a/PasswordGenerator/IPasswordGenerator.cs b/PasswordGenerator/IPasswordGenerator.cs index e3651c9..69c17a5 100644 --- a/PasswordGenerator/IPasswordGenerator.cs +++ b/PasswordGenerator/IPasswordGenerator.cs @@ -35,5 +35,8 @@ public interface IPasswordGenerator /// Generates passwords, observing . ValueTask> GenerateAsync(int count, CancellationToken cancellationToken = default); + + /// Estimates the strength, in bits, of the output produced by this generator. + double EstimateEntropyBits(); } } diff --git a/PasswordGenerator/PassphraseGenerator.cs b/PasswordGenerator/PassphraseGenerator.cs index 00549e1..2f16631 100644 --- a/PasswordGenerator/PassphraseGenerator.cs +++ b/PasswordGenerator/PassphraseGenerator.cs @@ -18,16 +18,30 @@ public class PassphraseGenerator : IPasswordGenerator, IDisposable /// Creates a passphrase generator. /// The number of words in each passphrase; must be at least one. - /// The character placed between words (and before the trailing number). + /// + /// The character placed between words (and before the trailing number). Note that a few EFF + /// words contain a hyphen (e.g. "t-shirt"), so if you need to split the output back into words + /// choose a separator that does not occur in any word, such as '.' or a space. + /// /// Whether to capitalize the first letter of each word. /// Whether to append a random two-digit number. + /// + /// Whether to attach a random symbol to one randomly chosen word, so the phrase satisfies + /// "must contain a symbol" composition rules while staying memorable. + /// + /// + /// An optional entropy floor. When greater than zero, the configuration is rejected if its + /// estimated entropy is below this many bits, so callers cannot silently produce weak phrases. + /// /// /// An optional random source. When supplied, the caller owns it; otherwise a /// is created and owned by this instance. /// /// is less than one. + /// The estimated entropy is below . public PassphraseGenerator(int wordCount = 4, char separator = '-', bool capitalize = false, - bool includeNumber = true, IRandomSource? randomSource = null) + bool includeNumber = true, bool includeSymbol = false, double minimumEntropyBits = 0, + IRandomSource? randomSource = null) { if (wordCount < 1) throw new ArgumentOutOfRangeException(nameof(wordCount), "A passphrase needs at least one word."); @@ -36,8 +50,17 @@ public PassphraseGenerator(int wordCount = 4, char separator = '-', bool capital Separator = separator; Capitalize = capitalize; IncludeNumber = includeNumber; + IncludeSymbol = includeSymbol; + MinimumEntropyBits = minimumEntropyBits; _random = randomSource ?? new CryptoRandomSource(); _ownsRandom = randomSource == null; + + if (minimumEntropyBits > 0 && EstimateEntropyBits() < minimumEntropyBits) + throw new ArgumentException( + $"This passphrase yields about {EstimateEntropyBits():F1} bits of entropy, below the " + + $"required minimum of {minimumEntropyBits:F1} bits. Use more words " + + $"(at least {WordCountForEntropy(minimumEntropyBits, includeNumber)}).", + nameof(minimumEntropyBits)); } /// The number of words in each passphrase. @@ -52,6 +75,15 @@ public PassphraseGenerator(int wordCount = 4, char separator = '-', bool capital /// Whether a random two-digit number is appended. public bool IncludeNumber { get; } + /// Whether a random symbol is attached to one randomly chosen word. + public bool IncludeSymbol { get; } + + /// The entropy floor enforced at construction, in bits; zero means no floor. + public double MinimumEntropyBits { get; } + + /// The symbols eligible for injection when is set. + private const string Symbols = "!@#$%&*?"; + /// The number of passphrases produced by the parameterless overload. public int DefaultBatchCount { get; set; } = 1; @@ -60,6 +92,16 @@ public string Next() { var sb = new StringBuilder(); + // Pick which word gets the symbol (and which symbol) up front, so placement varies + // and the choice is unpredictable rather than always trailing. + var symbolWordIndex = -1; + var symbol = '\0'; + if (IncludeSymbol) + { + symbolWordIndex = _random.NextInt(WordCount); + symbol = Symbols[_random.NextInt(Symbols.Length)]; + } + for (var i = 0; i < WordCount; i++) { if (i > 0) sb.Append(Separator); @@ -74,6 +116,8 @@ public string Next() { sb.Append(word); } + + if (i == symbolWordIndex) sb.Append(symbol); } if (IncludeNumber) @@ -145,14 +189,34 @@ public ValueTask> GenerateAsync(int count, CancellationTok return new ValueTask>(passphrases); } - /// Estimates passphrase entropy in bits from the word-list size, word count, and trailing number. + /// + /// Estimates passphrase entropy in bits from the word-list size and word count, plus the + /// trailing number (90 possible values, 10-99) and the injected symbol (its value and which + /// word it lands on) when those are enabled. + /// public double EstimateEntropyBits() { var bits = WordCount * Math.Log(WordList.Words.Length, 2); if (IncludeNumber) bits += Math.Log(90, 2); + if (IncludeSymbol) bits += Math.Log(Symbols.Length, 2) + Math.Log(WordCount, 2); return bits; } + /// + /// Returns the smallest word count whose estimated entropy is at least + /// , accounting for the trailing number when requested. + /// + /// The desired minimum entropy in bits. + /// Whether a trailing two-digit number will also be included. + public static int WordCountForEntropy(double targetBits, bool includeNumber = true) + { + var remaining = targetBits - (includeNumber ? Math.Log(90, 2) : 0); + if (remaining <= 0) return 1; + + var bitsPerWord = Math.Log(WordList.Words.Length, 2); + return Math.Max(1, (int)Math.Ceiling(remaining / bitsPerWord)); + } + /// Disposes the random source when this instance owns it (i.e. it was not supplied by the caller). public void Dispose() { diff --git a/PasswordGenerator/PassphraseOptions.cs b/PasswordGenerator/PassphraseOptions.cs new file mode 100644 index 0000000..c5cf6f5 --- /dev/null +++ b/PasswordGenerator/PassphraseOptions.cs @@ -0,0 +1,28 @@ +namespace PasswordGenerator +{ + /// + /// Configuration for passphrase generation, used when registering via dependency injection or + /// binding from configuration (e.g. the "Passphrase" section of appSettings.json). When this is + /// set on , the registered generator produces passphrases. + /// + public class PassphraseOptions + { + /// The number of words in each passphrase. Defaults to 4. + public int WordCount { get; set; } = 4; + + /// The character placed between words. Defaults to '-'. + public char Separator { get; set; } = '-'; + + /// Whether the first letter of each word is capitalized. + public bool Capitalize { get; set; } + + /// Whether a random two-digit number is appended. Defaults to . + public bool IncludeNumber { get; set; } = true; + + /// Whether a random symbol is attached to one randomly chosen word. + public bool IncludeSymbol { get; set; } + + /// An optional entropy floor in bits; zero (the default) means no floor. + public double MinimumEntropyBits { get; set; } + } +} diff --git a/PasswordGenerator/Password.cs b/PasswordGenerator/Password.cs index 8cc48f5..d01f022 100644 --- a/PasswordGenerator/Password.cs +++ b/PasswordGenerator/Password.cs @@ -456,11 +456,47 @@ public static IPassword ForEnvironmentName(int length = 12) .LengthRequired(length); } - /// Diceware-style passphrase built from a built-in word list. + /// Diceware-style passphrase built from the EFF Large Wordlist. + /// The number of words in the passphrase. + /// The character placed between words. + /// Whether to capitalize the first letter of each word. + /// Whether to append a random two-digit number. + /// Whether to attach a random symbol to one randomly chosen word. + /// + /// An optional entropy floor; when greater than zero the configuration is rejected if it + /// falls below this many bits. + /// public static IPasswordGenerator ForPassphrase(int words = 4, char separator = '-', - bool capitalize = false, bool includeNumber = true) + bool capitalize = false, bool includeNumber = true, bool includeSymbol = false, + double minimumEntropyBits = 0) { - return new PassphraseGenerator(words, separator, capitalize, includeNumber); + return new PassphraseGenerator(words, separator, capitalize, includeNumber, includeSymbol, + minimumEntropyBits); + } + + /// + /// Diceware-style passphrase with at least bits of entropy. + /// The word count is derived from the word-list size, and the same value is enforced as a floor. + /// + /// The minimum entropy in bits (defaults to 80, a strong target). + /// The character placed between words. + /// Whether to capitalize the first letter of each word. + /// Whether to append a random two-digit number. + /// Whether to attach a random symbol to one randomly chosen word. + public static IPasswordGenerator ForPassphraseWithEntropy(double targetBits = 80, char separator = '-', + bool capitalize = false, bool includeNumber = true, bool includeSymbol = false) + { + var words = PassphraseGenerator.WordCountForEntropy(targetBits, includeNumber); + return new PassphraseGenerator(words, separator, capitalize, includeNumber, includeSymbol, targetBits); + } + + /// + /// A memorable, high-strength passphrase preset: capitalized words with a trailing number, + /// sized to at least 80 bits of entropy. + /// + public static IPasswordGenerator ForMemorable() + { + return ForPassphraseWithEntropy(80, separator: '-', capitalize: true, includeNumber: true); } } } diff --git a/PasswordGenerator/PasswordGenerator.csproj b/PasswordGenerator/PasswordGenerator.csproj index a4c08df..903a0f3 100644 --- a/PasswordGenerator/PasswordGenerator.csproj +++ b/PasswordGenerator/PasswordGenerator.csproj @@ -41,6 +41,10 @@
+ + + + diff --git a/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs b/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs index fa3e13e..ac67552 100644 --- a/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs +++ b/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs @@ -42,8 +42,20 @@ private static IServiceCollection AddCore(IServiceCollection services, PasswordO return services; } - private static Password CreateGenerator(PasswordOptions options, IRandomSource randomSource) + private static IPasswordGenerator CreateGenerator(PasswordOptions options, IRandomSource randomSource) { + if (options.Passphrase != null) + { + var p = options.Passphrase; + // The random source is a DI singleton owned by the container, so it is passed in and + // must not be disposed by the generator. + return new PassphraseGenerator(p.WordCount, p.Separator, p.Capitalize, p.IncludeNumber, + p.IncludeSymbol, p.MinimumEntropyBits, randomSource) + { + DefaultBatchCount = options.DefaultBatchCount + }; + } + // Build with the non-special classes first, then layer special characters on (default or // custom) so the combined character set is assembled correctly. var settings = new PasswordSettings(options.IncludeLowercase, options.IncludeUppercase, diff --git a/PasswordGenerator/PasswordOptions.cs b/PasswordGenerator/PasswordOptions.cs index a025bbe..499205b 100644 --- a/PasswordGenerator/PasswordOptions.cs +++ b/PasswordGenerator/PasswordOptions.cs @@ -29,5 +29,11 @@ public class PasswordOptions /// The number of passwords produced by the parameterless Generate() overload. public int DefaultBatchCount { get; set; } = 1; + + /// + /// When set, the registered generator produces passphrases (from the EFF Large Wordlist) + /// instead of character passwords, and the character-pool options above are ignored. + /// + public PassphraseOptions? Passphrase { get; set; } } } diff --git a/PasswordGenerator/WordList.cs b/PasswordGenerator/WordList.cs index 1d03b3f..51ef08f 100644 --- a/PasswordGenerator/WordList.cs +++ b/PasswordGenerator/WordList.cs @@ -1,53 +1,1019 @@ +// ----------------------------------------------------------------------------- +// EFF Large Wordlist +// +// The word data in this file is the "EFF Large Wordlist" created by the +// Electronic Frontier Foundation (Joseph Bonneau) and published at: +// https://www.eff.org/dice +// https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt +// +// The word list is licensed by the EFF under the Creative Commons +// Attribution 3.0 United States license (CC BY 3.0 US): +// https://creativecommons.org/licenses/by/3.0/us/ +// +// Modifications: the original tab-separated "dice-numberword" rows have +// been reformatted into the C# string array below. The words themselves are +// unchanged. See THIRD-PARTY-NOTICES.md for full attribution. +// +// The rest of this project is licensed under the MIT License (see License.md). +// ----------------------------------------------------------------------------- + namespace PasswordGenerator { /// - /// A small built-in list of common, readable words used by . - /// This is intentionally compact (not a full diceware list); each word contributes - /// log2(.Length) bits of entropy. + /// The EFF Large Wordlist (7,776 words) used by . + /// Each word contributes log2(7776) ~= 12.925 bits of entropy when chosen uniformly + /// at random. The list is curated by the EFF for memorability and to avoid confusable + /// or offensive words. See the header of this file and THIRD-PARTY-NOTICES.md for the + /// CC BY 3.0 attribution. /// internal static class WordList { public static readonly string[] Words = { - "able", "acid", "acorn", "actor", "agile", "alarm", "album", "alert", "alley", "amber", - "amend", "angle", "ankle", "apple", "april", "arena", "armor", "arrow", "aside", "asset", - "atlas", "audio", "aunt", "avoid", "awake", "award", "bacon", "badge", "baker", "banjo", - "barge", "basil", "basin", "beach", "beard", "beast", "begin", "berry", "birch", "bison", - "blade", "blaze", "blend", "blink", "block", "bloom", "board", "boost", "booth", "brace", - "brain", "brand", "brave", "bread", "brick", "brief", "broad", "brook", "brush", "buddy", - "bunch", "cabin", "cable", "cacao", "camel", "candy", "canoe", "cargo", "carol", "carve", - "catch", "cedar", "chalk", "charm", "chase", "cheek", "chess", "chief", "chili", "chime", - "civic", "claim", "clamp", "clay", "clean", "clear", "cliff", "climb", "clock", "cloud", - "clove", "coast", "cobra", "cocoa", "comet", "coral", "couch", "cover", "crane", "crate", - "crisp", "crown", "crumb", "curve", "daisy", "dance", "delta", "diner", "ditch", "diver", - "dodge", "donor", "draft", "drama", "dream", "dress", "drift", "drink", "drive", "eagle", - "early", "earth", "ember", "emery", "enjoy", "equal", "ethos", "every", "fable", "fancy", - "feast", "fiber", "field", "final", "flame", "flank", "flash", "fleet", "flint", "float", - "flock", "flora", "flute", "focus", "forge", "frame", "frost", "fruit", "gauge", "ghost", - "giant", "glade", "glass", "glide", "globe", "glory", "grace", "grain", "grand", "grape", - "grasp", "grass", "green", "grove", "guide", "habit", "happy", "harbor", "haven", "hazel", - "heart", "hedge", "honey", "horse", "hotel", "house", "human", "humor", "ideal", "image", - "index", "inlet", "ivory", "jelly", "jewel", "jolly", "joust", "judge", "juice", "karma", - "kayak", "ketch", "kitten", "knack", "knife", "koala", "label", "lance", "laser", "latch", - "layer", "leaf", "ledge", "lemon", "lever", "light", "lilac", "linen", "llama", "lodge", - "lotus", "lunar", "lyric", "magic", "maize", "mango", "maple", "march", "marsh", "match", - "medal", "melon", "mercy", "metro", "manor", "mocha", "model", "money", "month", "motor", - "mound", "mount", "mouse", "music", "noble", "north", "novel", "ocean", "olive", "onion", - "opera", "orbit", "otter", "owl", "paint", "panda", "paper", "party", "pasta", "patch", - "peach", "pearl", "pedal", "perch", "piano", "pilot", "pixel", "pizza", "plank", "plant", - "plaza", "plume", "polar", "porch", "prism", "prize", "proud", "pulse", "punch", "quail", - "quartz", "queen", "quest", "quiet", "quill", "quilt", "radar", "rapid", "raven", "reach", - "realm", "rebel", "relay", "rhino", "ridge", "river", "roast", "robin", "rocky", "rover", - "royal", "ruby", "salad", "salsa", "sandy", "scarf", "scout", "shade", "shard", "shark", - "sheep", "shelf", "shell", "shine", "shore", "siren", "skate", "skiff", "slate", "sleek", - "slope", "smile", "smoke", "snail", "solar", "sonic", "spade", "spark", "spice", "spine", - "spire", "spoon", "sport", "spray", "spruce", "stack", "stage", "stalk", "stamp", "stark", - "steam", "steel", "stork", "storm", "story", "stove", "straw", "strip", "sugar", "swift", - "table", "tango", "teal", "thorn", "tidal", "tiger", "toast", "topaz", "torch", "totem", - "tower", "trail", "treat", "trend", "tribe", "trout", "tulip", "tundra", "ultra", "umber", - "unity", "urban", "valley", "value", "vapor", "vault", "venus", "vigor", "villa", "vinyl", - "viola", "vivid", "vocal", "wafer", "wagon", "waltz", "water", "wheat", "whale", "wharf", - "wheel", "whisk", "willow", "windy", "woven", "yacht", "yearn", "yield", "zebra", "zesty" + "abacus", "abdomen", "abdominal", "abide", "abiding", "ability", "ablaze", "able", + "abnormal", "abrasion", "abrasive", "abreast", "abridge", "abroad", "abruptly", "absence", + "absentee", "absently", "absinthe", "absolute", "absolve", "abstain", "abstract", "absurd", + "accent", "acclaim", "acclimate", "accompany", "account", "accuracy", "accurate", + "accustom", "acetone", "achiness", "aching", "acid", "acorn", "acquaint", "acquire", + "acre", "acrobat", "acronym", "acting", "action", "activate", "activator", "active", + "activism", "activist", "activity", "actress", "acts", "acutely", "acuteness", "aeration", + "aerobics", "aerosol", "aerospace", "afar", "affair", "affected", "affecting", "affection", + "affidavit", "affiliate", "affirm", "affix", "afflicted", "affluent", "afford", "affront", + "aflame", "afloat", "aflutter", "afoot", "afraid", "afterglow", "afterlife", "aftermath", + "aftermost", "afternoon", "aged", "ageless", "agency", "agenda", "agent", "aggregate", + "aghast", "agile", "agility", "aging", "agnostic", "agonize", "agonizing", "agony", + "agreeable", "agreeably", "agreed", "agreeing", "agreement", "aground", "ahead", "ahoy", + "aide", "aids", "aim", "ajar", "alabaster", "alarm", "albatross", "album", "alfalfa", + "algebra", "algorithm", "alias", "alibi", "alienable", "alienate", "aliens", "alike", + "alive", "alkaline", "alkalize", "almanac", "almighty", "almost", "aloe", "aloft", "aloha", + "alone", "alongside", "aloof", "alphabet", "alright", "although", "altitude", "alto", + "aluminum", "alumni", "always", "amaretto", "amaze", "amazingly", "amber", "ambiance", + "ambiguity", "ambiguous", "ambition", "ambitious", "ambulance", "ambush", "amendable", + "amendment", "amends", "amenity", "amiable", "amicably", "amid", "amigo", "amino", "amiss", + "ammonia", "ammonium", "amnesty", "amniotic", "among", "amount", "amperage", "ample", + "amplifier", "amplify", "amply", "amuck", "amulet", "amusable", "amused", "amusement", + "amuser", "amusing", "anaconda", "anaerobic", "anagram", "anatomist", "anatomy", "anchor", + "anchovy", "ancient", "android", "anemia", "anemic", "aneurism", "anew", "angelfish", + "angelic", "anger", "angled", "angler", "angles", "angling", "angrily", "angriness", + "anguished", "angular", "animal", "animate", "animating", "animation", "animator", "anime", + "animosity", "ankle", "annex", "annotate", "announcer", "annoying", "annually", "annuity", + "anointer", "another", "answering", "antacid", "antarctic", "anteater", "antelope", + "antennae", "anthem", "anthill", "anthology", "antibody", "antics", "antidote", "antihero", + "antiquely", "antiques", "antiquity", "antirust", "antitoxic", "antitrust", "antiviral", + "antivirus", "antler", "antonym", "antsy", "anvil", "anybody", "anyhow", "anymore", + "anyone", "anyplace", "anything", "anytime", "anyway", "anywhere", "aorta", "apache", + "apostle", "appealing", "appear", "appease", "appeasing", "appendage", "appendix", + "appetite", "appetizer", "applaud", "applause", "apple", "appliance", "applicant", + "applied", "apply", "appointee", "appraisal", "appraiser", "apprehend", "approach", + "approval", "approve", "apricot", "april", "apron", "aptitude", "aptly", "aqua", + "aqueduct", "arbitrary", "arbitrate", "ardently", "area", "arena", "arguable", "arguably", + "argue", "arise", "armadillo", "armband", "armchair", "armed", "armful", "armhole", + "arming", "armless", "armoire", "armored", "armory", "armrest", "army", "aroma", "arose", + "around", "arousal", "arrange", "array", "arrest", "arrival", "arrive", "arrogance", + "arrogant", "arson", "art", "ascend", "ascension", "ascent", "ascertain", "ashamed", + "ashen", "ashes", "ashy", "aside", "askew", "asleep", "asparagus", "aspect", "aspirate", + "aspire", "aspirin", "astonish", "astound", "astride", "astrology", "astronaut", + "astronomy", "astute", "atlantic", "atlas", "atom", "atonable", "atop", "atrium", + "atrocious", "atrophy", "attach", "attain", "attempt", "attendant", "attendee", + "attention", "attentive", "attest", "attic", "attire", "attitude", "attractor", + "attribute", "atypical", "auction", "audacious", "audacity", "audible", "audibly", + "audience", "audio", "audition", "augmented", "august", "authentic", "author", "autism", + "autistic", "autograph", "automaker", "automated", "automatic", "autopilot", "available", + "avalanche", "avatar", "avenge", "avenging", "avenue", "average", "aversion", "avert", + "aviation", "aviator", "avid", "avoid", "await", "awaken", "award", "aware", "awhile", + "awkward", "awning", "awoke", "awry", "axis", "babble", "babbling", "babied", "baboon", + "backache", "backboard", "backboned", "backdrop", "backed", "backer", "backfield", + "backfire", "backhand", "backing", "backlands", "backlash", "backless", "backlight", + "backlit", "backlog", "backpack", "backpedal", "backrest", "backroom", "backshift", + "backside", "backslid", "backspace", "backspin", "backstab", "backstage", "backtalk", + "backtrack", "backup", "backward", "backwash", "backwater", "backyard", "bacon", + "bacteria", "bacterium", "badass", "badge", "badland", "badly", "badness", "baffle", + "baffling", "bagel", "bagful", "baggage", "bagged", "baggie", "bagginess", "bagging", + "baggy", "bagpipe", "baguette", "baked", "bakery", "bakeshop", "baking", "balance", + "balancing", "balcony", "balmy", "balsamic", "bamboo", "banana", "banish", "banister", + "banjo", "bankable", "bankbook", "banked", "banker", "banking", "banknote", "bankroll", + "banner", "bannister", "banshee", "banter", "barbecue", "barbed", "barbell", "barber", + "barcode", "barge", "bargraph", "barista", "baritone", "barley", "barmaid", "barman", + "barn", "barometer", "barrack", "barracuda", "barrel", "barrette", "barricade", "barrier", + "barstool", "bartender", "barterer", "bash", "basically", "basics", "basil", "basin", + "basis", "basket", "batboy", "batch", "bath", "baton", "bats", "battalion", "battered", + "battering", "battery", "batting", "battle", "bauble", "bazooka", "blabber", "bladder", + "blade", "blah", "blame", "blaming", "blanching", "blandness", "blank", "blaspheme", + "blasphemy", "blast", "blatancy", "blatantly", "blazer", "blazing", "bleach", "bleak", + "bleep", "blemish", "blend", "bless", "blighted", "blimp", "bling", "blinked", "blinker", + "blinking", "blinks", "blip", "blissful", "blitz", "blizzard", "bloated", "bloating", + "blob", "blog", "bloomers", "blooming", "blooper", "blot", "blouse", "blubber", "bluff", + "bluish", "blunderer", "blunt", "blurb", "blurred", "blurry", "blurt", "blush", "blustery", + "boaster", "boastful", "boasting", "boat", "bobbed", "bobbing", "bobble", "bobcat", + "bobsled", "bobtail", "bodacious", "body", "bogged", "boggle", "bogus", "boil", "bok", + "bolster", "bolt", "bonanza", "bonded", "bonding", "bondless", "boned", "bonehead", + "boneless", "bonelike", "boney", "bonfire", "bonnet", "bonsai", "bonus", "bony", + "boogeyman", "boogieman", "book", "boondocks", "booted", "booth", "bootie", "booting", + "bootlace", "bootleg", "boots", "boozy", "borax", "boring", "borough", "borrower", + "borrowing", "boss", "botanical", "botanist", "botany", "botch", "both", "bottle", + "bottling", "bottom", "bounce", "bouncing", "bouncy", "bounding", "boundless", "bountiful", + "bovine", "boxcar", "boxer", "boxing", "boxlike", "boxy", "breach", "breath", "breeches", + "breeching", "breeder", "breeding", "breeze", "breezy", "brethren", "brewery", "brewing", + "briar", "bribe", "brick", "bride", "bridged", "brigade", "bright", "brilliant", "brim", + "bring", "brink", "brisket", "briskly", "briskness", "bristle", "brittle", "broadband", + "broadcast", "broaden", "broadly", "broadness", "broadside", "broadways", "broiler", + "broiling", "broken", "broker", "bronchial", "bronco", "bronze", "bronzing", "brook", + "broom", "brought", "browbeat", "brownnose", "browse", "browsing", "bruising", "brunch", + "brunette", "brunt", "brush", "brussels", "brute", "brutishly", "bubble", "bubbling", + "bubbly", "buccaneer", "bucked", "bucket", "buckle", "buckshot", "buckskin", "bucktooth", + "buckwheat", "buddhism", "buddhist", "budding", "buddy", "budget", "buffalo", "buffed", + "buffer", "buffing", "buffoon", "buggy", "bulb", "bulge", "bulginess", "bulgur", "bulk", + "bulldog", "bulldozer", "bullfight", "bullfrog", "bullhorn", "bullion", "bullish", + "bullpen", "bullring", "bullseye", "bullwhip", "bully", "bunch", "bundle", "bungee", + "bunion", "bunkbed", "bunkhouse", "bunkmate", "bunny", "bunt", "busboy", "bush", "busily", + "busload", "bust", "busybody", "buzz", "cabana", "cabbage", "cabbie", "cabdriver", "cable", + "caboose", "cache", "cackle", "cacti", "cactus", "caddie", "caddy", "cadet", "cadillac", + "cadmium", "cage", "cahoots", "cake", "calamari", "calamity", "calcium", "calculate", + "calculus", "caliber", "calibrate", "calm", "caloric", "calorie", "calzone", "camcorder", + "cameo", "camera", "camisole", "camper", "campfire", "camping", "campsite", "campus", + "canal", "canary", "cancel", "candied", "candle", "candy", "cane", "canine", "canister", + "cannabis", "canned", "canning", "cannon", "cannot", "canola", "canon", "canopener", + "canopy", "canteen", "canyon", "capable", "capably", "capacity", "cape", "capillary", + "capital", "capitol", "capped", "capricorn", "capsize", "capsule", "caption", "captivate", + "captive", "captivity", "capture", "caramel", "carat", "caravan", "carbon", "cardboard", + "carded", "cardiac", "cardigan", "cardinal", "cardstock", "carefully", "caregiver", + "careless", "caress", "caretaker", "cargo", "caring", "carless", "carload", "carmaker", + "carnage", "carnation", "carnival", "carnivore", "carol", "carpenter", "carpentry", + "carpool", "carport", "carried", "carrot", "carrousel", "carry", "cartel", "cartload", + "carton", "cartoon", "cartridge", "cartwheel", "carve", "carving", "carwash", "cascade", + "case", "cash", "casing", "casino", "casket", "cassette", "casually", "casualty", + "catacomb", "catalog", "catalyst", "catalyze", "catapult", "cataract", "catatonic", + "catcall", "catchable", "catcher", "catching", "catchy", "caterer", "catering", "catfight", + "catfish", "cathedral", "cathouse", "catlike", "catnap", "catnip", "catsup", "cattail", + "cattishly", "cattle", "catty", "catwalk", "caucasian", "caucus", "causal", "causation", + "cause", "causing", "cauterize", "caution", "cautious", "cavalier", "cavalry", "caviar", + "cavity", "cedar", "celery", "celestial", "celibacy", "celibate", "celtic", "cement", + "census", "ceramics", "ceremony", "certainly", "certainty", "certified", "certify", + "cesarean", "cesspool", "chafe", "chaffing", "chain", "chair", "chalice", "challenge", + "chamber", "chamomile", "champion", "chance", "change", "channel", "chant", "chaos", + "chaperone", "chaplain", "chapped", "chaps", "chapter", "character", "charbroil", + "charcoal", "charger", "charging", "chariot", "charity", "charm", "charred", "charter", + "charting", "chase", "chasing", "chaste", "chastise", "chastity", "chatroom", "chatter", + "chatting", "chatty", "cheating", "cheddar", "cheek", "cheer", "cheese", "cheesy", "chef", + "chemicals", "chemist", "chemo", "cherisher", "cherub", "chess", "chest", "chevron", + "chevy", "chewable", "chewer", "chewing", "chewy", "chief", "chihuahua", "childcare", + "childhood", "childish", "childless", "childlike", "chili", "chill", "chimp", "chip", + "chirping", "chirpy", "chitchat", "chivalry", "chive", "chloride", "chlorine", "choice", + "chokehold", "choking", "chomp", "chooser", "choosing", "choosy", "chop", "chosen", + "chowder", "chowtime", "chrome", "chubby", "chuck", "chug", "chummy", "chump", "chunk", + "churn", "chute", "cider", "cilantro", "cinch", "cinema", "cinnamon", "circle", "circling", + "circular", "circulate", "circus", "citable", "citadel", "citation", "citizen", "citric", + "citrus", "city", "civic", "civil", "clad", "claim", "clambake", "clammy", "clamor", + "clamp", "clamshell", "clang", "clanking", "clapped", "clapper", "clapping", "clarify", + "clarinet", "clarity", "clash", "clasp", "class", "clatter", "clause", "clavicle", "claw", + "clay", "clean", "clear", "cleat", "cleaver", "cleft", "clench", "clergyman", "clerical", + "clerk", "clever", "clicker", "client", "climate", "climatic", "cling", "clinic", + "clinking", "clip", "clique", "cloak", "clobber", "clock", "clone", "cloning", "closable", + "closure", "clothes", "clothing", "cloud", "clover", "clubbed", "clubbing", "clubhouse", + "clump", "clumsily", "clumsy", "clunky", "clustered", "clutch", "clutter", "coach", + "coagulant", "coastal", "coaster", "coasting", "coastland", "coastline", "coat", + "coauthor", "cobalt", "cobbler", "cobweb", "cocoa", "coconut", "cod", "coeditor", "coerce", + "coexist", "coffee", "cofounder", "cognition", "cognitive", "cogwheel", "coherence", + "coherent", "cohesive", "coil", "coke", "cola", "cold", "coleslaw", "coliseum", "collage", + "collapse", "collar", "collected", "collector", "collide", "collie", "collision", + "colonial", "colonist", "colonize", "colony", "colossal", "colt", "coma", "come", + "comfort", "comfy", "comic", "coming", "comma", "commence", "commend", "comment", + "commerce", "commode", "commodity", "commodore", "common", "commotion", "commute", + "commuting", "compacted", "compacter", "compactly", "compactor", "companion", "company", + "compare", "compel", "compile", "comply", "component", "composed", "composer", "composite", + "compost", "composure", "compound", "compress", "comprised", "computer", "computing", + "comrade", "concave", "conceal", "conceded", "concept", "concerned", "concert", "conch", + "concierge", "concise", "conclude", "concrete", "concur", "condense", "condiment", + "condition", "condone", "conducive", "conductor", "conduit", "cone", "confess", "confetti", + "confidant", "confident", "confider", "confiding", "configure", "confined", "confining", + "confirm", "conflict", "conform", "confound", "confront", "confused", "confusing", + "confusion", "congenial", "congested", "congrats", "congress", "conical", "conjoined", + "conjure", "conjuror", "connected", "connector", "consensus", "consent", "console", + "consoling", "consonant", "constable", "constant", "constrain", "constrict", "construct", + "consult", "consumer", "consuming", "contact", "container", "contempt", "contend", + "contented", "contently", "contents", "contest", "context", "contort", "contour", + "contrite", "control", "contusion", "convene", "convent", "copartner", "cope", "copied", + "copier", "copilot", "coping", "copious", "copper", "copy", "coral", "cork", "cornball", + "cornbread", "corncob", "cornea", "corned", "corner", "cornfield", "cornflake", "cornhusk", + "cornmeal", "cornstalk", "corny", "coronary", "coroner", "corporal", "corporate", "corral", + "correct", "corridor", "corrode", "corroding", "corrosive", "corsage", "corset", "cortex", + "cosigner", "cosmetics", "cosmic", "cosmos", "cosponsor", "cost", "cottage", "cotton", + "couch", "cough", "could", "countable", "countdown", "counting", "countless", "country", + "county", "courier", "covenant", "cover", "coveted", "coveting", "coyness", "cozily", + "coziness", "cozy", "crabbing", "crabgrass", "crablike", "crabmeat", "cradle", "cradling", + "crafter", "craftily", "craftsman", "craftwork", "crafty", "cramp", "cranberry", "crane", + "cranial", "cranium", "crank", "crate", "crave", "craving", "crawfish", "crawlers", + "crawling", "crayfish", "crayon", "crazed", "crazily", "craziness", "crazy", "creamed", + "creamer", "creamlike", "crease", "creasing", "creatable", "create", "creation", + "creative", "creature", "credible", "credibly", "credit", "creed", "creme", "creole", + "crepe", "crept", "crescent", "crested", "cresting", "crestless", "crevice", "crewless", + "crewman", "crewmate", "crib", "cricket", "cried", "crier", "crimp", "crimson", "cringe", + "cringing", "crinkle", "crinkly", "crisped", "crisping", "crisply", "crispness", "crispy", + "criteria", "critter", "croak", "crock", "crook", "croon", "crop", "cross", "crouch", + "crouton", "crowbar", "crowd", "crown", "crucial", "crudely", "crudeness", "cruelly", + "cruelness", "cruelty", "crumb", "crummiest", "crummy", "crumpet", "crumpled", "cruncher", + "crunching", "crunchy", "crusader", "crushable", "crushed", "crusher", "crushing", "crust", + "crux", "crying", "cryptic", "crystal", "cubbyhole", "cube", "cubical", "cubicle", + "cucumber", "cuddle", "cuddly", "cufflink", "culinary", "culminate", "culpable", "culprit", + "cultivate", "cultural", "culture", "cupbearer", "cupcake", "cupid", "cupped", "cupping", + "curable", "curator", "curdle", "cure", "curfew", "curing", "curled", "curler", + "curliness", "curling", "curly", "curry", "curse", "cursive", "cursor", "curtain", + "curtly", "curtsy", "curvature", "curve", "curvy", "cushy", "cusp", "cussed", "custard", + "custodian", "custody", "customary", "customer", "customize", "customs", "cut", "cycle", + "cyclic", "cycling", "cyclist", "cylinder", "cymbal", "cytoplasm", "cytoplast", "dab", + "dad", "daffodil", "dagger", "daily", "daintily", "dainty", "dairy", "daisy", "dallying", + "dance", "dancing", "dandelion", "dander", "dandruff", "dandy", "danger", "dangle", + "dangling", "daredevil", "dares", "daringly", "darkened", "darkening", "darkish", + "darkness", "darkroom", "darling", "darn", "dart", "darwinism", "dash", "dastardly", + "data", "datebook", "dating", "daughter", "daunting", "dawdler", "dawn", "daybed", + "daybreak", "daycare", "daydream", "daylight", "daylong", "dayroom", "daytime", "dazzler", + "dazzling", "deacon", "deafening", "deafness", "dealer", "dealing", "dealmaker", "dealt", + "dean", "debatable", "debate", "debating", "debit", "debrief", "debtless", "debtor", + "debug", "debunk", "decade", "decaf", "decal", "decathlon", "decay", "deceased", "deceit", + "deceiver", "deceiving", "december", "decency", "decent", "deception", "deceptive", + "decibel", "decidable", "decimal", "decimeter", "decipher", "deck", "declared", "decline", + "decode", "decompose", "decorated", "decorator", "decoy", "decrease", "decree", "dedicate", + "dedicator", "deduce", "deduct", "deed", "deem", "deepen", "deeply", "deepness", "deface", + "defacing", "defame", "default", "defeat", "defection", "defective", "defendant", + "defender", "defense", "defensive", "deferral", "deferred", "defiance", "defiant", + "defile", "defiling", "define", "definite", "deflate", "deflation", "deflator", + "deflected", "deflector", "defog", "deforest", "defraud", "defrost", "deftly", "defuse", + "defy", "degraded", "degrading", "degrease", "degree", "dehydrate", "deity", "dejected", + "delay", "delegate", "delegator", "delete", "deletion", "delicacy", "delicate", + "delicious", "delighted", "delirious", "delirium", "deliverer", "delivery", "delouse", + "delta", "deluge", "delusion", "deluxe", "demanding", "demeaning", "demeanor", "demise", + "democracy", "democrat", "demote", "demotion", "demystify", "denatured", "deniable", + "denial", "denim", "denote", "dense", "density", "dental", "dentist", "denture", "deny", + "deodorant", "deodorize", "departed", "departure", "depict", "deplete", "depletion", + "deplored", "deploy", "deport", "depose", "depraved", "depravity", "deprecate", "depress", + "deprive", "depth", "deputize", "deputy", "derail", "deranged", "derby", "derived", + "desecrate", "deserve", "deserving", "designate", "designed", "designer", "designing", + "deskbound", "desktop", "deskwork", "desolate", "despair", "despise", "despite", "destiny", + "destitute", "destruct", "detached", "detail", "detection", "detective", "detector", + "detention", "detergent", "detest", "detonate", "detonator", "detoxify", "detract", + "deuce", "devalue", "deviancy", "deviant", "deviate", "deviation", "deviator", "device", + "devious", "devotedly", "devotee", "devotion", "devourer", "devouring", "devoutly", + "dexterity", "dexterous", "diabetes", "diabetic", "diabolic", "diagnoses", "diagnosis", + "diagram", "dial", "diameter", "diaper", "diaphragm", "diary", "dice", "dicing", "dictate", + "dictation", "dictator", "difficult", "diffused", "diffuser", "diffusion", "diffusive", + "dig", "dilation", "diligence", "diligent", "dill", "dilute", "dime", "diminish", "dimly", + "dimmed", "dimmer", "dimness", "dimple", "diner", "dingbat", "dinghy", "dinginess", + "dingo", "dingy", "dining", "dinner", "diocese", "dioxide", "diploma", "dipped", "dipper", + "dipping", "directed", "direction", "directive", "directly", "directory", "direness", + "dirtiness", "disabled", "disagree", "disallow", "disarm", "disarray", "disaster", + "disband", "disbelief", "disburse", "discard", "discern", "discharge", "disclose", + "discolor", "discount", "discourse", "discover", "discuss", "disdain", "disengage", + "disfigure", "disgrace", "dish", "disinfect", "disjoin", "disk", "dislike", "disliking", + "dislocate", "dislodge", "disloyal", "dismantle", "dismay", "dismiss", "dismount", + "disobey", "disorder", "disown", "disparate", "disparity", "dispatch", "dispense", + "dispersal", "dispersed", "disperser", "displace", "display", "displease", "disposal", + "dispose", "disprove", "dispute", "disregard", "disrupt", "dissuade", "distance", + "distant", "distaste", "distill", "distinct", "distort", "distract", "distress", + "district", "distrust", "ditch", "ditto", "ditzy", "dividable", "divided", "dividend", + "dividers", "dividing", "divinely", "diving", "divinity", "divisible", "divisibly", + "division", "divisive", "divorcee", "dizziness", "dizzy", "doable", "docile", "dock", + "doctrine", "document", "dodge", "dodgy", "doily", "doing", "dole", "dollar", "dollhouse", + "dollop", "dolly", "dolphin", "domain", "domelike", "domestic", "dominion", "dominoes", + "donated", "donation", "donator", "donor", "donut", "doodle", "doorbell", "doorframe", + "doorknob", "doorman", "doormat", "doornail", "doorpost", "doorstep", "doorstop", + "doorway", "doozy", "dork", "dormitory", "dorsal", "dosage", "dose", "dotted", "doubling", + "douche", "dove", "down", "dowry", "doze", "drab", "dragging", "dragonfly", "dragonish", + "dragster", "drainable", "drainage", "drained", "drainer", "drainpipe", "dramatic", + "dramatize", "drank", "drapery", "drastic", "draw", "dreaded", "dreadful", "dreadlock", + "dreamboat", "dreamily", "dreamland", "dreamless", "dreamlike", "dreamt", "dreamy", + "drearily", "dreary", "drench", "dress", "drew", "dribble", "dried", "drier", "drift", + "driller", "drilling", "drinkable", "drinking", "dripping", "drippy", "drivable", "driven", + "driver", "driveway", "driving", "drizzle", "drizzly", "drone", "drool", "droop", + "drop-down", "dropbox", "dropkick", "droplet", "dropout", "dropper", "drove", "drown", + "drowsily", "drudge", "drum", "dry", "dubbed", "dubiously", "duchess", "duckbill", + "ducking", "duckling", "ducktail", "ducky", "duct", "dude", "duffel", "dugout", "duh", + "duke", "duller", "dullness", "duly", "dumping", "dumpling", "dumpster", "duo", "dupe", + "duplex", "duplicate", "duplicity", "durable", "durably", "duration", "duress", "during", + "dusk", "dust", "dutiful", "duty", "duvet", "dwarf", "dweeb", "dwelled", "dweller", + "dwelling", "dwindle", "dwindling", "dynamic", "dynamite", "dynasty", "dyslexia", + "dyslexic", "each", "eagle", "earache", "eardrum", "earflap", "earful", "earlobe", "early", + "earmark", "earmuff", "earphone", "earpiece", "earplugs", "earring", "earshot", "earthen", + "earthlike", "earthling", "earthly", "earthworm", "earthy", "earwig", "easeful", "easel", + "easiest", "easily", "easiness", "easing", "eastbound", "eastcoast", "easter", "eastward", + "eatable", "eaten", "eatery", "eating", "eats", "ebay", "ebony", "ebook", "ecard", + "eccentric", "echo", "eclair", "eclipse", "ecologist", "ecology", "economic", "economist", + "economy", "ecosphere", "ecosystem", "edge", "edginess", "edging", "edgy", "edition", + "editor", "educated", "education", "educator", "eel", "effective", "effects", "efficient", + "effort", "eggbeater", "egging", "eggnog", "eggplant", "eggshell", "egomaniac", "egotism", + "egotistic", "either", "eject", "elaborate", "elastic", "elated", "elbow", "eldercare", + "elderly", "eldest", "electable", "election", "elective", "elephant", "elevate", + "elevating", "elevation", "elevator", "eleven", "elf", "eligible", "eligibly", "eliminate", + "elite", "elitism", "elixir", "elk", "ellipse", "elliptic", "elm", "elongated", "elope", + "eloquence", "eloquent", "elsewhere", "elude", "elusive", "elves", "email", "embargo", + "embark", "embassy", "embattled", "embellish", "ember", "embezzle", "emblaze", "emblem", + "embody", "embolism", "emboss", "embroider", "emcee", "emerald", "emergency", "emission", + "emit", "emote", "emoticon", "emotion", "empathic", "empathy", "emperor", "emphases", + "emphasis", "emphasize", "emphatic", "empirical", "employed", "employee", "employer", + "emporium", "empower", "emptier", "emptiness", "empty", "emu", "enable", "enactment", + "enamel", "enchanted", "enchilada", "encircle", "enclose", "enclosure", "encode", "encore", + "encounter", "encourage", "encroach", "encrust", "encrypt", "endanger", "endeared", + "endearing", "ended", "ending", "endless", "endnote", "endocrine", "endorphin", "endorse", + "endowment", "endpoint", "endurable", "endurance", "enduring", "energetic", "energize", + "energy", "enforced", "enforcer", "engaged", "engaging", "engine", "engorge", "engraved", + "engraver", "engraving", "engross", "engulf", "enhance", "enigmatic", "enjoyable", + "enjoyably", "enjoyer", "enjoying", "enjoyment", "enlarged", "enlarging", "enlighten", + "enlisted", "enquirer", "enrage", "enrich", "enroll", "enslave", "ensnare", "ensure", + "entail", "entangled", "entering", "entertain", "enticing", "entire", "entitle", "entity", + "entomb", "entourage", "entrap", "entree", "entrench", "entrust", "entryway", "entwine", + "enunciate", "envelope", "enviable", "enviably", "envious", "envision", "envoy", "envy", + "enzyme", "epic", "epidemic", "epidermal", "epidermis", "epidural", "epilepsy", + "epileptic", "epilogue", "epiphany", "episode", "equal", "equate", "equation", "equator", + "equinox", "equipment", "equity", "equivocal", "eradicate", "erasable", "erased", "eraser", + "erasure", "ergonomic", "errand", "errant", "erratic", "error", "erupt", "escalate", + "escalator", "escapable", "escapade", "escapist", "escargot", "eskimo", "esophagus", + "espionage", "espresso", "esquire", "essay", "essence", "essential", "establish", "estate", + "esteemed", "estimate", "estimator", "estranged", "estrogen", "etching", "eternal", + "eternity", "ethanol", "ether", "ethically", "ethics", "euphemism", "evacuate", "evacuee", + "evade", "evaluate", "evaluator", "evaporate", "evasion", "evasive", "even", "everglade", + "evergreen", "everybody", "everyday", "everyone", "evict", "evidence", "evident", "evil", + "evoke", "evolution", "evolve", "exact", "exalted", "example", "excavate", "excavator", + "exceeding", "exception", "excess", "exchange", "excitable", "exciting", "exclaim", + "exclude", "excluding", "exclusion", "exclusive", "excretion", "excretory", "excursion", + "excusable", "excusably", "excuse", "exemplary", "exemplify", "exemption", "exerciser", + "exert", "exes", "exfoliate", "exhale", "exhaust", "exhume", "exile", "existing", "exit", + "exodus", "exonerate", "exorcism", "exorcist", "expand", "expanse", "expansion", + "expansive", "expectant", "expedited", "expediter", "expel", "expend", "expenses", + "expensive", "expert", "expire", "expiring", "explain", "expletive", "explicit", "explode", + "exploit", "explore", "exploring", "exponent", "exporter", "exposable", "expose", + "exposure", "express", "expulsion", "exquisite", "extended", "extending", "extent", + "extenuate", "exterior", "external", "extinct", "extortion", "extradite", "extras", + "extrovert", "extrude", "extruding", "exuberant", "fable", "fabric", "fabulous", + "facebook", "facecloth", "facedown", "faceless", "facelift", "faceplate", "faceted", + "facial", "facility", "facing", "facsimile", "faction", "factoid", "factor", "factsheet", + "factual", "faculty", "fade", "fading", "failing", "falcon", "fall", "false", "falsify", + "fame", "familiar", "family", "famine", "famished", "fanatic", "fancied", "fanciness", + "fancy", "fanfare", "fang", "fanning", "fantasize", "fantastic", "fantasy", "fascism", + "fastball", "faster", "fasting", "fastness", "faucet", "favorable", "favorably", "favored", + "favoring", "favorite", "fax", "feast", "federal", "fedora", "feeble", "feed", "feel", + "feisty", "feline", "felt-tip", "feminine", "feminism", "feminist", "feminize", "femur", + "fence", "fencing", "fender", "ferment", "fernlike", "ferocious", "ferocity", "ferret", + "ferris", "ferry", "fervor", "fester", "festival", "festive", "festivity", "fetal", + "fetch", "fever", "fiber", "fiction", "fiddle", "fiddling", "fidelity", "fidgeting", + "fidgety", "fifteen", "fifth", "fiftieth", "fifty", "figment", "figure", "figurine", + "filing", "filled", "filler", "filling", "film", "filter", "filth", "filtrate", "finale", + "finalist", "finalize", "finally", "finance", "financial", "finch", "fineness", "finer", + "finicky", "finished", "finisher", "finishing", "finite", "finless", "finlike", "fiscally", + "fit", "five", "flaccid", "flagman", "flagpole", "flagship", "flagstick", "flagstone", + "flail", "flakily", "flaky", "flame", "flammable", "flanked", "flanking", "flannels", + "flap", "flaring", "flashback", "flashbulb", "flashcard", "flashily", "flashing", "flashy", + "flask", "flatbed", "flatfoot", "flatly", "flatness", "flatten", "flattered", "flatterer", + "flattery", "flattop", "flatware", "flatworm", "flavored", "flavorful", "flavoring", + "flaxseed", "fled", "fleshed", "fleshy", "flick", "flier", "flight", "flinch", "fling", + "flint", "flip", "flirt", "float", "flock", "flogging", "flop", "floral", "florist", + "floss", "flounder", "flyable", "flyaway", "flyer", "flying", "flyover", "flypaper", + "foam", "foe", "fog", "foil", "folic", "folk", "follicle", "follow", "fondling", "fondly", + "fondness", "fondue", "font", "food", "fool", "footage", "football", "footbath", + "footboard", "footer", "footgear", "foothill", "foothold", "footing", "footless", + "footman", "footnote", "footpad", "footpath", "footprint", "footrest", "footsie", + "footsore", "footwear", "footwork", "fossil", "foster", "founder", "founding", "fountain", + "fox", "foyer", "fraction", "fracture", "fragile", "fragility", "fragment", "fragrance", + "fragrant", "frail", "frame", "framing", "frantic", "fraternal", "frayed", "fraying", + "frays", "freckled", "freckles", "freebase", "freebee", "freebie", "freedom", "freefall", + "freehand", "freeing", "freeload", "freely", "freemason", "freeness", "freestyle", + "freeware", "freeway", "freewill", "freezable", "freezing", "freight", "french", + "frenzied", "frenzy", "frequency", "frequent", "fresh", "fretful", "fretted", "friction", + "friday", "fridge", "fried", "friend", "frighten", "frightful", "frigidity", "frigidly", + "frill", "fringe", "frisbee", "frisk", "fritter", "frivolous", "frolic", "from", "front", + "frostbite", "frosted", "frostily", "frosting", "frostlike", "frosty", "froth", "frown", + "frozen", "fructose", "frugality", "frugally", "fruit", "frustrate", "frying", "gab", + "gaffe", "gag", "gainfully", "gaining", "gains", "gala", "gallantly", "galleria", + "gallery", "galley", "gallon", "gallows", "gallstone", "galore", "galvanize", "gambling", + "game", "gaming", "gamma", "gander", "gangly", "gangrene", "gangway", "gap", "garage", + "garbage", "garden", "gargle", "garland", "garlic", "garment", "garnet", "garnish", + "garter", "gas", "gatherer", "gathering", "gating", "gauging", "gauntlet", "gauze", "gave", + "gawk", "gazing", "gear", "gecko", "geek", "geiger", "gem", "gender", "generic", + "generous", "genetics", "genre", "gentile", "gentleman", "gently", "gents", "geography", + "geologic", "geologist", "geology", "geometric", "geometry", "geranium", "gerbil", + "geriatric", "germicide", "germinate", "germless", "germproof", "gestate", "gestation", + "gesture", "getaway", "getting", "getup", "giant", "gibberish", "giblet", "giddily", + "giddiness", "giddy", "gift", "gigabyte", "gigahertz", "gigantic", "giggle", "giggling", + "giggly", "gigolo", "gilled", "gills", "gimmick", "girdle", "giveaway", "given", "giver", + "giving", "gizmo", "gizzard", "glacial", "glacier", "glade", "gladiator", "gladly", + "glamorous", "glamour", "glance", "glancing", "glandular", "glare", "glaring", "glass", + "glaucoma", "glazing", "gleaming", "gleeful", "glider", "gliding", "glimmer", "glimpse", + "glisten", "glitch", "glitter", "glitzy", "gloater", "gloating", "gloomily", "gloomy", + "glorified", "glorifier", "glorify", "glorious", "glory", "gloss", "glove", "glowing", + "glowworm", "glucose", "glue", "gluten", "glutinous", "glutton", "gnarly", "gnat", "goal", + "goatskin", "goes", "goggles", "going", "goldfish", "goldmine", "goldsmith", "golf", + "goliath", "gonad", "gondola", "gone", "gong", "good", "gooey", "goofball", "goofiness", + "goofy", "google", "goon", "gopher", "gore", "gorged", "gorgeous", "gory", "gosling", + "gossip", "gothic", "gotten", "gout", "gown", "grab", "graceful", "graceless", "gracious", + "gradation", "graded", "grader", "gradient", "grading", "gradually", "graduate", + "graffiti", "grafted", "grafting", "grain", "granddad", "grandkid", "grandly", "grandma", + "grandpa", "grandson", "granite", "granny", "granola", "grant", "granular", "grape", + "graph", "grapple", "grappling", "grasp", "grass", "gratified", "gratify", "grating", + "gratitude", "gratuity", "gravel", "graveness", "graves", "graveyard", "gravitate", + "gravity", "gravy", "gray", "grazing", "greasily", "greedily", "greedless", "greedy", + "green", "greeter", "greeting", "grew", "greyhound", "grid", "grief", "grievance", + "grieving", "grievous", "grill", "grimace", "grimacing", "grime", "griminess", "grimy", + "grinch", "grinning", "grip", "gristle", "grit", "groggily", "groggy", "groin", "groom", + "groove", "grooving", "groovy", "grope", "ground", "grouped", "grout", "grove", "grower", + "growing", "growl", "grub", "grudge", "grudging", "grueling", "gruffly", "grumble", + "grumbling", "grumbly", "grumpily", "grunge", "grunt", "guacamole", "guidable", "guidance", + "guide", "guiding", "guileless", "guise", "gulf", "gullible", "gully", "gulp", "gumball", + "gumdrop", "gumminess", "gumming", "gummy", "gurgle", "gurgling", "guru", "gush", "gusto", + "gusty", "gutless", "guts", "gutter", "guy", "guzzler", "gyration", "habitable", + "habitant", "habitat", "habitual", "hacked", "hacker", "hacking", "hacksaw", "had", + "haggler", "haiku", "half", "halogen", "halt", "halved", "halves", "hamburger", "hamlet", + "hammock", "hamper", "hamster", "hamstring", "handbag", "handball", "handbook", + "handbrake", "handcart", "handclap", "handclasp", "handcraft", "handcuff", "handed", + "handful", "handgrip", "handgun", "handheld", "handiness", "handiwork", "handlebar", + "handled", "handler", "handling", "handmade", "handoff", "handpick", "handprint", + "handrail", "handsaw", "handset", "handsfree", "handshake", "handstand", "handwash", + "handwork", "handwoven", "handwrite", "handyman", "hangnail", "hangout", "hangover", + "hangup", "hankering", "hankie", "hanky", "haphazard", "happening", "happier", "happiest", + "happily", "happiness", "happy", "harbor", "hardcopy", "hardcore", "hardcover", "harddisk", + "hardened", "hardener", "hardening", "hardhat", "hardhead", "hardiness", "hardly", + "hardness", "hardship", "hardware", "hardwired", "hardwood", "hardy", "harmful", + "harmless", "harmonica", "harmonics", "harmonize", "harmony", "harness", "harpist", + "harsh", "harvest", "hash", "hassle", "haste", "hastily", "hastiness", "hasty", "hatbox", + "hatchback", "hatchery", "hatchet", "hatching", "hatchling", "hate", "hatless", "hatred", + "haunt", "haven", "hazard", "hazelnut", "hazily", "haziness", "hazing", "hazy", "headache", + "headband", "headboard", "headcount", "headdress", "headed", "header", "headfirst", + "headgear", "heading", "headlamp", "headless", "headlock", "headphone", "headpiece", + "headrest", "headroom", "headscarf", "headset", "headsman", "headstand", "headstone", + "headway", "headwear", "heap", "heat", "heave", "heavily", "heaviness", "heaving", "hedge", + "hedging", "heftiness", "hefty", "helium", "helmet", "helper", "helpful", "helping", + "helpless", "helpline", "hemlock", "hemstitch", "hence", "henchman", "henna", "herald", + "herbal", "herbicide", "herbs", "heritage", "hermit", "heroics", "heroism", "herring", + "herself", "hertz", "hesitancy", "hesitant", "hesitate", "hexagon", "hexagram", "hubcap", + "huddle", "huddling", "huff", "hug", "hula", "hulk", "hull", "human", "humble", "humbling", + "humbly", "humid", "humiliate", "humility", "humming", "hummus", "humongous", "humorist", + "humorless", "humorous", "humpback", "humped", "humvee", "hunchback", "hundredth", + "hunger", "hungrily", "hungry", "hunk", "hunter", "hunting", "huntress", "huntsman", + "hurdle", "hurled", "hurler", "hurling", "hurray", "hurricane", "hurried", "hurry", "hurt", + "husband", "hush", "husked", "huskiness", "hut", "hybrid", "hydrant", "hydrated", + "hydration", "hydrogen", "hydroxide", "hyperlink", "hypertext", "hyphen", "hypnoses", + "hypnosis", "hypnotic", "hypnotism", "hypnotist", "hypnotize", "hypocrisy", "hypocrite", + "ibuprofen", "ice", "iciness", "icing", "icky", "icon", "icy", "idealism", "idealist", + "idealize", "ideally", "idealness", "identical", "identify", "identity", "ideology", + "idiocy", "idiom", "idly", "igloo", "ignition", "ignore", "iguana", "illicitly", + "illusion", "illusive", "image", "imaginary", "imagines", "imaging", "imbecile", "imitate", + "imitation", "immature", "immerse", "immersion", "imminent", "immobile", "immodest", + "immorally", "immortal", "immovable", "immovably", "immunity", "immunize", "impaired", + "impale", "impart", "impatient", "impeach", "impeding", "impending", "imperfect", + "imperial", "impish", "implant", "implement", "implicate", "implicit", "implode", + "implosion", "implosive", "imply", "impolite", "important", "importer", "impose", + "imposing", "impotence", "impotency", "impotent", "impound", "imprecise", "imprint", + "imprison", "impromptu", "improper", "improve", "improving", "improvise", "imprudent", + "impulse", "impulsive", "impure", "impurity", "iodine", "iodize", "ion", "ipad", "iphone", + "ipod", "irate", "irk", "iron", "irregular", "irrigate", "irritable", "irritably", + "irritant", "irritate", "islamic", "islamist", "isolated", "isolating", "isolation", + "isotope", "issue", "issuing", "italicize", "italics", "item", "itinerary", "itunes", + "ivory", "ivy", "jab", "jackal", "jacket", "jackknife", "jackpot", "jailbird", "jailbreak", + "jailer", "jailhouse", "jalapeno", "jam", "janitor", "january", "jargon", "jarring", + "jasmine", "jaundice", "jaunt", "java", "jawed", "jawless", "jawline", "jaws", "jaybird", + "jaywalker", "jazz", "jeep", "jeeringly", "jellied", "jelly", "jersey", "jester", "jet", + "jiffy", "jigsaw", "jimmy", "jingle", "jingling", "jinx", "jitters", "jittery", "job", + "jockey", "jockstrap", "jogger", "jogging", "john", "joining", "jokester", "jokingly", + "jolliness", "jolly", "jolt", "jot", "jovial", "joyfully", "joylessly", "joyous", + "joyride", "joystick", "jubilance", "jubilant", "judge", "judgingly", "judicial", + "judiciary", "judo", "juggle", "juggling", "jugular", "juice", "juiciness", "juicy", + "jujitsu", "jukebox", "july", "jumble", "jumbo", "jump", "junction", "juncture", "june", + "junior", "juniper", "junkie", "junkman", "junkyard", "jurist", "juror", "jury", "justice", + "justifier", "justify", "justly", "justness", "juvenile", "kabob", "kangaroo", "karaoke", + "karate", "karma", "kebab", "keenly", "keenness", "keep", "keg", "kelp", "kennel", "kept", + "kerchief", "kerosene", "kettle", "kick", "kiln", "kilobyte", "kilogram", "kilometer", + "kilowatt", "kilt", "kimono", "kindle", "kindling", "kindly", "kindness", "kindred", + "kinetic", "kinfolk", "king", "kinship", "kinsman", "kinswoman", "kissable", "kisser", + "kissing", "kitchen", "kite", "kitten", "kitty", "kiwi", "kleenex", "knapsack", "knee", + "knelt", "knickers", "knoll", "koala", "kooky", "kosher", "krypton", "kudos", "kung", + "labored", "laborer", "laboring", "laborious", "labrador", "ladder", "ladies", "ladle", + "ladybug", "ladylike", "lagged", "lagging", "lagoon", "lair", "lake", "lance", "landed", + "landfall", "landfill", "landing", "landlady", "landless", "landline", "landlord", + "landmark", "landmass", "landmine", "landowner", "landscape", "landside", "landslide", + "language", "lankiness", "lanky", "lantern", "lapdog", "lapel", "lapped", "lapping", + "laptop", "lard", "large", "lark", "lash", "lasso", "last", "latch", "late", "lather", + "latitude", "latrine", "latter", "latticed", "launch", "launder", "laundry", "laurel", + "lavender", "lavish", "laxative", "lazily", "laziness", "lazy", "lecturer", "left", + "legacy", "legal", "legend", "legged", "leggings", "legible", "legibly", "legislate", + "lego", "legroom", "legume", "legwarmer", "legwork", "lemon", "lend", "length", "lens", + "lent", "leotard", "lesser", "letdown", "lethargic", "lethargy", "letter", "lettuce", + "level", "leverage", "levers", "levitate", "levitator", "liability", "liable", "liberty", + "librarian", "library", "licking", "licorice", "lid", "life", "lifter", "lifting", + "liftoff", "ligament", "likely", "likeness", "likewise", "liking", "lilac", "lilly", + "lily", "limb", "limeade", "limelight", "limes", "limit", "limping", "limpness", "line", + "lingo", "linguini", "linguist", "lining", "linked", "linoleum", "linseed", "lint", "lion", + "lip", "liquefy", "liqueur", "liquid", "lisp", "list", "litigate", "litigator", "litmus", + "litter", "little", "livable", "lived", "lively", "liver", "livestock", "lividly", + "living", "lizard", "lubricant", "lubricate", "lucid", "luckily", "luckiness", "luckless", + "lucrative", "ludicrous", "lugged", "lukewarm", "lullaby", "lumber", "luminance", + "luminous", "lumpiness", "lumping", "lumpish", "lunacy", "lunar", "lunchbox", "luncheon", + "lunchroom", "lunchtime", "lung", "lurch", "lure", "luridness", "lurk", "lushly", + "lushness", "luster", "lustfully", "lustily", "lustiness", "lustrous", "lusty", + "luxurious", "luxury", "lying", "lyrically", "lyricism", "lyricist", "lyrics", "macarena", + "macaroni", "macaw", "mace", "machine", "machinist", "magazine", "magenta", "maggot", + "magical", "magician", "magma", "magnesium", "magnetic", "magnetism", "magnetize", + "magnifier", "magnify", "magnitude", "magnolia", "mahogany", "maimed", "majestic", + "majesty", "majorette", "majority", "makeover", "maker", "makeshift", "making", + "malformed", "malt", "mama", "mammal", "mammary", "mammogram", "manager", "managing", + "manatee", "mandarin", "mandate", "mandatory", "mandolin", "manger", "mangle", "mango", + "mangy", "manhandle", "manhole", "manhood", "manhunt", "manicotti", "manicure", + "manifesto", "manila", "mankind", "manlike", "manliness", "manly", "manmade", "manned", + "mannish", "manor", "manpower", "mantis", "mantra", "manual", "many", "map", "marathon", + "marauding", "marbled", "marbles", "marbling", "march", "mardi", "margarine", "margarita", + "margin", "marigold", "marina", "marine", "marital", "maritime", "marlin", "marmalade", + "maroon", "married", "marrow", "marry", "marshland", "marshy", "marsupial", "marvelous", + "marxism", "mascot", "masculine", "mashed", "mashing", "massager", "masses", "massive", + "mastiff", "matador", "matchbook", "matchbox", "matcher", "matching", "matchless", + "material", "maternal", "maternity", "math", "mating", "matriarch", "matrimony", "matrix", + "matron", "matted", "matter", "maturely", "maturing", "maturity", "mauve", "maverick", + "maximize", "maximum", "maybe", "mayday", "mayflower", "moaner", "moaning", "mobile", + "mobility", "mobilize", "mobster", "mocha", "mocker", "mockup", "modified", "modify", + "modular", "modulator", "module", "moisten", "moistness", "moisture", "molar", "molasses", + "mold", "molecular", "molecule", "molehill", "mollusk", "mom", "monastery", "monday", + "monetary", "monetize", "moneybags", "moneyless", "moneywise", "mongoose", "mongrel", + "monitor", "monkhood", "monogamy", "monogram", "monologue", "monopoly", "monorail", + "monotone", "monotype", "monoxide", "monsieur", "monsoon", "monstrous", "monthly", + "monument", "moocher", "moodiness", "moody", "mooing", "moonbeam", "mooned", "moonlight", + "moonlike", "moonlit", "moonrise", "moonscape", "moonshine", "moonstone", "moonwalk", + "mop", "morale", "morality", "morally", "morbidity", "morbidly", "morphine", "morphing", + "morse", "mortality", "mortally", "mortician", "mortified", "mortify", "mortuary", + "mosaic", "mossy", "most", "mothball", "mothproof", "motion", "motivate", "motivator", + "motive", "motocross", "motor", "motto", "mountable", "mountain", "mounted", "mounting", + "mourner", "mournful", "mouse", "mousiness", "moustache", "mousy", "mouth", "movable", + "move", "movie", "moving", "mower", "mowing", "much", "muck", "mud", "mug", "mulberry", + "mulch", "mule", "mulled", "mullets", "multiple", "multiply", "multitask", "multitude", + "mumble", "mumbling", "mumbo", "mummified", "mummify", "mummy", "mumps", "munchkin", + "mundane", "municipal", "muppet", "mural", "murkiness", "murky", "murmuring", "muscular", + "museum", "mushily", "mushiness", "mushroom", "mushy", "music", "musket", "muskiness", + "musky", "mustang", "mustard", "muster", "mustiness", "musty", "mutable", "mutate", + "mutation", "mute", "mutilated", "mutilator", "mutiny", "mutt", "mutual", "muzzle", + "myself", "myspace", "mystified", "mystify", "myth", "nacho", "nag", "nail", "name", + "naming", "nanny", "nanometer", "nape", "napkin", "napped", "napping", "nappy", "narrow", + "nastily", "nastiness", "national", "native", "nativity", "natural", "nature", "naturist", + "nautical", "navigate", "navigator", "navy", "nearby", "nearest", "nearly", "nearness", + "neatly", "neatness", "nebula", "nebulizer", "nectar", "negate", "negation", "negative", + "neglector", "negligee", "negligent", "negotiate", "nemeses", "nemesis", "neon", "nephew", + "nerd", "nervous", "nervy", "nest", "net", "neurology", "neuron", "neurosis", "neurotic", + "neuter", "neutron", "never", "next", "nibble", "nickname", "nicotine", "niece", "nifty", + "nimble", "nimbly", "nineteen", "ninetieth", "ninja", "nintendo", "ninth", "nuclear", + "nuclei", "nucleus", "nugget", "nullify", "number", "numbing", "numbly", "numbness", + "numeral", "numerate", "numerator", "numeric", "numerous", "nuptials", "nursery", + "nursing", "nurture", "nutcase", "nutlike", "nutmeg", "nutrient", "nutshell", "nuttiness", + "nutty", "nuzzle", "nylon", "oaf", "oak", "oasis", "oat", "obedience", "obedient", + "obituary", "object", "obligate", "obliged", "oblivion", "oblivious", "oblong", + "obnoxious", "oboe", "obscure", "obscurity", "observant", "observer", "observing", + "obsessed", "obsession", "obsessive", "obsolete", "obstacle", "obstinate", "obstruct", + "obtain", "obtrusive", "obtuse", "obvious", "occultist", "occupancy", "occupant", + "occupier", "occupy", "ocean", "ocelot", "octagon", "octane", "october", "octopus", "ogle", + "oil", "oink", "ointment", "okay", "old", "olive", "olympics", "omega", "omen", "ominous", + "omission", "omit", "omnivore", "onboard", "oncoming", "ongoing", "onion", "online", + "onlooker", "only", "onscreen", "onset", "onshore", "onslaught", "onstage", "onto", + "onward", "onyx", "oops", "ooze", "oozy", "opacity", "opal", "open", "operable", "operate", + "operating", "operation", "operative", "operator", "opium", "opossum", "opponent", + "oppose", "opposing", "opposite", "oppressed", "oppressor", "opt", "opulently", "osmosis", + "other", "otter", "ouch", "ought", "ounce", "outage", "outback", "outbid", "outboard", + "outbound", "outbreak", "outburst", "outcast", "outclass", "outcome", "outdated", + "outdoors", "outer", "outfield", "outfit", "outflank", "outgoing", "outgrow", "outhouse", + "outing", "outlast", "outlet", "outline", "outlook", "outlying", "outmatch", "outmost", + "outnumber", "outplayed", "outpost", "outpour", "output", "outrage", "outrank", "outreach", + "outright", "outscore", "outsell", "outshine", "outshoot", "outsider", "outskirts", + "outsmart", "outsource", "outspoken", "outtakes", "outthink", "outward", "outweigh", + "outwit", "oval", "ovary", "oven", "overact", "overall", "overarch", "overbid", "overbill", + "overbite", "overblown", "overboard", "overbook", "overbuilt", "overcast", "overcoat", + "overcome", "overcook", "overcrowd", "overdraft", "overdrawn", "overdress", "overdrive", + "overdue", "overeager", "overeater", "overexert", "overfed", "overfeed", "overfill", + "overflow", "overfull", "overgrown", "overhand", "overhang", "overhaul", "overhead", + "overhear", "overheat", "overhung", "overjoyed", "overkill", "overlabor", "overlaid", + "overlap", "overlay", "overload", "overlook", "overlord", "overlying", "overnight", + "overpass", "overpay", "overplant", "overplay", "overpower", "overprice", "overrate", + "overreach", "overreact", "override", "overripe", "overrule", "overrun", "overshoot", + "overshot", "oversight", "oversized", "oversleep", "oversold", "overspend", "overstate", + "overstay", "overstep", "overstock", "overstuff", "oversweet", "overtake", "overthrow", + "overtime", "overtly", "overtone", "overture", "overturn", "overuse", "overvalue", + "overview", "overwrite", "owl", "oxford", "oxidant", "oxidation", "oxidize", "oxidizing", + "oxygen", "oxymoron", "oyster", "ozone", "paced", "pacemaker", "pacific", "pacifier", + "pacifism", "pacifist", "pacify", "padded", "padding", "paddle", "paddling", "padlock", + "pagan", "pager", "paging", "pajamas", "palace", "palatable", "palm", "palpable", + "palpitate", "paltry", "pampered", "pamperer", "pampers", "pamphlet", "panama", "pancake", + "pancreas", "panda", "pandemic", "pang", "panhandle", "panic", "panning", "panorama", + "panoramic", "panther", "pantomime", "pantry", "pants", "pantyhose", "paparazzi", "papaya", + "paper", "paprika", "papyrus", "parabola", "parachute", "parade", "paradox", "paragraph", + "parakeet", "paralegal", "paralyses", "paralysis", "paralyze", "paramedic", "parameter", + "paramount", "parasail", "parasite", "parasitic", "parcel", "parched", "parchment", + "pardon", "parish", "parka", "parking", "parkway", "parlor", "parmesan", "parole", + "parrot", "parsley", "parsnip", "partake", "parted", "parting", "partition", "partly", + "partner", "partridge", "party", "passable", "passably", "passage", "passcode", + "passenger", "passerby", "passing", "passion", "passive", "passivism", "passover", + "passport", "password", "pasta", "pasted", "pastel", "pastime", "pastor", "pastrami", + "pasture", "pasty", "patchwork", "patchy", "paternal", "paternity", "path", "patience", + "patient", "patio", "patriarch", "patriot", "patrol", "patronage", "patronize", "pauper", + "pavement", "paver", "pavestone", "pavilion", "paving", "pawing", "payable", "payback", + "paycheck", "payday", "payee", "payer", "paying", "payment", "payphone", "payroll", + "pebble", "pebbly", "pecan", "pectin", "peculiar", "peddling", "pediatric", "pedicure", + "pedigree", "pedometer", "pegboard", "pelican", "pellet", "pelt", "pelvis", "penalize", + "penalty", "pencil", "pendant", "pending", "penholder", "penknife", "pennant", "penniless", + "penny", "penpal", "pension", "pentagon", "pentagram", "pep", "perceive", "percent", + "perch", "percolate", "perennial", "perfected", "perfectly", "perfume", "periscope", + "perish", "perjurer", "perjury", "perkiness", "perky", "perm", "peroxide", "perpetual", + "perplexed", "persecute", "persevere", "persuaded", "persuader", "pesky", "peso", + "pessimism", "pessimist", "pester", "pesticide", "petal", "petite", "petition", "petri", + "petroleum", "petted", "petticoat", "pettiness", "petty", "petunia", "phantom", "phobia", + "phoenix", "phonebook", "phoney", "phonics", "phoniness", "phony", "phosphate", "photo", + "phrase", "phrasing", "placard", "placate", "placidly", "plank", "planner", "plant", + "plasma", "plaster", "plastic", "plated", "platform", "plating", "platinum", "platonic", + "platter", "platypus", "plausible", "plausibly", "playable", "playback", "player", + "playful", "playgroup", "playhouse", "playing", "playlist", "playmaker", "playmate", + "playoff", "playpen", "playroom", "playset", "plaything", "playtime", "plaza", "pleading", + "pleat", "pledge", "plentiful", "plenty", "plethora", "plexiglas", "pliable", "plod", + "plop", "plot", "plow", "ploy", "pluck", "plug", "plunder", "plunging", "plural", "plus", + "plutonium", "plywood", "poach", "pod", "poem", "poet", "pogo", "pointed", "pointer", + "pointing", "pointless", "pointy", "poise", "poison", "poker", "poking", "polar", "police", + "policy", "polio", "polish", "politely", "polka", "polo", "polyester", "polygon", + "polygraph", "polymer", "poncho", "pond", "pony", "popcorn", "pope", "poplar", "popper", + "poppy", "popsicle", "populace", "popular", "populate", "porcupine", "pork", "porous", + "porridge", "portable", "portal", "portfolio", "porthole", "portion", "portly", "portside", + "poser", "posh", "posing", "possible", "possibly", "possum", "postage", "postal", + "postbox", "postcard", "posted", "poster", "posting", "postnasal", "posture", "postwar", + "pouch", "pounce", "pouncing", "pound", "pouring", "pout", "powdered", "powdering", + "powdery", "power", "powwow", "pox", "praising", "prance", "prancing", "pranker", + "prankish", "prankster", "prayer", "praying", "preacher", "preaching", "preachy", + "preamble", "precinct", "precise", "precision", "precook", "precut", "predator", + "predefine", "predict", "preface", "prefix", "preflight", "preformed", "pregame", + "pregnancy", "pregnant", "preheated", "prelaunch", "prelaw", "prelude", "premiere", + "premises", "premium", "prenatal", "preoccupy", "preorder", "prepaid", "prepay", "preplan", + "preppy", "preschool", "prescribe", "preseason", "preset", "preshow", "president", + "presoak", "press", "presume", "presuming", "preteen", "pretended", "pretender", + "pretense", "pretext", "pretty", "pretzel", "prevail", "prevalent", "prevent", "preview", + "previous", "prewar", "prewashed", "prideful", "pried", "primal", "primarily", "primary", + "primate", "primer", "primp", "princess", "print", "prior", "prism", "prison", "prissy", + "pristine", "privacy", "private", "privatize", "prize", "proactive", "probable", + "probably", "probation", "probe", "probing", "probiotic", "problem", "procedure", + "process", "proclaim", "procreate", "procurer", "prodigal", "prodigy", "produce", + "product", "profane", "profanity", "professed", "professor", "profile", "profound", + "profusely", "progeny", "prognosis", "program", "progress", "projector", "prologue", + "prolonged", "promenade", "prominent", "promoter", "promotion", "prompter", "promptly", + "prone", "prong", "pronounce", "pronto", "proofing", "proofread", "proofs", "propeller", + "properly", "property", "proponent", "proposal", "propose", "props", "prorate", + "protector", "protegee", "proton", "prototype", "protozoan", "protract", "protrude", + "proud", "provable", "proved", "proven", "provided", "provider", "providing", "province", + "proving", "provoke", "provoking", "provolone", "prowess", "prowler", "prowling", + "proximity", "proxy", "prozac", "prude", "prudishly", "prune", "pruning", "pry", "psychic", + "public", "publisher", "pucker", "pueblo", "pug", "pull", "pulmonary", "pulp", "pulsate", + "pulse", "pulverize", "puma", "pumice", "pummel", "punch", "punctual", "punctuate", + "punctured", "pungent", "punisher", "punk", "pupil", "puppet", "puppy", "purchase", + "pureblood", "purebred", "purely", "pureness", "purgatory", "purge", "purging", "purifier", + "purify", "purist", "puritan", "purity", "purple", "purplish", "purposely", "purr", + "purse", "pursuable", "pursuant", "pursuit", "purveyor", "pushcart", "pushchair", "pusher", + "pushiness", "pushing", "pushover", "pushpin", "pushup", "pushy", "putdown", "putt", + "puzzle", "puzzling", "pyramid", "pyromania", "python", "quack", "quadrant", "quail", + "quaintly", "quake", "quaking", "qualified", "qualifier", "qualify", "quality", "qualm", + "quantum", "quarrel", "quarry", "quartered", "quarterly", "quarters", "quartet", "quench", + "query", "quicken", "quickly", "quickness", "quicksand", "quickstep", "quiet", "quill", + "quilt", "quintet", "quintuple", "quirk", "quit", "quiver", "quizzical", "quotable", + "quotation", "quote", "rabid", "race", "racing", "racism", "rack", "racoon", "radar", + "radial", "radiance", "radiantly", "radiated", "radiation", "radiator", "radio", "radish", + "raffle", "raft", "rage", "ragged", "raging", "ragweed", "raider", "railcar", "railing", + "railroad", "railway", "raisin", "rake", "raking", "rally", "ramble", "rambling", "ramp", + "ramrod", "ranch", "rancidity", "random", "ranged", "ranger", "ranging", "ranked", + "ranking", "ransack", "ranting", "rants", "rare", "rarity", "rascal", "rash", "rasping", + "ravage", "raven", "ravine", "raving", "ravioli", "ravishing", "reabsorb", "reach", + "reacquire", "reaction", "reactive", "reactor", "reaffirm", "ream", "reanalyze", + "reappear", "reapply", "reappoint", "reapprove", "rearrange", "rearview", "reason", + "reassign", "reassure", "reattach", "reawake", "rebalance", "rebate", "rebel", "rebirth", + "reboot", "reborn", "rebound", "rebuff", "rebuild", "rebuilt", "reburial", "rebuttal", + "recall", "recant", "recapture", "recast", "recede", "recent", "recess", "recharger", + "recipient", "recital", "recite", "reckless", "reclaim", "recliner", "reclining", + "recluse", "reclusive", "recognize", "recoil", "recollect", "recolor", "reconcile", + "reconfirm", "reconvene", "recopy", "record", "recount", "recoup", "recovery", "recreate", + "rectal", "rectangle", "rectified", "rectify", "recycled", "recycler", "recycling", + "reemerge", "reenact", "reenter", "reentry", "reexamine", "referable", "referee", + "reference", "refill", "refinance", "refined", "refinery", "refining", "refinish", + "reflected", "reflector", "reflex", "reflux", "refocus", "refold", "reforest", "reformat", + "reformed", "reformer", "reformist", "refract", "refrain", "refreeze", "refresh", + "refried", "refueling", "refund", "refurbish", "refurnish", "refusal", "refuse", + "refusing", "refutable", "refute", "regain", "regalia", "regally", "reggae", "regime", + "region", "register", "registrar", "registry", "regress", "regretful", "regroup", + "regular", "regulate", "regulator", "rehab", "reheat", "rehire", "rehydrate", "reimburse", + "reissue", "reiterate", "rejoice", "rejoicing", "rejoin", "rekindle", "relapse", + "relapsing", "relatable", "related", "relation", "relative", "relax", "relay", "relearn", + "release", "relenting", "reliable", "reliably", "reliance", "reliant", "relic", "relieve", + "relieving", "relight", "relish", "relive", "reload", "relocate", "relock", "reluctant", + "rely", "remake", "remark", "remarry", "rematch", "remedial", "remedy", "remember", + "reminder", "remindful", "remission", "remix", "remnant", "remodeler", "remold", "remorse", + "remote", "removable", "removal", "removed", "remover", "removing", "rename", "renderer", + "rendering", "rendition", "renegade", "renewable", "renewably", "renewal", "renewed", + "renounce", "renovate", "renovator", "rentable", "rental", "rented", "renter", "reoccupy", + "reoccur", "reopen", "reorder", "repackage", "repacking", "repaint", "repair", "repave", + "repaying", "repayment", "repeal", "repeated", "repeater", "repent", "rephrase", "replace", + "replay", "replica", "reply", "reporter", "repose", "repossess", "repost", "repressed", + "reprimand", "reprint", "reprise", "reproach", "reprocess", "reproduce", "reprogram", + "reps", "reptile", "reptilian", "repugnant", "repulsion", "repulsive", "repurpose", + "reputable", "reputably", "request", "require", "requisite", "reroute", "rerun", "resale", + "resample", "rescuer", "reseal", "research", "reselect", "reseller", "resemble", "resend", + "resent", "reset", "reshape", "reshoot", "reshuffle", "residence", "residency", "resident", + "residual", "residue", "resigned", "resilient", "resistant", "resisting", "resize", + "resolute", "resolved", "resonant", "resonate", "resort", "resource", "respect", + "resubmit", "result", "resume", "resupply", "resurface", "resurrect", "retail", "retainer", + "retaining", "retake", "retaliate", "retention", "rethink", "retinal", "retired", + "retiree", "retiring", "retold", "retool", "retorted", "retouch", "retrace", "retract", + "retrain", "retread", "retreat", "retrial", "retrieval", "retriever", "retry", "return", + "retying", "retype", "reunion", "reunite", "reusable", "reuse", "reveal", "reveler", + "revenge", "revenue", "reverb", "revered", "reverence", "reverend", "reversal", "reverse", + "reversing", "reversion", "revert", "revisable", "revise", "revision", "revisit", + "revivable", "revival", "reviver", "reviving", "revocable", "revoke", "revolt", "revolver", + "revolving", "reward", "rewash", "rewind", "rewire", "reword", "rework", "rewrap", + "rewrite", "rhyme", "ribbon", "ribcage", "rice", "riches", "richly", "richness", "rickety", + "ricotta", "riddance", "ridden", "ride", "riding", "rifling", "rift", "rigging", "rigid", + "rigor", "rimless", "rimmed", "rind", "rink", "rinse", "rinsing", "riot", "ripcord", + "ripeness", "ripening", "ripping", "ripple", "rippling", "riptide", "rise", "rising", + "risk", "risotto", "ritalin", "ritzy", "rival", "riverbank", "riverbed", "riverboat", + "riverside", "riveter", "riveting", "roamer", "roaming", "roast", "robbing", "robe", + "robin", "robotics", "robust", "rockband", "rocker", "rocket", "rockfish", "rockiness", + "rocking", "rocklike", "rockslide", "rockstar", "rocky", "rogue", "roman", "romp", "rope", + "roping", "roster", "rosy", "rotten", "rotting", "rotunda", "roulette", "rounding", + "roundish", "roundness", "roundup", "roundworm", "routine", "routing", "rover", "roving", + "royal", "rubbed", "rubber", "rubbing", "rubble", "rubdown", "ruby", "ruckus", "rudder", + "rug", "ruined", "rule", "rumble", "rumbling", "rummage", "rumor", "runaround", "rundown", + "runner", "running", "runny", "runt", "runway", "rupture", "rural", "ruse", "rush", "rust", + "rut", "sabbath", "sabotage", "sacrament", "sacred", "sacrifice", "sadden", "saddlebag", + "saddled", "saddling", "sadly", "sadness", "safari", "safeguard", "safehouse", "safely", + "safeness", "saffron", "saga", "sage", "sagging", "saggy", "said", "saint", "sake", + "salad", "salami", "salaried", "salary", "saline", "salon", "saloon", "salsa", "salt", + "salutary", "salute", "salvage", "salvaging", "salvation", "same", "sample", "sampling", + "sanction", "sanctity", "sanctuary", "sandal", "sandbag", "sandbank", "sandbar", + "sandblast", "sandbox", "sanded", "sandfish", "sanding", "sandlot", "sandpaper", "sandpit", + "sandstone", "sandstorm", "sandworm", "sandy", "sanitary", "sanitizer", "sank", "santa", + "sapling", "sappiness", "sappy", "sarcasm", "sarcastic", "sardine", "sash", "sasquatch", + "sassy", "satchel", "satiable", "satin", "satirical", "satisfied", "satisfy", "saturate", + "saturday", "sauciness", "saucy", "sauna", "savage", "savanna", "saved", "savings", + "savior", "savor", "saxophone", "say", "scabbed", "scabby", "scalded", "scalding", "scale", + "scaling", "scallion", "scallop", "scalping", "scam", "scandal", "scanner", "scanning", + "scant", "scapegoat", "scarce", "scarcity", "scarecrow", "scared", "scarf", "scarily", + "scariness", "scarring", "scary", "scavenger", "scenic", "schedule", "schematic", "scheme", + "scheming", "schilling", "schnapps", "scholar", "science", "scientist", "scion", "scoff", + "scolding", "scone", "scoop", "scooter", "scope", "scorch", "scorebook", "scorecard", + "scored", "scoreless", "scorer", "scoring", "scorn", "scorpion", "scotch", "scoundrel", + "scoured", "scouring", "scouting", "scouts", "scowling", "scrabble", "scraggly", + "scrambled", "scrambler", "scrap", "scratch", "scrawny", "screen", "scribble", "scribe", + "scribing", "scrimmage", "script", "scroll", "scrooge", "scrounger", "scrubbed", + "scrubber", "scruffy", "scrunch", "scrutiny", "scuba", "scuff", "sculptor", "sculpture", + "scurvy", "scuttle", "secluded", "secluding", "seclusion", "second", "secrecy", "secret", + "sectional", "sector", "secular", "securely", "security", "sedan", "sedate", "sedation", + "sedative", "sediment", "seduce", "seducing", "segment", "seismic", "seizing", "seldom", + "selected", "selection", "selective", "selector", "self", "seltzer", "semantic", + "semester", "semicolon", "semifinal", "seminar", "semisoft", "semisweet", "senate", + "senator", "send", "senior", "senorita", "sensation", "sensitive", "sensitize", + "sensually", "sensuous", "sepia", "september", "septic", "septum", "sequel", "sequence", + "sequester", "series", "sermon", "serotonin", "serpent", "serrated", "serve", "service", + "serving", "sesame", "sessions", "setback", "setting", "settle", "settling", "setup", + "sevenfold", "seventeen", "seventh", "seventy", "severity", "shabby", "shack", "shaded", + "shadily", "shadiness", "shading", "shadow", "shady", "shaft", "shakable", "shakily", + "shakiness", "shaking", "shaky", "shale", "shallot", "shallow", "shame", "shampoo", + "shamrock", "shank", "shanty", "shape", "shaping", "share", "sharpener", "sharper", + "sharpie", "sharply", "sharpness", "shawl", "sheath", "shed", "sheep", "sheet", "shelf", + "shell", "shelter", "shelve", "shelving", "sherry", "shield", "shifter", "shifting", + "shiftless", "shifty", "shimmer", "shimmy", "shindig", "shine", "shingle", "shininess", + "shining", "shiny", "ship", "shirt", "shivering", "shock", "shone", "shoplift", "shopper", + "shopping", "shoptalk", "shore", "shortage", "shortcake", "shortcut", "shorten", "shorter", + "shorthand", "shortlist", "shortly", "shortness", "shorts", "shortwave", "shorty", "shout", + "shove", "showbiz", "showcase", "showdown", "shower", "showgirl", "showing", "showman", + "shown", "showoff", "showpiece", "showplace", "showroom", "showy", "shrank", "shrapnel", + "shredder", "shredding", "shrewdly", "shriek", "shrill", "shrimp", "shrine", "shrink", + "shrivel", "shrouded", "shrubbery", "shrubs", "shrug", "shrunk", "shucking", "shudder", + "shuffle", "shuffling", "shun", "shush", "shut", "shy", "siamese", "siberian", "sibling", + "siding", "sierra", "siesta", "sift", "sighing", "silenced", "silencer", "silent", + "silica", "silicon", "silk", "silliness", "silly", "silo", "silt", "silver", "similarly", + "simile", "simmering", "simple", "simplify", "simply", "sincere", "sincerity", "singer", + "singing", "single", "singular", "sinister", "sinless", "sinner", "sinuous", "sip", + "siren", "sister", "sitcom", "sitter", "sitting", "situated", "situation", "sixfold", + "sixteen", "sixth", "sixties", "sixtieth", "sixtyfold", "sizable", "sizably", "size", + "sizing", "sizzle", "sizzling", "skater", "skating", "skedaddle", "skeletal", "skeleton", + "skeptic", "sketch", "skewed", "skewer", "skid", "skied", "skier", "skies", "skiing", + "skilled", "skillet", "skillful", "skimmed", "skimmer", "skimming", "skimpily", "skincare", + "skinhead", "skinless", "skinning", "skinny", "skintight", "skipper", "skipping", + "skirmish", "skirt", "skittle", "skydiver", "skylight", "skyline", "skype", "skyrocket", + "skyward", "slab", "slacked", "slacker", "slacking", "slackness", "slacks", "slain", + "slam", "slander", "slang", "slapping", "slapstick", "slashed", "slashing", "slate", + "slather", "slaw", "sled", "sleek", "sleep", "sleet", "sleeve", "slept", "sliceable", + "sliced", "slicer", "slicing", "slick", "slider", "slideshow", "sliding", "slighted", + "slighting", "slightly", "slimness", "slimy", "slinging", "slingshot", "slinky", "slip", + "slit", "sliver", "slobbery", "slogan", "sloped", "sloping", "sloppily", "sloppy", "slot", + "slouching", "slouchy", "sludge", "slug", "slum", "slurp", "slush", "sly", "small", + "smartly", "smartness", "smasher", "smashing", "smashup", "smell", "smelting", "smile", + "smilingly", "smirk", "smite", "smith", "smitten", "smock", "smog", "smoked", "smokeless", + "smokiness", "smoking", "smoky", "smolder", "smooth", "smother", "smudge", "smudgy", + "smuggler", "smuggling", "smugly", "smugness", "snack", "snagged", "snaking", "snap", + "snare", "snarl", "snazzy", "sneak", "sneer", "sneeze", "sneezing", "snide", "sniff", + "snippet", "snipping", "snitch", "snooper", "snooze", "snore", "snoring", "snorkel", + "snort", "snout", "snowbird", "snowboard", "snowbound", "snowcap", "snowdrift", "snowdrop", + "snowfall", "snowfield", "snowflake", "snowiness", "snowless", "snowman", "snowplow", + "snowshoe", "snowstorm", "snowsuit", "snowy", "snub", "snuff", "snuggle", "snugly", + "snugness", "speak", "spearfish", "spearhead", "spearman", "spearmint", "species", + "specimen", "specked", "speckled", "specks", "spectacle", "spectator", "spectrum", + "speculate", "speech", "speed", "spellbind", "speller", "spelling", "spendable", "spender", + "spending", "spent", "spew", "sphere", "spherical", "sphinx", "spider", "spied", "spiffy", + "spill", "spilt", "spinach", "spinal", "spindle", "spinner", "spinning", "spinout", + "spinster", "spiny", "spiral", "spirited", "spiritism", "spirits", "spiritual", "splashed", + "splashing", "splashy", "splatter", "spleen", "splendid", "splendor", "splice", "splicing", + "splinter", "splotchy", "splurge", "spoilage", "spoiled", "spoiler", "spoiling", "spoils", + "spoken", "spokesman", "sponge", "spongy", "sponsor", "spoof", "spookily", "spooky", + "spool", "spoon", "spore", "sporting", "sports", "sporty", "spotless", "spotlight", + "spotted", "spotter", "spotting", "spotty", "spousal", "spouse", "spout", "sprain", + "sprang", "sprawl", "spray", "spree", "sprig", "spring", "sprinkled", "sprinkler", + "sprint", "sprite", "sprout", "spruce", "sprung", "spry", "spud", "spur", "sputter", + "spyglass", "squabble", "squad", "squall", "squander", "squash", "squatted", "squatter", + "squatting", "squeak", "squealer", "squealing", "squeamish", "squeegee", "squeeze", + "squeezing", "squid", "squiggle", "squiggly", "squint", "squire", "squirt", "squishier", + "squishy", "stability", "stabilize", "stable", "stack", "stadium", "staff", "stage", + "staging", "stagnant", "stagnate", "stainable", "stained", "staining", "stainless", + "stalemate", "staleness", "stalling", "stallion", "stamina", "stammer", "stamp", "stand", + "stank", "staple", "stapling", "starboard", "starch", "stardom", "stardust", "starfish", + "stargazer", "staring", "stark", "starless", "starlet", "starlight", "starlit", "starring", + "starry", "starship", "starter", "starting", "startle", "startling", "startup", "starved", + "starving", "stash", "state", "static", "statistic", "statue", "stature", "status", + "statute", "statutory", "staunch", "stays", "steadfast", "steadier", "steadily", + "steadying", "steam", "steed", "steep", "steerable", "steering", "steersman", "stegosaur", + "stellar", "stem", "stench", "stencil", "step", "stereo", "sterile", "sterility", + "sterilize", "sterling", "sternness", "sternum", "stew", "stick", "stiffen", "stiffly", + "stiffness", "stifle", "stifling", "stillness", "stilt", "stimulant", "stimulate", + "stimuli", "stimulus", "stinger", "stingily", "stinging", "stingray", "stingy", "stinking", + "stinky", "stipend", "stipulate", "stir", "stitch", "stock", "stoic", "stoke", "stole", + "stomp", "stonewall", "stoneware", "stonework", "stoning", "stony", "stood", "stooge", + "stool", "stoop", "stoplight", "stoppable", "stoppage", "stopped", "stopper", "stopping", + "stopwatch", "storable", "storage", "storeroom", "storewide", "storm", "stout", "stove", + "stowaway", "stowing", "straddle", "straggler", "strained", "strainer", "straining", + "strangely", "stranger", "strangle", "strategic", "strategy", "stratus", "straw", "stray", + "streak", "stream", "street", "strength", "strenuous", "strep", "stress", "stretch", + "strewn", "stricken", "strict", "stride", "strife", "strike", "striking", "strive", + "striving", "strobe", "strode", "stroller", "strongbox", "strongly", "strongman", "struck", + "structure", "strudel", "struggle", "strum", "strung", "strut", "stubbed", "stubble", + "stubbly", "stubborn", "stucco", "stuck", "student", "studied", "studio", "study", + "stuffed", "stuffing", "stuffy", "stumble", "stumbling", "stump", "stung", "stunned", + "stunner", "stunning", "stunt", "stupor", "sturdily", "sturdy", "styling", "stylishly", + "stylist", "stylized", "stylus", "suave", "subarctic", "subatomic", "subdivide", "subdued", + "subduing", "subfloor", "subgroup", "subheader", "subject", "sublease", "sublet", + "sublevel", "sublime", "submarine", "submerge", "submersed", "submitter", "subpanel", + "subpar", "subplot", "subprime", "subscribe", "subscript", "subsector", "subside", + "subsiding", "subsidize", "subsidy", "subsoil", "subsonic", "substance", "subsystem", + "subtext", "subtitle", "subtly", "subtotal", "subtract", "subtype", "suburb", "subway", + "subwoofer", "subzero", "succulent", "such", "suction", "sudden", "sudoku", "suds", + "sufferer", "suffering", "suffice", "suffix", "suffocate", "suffrage", "sugar", "suggest", + "suing", "suitable", "suitably", "suitcase", "suitor", "sulfate", "sulfide", "sulfite", + "sulfur", "sulk", "sullen", "sulphate", "sulphuric", "sultry", "superbowl", "superglue", + "superhero", "superior", "superjet", "superman", "supermom", "supernova", "supervise", + "supper", "supplier", "supply", "support", "supremacy", "supreme", "surcharge", "surely", + "sureness", "surface", "surfacing", "surfboard", "surfer", "surgery", "surgical", + "surging", "surname", "surpass", "surplus", "surprise", "surreal", "surrender", + "surrogate", "surround", "survey", "survival", "survive", "surviving", "survivor", "sushi", + "suspect", "suspend", "suspense", "sustained", "sustainer", "swab", "swaddling", "swagger", + "swampland", "swan", "swapping", "swarm", "sway", "swear", "sweat", "sweep", "swell", + "swept", "swerve", "swifter", "swiftly", "swiftness", "swimmable", "swimmer", "swimming", + "swimsuit", "swimwear", "swinger", "swinging", "swipe", "swirl", "switch", "swivel", + "swizzle", "swooned", "swoop", "swoosh", "swore", "sworn", "swung", "sycamore", "sympathy", + "symphonic", "symphony", "symptom", "synapse", "syndrome", "synergy", "synopses", + "synopsis", "synthesis", "synthetic", "syrup", "system", "t-shirt", "tabasco", "tabby", + "tableful", "tables", "tablet", "tableware", "tabloid", "tackiness", "tacking", "tackle", + "tackling", "tacky", "taco", "tactful", "tactical", "tactics", "tactile", "tactless", + "tadpole", "taekwondo", "tag", "tainted", "take", "taking", "talcum", "talisman", "tall", + "talon", "tamale", "tameness", "tamer", "tamper", "tank", "tanned", "tannery", "tanning", + "tantrum", "tapeless", "tapered", "tapering", "tapestry", "tapioca", "tapping", "taps", + "tarantula", "target", "tarmac", "tarnish", "tarot", "tartar", "tartly", "tartness", + "task", "tassel", "taste", "tastiness", "tasting", "tasty", "tattered", "tattle", + "tattling", "tattoo", "taunt", "tavern", "thank", "that", "thaw", "theater", "theatrics", + "thee", "theft", "theme", "theology", "theorize", "thermal", "thermos", "thesaurus", + "these", "thesis", "thespian", "thicken", "thicket", "thickness", "thieving", "thievish", + "thigh", "thimble", "thing", "think", "thinly", "thinner", "thinness", "thinning", + "thirstily", "thirsting", "thirsty", "thirteen", "thirty", "thong", "thorn", "those", + "thousand", "thrash", "thread", "threaten", "threefold", "thrift", "thrill", "thrive", + "thriving", "throat", "throbbing", "throng", "throttle", "throwaway", "throwback", + "thrower", "throwing", "thud", "thumb", "thumping", "thursday", "thus", "thwarting", + "thyself", "tiara", "tibia", "tidal", "tidbit", "tidiness", "tidings", "tidy", "tiger", + "tighten", "tightly", "tightness", "tightrope", "tightwad", "tigress", "tile", "tiling", + "till", "tilt", "timid", "timing", "timothy", "tinderbox", "tinfoil", "tingle", "tingling", + "tingly", "tinker", "tinkling", "tinsel", "tinsmith", "tint", "tinwork", "tiny", "tipoff", + "tipped", "tipper", "tipping", "tiptoeing", "tiptop", "tiring", "tissue", "trace", + "tracing", "track", "traction", "tractor", "trade", "trading", "tradition", "traffic", + "tragedy", "trailing", "trailside", "train", "traitor", "trance", "tranquil", "transfer", + "transform", "translate", "transpire", "transport", "transpose", "trapdoor", "trapeze", + "trapezoid", "trapped", "trapper", "trapping", "traps", "trash", "travel", "traverse", + "travesty", "tray", "treachery", "treading", "treadmill", "treason", "treat", "treble", + "tree", "trekker", "tremble", "trembling", "tremor", "trench", "trend", "trespass", + "triage", "trial", "triangle", "tribesman", "tribunal", "tribune", "tributary", "tribute", + "triceps", "trickery", "trickily", "tricking", "trickle", "trickster", "tricky", + "tricolor", "tricycle", "trident", "tried", "trifle", "trifocals", "trillion", "trilogy", + "trimester", "trimmer", "trimming", "trimness", "trinity", "trio", "tripod", "tripping", + "triumph", "trivial", "trodden", "trolling", "trombone", "trophy", "tropical", "tropics", + "trouble", "troubling", "trough", "trousers", "trout", "trowel", "truce", "truck", + "truffle", "trump", "trunks", "trustable", "trustee", "trustful", "trusting", "trustless", + "truth", "try", "tubby", "tubeless", "tubular", "tucking", "tuesday", "tug", "tuition", + "tulip", "tumble", "tumbling", "tummy", "turban", "turbine", "turbofan", "turbojet", + "turbulent", "turf", "turkey", "turmoil", "turret", "turtle", "tusk", "tutor", "tutu", + "tux", "tweak", "tweed", "tweet", "tweezers", "twelve", "twentieth", "twenty", "twerp", + "twice", "twiddle", "twiddling", "twig", "twilight", "twine", "twins", "twirl", + "twistable", "twisted", "twister", "twisting", "twisty", "twitch", "twitter", "tycoon", + "tying", "tyke", "udder", "ultimate", "ultimatum", "ultra", "umbilical", "umbrella", + "umpire", "unabashed", "unable", "unadorned", "unadvised", "unafraid", "unaired", + "unaligned", "unaltered", "unarmored", "unashamed", "unaudited", "unawake", "unaware", + "unbaked", "unbalance", "unbeaten", "unbend", "unbent", "unbiased", "unbitten", + "unblended", "unblessed", "unblock", "unbolted", "unbounded", "unboxed", "unbraided", + "unbridle", "unbroken", "unbuckled", "unbundle", "unburned", "unbutton", "uncanny", + "uncapped", "uncaring", "uncertain", "unchain", "unchanged", "uncharted", "uncheck", + "uncivil", "unclad", "unclaimed", "unclamped", "unclasp", "uncle", "unclip", "uncloak", + "unclog", "unclothed", "uncoated", "uncoiled", "uncolored", "uncombed", "uncommon", + "uncooked", "uncork", "uncorrupt", "uncounted", "uncouple", "uncouth", "uncover", + "uncross", "uncrown", "uncrushed", "uncured", "uncurious", "uncurled", "uncut", + "undamaged", "undated", "undaunted", "undead", "undecided", "undefined", "underage", + "underarm", "undercoat", "undercook", "undercut", "underdog", "underdone", "underfed", + "underfeed", "underfoot", "undergo", "undergrad", "underhand", "underline", "underling", + "undermine", "undermost", "underpaid", "underpass", "underpay", "underrate", "undertake", + "undertone", "undertook", "undertow", "underuse", "underwear", "underwent", "underwire", + "undesired", "undiluted", "undivided", "undocked", "undoing", "undone", "undrafted", + "undress", "undrilled", "undusted", "undying", "unearned", "unearth", "unease", "uneasily", + "uneasy", "uneatable", "uneaten", "unedited", "unelected", "unending", "unengaged", + "unenvied", "unequal", "unethical", "uneven", "unexpired", "unexposed", "unfailing", + "unfair", "unfasten", "unfazed", "unfeeling", "unfiled", "unfilled", "unfitted", + "unfitting", "unfixable", "unfixed", "unflawed", "unfocused", "unfold", "unfounded", + "unframed", "unfreeze", "unfrosted", "unfrozen", "unfunded", "unglazed", "ungloved", + "unglue", "ungodly", "ungraded", "ungreased", "unguarded", "unguided", "unhappily", + "unhappy", "unharmed", "unhealthy", "unheard", "unhearing", "unheated", "unhelpful", + "unhidden", "unhinge", "unhitched", "unholy", "unhook", "unicorn", "unicycle", "unified", + "unifier", "uniformed", "uniformly", "unify", "unimpeded", "uninjured", "uninstall", + "uninsured", "uninvited", "union", "uniquely", "unisexual", "unison", "unissued", "unit", + "universal", "universe", "unjustly", "unkempt", "unkind", "unknotted", "unknowing", + "unknown", "unlaced", "unlatch", "unlawful", "unleaded", "unlearned", "unleash", "unless", + "unleveled", "unlighted", "unlikable", "unlimited", "unlined", "unlinked", "unlisted", + "unlit", "unlivable", "unloaded", "unloader", "unlocked", "unlocking", "unlovable", + "unloved", "unlovely", "unloving", "unluckily", "unlucky", "unmade", "unmanaged", + "unmanned", "unmapped", "unmarked", "unmasked", "unmasking", "unmatched", "unmindful", + "unmixable", "unmixed", "unmolded", "unmoral", "unmovable", "unmoved", "unmoving", + "unnamable", "unnamed", "unnatural", "unneeded", "unnerve", "unnerving", "unnoticed", + "unopened", "unopposed", "unpack", "unpadded", "unpaid", "unpainted", "unpaired", + "unpaved", "unpeeled", "unpicked", "unpiloted", "unpinned", "unplanned", "unplanted", + "unpleased", "unpledged", "unplowed", "unplug", "unpopular", "unproven", "unquote", + "unranked", "unrated", "unraveled", "unreached", "unread", "unreal", "unreeling", + "unrefined", "unrelated", "unrented", "unrest", "unretired", "unrevised", "unrigged", + "unripe", "unrivaled", "unroasted", "unrobed", "unroll", "unruffled", "unruly", "unrushed", + "unsaddle", "unsafe", "unsaid", "unsalted", "unsaved", "unsavory", "unscathed", + "unscented", "unscrew", "unsealed", "unseated", "unsecured", "unseeing", "unseemly", + "unseen", "unselect", "unselfish", "unsent", "unsettled", "unshackle", "unshaken", + "unshaved", "unshaven", "unsheathe", "unshipped", "unsightly", "unsigned", "unskilled", + "unsliced", "unsmooth", "unsnap", "unsocial", "unsoiled", "unsold", "unsolved", "unsorted", + "unspoiled", "unspoken", "unstable", "unstaffed", "unstamped", "unsteady", "unsterile", + "unstirred", "unstitch", "unstopped", "unstuck", "unstuffed", "unstylish", "unsubtle", + "unsubtly", "unsuited", "unsure", "unsworn", "untagged", "untainted", "untaken", "untamed", + "untangled", "untapped", "untaxed", "unthawed", "unthread", "untidy", "untie", "until", + "untimed", "untimely", "untitled", "untoasted", "untold", "untouched", "untracked", + "untrained", "untreated", "untried", "untrimmed", "untrue", "untruth", "unturned", + "untwist", "untying", "unusable", "unused", "unusual", "unvalued", "unvaried", "unvarying", + "unveiled", "unveiling", "unvented", "unviable", "unvisited", "unvocal", "unwanted", + "unwarlike", "unwary", "unwashed", "unwatched", "unweave", "unwed", "unwelcome", "unwell", + "unwieldy", "unwilling", "unwind", "unwired", "unwitting", "unwomanly", "unworldly", + "unworn", "unworried", "unworthy", "unwound", "unwoven", "unwrapped", "unwritten", "unzip", + "upbeat", "upchuck", "upcoming", "upcountry", "update", "upfront", "upgrade", "upheaval", + "upheld", "uphill", "uphold", "uplifted", "uplifting", "upload", "upon", "upper", + "upright", "uprising", "upriver", "uproar", "uproot", "upscale", "upside", "upstage", + "upstairs", "upstart", "upstate", "upstream", "upstroke", "upswing", "uptake", "uptight", + "uptown", "upturned", "upward", "upwind", "uranium", "urban", "urchin", "urethane", + "urgency", "urgent", "urging", "urologist", "urology", "usable", "usage", "useable", + "used", "uselessly", "user", "usher", "usual", "utensil", "utility", "utilize", "utmost", + "utopia", "utter", "vacancy", "vacant", "vacate", "vacation", "vagabond", "vagrancy", + "vagrantly", "vaguely", "vagueness", "valiant", "valid", "valium", "valley", "valuables", + "value", "vanilla", "vanish", "vanity", "vanquish", "vantage", "vaporizer", "variable", + "variably", "varied", "variety", "various", "varmint", "varnish", "varsity", "varying", + "vascular", "vaseline", "vastly", "vastness", "veal", "vegan", "veggie", "vehicular", + "velcro", "velocity", "velvet", "vendetta", "vending", "vendor", "veneering", "vengeful", + "venomous", "ventricle", "venture", "venue", "venus", "verbalize", "verbally", "verbose", + "verdict", "verify", "verse", "version", "versus", "vertebrae", "vertical", "vertigo", + "very", "vessel", "vest", "veteran", "veto", "vexingly", "viability", "viable", "vibes", + "vice", "vicinity", "victory", "video", "viewable", "viewer", "viewing", "viewless", + "viewpoint", "vigorous", "village", "villain", "vindicate", "vineyard", "vintage", + "violate", "violation", "violator", "violet", "violin", "viper", "viral", "virtual", + "virtuous", "virus", "visa", "viscosity", "viscous", "viselike", "visible", "visibly", + "vision", "visiting", "visitor", "visor", "vista", "vitality", "vitalize", "vitally", + "vitamins", "vivacious", "vividly", "vividness", "vixen", "vocalist", "vocalize", + "vocally", "vocation", "voice", "voicing", "void", "volatile", "volley", "voltage", + "volumes", "voter", "voting", "voucher", "vowed", "vowel", "voyage", "wackiness", "wad", + "wafer", "waffle", "waged", "wager", "wages", "waggle", "wagon", "wake", "waking", "walk", + "walmart", "walnut", "walrus", "waltz", "wand", "wannabe", "wanted", "wanting", "wasabi", + "washable", "washbasin", "washboard", "washbowl", "washcloth", "washday", "washed", + "washer", "washhouse", "washing", "washout", "washroom", "washstand", "washtub", "wasp", + "wasting", "watch", "water", "waviness", "waving", "wavy", "whacking", "whacky", "wham", + "wharf", "wheat", "whenever", "whiff", "whimsical", "whinny", "whiny", "whisking", + "whoever", "whole", "whomever", "whoopee", "whooping", "whoops", "why", "wick", "widely", + "widen", "widget", "widow", "width", "wieldable", "wielder", "wife", "wifi", "wikipedia", + "wildcard", "wildcat", "wilder", "wildfire", "wildfowl", "wildland", "wildlife", "wildly", + "wildness", "willed", "willfully", "willing", "willow", "willpower", "wilt", "wimp", + "wince", "wincing", "wind", "wing", "winking", "winner", "winnings", "winter", "wipe", + "wired", "wireless", "wiring", "wiry", "wisdom", "wise", "wish", "wisplike", "wispy", + "wistful", "wizard", "wobble", "wobbling", "wobbly", "wok", "wolf", "wolverine", + "womanhood", "womankind", "womanless", "womanlike", "womanly", "womb", "woof", "wooing", + "wool", "woozy", "word", "work", "worried", "worrier", "worrisome", "worry", "worsening", + "worshiper", "worst", "wound", "woven", "wow", "wrangle", "wrath", "wreath", "wreckage", + "wrecker", "wrecking", "wrench", "wriggle", "wriggly", "wrinkle", "wrinkly", "wrist", + "writing", "written", "wrongdoer", "wronged", "wrongful", "wrongly", "wrongness", + "wrought", "xbox", "xerox", "yahoo", "yam", "yanking", "yapping", "yard", "yarn", "yeah", + "yearbook", "yearling", "yearly", "yearning", "yeast", "yelling", "yelp", "yen", + "yesterday", "yiddish", "yield", "yin", "yippee", "yo-yo", "yodel", "yoga", "yogurt", + "yonder", "yoyo", "yummy", "zap", "zealous", "zebra", "zen", "zeppelin", "zero", + "zestfully", "zesty", "zigzagged", "zipfile", "zipping", "zippy", "zips", "zit", "zodiac", + "zombie", "zone", "zoning", "zookeeper", "zoologist", "zoology", "zoom", }; } } diff --git a/Readme.md b/Readme.md index e3fa558..f4cb17b 100644 --- a/Readme.md +++ b/Readme.md @@ -90,8 +90,18 @@ string otp = Password.ForOtp(6).Next(); // 6-digit one-time code string apiKey = Password.ForApiKey(32).Next(); // URL-safe token string envName = Password.ForEnvironmentName(12).Next();// readable id, no look-alike characters string phrase = Password.ForPassphrase(4).Next(); // e.g. "maple-river-quartz-bloom-42" +string strong = Password.ForPassphraseWithEntropy(80).Next(); // word count derived to clear 80 bits +string memorable = Password.ForMemorable().Next(); // capitalized, ~80+ bits, e.g. "Maple-River-Quartz-Bloom-Glade-Vivid-42" ``` +`ForPassphraseWithEntropy(targetBits)` derives the word count needed to reach the target and +enforces it as a floor. You can also pass `minimumEntropyBits` to `ForPassphrase(...)` to reject +configurations that are too weak. + +For sites that require a digit and a symbol, pass `includeSymbol: true`. A random symbol is attached +to one randomly chosen word (e.g. `maple-river#-quartz-bloom-42`), so the phrase passes composition +rules while staying memorable. + ## Quality controls ```csharp @@ -146,8 +156,25 @@ public class SignupService(IPasswordGenerator generator) } ``` +To register a passphrase generator instead, set the `Passphrase` options (or bind a `Passphrase` +section from configuration): + +```csharp +services.AddPasswordGenerator(o => + o.Passphrase = new PassphraseOptions { WordCount = 6, Capitalize = true }); +``` + ## Documentation - [v2 → v3 migration guide](docs/migration-v2-to-v3.md) - [Changelog](CHANGELOG.md) - [Design & architecture docs](docs/README.md) + +## License & attribution + +PasswordGenerator is licensed under the [MIT License](License.md). + +Passphrases are generated from the **EFF Large Wordlist** (7,776 words) by the +[Electronic Frontier Foundation](https://www.eff.org/dice), used under the +[Creative Commons Attribution 3.0 US](https://creativecommons.org/licenses/by/3.0/us/) +license. See [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) for details. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..0ed49f3 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,21 @@ +# Third-Party Notices + +This project (PasswordGenerator) is licensed under the MIT License (see +`License.md`). It also includes third-party data, listed below, that is +distributed under its own license. + +## EFF Large Wordlist + +`PasswordGenerator/WordList.cs` embeds the **EFF Large Wordlist** (7,776 words), +created by the **Electronic Frontier Foundation** (Joseph Bonneau) and published at: + +- https://www.eff.org/dice +- https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt + +**License:** Creative Commons Attribution 3.0 United States (CC BY 3.0 US) +https://creativecommons.org/licenses/by/3.0/us/ + +**Modifications:** The original tab-separated `dice-numberword` rows were +reformatted into a C# `string[]` array. The words themselves are unchanged. + +The EFF Large Wordlist is used here to generate diceware-style passphrases. diff --git a/docs/api-surface.md b/docs/api-surface.md index 7e3fffc..8a47a15 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -31,6 +31,7 @@ flowchart TD g3["NextAsync(ct) : ValueTask~string~"] g4["Generate() / Generate(count)"] g5["GenerateAsync() / GenerateAsync(count, ct)"] + g6["EstimateEntropyBits() : double"] end Entry --> Build --> Gen classDef good fill:#e6ffe6,stroke:#009900; @@ -58,11 +59,26 @@ flowchart LR ForOwasp --> O["all printable ASCII, no forced composition"] ForNist --> Nn["NIST 800-63B aligned length/charset"] ForOtp --> Ot["short numeric, e.g. 4-6 digits"] - ForPassphrase --> Pp["diceware word-list"] + ForPassphrase --> Pp["EFF Large Wordlist (7,776 words)"] + ForPassphraseWithEntropy --> Pe["word count derived from a target bits"] + ForMemorable --> Pm["capitalized words, ~80+ bits"] ForApiKey --> Ak["long, URL-safe charset"] ForEnvironmentName --> En["readable, memorable identifiers"] ``` +### Passphrases + +Passphrases are built from the **EFF Large Wordlist** (7,776 words, ~12.9 bits/word; CC BY 3.0, see +`THIRD-PARTY-NOTICES.md`). Beyond `ForPassphrase(words, ...)`: + +- `ForPassphraseWithEntropy(targetBits)` derives the word count needed to clear a target and enforces + it as a floor; `ForPassphrase(..., minimumEntropyBits)` enforces a floor for an explicit word count. +- `includeSymbol: true` attaches a random symbol to one randomly chosen word, satisfying + "needs a number and a symbol" composition rules while staying memorable. +- `EstimateEntropyBits()` (on `IPasswordGenerator`) reports the estimated strength. +- Via DI, set `PasswordOptions.Passphrase` (a `PassphraseOptions`) in code or bind a `Passphrase` + configuration section to resolve a passphrase `IPasswordGenerator`. + Presets are static factory methods on `Password` (sugar over the fluent builder); any subsequent fluent call still overrides them (resolution order is documented in `configuration-and-di.md`). diff --git a/docs/architecture.md b/docs/architecture.md index c594d81..27961e9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,6 +17,7 @@ classDiagram +Generate(int count) IReadOnlyList +GenerateAsync(CancellationToken) Task +GenerateAsync(int count, CancellationToken) Task + +EstimateEntropyBits() double } class IPassword { <> @@ -36,15 +37,30 @@ classDiagram class Password { +static ForOwasp/ForNist/ForOtp() IPassword +static ForApiKey/ForEnvironmentName() IPassword - +static ForPassphrase() IPasswordGenerator + +static ForPassphrase()/ForPassphraseWithEntropy()/ForMemorable() IPasswordGenerator + +EstimateEntropyBits() double + } + class PassphraseGenerator { + +WordCount, Separator, Capitalize + +IncludeNumber, IncludeSymbol, MinimumEntropyBits + +static WordCountForEntropy(bits, includeNumber) int +EstimateEntropyBits() double } + class WordList { + <> + EFF Large Wordlist (7,776 words, CC BY 3.0) + } class PasswordOptions { +IncludeLowercase/Uppercase/Numeric/Special +SpecialCharacters, Length +ExcludeAmbiguous, DefaultBatchCount + +Passphrase : PassphraseOptions? +bind from IConfiguration } + class PassphraseOptions { + +WordCount, Separator, Capitalize + +IncludeNumber, IncludeSymbol, MinimumEntropyBits + } class IRandomSource { <> +int NextInt(int maxExclusive) @@ -61,7 +77,11 @@ classDiagram IPasswordGenerator <|.. Password IPasswordGenerator <|.. PassphraseGenerator Password --> IRandomSource : uses + PassphraseGenerator --> IRandomSource : uses + PassphraseGenerator --> WordList : samples PasswordOptions ..> Password : configures (DI) + PasswordOptions *-- PassphraseOptions + PassphraseOptions ..> PassphraseGenerator : configures (DI) IRandomSource <|.. CryptoRandomSource IEntropyEstimator <|.. PoolEntropyEstimator Password ..> PoolEntropyEstimator : EstimateEntropyBits @@ -71,7 +91,12 @@ Key points: - **`IRandomSource` abstraction** wraps the CSPRNG (unbiased `RandomNumberGenerator.GetInt32`). No `static`, injectable — a deterministic `IRandomSource` can be injected in unit tests — and the Guid-based `Shuffle` is gone in favour of Fisher–Yates. -- **`PasswordOptions`** is the DI config object, bindable from `IConfiguration`. +- **`PasswordOptions`** is the DI config object, bindable from `IConfiguration`. Setting its + `Passphrase` (a `PassphraseOptions`) makes the DI registration resolve a `PassphraseGenerator` + instead of a character `Password`. +- **Passphrases** (`PassphraseGenerator`) sample the internal `WordList` (the EFF Large Wordlist, + CC BY 3.0) and share the injectable `IRandomSource`, with entropy targeting/floor and optional + symbol injection. - **Presets** are static factory methods on `Password` that pre-fill the fluent builder. - The `[Obsolete]` v2 wrappers from earlier proposals are not present.