How to Hash Data with One API across Algorithms

Hash algorithms arrive from different places. Some are in the .NET base library, others come from third-party packages, and each has its own class, lifetime rules and way of hashing in pieces. Code that must support several algorithms, or let a file format name the one it uses, ends up with a switch statement and a set of adapters.

Sphere10 Framework puts every algorithm behind one static class and one enumeration. Hashers hashes with the algorithm named by a CHF value, pools the underlying hasher objects and is safe to call from several threads. This article covers the one-call methods, incremental hashing, combining digests, hashing serialized objects, and how further algorithms are registered.

Different hash algorithms selected through a single numbered registry.
Different hash algorithms selected through a single numbered registry.
💡 In short. Hash bytes, incremental input and serialized objects through Hashers and the CHF enumeration, and register further algorithms with the CryptoEx module.

1. Hash in one call

Hashers.Hash takes the algorithm and the bytes and returns a new digest array.


var sha256 = Hashers.Hash(CHF.SHA2_256, data);

A second overload writes into a buffer supplied by the caller, which avoids the allocation. GetDigestSizeBytes gives the length to reserve.


Span<byte> buffer = stackalloc byte[Hashers.GetDigestSizeBytes(CHF.SHA2_512)];
Hashers.Hash(CHF.SHA2_512, data, buffer);

Each call borrows a hasher from the pool for that algorithm and returns it afterwards, so no hasher object is created per call once the pool is warm.

2. How the registry is organised

CHF is an enumeration with one value per algorithm, such as CHF.SHA2_256 or CHF.SHA3_256. Hashers holds a registration for each value that has an implementation, and a pool of reusable IHashFunction instances behind each registration.

The core package registers a small set. The Sphere10.Framework.CryptoEx package registers the rest when its module is initialised.

Because the value is an ordinary enumeration member, it can be stored in a file header or a configuration setting, and the code that hashes does not change when the value does.

Diagram source
block-beta
    columns 2
    A["Hashers.Hash(CHF, bytes)"]:2
    B["CHF registry and hasher pools"]:2
    C["Core package"] D["CryptoEx module"]
    E["SHA-2, SHA-1, Blake2b"] F["SHA-3, RIPEMD, others"]

3. Hash in pieces

When the input arrives in parts, borrow a hasher for the duration of the work. EnterBorrowHasherScope returns a scope and hands out an IHashFunction through its out parameter.


byte[] incremental;
using (Hashers.EnterBorrowHasherScope(CHF.SHA2_256, out var hasher)) {
	hasher.Transform(data.AsSpan(0, 10));
	hasher.Transform(data.AsSpan(10));
	incremental = hasher.GetResult();
}

Transform adds bytes to the running state and GetResult finishes the digest. Disposing the scope returns the hasher to its pool.

Two rules follow from the pooling. Dispose the scope exactly once, with a plain using as above, and do not use the hasher after the scope ends. A borrowed IHashFunction holds state between Transform and GetResult, so it must stay on one thread.

Diagram source
---
config:
  sequence:
    actorMargin: 8
    width: 96
    mirrorActors: false
    messageMargin: 28
    noteMargin: 8
---
sequenceDiagram
    participant C as Caller
    participant H as Hashers
    participant F as IHashFunction
    C->>H: EnterBorrowHasherScope
    H-->>C: scope, hasher
    C->>F: Transform(part)
    C->>F: Transform(part)
    C->>F: GetResult()
    C->>H: scope.Dispose()
    Note over H: back in pool

4. Combine and chain digests

Three helpers cover the common ways digests are combined.


var joined = Hashers.JoinHash(CHF.SHA2_256, sha256, incremental);
var twice = Hashers.Iterate(CHF.SHA2_256, data, 2);

  • JoinHash hashes the concatenation of its inputs. It is the node operation of a Merkle tree.
  • Iterate applies the hash to its own output the given number of times.
  • Aggregate folds a sequence of digests into one by repeated JoinHash.

5. Register further algorithms

A CHF value with no registration fails at first use with a "No implementation" error. The core package registers SHA2-256, SHA2-384, SHA2-512, SHA1-160 and the Blake2b sizes. The other values need the CryptoEx module.


Sphere10.Framework.CryptoEx.ModuleConfiguration.InitializeInternal();
foreach (var algorithm in new[] { CHF.SHA3_256, CHF.Keccak_256, CHF.RIPEMD_160 }) {
	var digest = Hashers.Hash(algorithm, data);
	Require(digest.Length == Hashers.GetDigestSizeBytes(algorithm), "Digest length differs from the registered size.");
	Console.WriteLine($"{algorithm}: {digest.ToHexString(true)}");
}

InitializeInternal() is the direct call for a program that has no application host. An application built on Sphere10.Framework.Application adds the module at startup with AddModule<Sphere10.Framework.CryptoEx.ModuleConfiguration>() instead. The module covers SHA-3, Keccak, RIPEMD, Blake2s, Whirlpool, Tiger and the other CHF values, mostly through the bundled HashLib4CSharp library.

Hashers.Register(CHF, Func<IHashFunction>) is the same mechanism the module uses. Registering replaces the pool for that value, so do it once during startup and not while other threads are hashing.

6. Hash a serialized object

An object is hashed through its serializer. Hashers.Hash has an overload that serializes the item and hashes the bytes in one step, and ItemDigestor<TItem> packages the same operation as an IItemHasher<TItem> for collections and Merkle trees that hash their items.


var serializer = SerializerBuilder.FactoryAssemble<Record>();
var record = new Record { Id = 1, Name = "first" };
var itemDigest = Hashers.Hash(CHF.SHA2_256, record, serializer);
var digestor = new ItemDigestor<Record>(CHF.SHA2_256, serializer);

The digest of an object is therefore only as stable as its serializer. Two programs agree on an object's digest when they use the same serializer layout and byte order.

7. Limits to plan for

⚠️ Keep these limits in mind.
  • Registration is process-wide. Hashers is static, so a registration made anywhere affects all callers.
  • Not every value is safe for every purpose. MD2, MD4, MD5, SHA-0 and SHA-1 are broken for collision resistance. Use SHA-2, SHA-3 or Blake2 for commitments and signatures.
  • The API is synchronous. There are no asynchronous Hashers methods.
  • Some modes have no CHF value. Extendable-output functions, keyed MACs and password hashes such as Argon2 are used through HashLib4CSharp directly.

8. Get the package and run the sample

Hashers and CHF are in the Sphere10.Framework NuGet package. Add Sphere10.Framework.CryptoEx for the algorithms beyond the core set. The sample targets .NET 10.


dotnet new console --framework net10.0 --name ArticleSample
cd ArticleSample
dotnet add package Sphere10.Framework --version 3.1.3
dotnet add package Sphere10.Framework.CryptoEx --version 3.1.3

The complete MIT-licensed sample contains the program shown above. Extract the download and run it.


dotnet run -c Release

The program checks SHA2-256 against its published test vector, compares the incremental and one-call digests, and hashes with three module-registered algorithms.


SHA2_256: d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592
Incremental digest equals one-call digest: passed
SHA3_256 before the CryptoEx module: not registered
SHA3_256: 69070dda01975c8c120c3aada1b282394e7f032fa9cf32f4cb2259a0897dfc04
Object digest equals digest of its serialized bytes: passed

The Cryptography guide documents Hashers and CHF, and the CryptoEx guide lists the algorithms its module registers.


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

The Sphere10 Framework overview describes the rest of the toolkit.

Author: Herman Schoenfeld