Skip to content

Commit bdc05c2

Browse files
committed
feat(merkle): add private leaves support - Add AddPrivateLeaf method, private leaf constructor, update ToJson, add tests and docs
1 parent 6b619b5 commit bdc05c2

File tree

4 files changed

+139
-9
lines changed

4 files changed

+139
-9
lines changed

docs/merkle/selective-disclosure.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,33 @@ Predicate<MerkleLeaf> revealOnlyDateOfBirth = leaf =>
282282
string ageProof = merkleTree.ToJson(revealOnlyDateOfBirth);
283283
```
284284

285+
### Creating Private Leaves
286+
287+
There are two ways to create private leaves in a Merkle tree:
288+
289+
1. **Using AddPrivateLeaf**: Create a leaf that is private from the start:
290+
```csharp
291+
// Create a tree with a private leaf
292+
var tree = new MerkleTree();
293+
var hash = Hex.Parse("0x1234567890abcdef");
294+
var privateLeaf = tree.AddPrivateLeaf(hash);
295+
```
296+
297+
2. **Using Selective Disclosure**: Make existing leaves private during serialization:
298+
```csharp
299+
// Create a predicate that makes certain leaves private
300+
Predicate<MerkleLeaf> makePrivate = leaf =>
301+
leaf.TryReadText(out string text) && text.Contains("documentNumber");
302+
303+
// Serialize with selective disclosure
304+
string json = tree.ToJson(makePrivate);
305+
```
306+
307+
Both approaches result in leaves that:
308+
- Only contain their hash in the JSON output
309+
- Maintain the tree's verifiability
310+
- Preserve privacy of the leaf's data
311+
285312
## Conclusion
286313

287314
Our selective disclosure implementation for Merkle trees offers a pragmatic balance between privacy and verifiability. By using a simple predicate-based approach and clean JSON serialization, we've created a solution that's easy to use, efficient to implement, and powerful in its applications.

src/Evoq.Blockchain/Blockchain.Merkle/MerkleLeaf.cs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
namespace Evoq.Blockchain.Merkle;
2-
3-
using System;
41
using System.Collections.Generic;
52
using System.Text.Json;
6-
using Evoq.Blockchain;
73

4+
namespace Evoq.Blockchain.Merkle;
85
/// <summary>
96
/// Represents a leaf node in a Merkle tree.
107
/// </summary>
@@ -19,12 +16,24 @@ public class MerkleLeaf
1916
/// <param name="contentType">The MIME content type of the data, including encoding information if applicable.</param>
2017
public MerkleLeaf(string contentType, Hex data, Hex salt, Hex hash)
2118
{
22-
this.ContentType = contentType;
19+
this.ContentType = contentType ?? string.Empty;
2320
this.Data = data;
2421
this.Salt = salt;
2522
this.Hash = hash;
2623
}
2724

25+
/// <summary>
26+
/// Initializes a new instance of the MerkleLeaf class for a private leaf that only contains a hash.
27+
/// </summary>
28+
/// <param name="hash">The hash of the leaf.</param>
29+
public MerkleLeaf(Hex hash)
30+
{
31+
this.ContentType = string.Empty;
32+
this.Data = Hex.Empty;
33+
this.Salt = Hex.Empty;
34+
this.Hash = hash;
35+
}
36+
2837
//
2938

3039
/// <summary>

src/Evoq.Blockchain/Blockchain.Merkle/MerkleTree.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,18 @@ public void AddLeaf(MerkleLeaf leaf)
151151
((List<MerkleLeaf>)this.Leaves).Add(leaf);
152152
}
153153

154+
/// <summary>
155+
/// Adds a new private leaf to the Merkle tree that only contains a hash.
156+
/// </summary>
157+
/// <param name="hash">The hash of the leaf.</param>
158+
/// <returns>The added leaf.</returns>
159+
public MerkleLeaf AddPrivateLeaf(Hex hash)
160+
{
161+
var leaf = new MerkleLeaf(hash);
162+
this.AddLeaf(leaf);
163+
return leaf;
164+
}
165+
154166
/// <summary>
155167
/// Verifies that the current root matches the computed root from the leaves.
156168
/// </summary>
@@ -303,7 +315,7 @@ public string ToJson()
303315
/// Converts the MerkleTree object to its JSON string representation after verifying the root.
304316
/// </summary>
305317
/// <param name="hashFunction">The hash function to use for verification.</param>
306-
/// <param name="makePrivate">A predicate to determine if a leaf should be made private.</param>
318+
/// <param name="makePrivate">A predicate to determine if a leaf should be made private, in addition to the leaf's own IsPrivate property.</param>
307319
/// <param name="options">JSON serializer options to customize the output.</param>
308320
/// <returns>A JSON string representation of the MerkleTree object.</returns>
309321
/// <exception cref="InvalidRootException">Thrown if the root verification fails.</exception>
@@ -320,17 +332,21 @@ public string ToJson(HashFunction hashFunction, Predicate<MerkleLeaf>? makePriva
320332
{
321333
WriteIndented = true,
322334
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
323-
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
335+
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
336+
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
324337
};
325338

339+
// Combine the leaf's IsPrivate property with any additional predicate
340+
Predicate<MerkleLeaf> combinedPredicate = leaf => leaf.IsPrivate || (makePrivate?.Invoke(leaf) ?? false);
341+
326342
if (Metadata.Version == MerkleTreeVersionStrings.V1_0)
327343
{
328-
MerkleTreeV1Dto v1 = this.ToV1Dto(makePrivate ?? (leaf => false));
344+
MerkleTreeV1Dto v1 = this.ToV1Dto(combinedPredicate);
329345
return JsonSerializer.Serialize(v1, options);
330346
}
331347
else if (Metadata.Version == MerkleTreeVersionStrings.V2_0)
332348
{
333-
MerkleTreeV2Dto v2 = this.ToV2Dto(makePrivate ?? (leaf => false));
349+
MerkleTreeV2Dto v2 = this.ToV2Dto(combinedPredicate);
334350
return JsonSerializer.Serialize(v2, options);
335351
}
336352
else

tests/Evoq.Blockchain.Tests/Blockchain.Merkle/MerkleTreeV2Tests.cs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,82 @@ public void VersionString_ShouldNotBeEscapedInJson()
9797
Assert.IsFalse(json.Contains("\\u002B"),
9898
"Plus sign should not be escaped as Unicode in JSON output");
9999
}
100+
101+
[TestMethod]
102+
public void AddPrivateLeaf_ShouldCreateLeafWithOnlyHash()
103+
{
104+
// Arrange
105+
var tree = new MerkleTree(MerkleTreeVersionStrings.V2_0);
106+
var hash = Hex.Parse("0x1234567890abcdef");
107+
108+
// Act
109+
var leaf = tree.AddPrivateLeaf(hash);
110+
tree.RecomputeSha256Root();
111+
112+
// Assert
113+
Assert.IsTrue(leaf.IsPrivate, "Private leaf should be marked as private");
114+
Assert.AreEqual(hash, leaf.Hash, "Hash should match the provided hash");
115+
Assert.IsTrue(leaf.Data.IsEmpty(), "Data should be empty");
116+
Assert.IsTrue(leaf.Salt.IsEmpty(), "Salt should be empty");
117+
Assert.AreEqual(string.Empty, leaf.ContentType, "Content type should be empty");
118+
119+
// Verify the leaf is properly serialized
120+
string json = tree.ToJson(MerkleTree.ComputeSha256Hash);
121+
122+
Console.WriteLine(json);
123+
124+
var jsonDoc = JsonDocument.Parse(json);
125+
var leaves = jsonDoc.RootElement.GetProperty("leaves").EnumerateArray().ToArray();
126+
127+
Assert.AreEqual(1, leaves.Length, "Should have one leaf");
128+
var leafJson = leaves[0];
129+
130+
Assert.IsTrue(leafJson.TryGetProperty("hash", out var hashProp), "Should have hash property");
131+
Assert.AreEqual(hash.ToString(), hashProp.GetString(), "Hash should match");
132+
Assert.IsFalse(leafJson.TryGetProperty("data", out _), "Should not have data property");
133+
Assert.IsFalse(leafJson.TryGetProperty("salt", out _), "Should not have salt property");
134+
Assert.IsFalse(leafJson.TryGetProperty("contentType", out _), "Should not have contentType property");
135+
}
136+
137+
[TestMethod]
138+
public void RoundTrip_WithPrivateLeaf_ShouldPreservePrivacy()
139+
{
140+
// Arrange - Create a tree with one private leaf
141+
var tree = new MerkleTree(MerkleTreeVersionStrings.V2_0);
142+
var hash = Hex.Parse("0x1234567890abcdef");
143+
var leaf = tree.AddPrivateLeaf(hash);
144+
tree.RecomputeSha256Root();
145+
146+
// Act - Roundtrip through JSON
147+
string json = tree.ToJson();
148+
var parsedTree = MerkleTree.Parse(json);
149+
150+
// Assert
151+
Assert.AreEqual(1, parsedTree.Leaves.Count, "Should have one leaf after roundtrip");
152+
var roundtrippedLeaf = parsedTree.Leaves[0];
153+
154+
// Verify the leaf is still private
155+
Assert.IsTrue(roundtrippedLeaf.IsPrivate, "Leaf should still be private after roundtrip");
156+
Assert.AreEqual(hash, roundtrippedLeaf.Hash, "Hash should be preserved");
157+
Assert.IsTrue(roundtrippedLeaf.Data.IsEmpty(), "Data should still be empty");
158+
Assert.IsTrue(roundtrippedLeaf.Salt.IsEmpty(), "Salt should still be empty");
159+
Assert.AreEqual(string.Empty, roundtrippedLeaf.ContentType, "Content type should be empty.");
160+
161+
// Verify the tree still validates
162+
Assert.IsTrue(parsedTree.VerifySha256Root(), "Tree should still verify after roundtrip");
163+
164+
// Verify the JSON still only contains the hash
165+
string roundtrippedJson = parsedTree.ToJson();
166+
var jsonDoc = JsonDocument.Parse(roundtrippedJson);
167+
var leaves = jsonDoc.RootElement.GetProperty("leaves").EnumerateArray().ToArray();
168+
169+
Assert.AreEqual(1, leaves.Length, "Should have one leaf in JSON");
170+
var leafJson = leaves[0];
171+
172+
Assert.IsTrue(leafJson.TryGetProperty("hash", out var hashProp), "Should have hash property");
173+
Assert.AreEqual(hash.ToString(), hashProp.GetString(), "Hash should match");
174+
Assert.IsFalse(leafJson.TryGetProperty("data", out _), "Should not have data property");
175+
Assert.IsFalse(leafJson.TryGetProperty("salt", out _), "Should not have salt property");
176+
Assert.IsFalse(leafJson.TryGetProperty("contentType", out _), "Should not have contentType property");
177+
}
100178
}

0 commit comments

Comments
 (0)