SaddamHossain.Toolkit.Security 1.0.0

dotnet add package SaddamHossain.Toolkit.Security --version 1.0.0
                    
NuGet\Install-Package SaddamHossain.Toolkit.Security -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SaddamHossain.Toolkit.Security" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SaddamHossain.Toolkit.Security" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="SaddamHossain.Toolkit.Security" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SaddamHossain.Toolkit.Security --version 1.0.0
                    
#r "nuget: SaddamHossain.Toolkit.Security, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SaddamHossain.Toolkit.Security@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SaddamHossain.Toolkit.Security&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=SaddamHossain.Toolkit.Security&version=1.0.0
                    
Install as a Cake Tool

SaddamHossain.Toolkit.Security

NuGet Downloads Build CodeQL License: MIT .NET

Secure passwords, tokens, hashes and randomness for modern .NET — dependency-free, and built entirely on RandomNumberGenerator.

Version 1.0.0 — 12 public types and 68 public members across password generation, password strength analysis, hashing, token generation and random utilities. Multi-targets net8.0, net9.0 and net10.0. Trim- and Native AOT-safe. See the CHANGELOG.


Contents

Overview · Features · Installation · Quick start · Password generation · Password strength · Hashing · Token generation · Random utilities · Examples · Performance · Architecture · Roadmap · Contributing · Security · Versioning · License


Overview

Every application eventually needs the same handful of security primitives: generate a password, score the one the user chose, hash a value, mint an API key, produce a reset token. They get written in a hurry, they get copied between projects, and a surprising number of them reach for System.Random — which is a deterministic algorithm whose entire output sequence can be reconstructed from a handful of observed values.

SaddamHossain.Toolkit.Security is that layer, done once and done properly. Every random value in this package comes from System.Security.Cryptography.RandomNumberGenerator. System.Random is not used anywhere, and CA5394 is promoted to a build error so it cannot be introduced later.

The package is also honest about its limits. Where a technique is not good enough — where a strength score cannot see a dictionary word, where MD5 exists only for interoperability, where a hash is not a password hash — the documentation says so on the API itself, in the tooltip you see while writing the call, rather than in a document nobody opens.

Features

  • Cryptographically secure by construction. Everything routes through one internal type that wraps RandomNumberGenerator. An audit of this package's randomness is an audit of one file.
  • Unbiased selection. Characters are drawn with RandomNumberGenerator.GetItems, which rejection-samples in the framework. The obvious randomByte % alphabet.Length favours the start of the alphabet for every length that does not divide 256 — invisibly, and in every test that does not specifically look for it. The suite has a test that does.
  • Zero dependencies. The package references no other NuGet package, and a test pins that by inspecting the shipped assembly.
  • Multi-targeted for net8.0, net9.0 and net10.0 — with a single implementation compiled identically on all three. There is no #if anywhere in the library.
  • Trim- and AOT-safe. Marked IsAotCompatible. No reflection, no dynamic, no unsafe, no regular expressions.
  • Thread-safe. Every public member is a pure function over its arguments with no shared mutable state — pinned by a test that rejects any mutable static field.
  • Allocation-conscious. stackalloc below 256 units, ArrayPool above it, and every pooled buffer that held a secret is returned cleared.
  • Guards that throw, never correct. A zero length, an inverted range, a short JWT secret — all raise. A silently corrected security value looks fine and is not what you asked for.
  • Fully documented. Every public member ships XML documentation with parameters, return values, thrown exceptions, a worked example, and the reasoning behind the choice where one was made.
  • Source Link + symbols. Step straight into the source from your debugger.

Installation

dotnet add package SaddamHossain.Toolkit.Security

Or via the Package Manager Console:

Install-Package SaddamHossain.Toolkit.Security

Quick Start

Every public type lives in a single namespace, so one using makes the whole library discoverable through IntelliSense:

using SaddamHossain.Toolkit.Security;
// Passwords
string password = PasswordGenerator.Generate();          // "kQ7mZp2XvB9nRt4W"
string strong   = PasswordGenerator.GenerateStrong();    // "G0GV[nMf+3j6BKO6125q"

