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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ See the [v2 → v3 migration guide](docs/migration-v2-to-v3.md).
- **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.
- **Optional passphrase separator:** the separator is now a `char?` — pass `separator: null` (or an
empty string when binding from configuration) to concatenate words with no separator. This does not
affect entropy.
- **`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
Expand Down
2 changes: 1 addition & 1 deletion PasswordGenerator.Tests/DocumentationSnippetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
namespace PasswordGenerator.Tests
{
/// <summary>
/// Compile-and-run guards for the snippets in Readme.md and the v2->v3 migration guide, so the
/// 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.
/// </summary>
public class DocumentationSnippetTests
Expand Down
72 changes: 72 additions & 0 deletions PasswordGenerator.Tests/PassphraseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -212,5 +212,77 @@ public void Di_BindsPassphraseFromConfiguration()
var parts = generator.Next().Split('.');
Assert.That(parts.Length, Is.EqualTo(5)); // 5 words, no trailing number
}

[Test]
public void Next_WithNullSeparator_ConcatenatesWordsDirectly()
{
var rng = new FixedRandomSource(0, 1, 2, 3);
var generator = new PassphraseGenerator(4, separator: null, capitalize: false,
includeNumber: false, includeSymbol: false, minimumEntropyBits: 0, randomSource: rng);

var expected = string.Concat(
WordList.Words[0], WordList.Words[1], WordList.Words[2], WordList.Words[3]);
Assert.That(generator.Next(), Is.EqualTo(expected));
}

[Test]
public void Next_WithNullSeparator_AppendsNumberWithoutSeparator()
{
// Draw order: one index per word, then the trailing number (NextInt(90) + 10).
var rng = new FixedRandomSource(0, 1, 5);
var generator = new PassphraseGenerator(2, separator: null, capitalize: false,
includeNumber: true, includeSymbol: false, minimumEntropyBits: 0, randomSource: rng);

var expected = WordList.Words[0] + WordList.Words[1] + "15"; // 5 % 90 + 10
Assert.That(generator.Next(), Is.EqualTo(expected));
}

[Test]
public void Next_WithNullSeparator_StillCapitalizesEachWord()
{
var rng = new FixedRandomSource(0, 1);
var generator = new PassphraseGenerator(2, separator: null, capitalize: true,
includeNumber: false, includeSymbol: false, minimumEntropyBits: 0, randomSource: rng);

static string Cap(string w) => char.ToUpperInvariant(w[0]) + w.Substring(1);
var expected = Cap(WordList.Words[0]) + Cap(WordList.Words[1]);
Assert.That(generator.Next(), Is.EqualTo(expected));
}

[Test]
public void ForPassphrase_AcceptsNullSeparator()
{
var generator = (PassphraseGenerator)Password.ForPassphrase(4, separator: null);
Assert.That(generator.Separator, Is.Null);
}

[Test]
public void NullSeparator_DoesNotChangeEntropy()
{
var withSeparator = Password.ForPassphrase(6, separator: '-').EstimateEntropyBits();
var withoutSeparator = Password.ForPassphrase(6, separator: null).EstimateEntropyBits();
Assert.That(withoutSeparator, Is.EqualTo(withSeparator));
}

[Test]
public void Di_BindsEmptySeparatorAsNull()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Passphrase:WordCount"] = "3",
["Passphrase:Separator"] = "",
["Passphrase:IncludeNumber"] = "false"
})
.Build();

var services = new ServiceCollection();
services.AddPasswordGenerator(configuration);

using var provider = services.BuildServiceProvider();
var generator = (PassphraseGenerator)provider.GetRequiredService<IPasswordGenerator>();

Assert.That(generator.Separator, Is.Null);
}
}
}
3 changes: 3 additions & 0 deletions PasswordGenerator/IPassword.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public interface IPassword
IPassword IncludeSpecial(string specialCharactersToInclude);

/// <summary>Replaces the pool with an explicit set of characters (no forced composition).</summary>
/// <exception cref="System.ArgumentNullException"><paramref name="characters" /> is <see langword="null" />.</exception>
IPassword WithCharacters(string characters);

/// <summary>Uses every printable ASCII character as the pool (no forced composition).</summary>
Expand All @@ -39,6 +40,8 @@ public interface IPassword
IPassword ExcludeAmbiguous();

/// <summary>Requires at least <paramref name="count" /> characters from the given class.</summary>
/// <exception cref="System.ArgumentOutOfRangeException"><paramref name="count" /> is negative.</exception>
/// <exception cref="System.InvalidOperationException">A custom character pool is in use (per-class minimums cannot be combined with it).</exception>
IPassword RequireAtLeast(CharacterClass characterClass, int count);

/// <summary>Sets the required password length.</summary>
Expand Down
2 changes: 2 additions & 0 deletions PasswordGenerator/IPasswordGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ public interface IPasswordGenerator
IReadOnlyList<string> Generate();

/// <summary>Generates <paramref name="count" /> passwords.</summary>
/// <exception cref="System.ArgumentOutOfRangeException"><paramref name="count" /> is negative.</exception>
IReadOnlyList<string> Generate(int count);

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

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

/// <summary>Estimates the strength, in bits, of the output produced by this generator.</summary>
Expand Down
3 changes: 3 additions & 0 deletions PasswordGenerator/IPasswordSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public interface IPasswordSettings
IPasswordSettings AddSpecial(string specialCharactersToAdd);

/// <summary>Replaces the entire pool with an explicit set of characters (no forced composition).</summary>
/// <exception cref="System.ArgumentNullException"><paramref name="characters" /> is <see langword="null" />.</exception>
IPasswordSettings UseCharacters(string characters);

/// <summary>Uses every printable ASCII character as the pool (no forced composition).</summary>
Expand All @@ -80,6 +81,8 @@ public interface IPasswordSettings
IPasswordSettings ExcludeAmbiguousCharacters();

/// <summary>Requires at least <paramref name="count" /> characters from the given class, enabling it if needed.</summary>
/// <exception cref="System.ArgumentOutOfRangeException"><paramref name="count" /> is negative.</exception>
/// <exception cref="System.InvalidOperationException">A custom character pool is in use (per-class minimums cannot be combined with it).</exception>
IPasswordSettings RequireAtLeast(CharacterClass characterClass, int count);

/// <summary>The special characters used when special characters are included.</summary>
Expand Down
17 changes: 9 additions & 8 deletions PasswordGenerator/PassphraseGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ public class PassphraseGenerator : IPasswordGenerator, IDisposable
/// <summary>Creates a passphrase generator.</summary>
/// <param name="wordCount">The number of words in each passphrase; must be at least one.</param>
/// <param name="separator">
/// 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.
/// The character placed between words (and before the trailing number), or <see langword="null" />
/// for no separator at all (the words are concatenated directly). 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.
/// </param>
/// <param name="capitalize">Whether to capitalize the first letter of each word.</param>
/// <param name="includeNumber">Whether to append a random two-digit number.</param>
Expand All @@ -39,7 +40,7 @@ public class PassphraseGenerator : IPasswordGenerator, IDisposable
/// </param>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="wordCount" /> is less than one.</exception>
/// <exception cref="ArgumentException">The estimated entropy is below <paramref name="minimumEntropyBits" />.</exception>
public PassphraseGenerator(int wordCount = 4, char separator = '-', bool capitalize = false,
public PassphraseGenerator(int wordCount = 4, char? separator = '-', bool capitalize = false,
bool includeNumber = true, bool includeSymbol = false, double minimumEntropyBits = 0,
IRandomSource? randomSource = null)
{
Expand All @@ -66,8 +67,8 @@ public PassphraseGenerator(int wordCount = 4, char separator = '-', bool capital
/// <summary>The number of words in each passphrase.</summary>
public int WordCount { get; }

/// <summary>The character placed between words.</summary>
public char Separator { get; }
/// <summary>The character placed between words, or <see langword="null" /> for no separator.</summary>
public char? Separator { get; }

/// <summary>Whether the first letter of each word is capitalized.</summary>
public bool Capitalize { get; }
Expand Down Expand Up @@ -104,7 +105,7 @@ public string Next()

for (var i = 0; i < WordCount; i++)
{
if (i > 0) sb.Append(Separator);
if (i > 0 && Separator is char sep) sb.Append(sep);

var word = WordList.Words[_random.NextInt(WordList.Words.Length)];
if (Capitalize && word.Length > 0)
Expand All @@ -122,7 +123,7 @@ public string Next()

if (IncludeNumber)
{
sb.Append(Separator);
if (Separator is char numSep) sb.Append(numSep);
sb.Append((_random.NextInt(90) + 10).ToString(CultureInfo.InvariantCulture));
}

Expand Down
7 changes: 5 additions & 2 deletions PasswordGenerator/PassphraseOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ public class PassphraseOptions
/// <summary>The number of words in each passphrase. Defaults to <c>4</c>.</summary>
public int WordCount { get; set; } = 4;

/// <summary>The character placed between words. Defaults to <c>'-'</c>.</summary>
public char Separator { get; set; } = '-';
/// <summary>
/// The character placed between words. Defaults to <c>'-'</c>. Set to <see langword="null" />
/// (or an empty string in configuration) for no separator.
/// </summary>
public char? Separator { get; set; } = '-';

/// <summary>Whether the first letter of each word is capitalized.</summary>
public bool Capitalize { get; set; }
Expand Down
9 changes: 5 additions & 4 deletions PasswordGenerator/Password.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public Password(bool includeLowercase, bool includeUppercase, bool includeNumeri
/// Creates a password generator with an explicit random source. The caller owns the
/// supplied <paramref name="randomSource" /> and is responsible for disposing it.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="randomSource" /> is <see langword="null" />.</exception>
public Password(IPasswordSettings settings, IRandomSource randomSource)
{
Settings = settings;
Expand Down Expand Up @@ -458,15 +459,15 @@ public static IPassword ForEnvironmentName(int length = 12)

/// <summary>Diceware-style passphrase built from the EFF Large Wordlist.</summary>
/// <param name="words">The number of words in the passphrase.</param>
/// <param name="separator">The character placed between words.</param>
/// <param name="separator">The character placed between words, or <see langword="null" /> for no separator.</param>
/// <param name="capitalize">Whether to capitalize the first letter of each word.</param>
/// <param name="includeNumber">Whether to append a random two-digit number.</param>
/// <param name="includeSymbol">Whether to attach a random symbol to one randomly chosen word.</param>
/// <param name="minimumEntropyBits">
/// An optional entropy floor; when greater than zero the configuration is rejected if it
/// falls below this many bits.
/// </param>
public static IPasswordGenerator ForPassphrase(int words = 4, char separator = '-',
public static IPasswordGenerator ForPassphrase(int words = 4, char? separator = '-',
bool capitalize = false, bool includeNumber = true, bool includeSymbol = false,
double minimumEntropyBits = 0)
{
Expand All @@ -479,11 +480,11 @@ public static IPasswordGenerator ForPassphrase(int words = 4, char separator = '
/// The word count is derived from the word-list size, and the same value is enforced as a floor.
/// </summary>
/// <param name="targetBits">The minimum entropy in bits (defaults to 80, a strong target).</param>
/// <param name="separator">The character placed between words.</param>
/// <param name="separator">The character placed between words, or <see langword="null" /> for no separator.</param>
/// <param name="capitalize">Whether to capitalize the first letter of each word.</param>
/// <param name="includeNumber">Whether to append a random two-digit number.</param>
/// <param name="includeSymbol">Whether to attach a random symbol to one randomly chosen word.</param>
public static IPasswordGenerator ForPassphraseWithEntropy(double targetBits = 80, char separator = '-',
public static IPasswordGenerator ForPassphraseWithEntropy(double targetBits = 80, char? separator = '-',
bool capitalize = false, bool includeNumber = true, bool includeSymbol = false)
{
var words = PassphraseGenerator.WordCountForEntropy(targetBits, includeNumber);
Expand Down
10 changes: 8 additions & 2 deletions PasswordGenerator/PasswordGenerator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>

<!-- Ship XML docs so consumers get IntelliSense for the public API -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>

<!-- Catch accidental public-API breaks and packaging problems at pack time.
After 3.0.0 ships, add <PackageValidationBaselineVersion>3.0.0</...> here. -->
<EnablePackageValidation>true</EnablePackageValidation>

<!-- Single source of version truth -->
<Version>3.0.0</Version>
<AssemblyVersion>3.0.0.0</AssemblyVersion>
<FileVersion>3.0.0.0</FileVersion>

<Title>PasswordGenerator</Title>
<Authors>Paul Seal</Authors>
<Company>Paul Seal</Company>
<Description>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.</Description>
<Copyright>Copyright 2026</Copyright>
<Copyright>Copyright © 2026 Paul Seal</Copyright>
<PackageProjectUrl>https://github.com/prjseal/PasswordGenerator/</PackageProjectUrl>
<RepositoryUrl>https://github.com/prjseal/PasswordGenerator/</RepositoryUrl>
<RepositoryType>Git</RepositoryType>
Expand All @@ -37,7 +43,7 @@
</PropertyGroup>

<ItemGroup>
<None Include="..\Readme.md" Pack="true" PackagePath="\README.md" />
<None Include="..\README.md" Pack="true" PackagePath="\README.md" />
<None Include="..\passwordgeneratorlogo.png" Pack="true" PackagePath="\" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,21 @@ public static IServiceCollection AddPasswordGenerator(this IServiceCollection se
/// Registers the generator, binding options from configuration (e.g. appSettings.json) and then
/// applying an optional code override. Resolution order is <c>configure (code) &gt; configuration &gt; default</c>.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="configuration" /> is <see langword="null" />.</exception>
public static IServiceCollection AddPasswordGenerator(this IServiceCollection services,
IConfiguration configuration, Action<PasswordOptions>? configure = null)
{
if (configuration == null) throw new ArgumentNullException(nameof(configuration));

var options = new PasswordOptions();
configuration.Bind(options);

// The configuration binder skips empty string values, so an explicit empty separator
// (the way a config file asks for "no separator") would otherwise be lost and the
// default '-' kept. Honor it explicitly as null.
if (options.Passphrase != null && configuration["Passphrase:Separator"] == string.Empty)
options.Passphrase.Separator = null;

configure?.Invoke(options);
return AddCore(services, options);
}
Expand Down
Loading