How to Derive Keys from Passwords and Generate Random Values in C#
A password is not a key. It is short, guessable and the wrong length for a cipher, so it has to be stretched into key material with a salt and a deliberately slow function. The salts, nonces and seeds around it must come from a random source that suits their purpose, which is sometimes unpredictable and sometimes repeatable.
Sphere10 Framework puts these pieces behind a few small types. This article covers PBKDF2 for key derivation, the IRandomNumberGenerator implementations for secure, seeded and repeatable randomness, PasswordHasher for stored password hashes, and Encrypted<T>, which combines derivation, encryption and authentication for one stored object.
1. Derive a key with PBKDF2
PBKDF2.DeriveKey takes the secret, a salt, an iteration count, the number of key bytes wanted and the hash algorithm of the underlying HMAC. The same inputs always produce the same key, and a different salt produces an unrelated key.
var secret = Encoding.UTF8.GetBytes("correct horse battery staple");
var salt = Tools.Crypto.GenerateCryptographicallyRandomBytes(16);
var key = PBKDF2.DeriveKey(secret, salt, Iterations, 32, HashAlgorithmName.SHA256);
var again = PBKDF2.DeriveKey(secret, salt, Iterations, 32, HashAlgorithmName.SHA256);
var otherSalt = Tools.Crypto.GenerateCryptographicallyRandomBytes(16);
var otherKey = PBKDF2.DeriveKey(secret, otherSalt, Iterations, 32, HashAlgorithmName.SHA256);
Three choices matter.
-
Salt. Generate a new random salt for every secret and store it beside the result. It is not secret. Its job is to make each derivation unique.
-
Iterations. The count sets how much work each guess costs an attacker. The sample uses 600,000, which is also the default of
Encrypted<T>. Store the count with the salt so that it can be raised later. -
Key length. Ask for as many bytes as the consumer needs. One derivation can supply several keys by asking for a longer output and splitting it.
Compare derived keys and other secret values with Tools.Crypto.FixedTimeEquals, which takes the same time whether the first or the last byte differs.
Require(Tools.Crypto.FixedTimeEquals(key, again), "Same secret and salt gave a different key.");
Require(!Tools.Crypto.FixedTimeEquals(key, otherKey), "A different salt gave the same key.");
PBKDF2 has a second overload that takes the secret as a string and has no hash parameter. It uses SHA-1 so that data written by earlier versions stays readable. Use the byte overload with an explicit algorithm for anything new.
2. Choose a random number generator
Every generator implements IRandomNumberGenerator, whose single member fills a span with bytes. Extension methods add NextBytes(length), NextByte, NextUInt16, NextUInt32 and NextFloat. The generators differ in where their bytes come from.
| Generator | Source | Repeatable | Suitable for secrets |
SystemCRNG |
Operating system | No | Yes |
HashRandom |
Hash of a seed and a counter | Yes | Only if the seed is secret |
Mersenne32 |
Mersenne Twister | Yes | No |
SystemCRNG is the default choice for salts, nonces and keys. Tools.Crypto.GenerateCryptographicallyRandomBytes is the one-call form of the same source.
var system = new SystemCRNG();
var nonce = system.NextBytes(12);
HashRandom is deterministic. It hashes its seed with a counter and hands out the digest bytes, hashing again each time a digest is used up, so two instances with the same seed produce the same sequence. That makes it useful for reproducible tests and for deriving a long stream from one shared secret. The seed must be at least 16 bytes, and the default hash is SHA2-256.
var seed = Hashers.Hash(CHF.SHA2_256, Encoding.UTF8.GetBytes("sample seed"));
var first = new HashRandom(seed);
var second = new HashRandom(seed);
var firstBytes = first.NextBytes(64);
var secondBytes = second.NextBytes(64);
Mersenne32 takes a uint seed and is fast and repeatable, but its output can be predicted from earlier output. Keep it for simulations and test data.
HashRandom, Mersenne32 and the IRandomNumberGenerator interface are in the Sphere10.Framework.Maths namespace, while SystemCRNG is in Sphere10.Framework.
3. Store a password hash
PasswordHasher.CreateHash produces a self-contained string for a credentials table, and ValidatePassword checks a candidate against it with a constant-time comparison.
var stored = PasswordHasher.CreateHash("correct horse battery staple");
Require(PasswordHasher.ValidatePassword("correct horse battery staple", stored), "Correct password was rejected.");
Require(!PasswordHasher.ValidatePassword("wrong password", stored), "Wrong password was accepted.");
The string has three colon-separated fields, which are the iteration count, the Base64 salt and the Base64 hash. Because the count is stored in each hash, existing hashes keep validating if the constant changes.
4. Encrypt an object under a password
Encrypted<T> applies the whole pattern to one serialized object. Encrypted.For generates a salt and a counter, derives 64 bytes with PBKDF2-SHA256 and splits them into two independent keys.
The first half encrypts the serialized object. The second half computes an HMAC-SHA256 tag over the iteration count, salt, counter and ciphertext.
var secret = Encoding.UTF8.GetBytes("correct horse battery staple");
var encrypted = Encrypted.For("stored value", secret);
Require(encrypted.TryDecrypt(secret, out var value) && value == "stored value", "Correct secret failed to decrypt.");
Require(!encrypted.TryDecrypt(Encoding.UTF8.GetBytes("wrong password"), out _), "Wrong secret decrypted.");
TryDecrypt derives the keys again and checks the tag before it decrypts anything, so a wrong secret and a modified ciphertext are both rejected without deserializing untrusted bytes.
5. Limits
-
The envelope is the whole instance.
EncryptedBytesholds only the ciphertext. TheSalt,InitialCounter,AuthenticationTagandKeyDerivationIterationsproperties are needed to decrypt, so persist all of them together. -
Derivation is slow on purpose. Each
Encrypted.For,SetItemandTryDecryptcall runs the full derivation. Keep them out of loops. -
PasswordHasheruses PBKDF2-SHA1 with 1,000 iterations. That keeps existing stored hashes valid. For new designs that need a stronger setting, call the byte overload ofPBKDF2.DeriveKeydirectly and store the parameters with the hash. -
A seeded generator is only as secret as its seed. Anyone who knows a
HashRandomseed can reproduce its output.
6. Get the package and run the sample
Everything in this article ships in the core Sphere10.Framework package. The sample targets .NET 10.
dotnet new console --framework net10.0 --name ArticleSample
cd ArticleSample
dotnet add package Sphere10.Framework
The complete MIT-licensed sample contains the program shown above. Extract the download over the generated project and run it.
dotnet run -c Release
Each line reports a group of checks that passed. A failed check throws.
Derived key: 32 bytes, repeatable with the same salt, different with a new salt
HashRandom and Mersenne32 repeat their sequence for the same seed
Password hash fields: 3 (iterations, salt, hash)
Encrypted<T> decrypts with the right secret and refuses the wrong one
The Cryptography guide lists every type in the domain, including the hash and signature registries.
The Sphere10 Framework overview describes the rest of the toolkit.
Author: Herman Schoenfeld