// Strength
PasswordStrengthResult result = PasswordStrength.Calculate(userInput);
if (result.Level < PasswordStrengthLevel.Strong)
{
    return BadRequest(result.Suggestions);
}

// Hashing
string digest = HashGenerator.Sha256("hello");
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"

// Tokens
string apiKey = TokenGenerator.Generate();               // 256 bits, URL-safe
string secret = TokenGenerator.JwtSecret();              // 256-bit HMAC key

// Randomness
int    dieRoll = RandomNumber.Generate(1, 7);
byte[] salt    = RandomBytes.Generate(16);
Guid   id      = RandomGuid.Generate();

The flat namespace is deliberate. Splitting it by feature would force you to import five namespaces to reach one package — the same reasoning behind System.Linq and the Microsoft.Extensions.* packages. The library is organised into folders internally; folders are for source, not for consumers.

Password Generation

Method Default Alphabet
Generate() / Generate(int) 16 chars, ~95 bits 62 alphanumeric
Generate(int, bool, bool, bool, bool) your choice of the four sets
GenerateStrong() / GenerateStrong(int) 20 chars, ~131 bits 92, all four sets
GenerateReadable() / GenerateReadable(int) 16 chars, ~93 bits 56 — no 0 O o 1 l I
GenerateNumeric() / GenerateNumeric(int) 6 digits 10
Generate(PasswordOptions) fully configurable
PasswordGenerator.Generate();                          // "SPQxQK7Ku6WLiam1"
PasswordGenerator.Generate(24);                        // "KiYwMPWXaaxnJ66WqNcWhNlT"
PasswordGenerator.Generate(20, true, true, true, true);// "tim,UkWN.n97/Nb0goOv"
PasswordGenerator.GenerateStrong();                    // "G0GV[nMf+3j6BKO6125q"
PasswordGenerator.GenerateReadable();                  // "PsFWisjHbJdneA76"
PasswordGenerator.GenerateNumeric();                   // "900166" — may start with 0

Three guarantees hold across every generator.

Every enabled character set appears at least once, and then the result is shuffled. The guarantee is what makes generated passwords pass "must contain a digit and an uppercase letter" validators every time — without it, a 16-character draw from a 62-character set omits digits entirely about one time in fourteen, which is rare enough to pass testing and common enough to generate support tickets forever. The shuffle is what makes the guarantee invisible: without it, position 0 would always hold an uppercase letter.

The symbol set excludes space, backslash and double quote. Those three are the characters most often trimmed by a login form or mangled by a shell, a connection string, a JSON blob or a CSV export on the way to the authentication system. Losing two characters costs about 0.09 bits each; a password that cannot be typed back in costs the whole account.

Invalid arguments throw. Length 0, length above 4 096, a length too small to hold one character from each enabled set, or every set disabled — each raises with a message naming the argument you wrote, not the internal options object it was funnelled through.

PasswordOptions

For policies the boolean overload cannot express:

string password = PasswordGenerator.Generate(new PasswordOptions
{
    Length = 24,
    IncludeUppercase = true,
    IncludeLowercase = true,
    IncludeNumbers = true,
    IncludeSymbols = false,
    ExcludeAmbiguous = true,        // drop 0 O o 1 l I
    RequireEachSelectedSet = true,  // one of each, guaranteed
});

ExcludeAmbiguous costs about 0.15 bits per character — for 16 characters, roughly one character's worth of length. Worth it whenever a human transcribes the password from a screen, a printed sheet or a phone call.

Password Strength

PasswordStrengthResult result = PasswordStrength.Calculate("Tr0ub4dor&3");

result.Score;        // 56       (0–100)
result.Level;        // Medium
result.Entropy;      // 72.27    (bits)
result.Suggestions;  // ["Use at least 12 characters. …"]
result.ToString();   // "Medium (score 56, 72.3 bits)"

