|
2 | 2 | package org.hiero.block.node.verification.session.impl; |
3 | 3 |
|
4 | 4 | import static java.lang.System.Logger.Level.INFO; |
| 5 | +import static java.lang.System.Logger.Level.WARNING; |
5 | 6 | import static org.hiero.block.common.hasher.HashingUtilities.getBlockItemHash; |
| 7 | +import static org.hiero.block.common.hasher.HashingUtilities.hashInternalNode; |
| 8 | +import static org.hiero.block.common.hasher.HashingUtilities.hashInternalNodeSingleChild; |
| 9 | +import static org.hiero.block.common.hasher.HashingUtilities.hashLeaf; |
6 | 10 | import static org.hiero.block.common.hasher.HashingUtilities.noThrowSha384HashOf; |
7 | 11 |
|
8 | 12 | import com.hedera.cryptography.tss.TSS; |
9 | 13 | import com.hedera.hapi.block.stream.BlockProof; |
| 14 | +import com.hedera.hapi.block.stream.MerklePath; |
| 15 | +import com.hedera.hapi.block.stream.SiblingNode; |
| 16 | +import com.hedera.hapi.block.stream.StateProof; |
10 | 17 | import com.hedera.hapi.block.stream.output.BlockFooter; |
11 | 18 | import com.hedera.hapi.block.stream.output.BlockHeader; |
12 | 19 | import com.hedera.hapi.node.transaction.SignedTransaction; |
@@ -163,15 +170,30 @@ public VerificationNotification finalizeVerification(Bytes rootHashOfAllBlockHas |
163 | 170 | // while we don't have state management, use the start of block state root hash from the footer |
164 | 171 | Bytes startOfBlockStateRootHash = this.blockFooter.startOfBlockStateRootHash(); |
165 | 172 |
|
166 | | - // for now, we only support TSS based signature proofs, we expect only 1 of these. |
167 | | - // @todo(2019) extend to support other proof types as well |
168 | | - BlockProof tssBasedProof = getSingle(blockProofs, BlockProof::hasSignedBlockProof); |
169 | | - if (tssBasedProof != null) { |
170 | | - return getVerificationResult( |
171 | | - tssBasedProof, previousBlockHashToUse, rootOfAllPreviousBlockHashes, startOfBlockStateRootHash); |
| 173 | + try { |
| 174 | + // Try direct TSS proof first (most common case) |
| 175 | + BlockProof tssBasedProof = getSingle(blockProofs, BlockProof::hasSignedBlockProof); |
| 176 | + if (tssBasedProof != null) { |
| 177 | + return getVerificationResult( |
| 178 | + tssBasedProof, previousBlockHashToUse, rootOfAllPreviousBlockHashes, startOfBlockStateRootHash); |
| 179 | + } |
| 180 | + |
| 181 | + // Try indirect (state) proof — used when TSS signing was delayed |
| 182 | + BlockProof stateBasedProof = getSingle(blockProofs, BlockProof::hasBlockStateProof); |
| 183 | + if (stateBasedProof != null) { |
| 184 | + return getStateProofVerificationResult( |
| 185 | + stateBasedProof, |
| 186 | + previousBlockHashToUse, |
| 187 | + rootOfAllPreviousBlockHashes, |
| 188 | + startOfBlockStateRootHash); |
| 189 | + } |
| 190 | + } catch (final IllegalStateException e) { |
| 191 | + LOGGER.log(WARNING, "Block [{0}] has malformed proof structure: %s".formatted(e.getMessage()), e); |
| 192 | + return new VerificationNotification( |
| 193 | + false, FailureType.BAD_BLOCK_PROOF, blockNumber, null, null, blockSource); |
172 | 194 | } |
173 | 195 |
|
174 | | - // was not able to finalize verification yet |
| 196 | + // No supported proof type found |
175 | 197 | return null; |
176 | 198 | } |
177 | 199 |
|
@@ -227,6 +249,184 @@ protected Boolean verifySignature(@NonNull Bytes hash, @NonNull Bytes signature) |
227 | 249 | } |
228 | 250 | } |
229 | 251 |
|
| 252 | + /// Verifies a block using an indirect (state) proof. A state proof proves that the target |
| 253 | + /// block's root hash is embedded in a later directly-signed block's merkle tree, forming |
| 254 | + /// a chain: target block hash -> merkle siblings -> signed block root -> TSS signature. |
| 255 | + /// |
| 256 | + /// @param blockProof the block proof containing a state proof |
| 257 | + /// @param previousBlockHash the previous block hash |
| 258 | + /// @param rootOfAllPreviousBlockHashes the root hash of all previous block hashes |
| 259 | + /// @param startOfBlockStateRootHash the start of block state root hash |
| 260 | + /// @return VerificationNotification indicating the result of the verification |
| 261 | + protected VerificationNotification getStateProofVerificationResult( |
| 262 | + BlockProof blockProof, |
| 263 | + Bytes previousBlockHash, |
| 264 | + Bytes rootOfAllPreviousBlockHashes, |
| 265 | + Bytes startOfBlockStateRootHash) { |
| 266 | + |
| 267 | + final Bytes blockRootHash = HashingUtilities.computeFinalBlockHash( |
| 268 | + blockHeader.blockTimestamp(), |
| 269 | + previousBlockHash, |
| 270 | + rootOfAllPreviousBlockHashes, |
| 271 | + startOfBlockStateRootHash, |
| 272 | + inputTreeHasher, |
| 273 | + outputTreeHasher, |
| 274 | + consensusHeaderHasher, |
| 275 | + stateChangesHasher, |
| 276 | + traceDataHasher); |
| 277 | + |
| 278 | + final boolean verified = verifyStateProof(blockRootHash, blockProof.blockStateProof()); |
| 279 | + |
| 280 | + return new VerificationNotification( |
| 281 | + verified, |
| 282 | + verified ? null : FailureType.BAD_BLOCK_PROOF, |
| 283 | + blockNumber, |
| 284 | + blockRootHash, |
| 285 | + verified ? new BlockUnparsed(blockItems) : null, |
| 286 | + blockSource); |
| 287 | + } |
| 288 | + |
| 289 | + /// Walks the merkle path chain in a state proof to reconstruct the directly-signed block's |
| 290 | + /// root hash, then verifies the TSS signature on that root. |
| 291 | + /// |
| 292 | + /// A valid block state proof contains 3 paths: |
| 293 | + /// |
| 294 | + /// - Path 0: timestamp leaf of the signed block |
| 295 | + /// - Path 1: sibling hashes from the target block through all `gap` (i.e. not directly signed) blocks to |
| 296 | + /// the signed block |
| 297 | + /// - Path 2: terminal (empty) |
| 298 | + /// |
| 299 | + /// |
| 300 | + /// Path 1's siblings are laid out as groups: |
| 301 | + /// |
| 302 | + /// - Per gap block (4 siblings): prevBlockRootsHash (right), depth5Node2 (right), depth4Node2 (right), |
| 303 | + /// _already-hashed_ timestamp (left) |
| 304 | + /// - Final signed block (3 siblings): prevBlockRootsHash (right), depth5Node2 (right), depth4Node2 (right) |
| 305 | + /// |
| 306 | + /// |
| 307 | + /// @param blockRootHash the computed root hash of the target block |
| 308 | + /// @param stateProof the state proof to verify |
| 309 | + /// @return true if the proof chain and TSS signature are valid |
| 310 | + protected boolean verifyStateProof(@NonNull Bytes blockRootHash, @NonNull StateProof stateProof) { |
| 311 | + List<MerklePath> paths = stateProof.paths(); |
| 312 | + if (paths.size() != 3) { |
| 313 | + LOGGER.log(WARNING, "Block {0} state proof has {1} paths, expected 3", blockNumber, paths.size()); |
| 314 | + return false; |
| 315 | + } |
| 316 | + |
| 317 | + MerklePath timestampPath = paths.get(0); |
| 318 | + MerklePath siblingPath = paths.get(1); |
| 319 | + MerklePath terminalPath = paths.get(2); |
| 320 | + |
| 321 | + if (!terminalPath.siblings().isEmpty() || terminalPath.hasTimestampLeaf()) { |
| 322 | + LOGGER.log(WARNING, "Block {0} state proof path 2 (terminal) is unexpectedly non-empty", blockNumber); |
| 323 | + return false; |
| 324 | + } |
| 325 | + |
| 326 | + if (!timestampPath.hasTimestampLeaf()) { |
| 327 | + LOGGER.log(WARNING, "Block {0} state proof path 0 (timestamp) is missing timestamp leaf", blockNumber); |
| 328 | + return false; |
| 329 | + } |
| 330 | + if (!stateProof.hasSignedBlockProof()) { |
| 331 | + LOGGER.log(WARNING, "Block {0} state proof is missing signed block proof", blockNumber); |
| 332 | + return false; |
| 333 | + } |
| 334 | + |
| 335 | + List<SiblingNode> siblings = siblingPath.siblings(); |
| 336 | + int totalSiblings = siblings.size(); |
| 337 | + // Must have at least 3 (signed block siblings) and remainder must be groups of 4 (gap blocks) |
| 338 | + if (totalSiblings < 3 || (totalSiblings - 3) % 4 != 0) { |
| 339 | + LOGGER.log( |
| 340 | + WARNING, |
| 341 | + "Block {0} state proof sibling count {1} is invalid (need >= 3, remainder must be multiple of 4)", |
| 342 | + blockNumber, |
| 343 | + totalSiblings); |
| 344 | + return false; |
| 345 | + } |
| 346 | + // Starting hash must be present and non-empty to avoid propagating incorrect hashes |
| 347 | + if (!siblingPath.hasHash() || siblingPath.hash().length() == 0) { |
| 348 | + LOGGER.log( |
| 349 | + WARNING, "Block {0} state proof path 1 (sibling) has missing or empty starting hash", blockNumber); |
| 350 | + return false; |
| 351 | + } |
| 352 | + for (SiblingNode sibling : siblings) { |
| 353 | + if (sibling.hash().length() == 0) { |
| 354 | + LOGGER.log(WARNING, "Block {0} state proof contains a sibling node with an empty hash", blockNumber); |
| 355 | + return false; |
| 356 | + } |
| 357 | + } |
| 358 | + |
| 359 | + // siblingPath.hash() is the previous block's (T-1) root hash. Walking the siblings in groups |
| 360 | + // first reconstructs the target block T's root hash, then each subsequent gap block's root hash, |
| 361 | + // until we reach the signed block. After the first iteration, current equals T's reconstructed |
| 362 | + // root hash — verify it matches the independently computed blockRootHash to ensure block content integrity. |
| 363 | + byte[] current = siblingPath.hash().toByteArray(); |
| 364 | + int index = 0; |
| 365 | + boolean firstIteration = true; |
| 366 | + |
| 367 | + // Process gap blocks (including the target block itself) — each contributes 4 siblings |
| 368 | + while (totalSiblings - index > 3) { |
| 369 | + SiblingNode prevBlockRootsHash = siblings.get(index); |
| 370 | + SiblingNode depth5Node2Sibling = siblings.get(index + 1); |
| 371 | + SiblingNode depth4Node2Sibling = siblings.get(index + 2); |
| 372 | + SiblingNode hashedTimestampSibling = siblings.get(index + 3); |
| 373 | + |
| 374 | + byte[] depth5Node1 = combineSibling(current, prevBlockRootsHash); |
| 375 | + byte[] depth4Node1 = combineSibling(depth5Node1, depth5Node2Sibling); |
| 376 | + byte[] depth3Node1 = combineSibling(depth4Node1, depth4Node2Sibling); |
| 377 | + byte[] depth2Node2 = hashInternalNodeSingleChild(depth3Node1); |
| 378 | + // The timestamp sibling hash is already hashLeaf(rawTimestamp) — a 48-byte node value. |
| 379 | + // Combine as depth2Node1 (left) with depth2Node2 (right) to reconstruct the gap block root. |
| 380 | + current = hashInternalNode(hashedTimestampSibling.hash().toByteArray(), depth2Node2); |
| 381 | + |
| 382 | + if (firstIteration) { |
| 383 | + // current now holds the reconstructed target block root hash; verify it matches |
| 384 | + // the hash computed from the actual block content we received. |
| 385 | + final Bytes reconstructed = Bytes.wrap(current); |
| 386 | + if (!blockRootHash.equals(reconstructed)) { |
| 387 | + LOGGER.log( |
| 388 | + WARNING, |
| 389 | + "Block {0} state proof integrity check failed: hash reconstructed from path 1 siblings" |
| 390 | + + " [{1}] does not match block root hash computed from block content [{2}]", |
| 391 | + blockNumber, |
| 392 | + reconstructed, |
| 393 | + blockRootHash); |
| 394 | + return false; |
| 395 | + } |
| 396 | + firstIteration = false; |
| 397 | + } |
| 398 | + |
| 399 | + index += 4; |
| 400 | + } |
| 401 | + |
| 402 | + // Process the signed block's 3 siblings |
| 403 | + byte[] depth5Node1 = combineSibling(current, siblings.get(index)); |
| 404 | + byte[] depth4Node1 = combineSibling(depth5Node1, siblings.get(index + 1)); |
| 405 | + byte[] depth3Node1 = combineSibling(depth4Node1, siblings.get(index + 2)); |
| 406 | + |
| 407 | + // Reconstruct the signed block's root hash using Path 0's raw timestamp bytes |
| 408 | + byte[] depth2Node2 = hashInternalNodeSingleChild(depth3Node1); |
| 409 | + byte[] hashedTimestampLeaf = hashLeaf(timestampPath.timestampLeaf().toByteArray()); |
| 410 | + byte[] signedBlockRoot = hashInternalNode(hashedTimestampLeaf, depth2Node2); |
| 411 | + |
| 412 | + // Verify TSS signature on the signed block's root hash |
| 413 | + return verifySignature( |
| 414 | + Bytes.wrap(signedBlockRoot), stateProof.signedBlockProof().blockSignature()); |
| 415 | + } |
| 416 | + |
| 417 | + /// Combines the current hash with a sibling node, respecting the sibling's position. |
| 418 | + /// |
| 419 | + /// @param current the current hash being walked up the tree |
| 420 | + /// @param sibling the sibling node with position and hash |
| 421 | + /// @return the parent hash |
| 422 | + private static byte[] combineSibling(byte[] current, SiblingNode sibling) { |
| 423 | + if (sibling.isLeft()) { |
| 424 | + return hashInternalNode(sibling.hash().toByteArray(), current); |
| 425 | + } else { |
| 426 | + return hashInternalNode(current, sibling.hash().toByteArray()); |
| 427 | + } |
| 428 | + } |
| 429 | + |
230 | 430 | /// Extracts a [LedgerIdPublicationTransactionBody] from a signed transaction, if present. |
231 | 431 | /// Pure parsing — no side effects. |
232 | 432 | /// |
@@ -255,14 +455,13 @@ private LedgerIdPublicationTransactionBody findLedgerIdPublication(Bytes signedT |
255 | 455 | return body.ledgerIdPublicationOrThrow(); |
256 | 456 | } |
257 | 457 |
|
258 | | - public static <T> T getSingle(List<T> list, Predicate<T> predicate) { |
| 458 | + /// Returns the single element matching the predicate, or null if none match. |
| 459 | + private <T> T getSingle(List<T> list, Predicate<T> predicate) { |
259 | 460 | List<T> filtered = list.stream().filter(predicate).toList(); |
260 | | - |
261 | | - if (filtered.size() != 1) { |
262 | | - throw new IllegalStateException(String.format( |
263 | | - "Expected exactly 1 element matching predicate [%s], but found %d.", predicate, filtered.size())); |
| 461 | + if (filtered.size() > 1) { |
| 462 | + throw new IllegalStateException( |
| 463 | + "Expected at most 1 element matching predicate, but found " + filtered.size()); |
264 | 464 | } |
265 | | - |
266 | | - return filtered.get(0); |
| 465 | + return filtered.isEmpty() ? null : filtered.get(0); |
267 | 466 | } |
268 | 467 | } |
0 commit comments