diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
new file mode 100644
index 0000000..256759e
--- /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@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ 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@v7
+ 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
new file mode 100644
index 0000000..d04e877
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,56 @@
+name: CI
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # full history so SourceLink can resolve the commit
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.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@v7
+ 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@v7
+ 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..07e75c6
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,48 @@
+name: Release
+
+on:
+ release:
+ types: [ published ]
+
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # full history so SourceLink can resolve the commit
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ 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
+ 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 b9b82b5..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
@@ -15,3 +15,14 @@ PasswordGenerator/bin/Debug/$RANDOM_SEED$
/.idea
/PasswordGenerator.2.0.0.nupkg
*.nupkg
+
+# build output (all projects/configurations)
+[Bb]in/
+[Oo]bj/
+/artifacts
+
+# test output
+/test-results
+
+# BenchmarkDotNet output
+BenchmarkDotNet.Artifacts/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..991b5c3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,62 @@
+# 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/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
+ (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.
+- 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`.
+- **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()`.
+- **Batch API:** `Generate(count)` and a parameterless `Generate()` driven by
+ `PasswordOptions.DefaultBatchCount`.
+
+### Packaging
+- 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.
+
+### 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/archive/V3_REVIEW_AND_DOCUMENTATION.md`](docs/archive/V3_REVIEW_AND_DOCUMENTATION.md).
diff --git a/PasswordGenerator.Benchmarks/Benchmarks/AsyncBenchmarks.cs b/PasswordGenerator.Benchmarks/Benchmarks/AsyncBenchmarks.cs
new file mode 100644
index 0000000..05b597d
--- /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 ValueTask NextAsync()
+ {
+ return _password.NextAsync();
+ }
+
+ [Benchmark]
+ public ValueTask> 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/PasswordGenerator.Benchmarks.csproj b/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj
new file mode 100644
index 0000000..f9c107c
--- /dev/null
+++ b/PasswordGenerator.Benchmarks/PasswordGenerator.Benchmarks.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net8.0;net10.0
+ enable
+ latest
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PasswordGenerator.Benchmarks/Program.cs b/PasswordGenerator.Benchmarks/Program.cs
new file mode 100644
index 0000000..d28536e
--- /dev/null
+++ b/PasswordGenerator.Benchmarks/Program.cs
@@ -0,0 +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)
+ {
+ // 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.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/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/PasswordGenerator.Tests.csproj b/PasswordGenerator.Tests/PasswordGenerator.Tests.csproj
index ef4ecf7..40a0cae 100644
--- a/PasswordGenerator.Tests/PasswordGenerator.Tests.csproj
+++ b/PasswordGenerator.Tests/PasswordGenerator.Tests.csproj
@@ -1,15 +1,19 @@
-
+
- netcoreapp2.2
+ net8.0;net10.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..2758096
--- /dev/null
+++ b/PasswordGenerator.Tests/Phase3Tests.cs
@@ -0,0 +1,130 @@
+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 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()
+ {
+ 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..579f0db
--- /dev/null
+++ b/PasswordGenerator.Tests/Phase4Tests.cs
@@ -0,0 +1,185 @@
+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()
+ {
+ // 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('.');
+
+ 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.Length > 0 && p.All(c => char.IsLetter(c) || c == '-')),
+ 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.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.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..e5947f3
--- /dev/null
+++ b/PasswordGenerator/CharacterClass.cs
@@ -0,0 +1,21 @@
+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 letters (a–z).
+ Lowercase,
+
+ /// Uppercase letters (A–Z).
+ Uppercase,
+
+ /// Digits (0–9).
+ Numeric,
+
+ /// Special / symbol characters.
+ 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..d3f7ace
--- /dev/null
+++ b/PasswordGenerator/CryptoRandomSource.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Security.Cryptography;
+
+namespace PasswordGenerator
+{
+ ///
+ /// backed by a cryptographic RNG. Uses
+ /// , which samples uniformly across the whole
+ /// range with no modulo bias.
+ ///
+ 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)
+ throw new ArgumentOutOfRangeException(nameof(maxExclusive), "maxExclusive must be positive.");
+
+ return RandomNumberGenerator.GetInt32(maxExclusive);
+ }
+ }
+}
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..a778230 100644
--- a/PasswordGenerator/IPassword.cs
+++ b/PasswordGenerator/IPassword.cs
@@ -2,15 +2,64 @@
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).
+ 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);
+
+ /// 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
new file mode 100644
index 0000000..69c17a5
--- /dev/null
+++ b/PasswordGenerator/IPasswordGenerator.cs
@@ -0,0 +1,42 @@
+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 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.
+ ///
+ ValueTask 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 .
+ ValueTask> GenerateAsync(CancellationToken cancellationToken = default);
+
+ /// 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/IPasswordSettings.cs b/PasswordGenerator/IPasswordSettings.cs
index 7ea2d9f..ddd2b97 100644
--- a/PasswordGenerator/IPasswordSettings.cs
+++ b/PasswordGenerator/IPasswordSettings.cs
@@ -1,3 +1,5 @@
+using System.Collections.Generic;
+
namespace PasswordGenerator
{
///
@@ -5,20 +7,82 @@ 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.
+ 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; }
+
+ /// 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).
+ 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);
+
+ /// The special characters used when special characters are included.
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..2f16631
--- /dev/null
+++ b/PasswordGenerator/PassphraseGenerator.cs
@@ -0,0 +1,227 @@
+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;
+
+ /// 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). 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, bool includeSymbol = false, double minimumEntropyBits = 0,
+ 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;
+ 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.
+ 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; }
+
+ /// 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;
+
+ ///
+ 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);
+
+ 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 (i == symbolWordIndex) sb.Append(symbol);
+ }
+
+ 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 ValueTask NextAsync(CancellationToken cancellationToken = default)
+ {
+ return cancellationToken.IsCancellationRequested
+ ? ValueTask.FromCanceled(cancellationToken)
+ : new ValueTask(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 ValueTask> GenerateAsync(CancellationToken cancellationToken = default)
+ {
+ return GenerateAsync(DefaultBatchCount, cancellationToken);
+ }
+
+ ///
+ 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++)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ return ValueTask.FromCanceled>(cancellationToken);
+ passphrases.Add(Next());
+ }
+
+ return new ValueTask>(passphrases);
+ }
+
+ ///
+ /// 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()
+ {
+ if (_ownsRandom && _random is IDisposable disposable)
+ disposable.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 ca9138a..d01f022 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,90 +16,161 @@ 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;
+
+ /// Creates a generator with the default settings (all character classes, length 16).
public Password()
{
Settings = new PasswordSettings(DefaultIncludeLowercase, DefaultIncludeUppercase,
DefaultIncludeNumeric, DefaultIncludeSpecial, DefaultPasswordLength, DefaultMaxPasswordAttempts,
true);
-
- _rng = RandomNumberGenerator.Create();
+ _random = new CryptoRandomSource();
+ _ownsRandom = true;
}
+ /// Creates a generator from the supplied settings.
+ /// The settings to use.
public Password(IPasswordSettings settings)
{
Settings = settings;
-
- _rng = RandomNumberGenerator.Create();
+ _random = new CryptoRandomSource();
+ _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,
DefaultIncludeNumeric, DefaultIncludeSpecial, passwordLength, DefaultMaxPasswordAttempts, true);
-
- _rng = RandomNumberGenerator.Create();
+ _random = new CryptoRandomSource();
+ _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,
includeSpecial, DefaultPasswordLength, DefaultMaxPasswordAttempts, false);
-
- _rng = RandomNumberGenerator.Create();
+ _random = new CryptoRandomSource();
+ _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)
{
Settings = new PasswordSettings(includeLowercase, includeUppercase, includeNumeric,
includeSpecial, passwordLength, DefaultMaxPasswordAttempts, false);
-
- _rng = RandomNumberGenerator.Create();
+ _random = new CryptoRandomSource();
+ _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)
{
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;
}
+ /// 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;
@@ -108,145 +178,325 @@ 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 ValueTask NextAsync(CancellationToken cancellationToken = default)
+ {
+ return cancellationToken.IsCancellationRequested
+ ? ValueTask.FromCanceled(cancellationToken)
+ : new ValueTask(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 ValueTask> GenerateAsync(CancellationToken cancellationToken = default)
+ {
+ return GenerateAsync(DefaultBatchCount, cancellationToken);
+ }
+
+ ///
+ 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++)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ return ValueTask.FromCanceled>(cancellationToken);
+ passwords.Add(Next());
}
- return password;
+ return new ValueTask>(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 EffectiveMinimum(IPasswordSettings settings, CharacterClass characterClass)
+ {
+ 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;
+ }
+
+ 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;
+ }
}
- private static int GetRandomNumberInRange(int min, int max)
+ private static string ClassCharacters(IPasswordSettings settings, CharacterClass characterClass)
{
- if (min > max)
- throw new ArgumentOutOfRangeException();
+ 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;
+ }
+ }
- var data = new byte[sizeof(int)];
- _rng.GetBytes(data);
- var randomNumber = BitConverter.ToInt32(data, 0);
+ private void ShuffleInPlace(char[] items)
+ {
+ 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;
+ }
+ }
- return (int)Math.Floor((double)(min + Math.Abs(randomNumber % (max - min))));
+ private static bool LengthIsValid(int passwordLength, int minLength, int maxLength)
+ {
+ return passwordLength >= minLength && passwordLength <= maxLength;
}
- private static int GetRngCryptoSeed(RNGCryptoServiceProvider rng)
+ /// Disposes the random source when this instance owns it (i.e. it was not supplied by the caller).
+ public void Dispose()
{
- var rngByteArray = new byte[4];
- rng.GetBytes(rngByteArray);
- return BitConverter.ToInt32(rngByteArray, 0);
+ if (_ownsRandom && _random is IDisposable disposable)
+ disposable.Dispose();
}
- ///
- /// 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)
+ // ----- Presets (sugar over the fluent builder; later fluent calls still override) -----
+
+ /// OWASP-style: the full printable-ASCII pool with no forced composition.
+ public static IPassword ForOwasp(int length = 16)
{
- const string regexLowercase = @"[a-z]";
- const string regexUppercase = @"[A-Z]";
- const string regexNumeric = @"[\d]";
+ return new Password().WithAllAscii().LengthRequired(length);
+ }
- 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);
+ /// 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);
+ }
- var specialIsValid = !settings.IncludeSpecial;
+ /// One-time-password style: a short numeric code.
+ public static IPassword ForOtp(int digits = 6)
+ {
+ return new Password().WithCharacters(PasswordSettings.NumericCharacters).LengthRequired(digits);
+ }
- if (settings.IncludeSpecial && !string.IsNullOrWhiteSpace(settings.SpecialCharacters))
- {
- var listA = settings.SpecialCharacters.ToCharArray();
- var listB = password.ToCharArray();
+ /// API-key style: a long URL-safe secret.
+ public static IPassword ForApiKey(int length = 32)
+ {
+ return new Password().WithCharacters(CharacterFilter.UrlSafeCharacters).LengthRequired(length);
+ }
- specialIsValid = listA.Any(x => listB.Contains(x));
- }
+ /// Readable identifier: lowercase letters and digits with look-alikes removed.
+ public static IPassword ForEnvironmentName(int length = 12)
+ {
+ return new Password()
+ .WithCharacters(PasswordSettings.LowercaseCharacters + PasswordSettings.NumericCharacters)
+ .ExcludeAmbiguous()
+ .LengthRequired(length);
+ }
- return lowerCaseIsValid && upperCaseIsValid && numericIsValid && specialIsValid &&
- LengthIsValid(password.Length, settings.MinimumLength, settings.MaximumLength);
+ /// 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 includeSymbol = false,
+ double minimumEntropyBits = 0)
+ {
+ return new PassphraseGenerator(words, separator, capitalize, includeNumber, includeSymbol,
+ minimumEntropyBits);
}
///
- /// Checks that the password is within the valid length range
+ /// 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 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)
+ /// 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)
{
- return passwordLength >= minLength && passwordLength <= maxLength;
+ var words = PassphraseGenerator.WordCountForEntropy(targetBits, includeNumber);
+ return new PassphraseGenerator(words, separator, capitalize, includeNumber, includeSymbol, targetBits);
}
- private static IEnumerable Shuffle(IEnumerable items)
+ ///
+ /// 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 from item in items orderby Guid.NewGuid() select item;
+ return ForPassphraseWithEntropy(80, separator: '-', capitalize: true, includeNumber: true);
}
}
-}
\ 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..903a0f3 100644
--- a/PasswordGenerator/PasswordGenerator.csproj
+++ b/PasswordGenerator/PasswordGenerator.csproj
@@ -1,28 +1,54 @@
-
+
- netstandard2.0
- 2.1.0
+ net8.0;net10.0
+ enable
+ latest
+
+
+ true
+
+
+ 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,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..ac67552
--- /dev/null
+++ b/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs
@@ -0,0 +1,80 @@
+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 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,
+ 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..499205b
--- /dev/null
+++ b/PasswordGenerator/PasswordOptions.cs
@@ -0,0 +1,39 @@
+namespace PasswordGenerator
+{
+ ///
+ /// Configuration for a password generator, used when registering via dependency injection or
+ /// binding from configuration (e.g. appSettings.json).
+ ///
+ 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.
+ public bool ExcludeAmbiguous { get; set; }
+
+ /// 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/PasswordSettings.cs b/PasswordGenerator/PasswordSettings.cs
index 9af29ab..5f9c947 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,14 +9,33 @@ namespace PasswordGenerator
///
public class PasswordSettings : IPasswordSettings
{
- private const string LowercaseCharacters = "abcdefghijklmnopqrstuvwxyz";
- private const string UppercaseCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
- private const string NumericCharacters = "0123456789";
+ /// 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)
{
@@ -32,17 +53,59 @@ 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
+ {
+ 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();
@@ -51,6 +114,7 @@ public IPasswordSettings AddLowercase()
return this;
}
+ ///
public IPasswordSettings AddUppercase()
{
StopUsingDefaults();
@@ -59,6 +123,7 @@ public IPasswordSettings AddUppercase()
return this;
}
+ ///
public IPasswordSettings AddNumeric()
{
StopUsingDefaults();
@@ -67,6 +132,7 @@ public IPasswordSettings AddNumeric()
return this;
}
+ ///
public IPasswordSettings AddSpecial()
{
StopUsingDefaults();
@@ -76,6 +142,7 @@ public IPasswordSettings AddSpecial()
return this;
}
+ ///
public IPasswordSettings AddSpecial(string specialCharactersToAdd)
{
StopUsingDefaults();
@@ -85,6 +152,66 @@ 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..b8a2308
--- /dev/null
+++ b/PasswordGenerator/PoolEntropyEstimator.cs
@@ -0,0 +1,30 @@
+using System;
+
+namespace PasswordGenerator
+{
+ ///
+ /// Default : entropy = length * log2(effective pool size).
+ ///
+ 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));
+
+ 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..51ef08f
--- /dev/null
+++ b/PasswordGenerator/WordList.cs
@@ -0,0 +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
+{
+ ///
+ /// 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 =
+ {
+ "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 3c97912..f4cb17b 100644
--- a/Readme.md
+++ b/Readme.md
@@ -2,7 +2,9 @@

-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,167 @@ 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 `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`).
+> **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".
## 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/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"
+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
+// 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();
+}
```
+
+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/appveyor.yml b/appveyor.yml
deleted file mode 100644
index d503d01..0000000
--- a/appveyor.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-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
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.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..33d9e0a
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,53 @@
+# PasswordGenerator — Documentation
+
+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 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 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
+ Docs -. superseded by .-> Archive
+```
+
+## Reading order
+
+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
+
+- 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/api-surface.md b/docs/api-surface.md
new file mode 100644
index 0000000..8a47a15
--- /dev/null
+++ b/docs/api-surface.md
@@ -0,0 +1,110 @@
+# Public API Surface
+
+Keeps the familiar fluent feel; adds safety, presets, batch, async, and custom pools.
+
+> 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
+
+```mermaid
+flowchart TD
+ subgraph Entry["Entry points"]
+ e1["new Password()"]
+ e2["Password.ForOwasp()/ForOtp()/... (static presets)"]
+ e3["inject IPasswordGenerator (DI)"]
+ end
+ subgraph Build["Fluent builder (IPassword)"]
+ direction TB
+ b1["IncludeLowercase/Uppercase/Numeric"]
+ b2["IncludeSpecial(string)"]
+ b3["WithAllAscii() / WithCharacters(string)"]
+ b4["ExcludeAmbiguous()"]
+ b5["RequireAtLeast(class, count)"]
+ b6["LengthRequired(int)"]
+ end
+ subgraph Gen["Generation (IPasswordGenerator)"]
+ g1["Next() : string (throws on bad config)"]
+ g2["TryNext(out string) : bool"]
+ 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;
+ class g2,g3,g4,g5,b3,b4,b5 good;
+```
+
+## Single vs batch (naming kept intentional)
+
+```mermaid
+flowchart LR
+ N["Next() — ONE password
(mirrors Random.Next())"]
+ 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: `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
+
+```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["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`).
+
+## Surfacing the broader purpose
+
+The library is **not password-only**. The same surface generates OTPs, environment names, API keys,
+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
+
+```mermaid
+flowchart TD
+ Old["v2: new Password().Next() → string (maybe error)"] --> Mig["v3 migration"]
+ 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"]
+ classDef warn fill:#fff0e6,stroke:#cc6600;
+ 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 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/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..27961e9
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,142 @@
+# Architecture
+
+> 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`.
+
+## Type relationships
+
+```mermaid
+classDiagram
+ class IPasswordGenerator {
+ <>
+ +Next() string
+ +TryNext(out string) bool
+ +NextAsync(CancellationToken) Task
+ +Generate() IReadOnlyList
+ +Generate(int count) IReadOnlyList
+ +GenerateAsync(CancellationToken) Task
+ +GenerateAsync(int count, CancellationToken) Task
+ +EstimateEntropyBits() double
+ }
+ class IPassword {
+ <>
+ +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()/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)
+ }
+ class CryptoRandomSource {
+ RandomNumberGenerator.GetInt32
+ }
+ class IEntropyEstimator {
+ <>
+ +double EstimateBits(IPasswordSettings)
+ }
+
+ IPassword <|.. Password
+ 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
+```
+
+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`. 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.
+
+## 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 Password()..."]
+ 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 net8["net8.0"]
+ b["RandomNumberGenerator.GetInt32"]
+ end
+ subgraph net10["net10.0"]
+ c["RandomNumberGenerator.GetInt32"]
+ end
+ IRandomSource --> net8
+ IRandomSource --> net10
+```
+
+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), and uses the fast, allocation-free built-in
+crypto API on every supported runtime.
diff --git a/docs/archive/V3_REVIEW_AND_DOCUMENTATION.md b/docs/archive/V3_REVIEW_AND_DOCUMENTATION.md
new file mode 100644
index 0000000..1c56c1c
--- /dev/null
+++ b/docs/archive/V3_REVIEW_AND_DOCUMENTATION.md
@@ -0,0 +1,330 @@
+# 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
+> 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
diff --git a/docs/archive/V3_VERIFICATION.md b/docs/archive/V3_VERIFICATION.md
new file mode 100644
index 0000000..35f8da2
--- /dev/null
+++ b/docs/archive/V3_VERIFICATION.md
@@ -0,0 +1,204 @@
+# 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**,
+> 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.
diff --git a/docs/archive/before-after.md b/docs/archive/before-after.md
new file mode 100644
index 0000000..e3ece99
--- /dev/null
+++ b/docs/archive/before-after.md
@@ -0,0 +1,127 @@
+# 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
+
+```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"]
+ 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/archive/current-state/api-surface.md b/docs/archive/current-state/api-surface.md
new file mode 100644
index 0000000..84b1089
--- /dev/null
+++ b/docs/archive/current-state/api-surface.md
@@ -0,0 +1,77 @@
+# 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](../../migration-v2-to-v3.md).
+
+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 `../../api-surface.md`.
diff --git a/docs/archive/current-state/architecture.md b/docs/archive/current-state/architecture.md
new file mode 100644
index 0000000..3de1bcf
--- /dev/null
+++ b/docs/archive/current-state/architecture.md
@@ -0,0 +1,100 @@
+# 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](../../migration-v2-to-v3.md).
+
+`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/archive/current-state/generation-flow.md b/docs/archive/current-state/generation-flow.md
new file mode 100644
index 0000000..ac54286
--- /dev/null
+++ b/docs/archive/current-state/generation-flow.md
@@ -0,0 +1,94 @@
+# 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](../../migration-v2-to-v3.md).
+
+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 `../../generation-flow.md`).
diff --git a/docs/archive/implementation-plan.md b/docs/archive/implementation-plan.md
new file mode 100644
index 0000000..56d0299
--- /dev/null
+++ b/docs/archive/implementation-plan.md
@@ -0,0 +1,344 @@
+# v3 Target — Implementation Plan (phased)
+
+> **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
+
+- **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 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
+
+```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.
+- **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).
+
+---
+
+## 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.
+- **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.
+
+---
+
+## 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**
+- **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`.
+
+---
+
+## Phase 3 — API: async, DI, remove v2 wrappers (Tier 2b)
+
+**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`);
+ `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`). `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`** (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.
+
+---
+
+## Phase 4 — New features (Tier 3)
+
+**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`
+ (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` (`PoolEntropyEstimator`) returning strength in bits.
+
+**Verification / exit criteria**
+- 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.
+
+---
+
+## Phase 5 — Packaging & release (Tier 2c)
+
+**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**.
+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.
+- **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.
+
+---
+
+## Phase 6 — Documentation & migration (Tier 4)
+
+**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 (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.
+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.
+- **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.
+
+---
+
+## 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
+# 4. only once tests are green, commit + push this phase (one commit per phase)
+git add -A && git commit -m "" && git push -u origin
+```
diff --git a/docs/archive/roadmap.md b/docs/archive/roadmap.md
new file mode 100644
index 0000000..5a95746
--- /dev/null
+++ b/docs/archive/roadmap.md
@@ -0,0 +1,88 @@
+# v3 Target — Roadmap
+
+> **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
+
+```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 (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
+ 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 (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
+ 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.
diff --git a/docs/configuration-and-di.md b/docs/configuration-and-di.md
new file mode 100644
index 0000000..31ed2fc
--- /dev/null
+++ b/docs/configuration-and-di.md
@@ -0,0 +1,77 @@
+# Configuration & Dependency Injection
+
+## 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,
+ "IncludeSpecial": true,
+ "SpecialCharacters": "!#$%&*@",
+ "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:
+
+```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["Password.ForOwasp().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`
+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/generation-flow.md b/docs/generation-flow.md
new file mode 100644
index 0000000..90d9f96
--- /dev/null
+++ b/docs/generation-flow.md
@@ -0,0 +1,81 @@
+# Generation Flow
+
+Uses **deterministic construction + exceptions** rather than probabilistic retry + string sentinels.
+
+## `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;
+```
+
+**Design properties:**
+- No retry loop, no `MaximumAttempts` gamble, **no `"Try again"` string**. Required classes are
+ *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.
+- Shuffle is a real crypto Fisher–Yates, not `orderby Guid.NewGuid()`.
+
+## 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() 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 exists to prove where async actually
+> pays off, with numbers published in every release note.
+
+## Failure contract — v2.1.0 vs v3
+
+```mermaid
+stateDiagram-v2
+ state "v2.1.0 (previous)" as Old {
+ [*] --> RetryLoop
+ RetryLoop --> OKo: valid
+ RetryLoop --> StrFail: attempts exhausted → 'Try again' STRING
+ }
+ state "v3 (current)" 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/migration-v2-to-v3.md b/docs/migration-v2-to-v3.md
new file mode 100644
index 0000000..809e4b9
--- /dev/null
+++ b/docs/migration-v2-to-v3.md
@@ -0,0 +1,154 @@
+# 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).
+
+> **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`
+
+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"
+```
diff --git a/docs/v3-local-nuget-test.md b/docs/v3-local-nuget-test.md
new file mode 100644
index 0000000..9f6b377
--- /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/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 `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
+> 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%i4QbvUgR'
+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` (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 |
+| 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.