Calculate returns an object rather than an int on purpose. A bare number tells a user their password is weak; it does not tell them what to do about it, and it leaves the API with nowhere to grow. Adding a property to a sealed class in a future minor release is source- and binary-compatible; changing a return type is not.

Level Score Approx. entropy Meaning
VeryWeak 0–19 < 26 bits Falls to an offline attack in seconds
Weak 20–39 26–51 bits Falls to a determined offline attack
Medium 40–59 51–77 bits Adequate behind rate limiting, not behind a leaked hash
Strong 60–79 77–102 bits Beyond practical offline attack today
VeryStrong 80–100 > 102 bits Beyond foreseeable offline attack
PasswordStrength.GetScore("Tr0ub4dor&3");   // 56
PasswordStrength.GetLevel("password");      // VeryWeak
PasswordStrength.IsStrong(candidate);       // Strong or VeryStrong
PasswordStrength.IsWeak(candidate);         // Weak or VeryWeak

IsStrong is not the negation of IsWeak. A Medium password is neither, and collapsing the two would erase the middle of the scale — which is precisely where a policy decision is interesting.

How the score is computed

The base figure is search-space entropy, length × log2(alphabet size), with three corrections for the structures that make that formula lie:

  • An alphabet cap for repetition. A thousand repeated as is the case a multiplicative penalty gets catastrophically wrong — half of an enormous number is still an enormous number, so such a penalty scores it 100. The estimate is instead capped at what the real attack costs: length × log2(distinct) to choose the arrangement, plus distinct × log2(pool) to choose which characters. That gives 4.7 bits, and a score of 0. For a well-formed password the cap sits far above the raw figure and never binds.
  • Deductions for structure — repeated runs, alphabetic and numeric sequences, and QWERTY keyboard walks. 10 bits per kind of pattern, not per occurrence: counting occurrences would rate a long password with a few incidental runs below a short one with none.
  • A ceiling for common passwords. "P@ssw0rd" uses four character classes and eight characters, which the raw formula calls 52 bits. It is also among the first guesses ever tried, so membership of the embedded list caps it at 4.

What it cannot see

This is not zxcvbn. There is no dictionary of English words, no leetspeak normalisation and no multi-megabyte corpus, because none of those fit in a dependency-free package. The consequence is stated rather than hidden:

PasswordStrength.GetLevel("correct horse battery staple");  // VeryStrong

That passphrase really does have 165 bits by the character-space formula, and really does fall to a dictionary attack in about 44. Nothing here calls the network either — the right way to check against the real breach corpus is the Have I Been Pwned range API, and a security library that makes an HTTP request the caller did not ask for is a security problem of its own.

And a score is never a substitute for storage: only a memory-hard password hash — Argon2id, scrypt, or PBKDF2 via Rfc2898DeriveBytes — makes a password safe to keep.

Hashing

HashGenerator.Sha256("hello");
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"

HashGenerator.Sha384("hello");   //  96 hex characters
HashGenerator.Sha512("hello");   // 128 hex characters
HashGenerator.Md5("hello");      //  32 — compatibility only
HashGenerator.Sha1("hello");     //  40 — compatibility only

Input is encoded as UTF-8; output is lowercase hexadecimal. Both halves matter. A hash is a function of bytes, so the encoding is part of the answer — the same string hashed as UTF-8 and as UTF-16 gives two completely different digests. And lowercase is what sha256sum, git, OpenSSL, Python's hexdigest() and Node's crypto all emit, so a digest from this package compares equal to theirs with no normalisation step that somebody will eventually forget.

MD5 and SHA-1 are for compatibility only. They are not recommended for security-sensitive applications. Both are cryptographically broken — practical MD5 collisions have existed since 2004, and SHA-1 fell to SHAttered in 2017. They ship because interoperability is a real requirement: S3 ETags, Gravatar identifiers, git object ids, the HIBP range API. Each carries this warning in its <summary>, so it appears in IntelliSense at the call site.

