Hashes, Signatures and Merkle Proofs: What Does Each Actually Prove?
Hashes, digital signatures and Merkle proofs are often used together, and it is easy to credit one of them with a guarantee that another provides. A matching hash says nothing about who produced the data. A valid signature says nothing about whether a record is part of a larger set. A valid Merkle proof says nothing about who published the root.
This article states what each check establishes, what it leaves open, and which Sphere10 Framework API performs it. The last section combines the three into one chain of trust.
1. A hash binds bytes to a digest
A cryptographic hash maps any byte sequence to a short digest. Comparing digests tells you whether two byte sequences are the same.
var expectedDigest = Hashers.Hash(Algorithm, records[1]);
Require(Hashers.Hash(Algorithm, records[1]).SequenceEqual(expectedDigest), "Unchanged record failed its digest.");
Require(!Hashers.Hash(Algorithm, changedRecord).SequenceEqual(expectedDigest), "Changed record matched the digest.");
Hashers is the Framework's registry of hash functions, and a CHF value selects the algorithm. The sample's Algorithm constant is CHF.SHA2_256.
It proves that the bytes you hold are the bytes the expected digest was computed from.
It does not prove where the expected digest came from. Anyone can compute a hash, so someone who can replace the data can replace a digest delivered alongside it. The check is only as good as the channel that supplied the expected value.
2. A signature binds bytes to a key
A digital signature is computed from a message and a private key, and is verified with the matching public key.
var signer = new ECDSA(ECDSAKeyType.SECP256K1);
var privateKey = signer.GeneratePrivateKey();
var publicKey = signer.DerivePublicKey(privateKey);
var signature = signer.Sign(privateKey, manifest);
Require(signer.Verify(signature, manifest, publicKey), "Genuine manifest signature failed.");
ECDSA is in the Sphere10.Framework.CryptoEx package. Sign and Verify take the message bytes and hash them internally, and SignDigest and VerifyDigest accept a digest you computed yourself. The same schemes are available by identifier through the Signers registry once the CryptoEx module has been registered.
It proves that the holder of the private key signed exactly these bytes. Changing one byte of the message, or verifying with a different public key, fails.
It does not prove who that key holder is, or that the message is current. Mapping a public key to an identity and rejecting replayed old messages are decisions the application makes.
3. A Merkle proof binds a record to a root
A Merkle tree hashes each record into a leaf and hashes pairs of nodes upward into a single root. An inclusion proof is the list of sibling hashes between one leaf and the root.
var leafHashes = records.Select(record => Hashers.Hash(Algorithm, record)).ToArray();
var tree = new FlatMerkleTree(Algorithm);
tree.Leafs.AddRange(leafHashes);
var root = (byte[])tree.Root.Clone();
var proof = tree.GenerateExistenceProof(1).ToArray();
Require(MerkleMath.VerifyExistenceProof(Algorithm, root, tree.Size, 1, leafHashes[1], proof), "Genuine record failed inclusion.");
FlatMerkleTree is the Framework's in-memory tree. GenerateExistenceProof returns the sibling hashes for a leaf index, and MerkleMath.VerifyExistenceProof checks them using only the root, the tree size, the index and the leaf hash.
For leaf 1 of the sample's four leaves, the proof holds two hashes. The verifier recomputes the two nodes on the path from the leaf to the root.
It proves that a record with this hash sits at this position in the dataset that the root commits to. A changed record or a different position fails.
It does not prove that the root is genuine or current. A proof verified against a root chosen by the sender proves nothing.
The sample uses a four-leaf tree held in memory. The inclusion proof article uses the Framework's persistent merkleized list on a larger dataset.
4. Combine the checks and reject changed context
Each check covers the gap left by the one below it. The usual construction signs a small manifest that contains the Merkle root, so one signature authenticates every record in the set.
The manifest should state what the root means as well as its value. The sample signs a format label, a revision number and the root together.
private static byte[] Manifest(byte[] root, int revision)
=> Encoding.UTF8.GetBytes($"sample-set-v1|revision={revision}|sha256-root={Convert.ToHexString(root)}");
Because the revision is inside the signed bytes, the same signature does not verify for another revision, and a root rebuilt from an altered record does not verify either.
Require(!signer.Verify(signature, Manifest(root, 8), publicKey), "Different revision verified.");
Require(!signer.Verify(signature, manifest, otherPublicKey), "Unrelated public key verified.");
Require(!signer.Verify(signature, Manifest(replacedTree.Root, 7), publicKey), "Replacement root passed the original signature.");
Verification therefore runs from the top down. First verify the manifest signature with a key you already trust. Then verify the record's inclusion proof against the root taken from that manifest.
| Check | Establishes | Still decided by the application |
| Digest comparison | The bytes match an expected digest. | Which digest to trust. |
| Signature verification | The key holder signed these exact bytes. | Which key has authority, and whether the message is current. |
| Merkle inclusion | The record is at this position under this root. | Which root to accept. |
5. Get the package and run the sample
The complete MIT-licensed project targets .NET 10 and uses two packages.
dotnet add package Sphere10.Framework
dotnet add package Sphere10.Framework.CryptoEx
dotnet run -c Release
It runs each check with a genuine input and with a changed one, and fails if any result differs from the table above.
Digest comparison detects a changed record: passed
Inclusion proof accepts the record only at its position: passed
Signature verifies only for the signed manifest and key: passed
Signature rejects a root rebuilt from a changed record: passed
Continue with the record inclusion proof article, the Merkleized ObjectSpace article and the CryptoEx guide .
For the broader Sphere 10 development toolkit, see the Sphere10 Framework overview .
Author: Herman Schoenfeld