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 @@ ![Password Logo](https://github.com/prjseal/PasswordGenerator/blob/dev/v2/passwordgeneratorlogo.png "Password Logo") -A .NET Standard library which generates random passwords with different settings to meet the OWASP requirements +A cross-platform .NET library that generates cryptographically secure random passwords, passphrases, +OTPs, API keys and readable identifiers. Configure it with a fluent API, ready-made presets +(OWASP/NIST) or dependency injection — with async support and entropy estimation. ## NuGet @@ -12,73 +14,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: - -![Compatibility Chart](https://github.com/prjseal/PasswordGenerator/blob/master/compatibility.png "Compatibility 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%i4Q&#bvUgR' +Generate(5) = 5 items + y3nbjyLgRKkrJ$T# + 3#&G$#vXU$3*mq!9 + CeN0FRM#RP9y@ryN + WlZ%IxX@kIrem8oc + Ysi@i*#qjA9SL0g8 +GenerateAsync(3) = 3 items +``` + +**Expected.** `NextAsync` returns a single password; `Generate(n)` / `GenerateAsync(n)` +return exactly `n` distinct passwords. + +### 11. Dependency injection (configured in code) + +```csharp +var services = new ServiceCollection(); +services.AddPasswordGenerator(o => +{ + o.Length = 20; + o.IncludeSpecial = true; + o.ExcludeAmbiguous = true; +}); +using var sp = services.BuildServiceProvider(); +var gen = sp.GetRequiredService(); +var password = gen.Next(); +``` + +Output: + +``` +DI(code) Next() = 'b#3$%zQ6j4xGtYBjYt&K' length=20 +``` + +**Expected.** `AddPasswordGenerator` registers `IPasswordGenerator`; the resolved generator +honours the code-configured options (length 20, specials on, ambiguous removed). + +### 12. Dependency injection (bound from `appsettings.json`) + +```csharp +var config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + +var services = new ServiceCollection(); +services.AddPasswordGenerator(config.GetSection("PasswordGenerator")); +using var sp = services.BuildServiceProvider(); +var gen = sp.GetRequiredService(); + +var password = gen.Next(); +var batch = gen.Generate(); // uses DefaultBatchCount from config +``` + +Output: + +``` +DI(config) Next() = '54LWWrw8F4fMHfQDSjPWjawP' length=24 (config Length=24, ExcludeAmbiguous, no special) +DI(config) Generate() default batch count = 3 (config DefaultBatchCount=3) +``` + +**Expected.** Options bind from the `PasswordGenerator` configuration section: length 24, no +special characters, ambiguous characters removed, and `Generate()` (parameterless) returns 3 +passwords matching `DefaultBatchCount`. + +--- + +## Summary + +| # | Scenario | Result | Expected? | +|---|----------|--------|-----------| +| – | `dotnet pack` (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.