These are not password hashes. SHA-2 is designed to be fast — a modern GPU computes billions of SHA-256 operations per second — which is exactly the wrong property for storing a credential. Use Argon2id, scrypt, or PBKDF2 with a high iteration count via Rfc2898DeriveBytes.

Token Generation

TokenGenerator.Generate();     // "Vulm_IkYOP7B564YQ5hTcoRd2ndCkq1tPwuMQatLVzg"  (43 chars)
TokenGenerator.UrlSafe();      // same encoding, named for the intent
TokenGenerator.Hex();          // "a873f5cbd9c60ed4…"                            (64 chars)
TokenGenerator.Base64();       // "Rw09tjlqNixsa4IYg2z80DnKGskGR1GpNrVonhgkrEw=" (44 chars)
TokenGenerator.Guid();         // "25c97c9904992a4fafb46ef1e43f159e"             (32 chars)
TokenGenerator.JwtSecret();    // 256-bit HMAC signing key, base64
Method Default entropy Encoding Use for
Generate() / UrlSafe() 256 bits base64url, unpadded URLs, headers, cookies, filenames
Hex() 256 bits lowercase hex logs, case-insensitive comparison, hex-only columns
Base64() 256 bits base64, padded protocols that specify it
Guid() 122 bits 32 hex chars, "N" correlation ids, external systems expecting a GUID
JwtSecret() 256 bits minimum base64, padded HMAC signing keys

Every default carries 256 bits, matching SHA-256 and AES-256 so a token is never the weakest link in the protocol using it. All four encodings wrap the same draw, so choose by where the token has to survive, not by how much entropy you want.

The URL-safe form is unpadded deliberately. = becomes %3D the moment a token enters a query string, so a padded token does not come back the way it left. JWT (RFC 7515 §2) omits it for the same reason.

JwtSecret enforces a 256-bit floor. RFC 7518 §3.2 requires an HMAC key to be at least as long as the hash output, so JwtSecret(16) throws rather than silently producing a weak key. Use 48 bytes for HS384 and 64 for HS512.

A token is a bearer credential: transmit over TLS, store hashed where the threat model allows, give it an expiry, and compare with CryptographicOperations.FixedTimeEquals rather than == on a server an attacker can time.

Random Utilities

// Strings
RandomString.Generate();              // 32 alphanumeric characters
RandomString.GenerateAlpha(12);       // letters only — valid where a leading digit is illegal
RandomString.GenerateAlphaNumeric();  // same alphabet as Generate(), named explicitly
RandomString.GenerateNumeric(10);     // "0074219358" — leading zeros preserved

// Numbers
RandomNumber.Generate();              // [0, int.MaxValue)  — non-negative
RandomNumber.Generate(1, 7);          // [1, 7)             — a fair die
RandomNumber.GenerateInt();           // full 32-bit range, negatives included
RandomNumber.GenerateLong();          // full 64-bit range
RandomNumber.GenerateDouble();        // [0, 1) — 1.0 is never returned

// Bytes, GUIDs, colours
RandomBytes.Generate();               // 32 bytes
RandomBytes.Generate(16);             // a salt
RandomGuid.Generate();                // version 4, all 16 bytes from the CSPRNG
RandomColor.GenerateHex();            // "#A1B2C3"
RandomColor.GenerateRgb();            // "rgb(161, 178, 195)"

