Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ See the [v2 → v3 migration guide](docs/migration-v2-to-v3.md).
- **Dependency injection:** `AddPasswordGenerator(...)` with code and `appSettings.json` binding
(resolution order: code-configure > appSettings > default).
- **Presets:** `ForOwasp`, `ForNist`, `ForOtp`, `ForApiKey`, `ForEnvironmentName`, `ForPassphrase`.
- **Passphrases use the EFF Large Wordlist** (7,776 words, ~12.9 bits/word), replacing the small
built-in list — a 6-word phrase is now ~77 bits. The list is © EFF under CC BY 3.0; see
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
- **Entropy-targeted passphrases:** `ForPassphraseWithEntropy(targetBits)` derives the word count
to meet a target, and `ForPassphrase(..., minimumEntropyBits)` enforces an entropy floor.
- **Symbol injection:** `ForPassphrase(..., includeSymbol: true)` attaches a random symbol to one
randomly chosen word, so passphrases satisfy "needs a number and a symbol" rules while staying
memorable. Entropy estimation now accounts for both the trailing number and the symbol.
- **`ForMemorable()` preset:** capitalized words sized to at least 80 bits of entropy.
- **Passphrases via dependency injection:** set `PasswordOptions.Passphrase` (a `PassphraseOptions`)
in code or bind a `Passphrase` section from configuration to resolve a passphrase
`IPasswordGenerator`.
- `EstimateEntropyBits()` is now part of the `IPasswordGenerator` interface.
- **Custom pools:** `WithCharacters(string)`, `WithAllAscii()`.
- **Quality controls:** `ExcludeAmbiguous()`, `RequireAtLeast(CharacterClass, count)`.
- **Entropy estimation:** `IEntropyEstimator` / `PoolEntropyEstimator` and `EstimateEntropyBits()`.
Expand Down
216 changes: 216 additions & 0 deletions PasswordGenerator.Tests/PassphraseTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() => 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<IPasswordGenerator>();

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<string>();
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<string, string?>
{
["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<IPasswordGenerator>();

var parts = generator.Next().Split('.');
Assert.That(parts.Length, Is.EqualTo(5)); // 5 words, no trailing number
}
}
}
9 changes: 6 additions & 3 deletions PasswordGenerator.Tests/Phase4Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,16 @@ public void ForEnvironmentName_IsLowercaseDigitsWithoutAmbiguous()
[Test]
public void ForPassphrase_ProducesRequestedWordsPlusNumber()
{
var generator = Password.ForPassphrase(4, '-', capitalize: false, includeNumber: true);
// Use '.' as the separator: a handful of EFF words are themselves hyphenated
// (e.g. "t-shirt"), so splitting on '-' would over-split the phrase.
var generator = Password.ForPassphrase(4, '.', capitalize: false, includeNumber: true);
var phrase = generator.Next();
var parts = phrase.Split('-');
var parts = phrase.Split('.');

Assert.That(parts.Length, Is.EqualTo(5)); // 4 words + trailing number
Assert.That(int.TryParse(parts[4], out _), Is.True, phrase);
Assert.That(parts.Take(4).All(p => p.All(char.IsLetter)), Is.True, phrase);
Assert.That(parts.Take(4).All(p => p.Length > 0 && p.All(c => char.IsLetter(c) || c == '-')),
Is.True, phrase);
}

// ----- DI / appSettings precedence -----
Expand Down
47 changes: 47 additions & 0 deletions PasswordGenerator.Tests/WordListTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
3 changes: 3 additions & 0 deletions PasswordGenerator/IPasswordGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,8 @@ public interface IPasswordGenerator

/// <summary>Generates <paramref name="count" /> passwords, observing <paramref name="cancellationToken" />.</summary>
ValueTask<IReadOnlyList<string>> GenerateAsync(int count, CancellationToken cancellationToken = default);

/// <summary>Estimates the strength, in bits, of the output produced by this generator.</summary>
double EstimateEntropyBits();
}
}
Loading