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
<PackageReference Include="SaddamHossain.Toolkit.Security" Version="1.0.0" />
<PackageVersion Include="SaddamHossain.Toolkit.Security" Version="1.0.0" />
<PackageReference Include="SaddamHossain.Toolkit.Security" />
paket add SaddamHossain.Toolkit.Security --version 1.0.0
#r "nuget: SaddamHossain.Toolkit.Security, 1.0.0"
#:package SaddamHossain.Toolkit.Security@1.0.0
#addin nuget:?package=SaddamHossain.Toolkit.Security&version=1.0.0
#tool nuget:?package=SaddamHossain.Toolkit.Security&version=1.0.0
SaddamHossain.Toolkit.Security
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.0andnet10.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 obviousrandomByte % alphabet.Lengthfavours 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.0andnet10.0— with a single implementation compiled identically on all three. There is no#ifanywhere in the library. - Trim- and AOT-safe. Marked
IsAotCompatible. No reflection, nodynamic, nounsafe, 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.
stackallocbelow 256 units,ArrayPoolabove 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, plusdistinct × 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.NextandRandomNumberGenerator.GetInt32, which is what makesGenerate(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.RandomGuidfills 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.RandomColoris 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);
...
}
A password-reset link that expires
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 generation —
HmacGenerator.Sha256(value, key)and friends, withFixedTimeEqualscomparison 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:
- No
System.Random. CA5394 is a build error. If you need randomness, route it throughRandomProvider. - 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
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.
Links
| 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
- RFC 4648 — base64 and base64url encoding
- RFC 7515 / RFC 7518 — JWS and JWA, including the 256-bit HMAC key minimum
- RFC 9562 — UUID version and variant layout
- NIST SP 800-63B — digital identity, authenticator guidance
| Product | Versions 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. |
-
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 |