A few decisions worth knowing about:

  • Generate() is non-negative; GenerateInt() is not. A value used as a count, an index or an identifier is almost never meant to be negative, so the short name is the safe one and the full signed range is opt-in.
  • The range is half-open, matching Random.Next and RandomNumberGenerator.GetInt32, which is what makes Generate(0, array.Length) correct without an off-by-one adjustment. An inverted range throws rather than being silently swapped — swapping turns a transposed pair of arguments into a plausible-looking wrong answer.
  • GenerateDouble() never returns 1.0. It is built from 53 random bits, exactly the significand width of an IEEE-754 double, which makes every representable value in [0, 1) equally likely and (int)(GenerateDouble() * count) always a valid index.
  • RandomBytes.Generate(0) throws. A caller asking for zero random bytes has computed a length from something that should not have been empty, and a silently empty salt survives every test until production.
  • RandomGuid fills all 16 bytes explicitly. Guid.NewGuid() guarantees uniqueness, not unpredictability — the randomness quality is a platform implementation detail. When a GUID is a capability (a share link, an unauthenticated download URL), predictability is the security model and deserves a guarantee. Version 4 rather than 7 on purpose: a v7 GUID embeds its creation timestamp, and a share link that discloses when it was created is leaking.
  • RandomColor is uniform over sRGB, which is the honest reading of "random colour" and is not the same as visually pleasant. For a palette, generate in HSL with fixed saturation and lightness.

Examples

Registration: generate, score, enforce

public IActionResult Register(RegisterRequest request)
{
    PasswordStrengthResult strength = PasswordStrength.Calculate(request.Password);

    if (strength.Level < PasswordStrengthLevel.Medium)
    {
        return BadRequest(new
        {
            score = strength.Score,
            level = strength.Level.ToString(),
            suggestions = strength.Suggestions,   // ordered by impact, best first
        });
    }

    // Never store the password itself — only a memory-hard derivation of it.
    byte[] salt = RandomBytes.Generate(16);
    ...
}
string token = TokenGenerator.UrlSafe();          // 256 bits, safe in a URL
string url = $"https://example.com/reset?token={token}";

await store.SaveAsync(new ResetToken
{
    // Store the hash, not the token: a leaked database should not yield working links.
    TokenHash = HashGenerator.Sha256(token),
    ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(15),
});

A one-time code

string code = PasswordGenerator.GenerateNumeric(6);   // "048291"

Six digits is about 20 bits — a million possibilities. That is right for a code delivered out of band and expiring in minutes, and wrong for anything persistent: its security comes from rate limiting and expiry, not from entropy, so a system using one must enforce both. It is returned as a string precisely so "048291" does not become 48291.

Credentials a human will read aloud

string temporary = PasswordGenerator.GenerateReadable(12);
// Never contains 0, O, o, 1, l or I.

A JWT signing key, generated once

string secret = TokenGenerator.JwtSecret();   // 256-bit minimum, base64
// Store in a key vault or an environment variable — never in source control.

var key = new SymmetricSecurityKey(Convert.FromBase64String(secret));

Content addressing and cache keys

string etag = HashGenerator.Sha256(payload);
string cacheKey = $"user:{userId}:{HashGenerator.Sha256(query)[..16]}";

Performance

Four suites, 37 benchmarks. Run them yourself:

cd benchmarks/SaddamHossain.Toolkit.Security.Benchmarks
dotnet run -c Release -f net10.0 -- --filter '*'

Measured with BenchmarkDotNet 0.15.8 on .NET 10.0.10, Intel Core i5-8500, Windows 11 x64, short-run job. Treat these as orders of magnitude, not as a specification — your hardware will differ.

Hashing (64-character input):

Operation Time Allocated
Sha256 666 ns 152 B
Sha384 680 ns 216 B
Sha512 734 ns 280 B
Md5 (legacy) 516 ns 88 B
Sha1 (legacy) 522 ns 104 B
Sha256, 1 MB input 2.66 ms 152 B

Allocation is constant in the input size — one buffer for the UTF-8 bytes and one for the returned string — which is why the 1 MB row allocates the same 152 bytes as the 64-character one.

Tokens (32 bytes / 256 bits):

Operation Time Allocated
TokenGenerator.Guid() 108 ns 88 B
TokenGenerator.JwtSecret() 155 ns 112 B
TokenGenerator.Base64() 158 ns 112 B
TokenGenerator.UrlSafe() 191 ns 112 B
TokenGenerator.Hex() 193 ns 152 B

The base64url substitution pass costs about 33 ns over standard base64 — one linear sweep over 44 characters. Worth knowing, and not worth optimising.

Randomness and passwords:

