Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 12 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ PasswordGenerator/bin/Debug/$RANDOM_SEED$
*.suo
/.vs
/PasswordGenerator/obj
/PasswordGenerator/bin/Debug
/PasswordGenerator/bin
/UnitTests/obj
/UnitTests/bin/Debug
/packages
Expand All @@ -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/
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
40 changes: 40 additions & 0 deletions PasswordGenerator.Benchmarks/Benchmarks/AsyncBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;

namespace PasswordGenerator.Benchmarks
{
/// <summary>Cost of the async generation APIs relative to their synchronous counterparts.</summary>
[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<string> NextAsync()
{
return _password.NextAsync();
}

[Benchmark]
public ValueTask<IReadOnlyList<string>> GenerateAsync()
{
return _password.GenerateAsync(Count);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using BenchmarkDotNet.Attributes;

namespace PasswordGenerator.Benchmarks
{
/// <summary>Cost of generating a batch of passwords in one call.</summary>
[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<string> Generate()
{
return _password.Generate(Count);
}
}
}
40 changes: 40 additions & 0 deletions PasswordGenerator.Benchmarks/Benchmarks/InstantiationBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System;
using BenchmarkDotNet.Attributes;
using Microsoft.Extensions.DependencyInjection;

namespace PasswordGenerator.Benchmarks
{
/// <summary>Overhead of resolving a generator from the DI container versus constructing one directly.</summary>
[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<IPasswordGenerator>().Next();
}
}
}
Loading
Loading