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.

A password and a salt stretched into key material beside secure and seeded random generators.
A password and a salt stretched into key material beside secure and seeded random generators.
💡 In short. Derive keys from passwords with PBKDF2, choose between the SystemCRNG, HashRandom and Mersenne32 generators, store password hashes with PasswordHasher and protect one object with Encrypted<T>.

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.

Diagram source
---
config:
  packet:
    bitsPerRow: 16
    bitWidth: 22
    showBits: false
---
packet-beta
0-7: "Encryption key, 32 bytes"
8-15: "HMAC key, 32 bytes"

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.

Diagram source
---
config:
  sequence:
    actorMargin: 8
    width: 96
    mirrorActors: false
    messageMargin: 28
    noteMargin: 8
---
sequenceDiagram
    participant C as Caller
    participant E as Encrypted
    participant K as PBKDF2
    C->>E: TryDecrypt(secret)
    E->>K: DeriveKey, 64 bytes
    K-->>E: two keys
    Note over E: recompute HMAC tag
    alt tag matches
        Note over E: decrypt, deserialize
        E-->>C: true, object
    else tag differs
        E-->>C: false
    end

5. Limits

⚠️ Keep these limits in mind.
  • The envelope is the whole instance. EncryptedBytes holds only the ciphertext. The Salt, InitialCounter, AuthenticationTag and KeyDerivationIterations properties are needed to decrypt, so persist all of them together.
  • Derivation is slow on purpose. Each Encrypted.For, SetItem and TryDecrypt call runs the full derivation. Keep them out of loops.
  • PasswordHasher uses PBKDF2-SHA1 with 1,000 iterations. That keeps existing stored hashes valid. For new designs that need a stronger setting, call the byte overload of PBKDF2.DeriveKey directly and store the parameters with the hash.
  • A seeded generator is only as secret as its seed. Anyone who knows a HashRandom seed 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 --version 3.1.3

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.


sample.zip
Download the complete worked example and build instructions (ZIP).

The Sphere10 Framework overview describes the rest of the toolkit.

Author: Herman Schoenfeld