Operation Time Allocated
RandomNumber.GenerateInt() 76 ns 0 B
RandomNumber.Generate() 78 ns 0 B
RandomNumber.GenerateDouble() 80 ns 0 B
RandomColor.GenerateHex() 85 ns 40 B
RandomGuid.Generate() 94 ns 0 B
RandomBytes.Generate(32) 115 ns 56 B
RandomString.Generate(32) 225 ns 88 B
PasswordStrength.Calculate (strong) 457 ns 96 B
PasswordStrength.Calculate (weak) 787 ns 616 B
PasswordGenerator.GenerateNumeric() 803 ns 72 B
PasswordGenerator.Generate() 2.04 µs 88 B
PasswordGenerator.GenerateStrong() 2.70 µs 96 B
System.Random.Next — predictable 2.3 ns 0 B

Three things worth reading off those tables.

Cryptographic randomness costs roughly 30× what System.Random does — about 78 ns against 2.3 ns per value. The benchmark suite publishes that comparison explicitly rather than hiding it, because a reader is entitled to make an informed choice. For a token, a password, a salt or a nonce the difference is invisible and buys unpredictability. For a Monte Carlo simulation drawing billions of values it is decisive, and this package is the wrong tool — use Random.Shared there, and mean it.

Password generation costs about 9× a random string of the same length, and the shuffle is why. RandomString.Generate(32) draws its characters in one batched call; PasswordGenerator.Generate() additionally makes one draw per enabled set and then runs a Fisher–Yates shuffle, which is one GetInt32 per position. At roughly 90 ns each that dominates the method. It is not a defect and it is not going to be optimised away: the shuffle is what stops position 0 always holding an uppercase letter, and 2 µs is nothing at the frequency passwords are actually generated.

PasswordStrength.Calculate is the one path with no randomness in it, and therefore the only one where an algorithmic regression shows up as a throughput change. A VeryStrong password allocates 96 bytes — the result object and nothing else, because the suggestion list is a shared empty array. A weak one allocates 616 bytes because it actually has advice to give.

Architecture

src/SaddamHossain.Toolkit.Security/
├── Password/      PasswordGenerator, PasswordStrength
├── Hashing/       HashGenerator
├── Tokens/        TokenGenerator
├── Random/        RandomString, RandomNumber, RandomBytes, RandomGuid, RandomColor
├── Models/        PasswordStrengthResult, PasswordStrengthLevel, PasswordOptions
├── Helpers/       EntropyCalculator, HexConverter, Base64Helper          (internal)
├── Internal/      RandomProvider, SecurityGuards, PasswordComposition,
│                  PasswordPatterns, CommonPasswords                      (internal)
└── Constants/     CharacterSets, SecurityLimits                          (internal)

Public types live in the flat SaddamHossain.Toolkit.Security namespace whatever folder holds them; internal helpers use sub-namespaces, which is what makes the public surface visually obvious in a directory listing.

Three structural decisions carry most of the weight:

One point of contact with the CSPRNG. Every random bit in the package passes through RandomProvider. An audit of this package's randomness is an audit of one file, and a stray System.Random has nowhere to hide — CA5394 is a build error, so the compiler enforces what the comment asserts.

One implementation per target framework. There is no #if in the library. Where a newer API existed — Convert.ToHexStringLower and System.Buffers.Text.Base64Url, both .NET 9+ — the handful of lines were written here instead, so the .NET 8 path is the same reviewed code rather than a fallback nobody reads. It is also what makes the cross-framework tests meaningful.

Rich results, not bare primitives. PasswordStrength.Calculate returns a PasswordStrengthResult rather than an int. That is the difference between an API that can grow — a crack-time estimate, a matched dictionary word — and one that is stuck at its first guess about what callers need.

Testing

550 tests, run against all three target frameworks — 1 650 executions per build. They cover known-answer hash vectors from the published specifications, argument validation on every entry point, boundary conditions on both sides of the stack-to-pool threshold, large inputs, Unicode and normalisation forms, culture invariance under tr-TR, az-AZ and bn-BD, distribution and bias detection, token uniqueness, and concurrent access.

