Skip to content

Commit 1d781b2

Browse files
committed
Extract inclusion proof verifier into a new library
Signed-off-by: Aaron Lew <[email protected]>
1 parent 546ed0e commit 1d781b2

File tree

5 files changed

+250
-56
lines changed

5 files changed

+250
-56
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright 2025 The Sigstore Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package dev.sigstore.merkle;
17+
18+
public class InclusionProofVerificationException extends Exception {
19+
public InclusionProofVerificationException(String message) {
20+
super(message);
21+
}
22+
23+
public InclusionProofVerificationException(String message, Throwable cause) {
24+
super(message, cause);
25+
}
26+
27+
public InclusionProofVerificationException(Throwable cause) {
28+
super(cause);
29+
}
30+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright 2025 The Sigstore Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package dev.sigstore.merkle;
17+
18+
import com.google.common.hash.Hashing;
19+
import java.util.Arrays;
20+
import java.util.List;
21+
import org.bouncycastle.util.encoders.Hex;
22+
23+
/** Verifier for inclusion proofs. */
24+
public class InclusionProofVerifier {
25+
/**
26+
* Verifies an inclusion proof.
27+
*
28+
* @param leafHash the hash of the leaf entry.
29+
* @param logIndex the index of the leaf in the log.
30+
* @param treeSize the size of the tree at the time the proof was generated.
31+
* @param proofHashes a list of hashes from the inclusion proof.
32+
* @param expectedRootHash the expected root hash of the Merkle tree.
33+
* @throws InclusionProofVerificationException if the proof is invalid.
34+
*/
35+
public static void verify(
36+
byte[] leafHash,
37+
long logIndex,
38+
long treeSize,
39+
List<byte[]> proofHashes,
40+
byte[] expectedRootHash)
41+
throws InclusionProofVerificationException {
42+
byte[] currentHash = leafHash;
43+
long nodeIndex = logIndex;
44+
long totalNodes = treeSize - 1;
45+
46+
for (byte[] hash : proofHashes) {
47+
if (totalNodes == 0) {
48+
throw new InclusionProofVerificationException("Inclusion proof failed, ended prematurely");
49+
}
50+
if (nodeIndex == totalNodes || nodeIndex % 2 == 1) {
51+
currentHash = hashChildren(hash, currentHash);
52+
while (nodeIndex % 2 == 0) {
53+
nodeIndex = nodeIndex >> 1;
54+
totalNodes = totalNodes >> 1;
55+
}
56+
} else {
57+
currentHash = hashChildren(currentHash, hash);
58+
}
59+
nodeIndex = nodeIndex >> 1;
60+
totalNodes = totalNodes >> 1;
61+
}
62+
63+
if (!Arrays.equals(currentHash, expectedRootHash)) {
64+
throw new InclusionProofVerificationException(
65+
"Calculated inclusion proof root hash does not match provided root hash\n"
66+
+ "calculated: "
67+
+ Hex.toHexString(currentHash)
68+
+ "\n"
69+
+ "provided: "
70+
+ Hex.toHexString(expectedRootHash));
71+
}
72+
}
73+
74+
/**
75+
* Hashes the concatenation of a 0x01 byte, the left child hash, and the right child hash using
76+
* SHA-256.
77+
*
78+
* @param left the left child hash.
79+
* @param right the right child hash.
80+
* @return the parent hash.
81+
*/
82+
public static byte[] hashChildren(byte[] left, byte[] right) {
83+
return Hashing.sha256()
84+
.newHasher()
85+
.putByte((byte) 0x01)
86+
.putBytes(left)
87+
.putBytes(right)
88+
.hash()
89+
.asBytes();
90+
}
91+
}

sigstore-java/src/main/java/dev/sigstore/rekor/client/RekorVerifier.java

Lines changed: 21 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
import com.google.common.hash.Hashing;
1919
import dev.sigstore.encryption.signers.Verifiers;
20+
import dev.sigstore.merkle.InclusionProofVerificationException;
21+
import dev.sigstore.merkle.InclusionProofVerifier;
2022
import dev.sigstore.rekor.client.RekorEntry.Checkpoint;
2123
import dev.sigstore.trustroot.SigstoreTrustedRoot;
2224
import dev.sigstore.trustroot.TransparencyLog;
@@ -25,6 +27,7 @@
2527
import java.security.NoSuchAlgorithmException;
2628
import java.security.SignatureException;
2729
import java.security.spec.InvalidKeySpecException;
30+
import java.util.ArrayList;
2831
import java.util.Arrays;
2932
import java.util.Base64;
3033
import java.util.List;
@@ -92,49 +95,32 @@ public void verifyEntry(RekorEntry entry) throws RekorVerificationException {
9295

9396
/** Verify that a Rekor Entry is in the log by checking inclusion proof. */
9497
private void verifyInclusionProof(RekorEntry entry) throws RekorVerificationException {
95-
9698
var inclusionProof = entry.getVerification().getInclusionProof();
9799

98100
var leafHash =
99101
Hashing.sha256()
100-
.hashBytes(combineBytes(new byte[] {0x00}, Base64.getDecoder().decode(entry.getBody())))
102+
.newHasher()
103+
.putByte((byte) 0x00)
104+
.putBytes(Base64.getDecoder().decode(entry.getBody()))
105+
.hash()
101106
.asBytes();
102107

103-
// see: https://datatracker.ietf.org/doc/rfc9162/ section 2.1.3.2
104-
105-
// nodeIndex and totalNodes represent values for a specific level in the tree
106-
// starting at the leafs and moving up to the root.
107-
var nodeIndex = inclusionProof.getLogIndex();
108-
var totalNodes = inclusionProof.getTreeSize() - 1;
109-
110-
var currentHash = leafHash;
111-
var hashes = inclusionProof.getHashes();
112-
113-
for (var hash : hashes) {
114-
byte[] p = Hex.decode(hash);
115-
if (totalNodes == 0) {
116-
throw new RekorVerificationException("Inclusion proof failed, ended prematurely");
117-
}
118-
if (nodeIndex == totalNodes || nodeIndex % 2 == 1) {
119-
currentHash = hashChildren(p, currentHash);
120-
while (nodeIndex % 2 == 0) {
121-
nodeIndex = nodeIndex >> 1;
122-
totalNodes = totalNodes >> 1;
123-
}
124-
} else {
125-
currentHash = hashChildren(currentHash, p);
126-
}
127-
nodeIndex = nodeIndex >> 1;
128-
totalNodes = totalNodes >> 1;
108+
List<byte[]> hashes = new ArrayList<>();
109+
for (String hash : inclusionProof.getHashes()) {
110+
hashes.add(Hex.decode(hash));
129111
}
130112

131-
var calcuatedRootHash = Hex.toHexString(currentHash);
132-
if (!calcuatedRootHash.equals(inclusionProof.getRootHash())) {
133-
throw new RekorVerificationException(
134-
"Calculated inclusion proof root hash does not match provided root hash\n"
135-
+ calcuatedRootHash
136-
+ "\n"
137-
+ inclusionProof.getRootHash());
113+
byte[] expectedRootHash = Hex.decode(inclusionProof.getRootHash());
114+
115+
try {
116+
InclusionProofVerifier.verify(
117+
leafHash,
118+
inclusionProof.getLogIndex(),
119+
inclusionProof.getTreeSize(),
120+
hashes,
121+
expectedRootHash);
122+
} catch (InclusionProofVerificationException e) {
123+
throw new RekorVerificationException("Inclusion proof verification failed", e);
138124
}
139125
}
140126

@@ -177,18 +163,4 @@ private void verifyCheckpoint(RekorEntry entry, TransparencyLog tlog)
177163
throw new RekorVerificationException("Could not verify checkpoint signature", ex);
178164
}
179165
}
180-
181-
private static byte[] combineBytes(byte[] first, byte[] second) {
182-
byte[] result = new byte[first.length + second.length];
183-
System.arraycopy(first, 0, result, 0, first.length);
184-
System.arraycopy(second, 0, result, first.length, second.length);
185-
return result;
186-
}
187-
188-
// hash the concatination of 0x01, left and right
189-
private static byte[] hashChildren(byte[] left, byte[] right) {
190-
return Hashing.sha256()
191-
.hashBytes(combineBytes(new byte[] {0x01}, combineBytes(left, right)))
192-
.asBytes();
193-
}
194166
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright 2025 The Sigstore Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package dev.sigstore.merkle;
17+
18+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
19+
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertThrows;
21+
import static org.junit.jupiter.api.Assertions.assertTrue;
22+
23+
import java.util.List;
24+
import org.bouncycastle.util.encoders.Hex;
25+
import org.junit.jupiter.api.BeforeAll;
26+
import org.junit.jupiter.api.Test;
27+
28+
public class InclusionProofVerifierTest {
29+
public static byte[] leafHash;
30+
public static long logIndex;
31+
public static long treeSize;
32+
public static List<byte[]> proofHashes;
33+
public static byte[] expectedRootHash;
34+
35+
@BeforeAll
36+
public static void initValues() throws Exception {
37+
// Test values taken from the inclusion proof in
38+
// src/test/resources/dev/sigstore/samples/rekor-response/valid/entry.json
39+
leafHash = Hex.decode("ac3650aee1c1b3821211cf07067c1a118d0a7f86867bbb1df340cb8fc9c221af");
40+
logIndex = 1227L;
41+
treeSize = 14358L;
42+
proofHashes =
43+
List.of(
44+
Hex.decode("810320ec3029914695826d60133c67021f66ee0cfb09a6f79eb267ed9f55de2c"),
45+
Hex.decode("67e9d9f66f0ad388f7e1a20991e9a2ae3efad5cbf281e8b3d2aaf1ef99a4618c"),
46+
Hex.decode("16a106400c53465f6e18c2475df6ba889ca30f5667bacf32b1a5661f14a5080c"),
47+
Hex.decode("b4439e8d71edbc96271723cb7a969dd725e23e73d139361864a62ed76ce8dc11"),
48+
Hex.decode("49b3e90806c7b63b5a86f5748e3ecb7d264ea0828eb74a45bc1a2cd7962408e8"),
49+
Hex.decode("5059ad9b48fa50bd9adcbff0dd81c5a0dcb60f37e0716e723a33805a464f72f8"),
50+
Hex.decode("6c2ce64219799e61d72996884eee9e19fb906e4d7fa04b71625fde4108f21762"),
51+
Hex.decode("784f79c817abb78db3ae99b6c1ede640470bf4bb678673a05bf3a6b50aaaddd6"),
52+
Hex.decode("c6d92ebf4e10cdba500ca410166cd0a8d8b312154d2f45bc4292d63dea6112f6"),
53+
Hex.decode("1768732027401f6718b0df7769e2803127cfc099eb130a8ed7d913218f6a65f6"),
54+
Hex.decode("0da021f68571b65e49e926e4c69024de3ac248a1319d254bc51a85a657b93c33"),
55+
Hex.decode("bc8cf0c8497d5c24841de0c9bef598ec99bbd59d9538d58568340646fe289e9a"),
56+
Hex.decode("be328fa737b8fa9461850b8034250f237ff5b0b590b9468e6223968df294872b"),
57+
Hex.decode("6f06f4025d0346f04830352b23f65c8cd9e3ce4b8cb899877c35282521ddaf85"));
58+
expectedRootHash =
59+
Hex.decode("effa4fa4575f72829016a64e584441203de533212f9470d63a56d1992e73465d");
60+
}
61+
62+
@Test
63+
public void verify() throws Exception {
64+
InclusionProofVerifier.verify(leafHash, logIndex, treeSize, proofHashes, expectedRootHash);
65+
}
66+
67+
@Test
68+
public void verify_endPrematurely() throws Exception {
69+
var invalidTreeSize = 1;
70+
71+
var thrown =
72+
assertThrows(
73+
InclusionProofVerificationException.class,
74+
() -> {
75+
InclusionProofVerifier.verify(
76+
leafHash, logIndex, invalidTreeSize, proofHashes, expectedRootHash);
77+
});
78+
assertEquals("Inclusion proof failed, ended prematurely", thrown.getMessage());
79+
}
80+
81+
@Test
82+
public void verify_rootHashMismatch() throws Exception {
83+
var unexpectedRootHash =
84+
Hex.decode("effa4fa4575f72829016a64e584441203de533212f9470d63a56d1992e73465e");
85+
86+
var thrown =
87+
assertThrows(
88+
InclusionProofVerificationException.class,
89+
() -> {
90+
InclusionProofVerifier.verify(
91+
leafHash, logIndex, treeSize, proofHashes, unexpectedRootHash);
92+
});
93+
assertTrue(
94+
thrown
95+
.getMessage()
96+
.startsWith("Calculated inclusion proof root hash does not match provided root hash"));
97+
}
98+
99+
@Test
100+
public void hashChildren() {
101+
byte[] left = Hex.decode("7170380079683de93335f887309004415054475045b410300586503918910106");
102+
byte[] right = Hex.decode("a45db0765e04aa28507698b000d4859f9066055a02895bd8083004890de15892");
103+
byte[] expectedParentHash =
104+
Hex.decode("467bd65e6c49dbf8f89ddcbf1537aac7a61a7be1b87182393c66ce25050d03c2");
105+
assertArrayEquals(expectedParentHash, InclusionProofVerifier.hashChildren(left, right));
106+
}
107+
}

sigstore-java/src/test/java/dev/sigstore/rekor/client/RekorVerifierTest.java

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@
2929
import java.util.List;
3030
import org.bouncycastle.util.encoders.Base64;
3131
import org.bouncycastle.util.encoders.Hex;
32-
import org.hamcrest.CoreMatchers;
33-
import org.hamcrest.MatcherAssert;
3432
import org.junit.jupiter.api.Assertions;
3533
import org.junit.jupiter.api.BeforeAll;
3634
import org.junit.jupiter.api.BeforeEach;
@@ -94,11 +92,7 @@ public void verifyEntry_withInvalidInclusionProof() throws Exception {
9492
var thrown =
9593
Assertions.assertThrows(
9694
RekorVerificationException.class, () -> verifier.verifyEntry(entry));
97-
98-
MatcherAssert.assertThat(
99-
thrown.getMessage(),
100-
CoreMatchers.startsWith(
101-
"Calculated inclusion proof root hash does not match provided root hash"));
95+
Assertions.assertEquals("Inclusion proof verification failed", thrown.getMessage());
10296
}
10397

10498
@Test

0 commit comments

Comments
 (0)