diff --git a/CHANGELOG.md b/CHANGELOG.md index 991b5c3..1457787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PasswordGenerator.Tests/DocumentationSnippetTests.cs b/PasswordGenerator.Tests/DocumentationSnippetTests.cs index d9a3140..7f438a1 100644 --- a/PasswordGenerator.Tests/DocumentationSnippetTests.cs +++ b/PasswordGenerator.Tests/DocumentationSnippetTests.cs @@ -9,7 +9,7 @@ namespace PasswordGenerator.Tests { /// - /// 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. /// public class DocumentationSnippetTests diff --git a/PasswordGenerator.Tests/PassphraseTests.cs b/PasswordGenerator.Tests/PassphraseTests.cs index 5465ce3..25f1492 100644 --- a/PasswordGenerator.Tests/PassphraseTests.cs +++ b/PasswordGenerator.Tests/PassphraseTests.cs @@ -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 + { + ["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(); + + Assert.That(generator.Separator, Is.Null); + } } } diff --git a/PasswordGenerator/IPassword.cs b/PasswordGenerator/IPassword.cs index a778230..49bb1ef 100644 --- a/PasswordGenerator/IPassword.cs +++ b/PasswordGenerator/IPassword.cs @@ -30,6 +30,7 @@ public interface IPassword IPassword IncludeSpecial(string specialCharactersToInclude); /// Replaces the pool with an explicit set of characters (no forced composition). + /// is . IPassword WithCharacters(string characters); /// Uses every printable ASCII character as the pool (no forced composition). @@ -39,6 +40,8 @@ public interface IPassword IPassword ExcludeAmbiguous(); /// Requires at least characters from the given class. + /// is negative. + /// A custom character pool is in use (per-class minimums cannot be combined with it). IPassword RequireAtLeast(CharacterClass characterClass, int count); /// Sets the required password length. diff --git a/PasswordGenerator/IPasswordGenerator.cs b/PasswordGenerator/IPasswordGenerator.cs index 69c17a5..8f37e51 100644 --- a/PasswordGenerator/IPasswordGenerator.cs +++ b/PasswordGenerator/IPasswordGenerator.cs @@ -28,12 +28,14 @@ public interface IPasswordGenerator IReadOnlyList Generate(); /// Generates passwords. + /// is negative. IReadOnlyList Generate(int count); /// Generates the default number of passwords, observing . ValueTask> GenerateAsync(CancellationToken cancellationToken = default); /// Generates passwords, observing . + /// is negative. ValueTask> GenerateAsync(int count, CancellationToken cancellationToken = default); /// Estimates the strength, in bits, of the output produced by this generator. diff --git a/PasswordGenerator/IPasswordSettings.cs b/PasswordGenerator/IPasswordSettings.cs index ddd2b97..62b93ec 100644 --- a/PasswordGenerator/IPasswordSettings.cs +++ b/PasswordGenerator/IPasswordSettings.cs @@ -71,6 +71,7 @@ public interface IPasswordSettings IPasswordSettings AddSpecial(string specialCharactersToAdd); /// Replaces the entire pool with an explicit set of characters (no forced composition). + /// is . IPasswordSettings UseCharacters(string characters); /// Uses every printable ASCII character as the pool (no forced composition). @@ -80,6 +81,8 @@ public interface IPasswordSettings IPasswordSettings ExcludeAmbiguousCharacters(); /// Requires at least characters from the given class, enabling it if needed. + /// is negative. + /// A custom character pool is in use (per-class minimums cannot be combined with it). IPasswordSettings RequireAtLeast(CharacterClass characterClass, int count); /// The special characters used when special characters are included. diff --git a/PasswordGenerator/PassphraseGenerator.cs b/PasswordGenerator/PassphraseGenerator.cs index 2f16631..8d9aa25 100644 --- a/PasswordGenerator/PassphraseGenerator.cs +++ b/PasswordGenerator/PassphraseGenerator.cs @@ -19,9 +19,10 @@ public class PassphraseGenerator : IPasswordGenerator, IDisposable /// 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. + /// The character placed between words (and before the trailing number), or + /// 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. /// /// Whether to capitalize the first letter of each word. /// Whether to append a random two-digit number. @@ -39,7 +40,7 @@ public class PassphraseGenerator : IPasswordGenerator, IDisposable /// /// is less than one. /// The estimated entropy is below . - 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) { @@ -66,8 +67,8 @@ public PassphraseGenerator(int wordCount = 4, char separator = '-', bool capital /// The number of words in each passphrase. public int WordCount { get; } - /// The character placed between words. - public char Separator { get; } + /// The character placed between words, or for no separator. + public char? Separator { get; } /// Whether the first letter of each word is capitalized. public bool Capitalize { get; } @@ -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) @@ -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)); } diff --git a/PasswordGenerator/PassphraseOptions.cs b/PasswordGenerator/PassphraseOptions.cs index c5cf6f5..5212747 100644 --- a/PasswordGenerator/PassphraseOptions.cs +++ b/PasswordGenerator/PassphraseOptions.cs @@ -10,8 +10,11 @@ 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; } = '-'; + /// + /// The character placed between words. Defaults to '-'. Set to + /// (or an empty string in configuration) for no separator. + /// + public char? Separator { get; set; } = '-'; /// Whether the first letter of each word is capitalized. public bool Capitalize { get; set; } diff --git a/PasswordGenerator/Password.cs b/PasswordGenerator/Password.cs index d01f022..64445d5 100644 --- a/PasswordGenerator/Password.cs +++ b/PasswordGenerator/Password.cs @@ -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 and is responsible for disposing it. /// + /// is . public Password(IPasswordSettings settings, IRandomSource randomSource) { Settings = settings; @@ -458,7 +459,7 @@ public static IPassword ForEnvironmentName(int length = 12) /// Diceware-style passphrase built from the EFF Large Wordlist. /// The number of words in the passphrase. - /// The character placed between words. + /// The character placed between words, or for no separator. /// 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. @@ -466,7 +467,7 @@ public static IPassword ForEnvironmentName(int length = 12) /// 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 = '-', + public static IPasswordGenerator ForPassphrase(int words = 4, char? separator = '-', bool capitalize = false, bool includeNumber = true, bool includeSymbol = false, double minimumEntropyBits = 0) { @@ -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. /// /// The minimum entropy in bits (defaults to 80, a strong target). - /// The character placed between words. + /// The character placed between words, or for no separator. /// 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 = '-', + public static IPasswordGenerator ForPassphraseWithEntropy(double targetBits = 80, char? separator = '-', bool capitalize = false, bool includeNumber = true, bool includeSymbol = false) { var words = PassphraseGenerator.WordCountForEntropy(targetBits, includeNumber); diff --git a/PasswordGenerator/PasswordGenerator.csproj b/PasswordGenerator/PasswordGenerator.csproj index 903a0f3..9cd289d 100644 --- a/PasswordGenerator/PasswordGenerator.csproj +++ b/PasswordGenerator/PasswordGenerator.csproj @@ -4,10 +4,15 @@ net8.0;net10.0 enable latest + true true + + true + 3.0.0 3.0.0.0 @@ -15,8 +20,9 @@ PasswordGenerator Paul Seal + Paul Seal 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 + Copyright © 2026 Paul Seal https://github.com/prjseal/PasswordGenerator/ https://github.com/prjseal/PasswordGenerator/ Git @@ -37,7 +43,7 @@ - + diff --git a/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs b/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs index ac67552..baffb74 100644 --- a/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs +++ b/PasswordGenerator/PasswordGeneratorServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ 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 configure (code) > configuration > default. /// + /// is . public static IServiceCollection AddPasswordGenerator(this IServiceCollection services, IConfiguration configuration, Action? configure = null) { @@ -30,6 +31,13 @@ public static IServiceCollection AddPasswordGenerator(this IServiceCollection se 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); } diff --git a/Readme.md b/README.md similarity index 67% rename from Readme.md rename to README.md index f4cb17b..ecfcdfc 100644 --- a/Readme.md +++ b/README.md @@ -1,6 +1,6 @@ # Password Generator -![Password Logo](https://github.com/prjseal/PasswordGenerator/blob/dev/v2/passwordgeneratorlogo.png "Password Logo") +![Password Logo](https://raw.githubusercontent.com/prjseal/PasswordGenerator/master/passwordgeneratorlogo.png "Password Logo") 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 @@ -17,12 +17,15 @@ Install via NuGet: ``` Install-Package PasswordGenerator ``` 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). +> **Upgrading from 2.x?** See the [v2 → v3 migration guide](https://github.com/prjseal/PasswordGenerator/blob/master/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 +> The examples below assume `using PasswordGenerator;` (and, for the dependency-injection section, +> `using Microsoft.Extensions.DependencyInjection;`). + ```csharp // By default, all character types are available and the length is 16. // Returns a random password with the default settings. @@ -80,8 +83,8 @@ 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. +[standards mapping](https://github.com/prjseal/PasswordGenerator/blob/master/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 @@ -90,7 +93,7 @@ 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 strongPhrase = 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" ``` @@ -102,13 +105,20 @@ For sites that require a digit and a symbol, pass `includeSymbol: true`. A rando to one randomly chosen word (e.g. `maple-river#-quartz-bloom-42`), so the phrase passes composition rules while staying memorable. +To omit the separator entirely, pass `separator: null` (or an empty string when binding from +configuration); the words are concatenated directly, e.g. +`Password.ForPassphrase(4, separator: null).Next()` → `"mapleriverquartzbloom42"`. This does not +change the passphrase's entropy — the separator is a fixed character and never contributes to +strength — it only affects readability. + ## 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 +// Guarantee at least N characters from a class. +// CharacterClass values: Lowercase, Uppercase, Numeric, Special. var pwd = new Password(16).RequireAtLeast(CharacterClass.Numeric, 2).Next(); // Use a custom pool, or every printable ASCII character @@ -132,10 +142,19 @@ if (new Password(16).TryNext(out var result)) ## Async and batches +`Generate(count)` returns a batch synchronously; the `Async` overloads return a `ValueTask` and honour +a `CancellationToken`. The parameterless `Generate()` returns `DefaultBatchCount` passwords (1 by +default; set the property to change it). + ```csharp -string password = await pwd.NextAsync(cancellationToken); -IReadOnlyList ten = pwd.Generate(10); -IReadOnlyList ten2 = await pwd.GenerateAsync(10, cancellationToken); +async Task ExampleAsync(CancellationToken cancellationToken) +{ + var pwd = new Password(16); + + string password = await pwd.NextAsync(cancellationToken); + IReadOnlyList ten = pwd.Generate(10); + IReadOnlyList ten2 = await pwd.GenerateAsync(10, cancellationToken); +} ``` ## Dependency injection @@ -164,17 +183,35 @@ services.AddPasswordGenerator(o => o.Passphrase = new PassphraseOptions { WordCount = 6, Capitalize = true }); ``` +You can also bind from `appSettings.json` (code configuration still takes precedence over bound +values, which in turn take precedence over the defaults): + +```jsonc +{ + "PasswordGenerator": { + "Length": 20, + "IncludeSpecial": true, + "ExcludeAmbiguous": true, + "DefaultBatchCount": 5 + } +} +``` + +```csharp +services.AddPasswordGenerator(config.GetSection("PasswordGenerator")); +``` + ## Documentation -- [v2 → v3 migration guide](docs/migration-v2-to-v3.md) -- [Changelog](CHANGELOG.md) -- [Design & architecture docs](docs/README.md) +- [v2 → v3 migration guide](https://github.com/prjseal/PasswordGenerator/blob/master/docs/migration-v2-to-v3.md) +- [Changelog](https://github.com/prjseal/PasswordGenerator/blob/master/CHANGELOG.md) +- [Design & architecture docs](https://github.com/prjseal/PasswordGenerator/blob/master/docs/README.md) ## License & attribution -PasswordGenerator is licensed under the [MIT License](License.md). +PasswordGenerator is licensed under the [MIT License](https://github.com/prjseal/PasswordGenerator/blob/master/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. +license. See [THIRD-PARTY-NOTICES.md](https://github.com/prjseal/PasswordGenerator/blob/master/THIRD-PARTY-NOTICES.md) for details. diff --git a/docs/api-surface.md b/docs/api-surface.md index 8a47a15..c6ba3e2 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -73,6 +73,11 @@ Passphrases are built from the **EFF Large Wordlist** (7,776 words, ~12.9 bits/w - `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. + The derivation is exposed directly as the static `PassphraseGenerator.WordCountForEntropy(targetBits, + includeNumber)` if you want the word count without building a generator. +- The separator is a `char?` — pass `separator: null` (or an empty string when binding from + configuration) to concatenate the words with no separator. This does not change entropy; the + separator is a fixed character and never contributes to strength. - `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. diff --git a/docs/configuration-and-di.md b/docs/configuration-and-di.md index 31ed2fc..7842f58 100644 --- a/docs/configuration-and-di.md +++ b/docs/configuration-and-di.md @@ -32,6 +32,26 @@ applies. `appSettings` binding is an **opt-in, separate step** — it is never a } ``` +## Example `appSettings.json` (passphrase) + +Setting a `Passphrase` section makes the registered generator produce passphrases instead of +character passwords (the character-pool options above are then ignored). An empty `Separator` +(`""`) means *no separator* — the configuration binder skips empty values, so the DI registration +maps an explicit empty string to `null` for you. + +```jsonc +{ + "PasswordGenerator": { + "Passphrase": { + "WordCount": 6, + "Separator": "", + "Capitalize": true, + "IncludeNumber": true + } + } +} +``` + ## DI registration (opt-in, not auto-registered on install) ```mermaid