Testing randomness without flakiness needs care: every probabilistic assertion in the suite documents the bound it relies on, and each is chosen so that a failure is real evidence of a defect rather than bad luck — the alphabet-coverage check, for instance, has a failure probability around 10⁻³⁵⁰.

The public API surface is pinned by a test that compares the shipped assembly against an approved list. Adding, removing or re-signing any public member fails the build with a readable diff, which turns "did we just break every consumer?" from a judgement call into a visible decision.

Roadmap

Nothing below is promised, and none of it is a breaking change.

  • HMAC generationHmacGenerator.Sha256(value, key) and friends, with FixedTimeEquals comparison built in.
  • Password hashing — a thin, correctly-defaulted wrapper over PBKDF2 via Rfc2898DeriveBytes, with the salt and iteration count encoded into a single verifiable string. Deliberately not Argon2id, which cannot be done without a dependency.
  • A crack-time estimate on PasswordStrengthResult — "3 hours at 10 billion guesses per second" communicates far more than a score does.
  • An optional word list for PasswordStrength, behind a separate package so the core stays dependency-free and weightless.
  • ReadOnlySpan<byte> hashing overloads for callers who already have bytes and should not have to round-trip through a string.
  • Base32 tokens — RFC 4648 §6, for TOTP secrets and anything else read aloud.

Contributing

Issues and pull requests are welcome. See CONTRIBUTING.md for the full guide — setup, build gates, test conventions and the release process.

A pull request is expected to keep the build clean: dotnet build treats warnings as errors, dotnet format --verify-no-changes must pass, and every public member needs XML documentation. If it changes the public API, PublicApiSurfaceTests will fail — update the approved list in the same commit and say what changed in CHANGELOG.md.

Two rules are specific to this package and are not negotiable:

  1. No System.Random. CA5394 is a build error. If you need randomness, route it through RandomProvider.
  2. New uses of MD5 or SHA-1 are build errors (CA5350/CA5351). The two that exist carry a local suppression with a written justification; a third needs the same standard of argument.

Participation is governed by the Code of Conduct.

Security

Please do not report security vulnerabilities through public issues. Use GitHub Security Advisories, which is private between you and the maintainer. SECURITY.md sets out supported versions, response targets, and what is in and out of scope — the documented limits above (MD5 and SHA-1 for interop, no dictionary in the strength model, hashes that are not password hashes) are characteristics rather than vulnerabilities.

The package is analysed by CodeQL on every push and weekly on a schedule, in addition to the full CA5xxx security analyzer set failing the build locally.

License

MIT. Use it however you like.

Versioning

Semantic Versioning 2.0.0.

AssemblyVersion moves only on a major bump, so an assembly compiled against 1.0.0 keeps binding to 1.4.2 with no redirect. It is pinned by AssemblyIdentityTests so it cannot drift by accident. From 1.1.0 onward, PackageValidationBaselineVersion diffs every build against the last published package, so a breaking change fails this build rather than yours.

Change Version
New members, new types Minor
Removed or re-signed members Major
Behavioural fix, no API change Patch

Scoring thresholds are treated as behaviour: a change that moves a password across a level boundary will be called out in the CHANGELOG, because code branching on IsStrong depends on it.

NuGet https://www.nuget.org/packages/SaddamHossain.Toolkit.Security
Source https://github.com/saddamhossain/SaddamHossain.Toolkit.Security
Issues https://github.com/saddamhossain/SaddamHossain.Toolkit.Security/issues
Changelog CHANGELOG.md
Contributing CONTRIBUTING.md
Security policy SECURITY.md
Code of Conduct CODE_OF_CONDUCT.md
Companion package SaddamHossain.Toolkit.Extensions

Every document link above is absolute rather than repository-relative, because this README is also the package README rendered on nuget.org, where a relative link has no repository to resolve against.

Standards referenced

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 32 8/7/2026