fix: BinaryTree.root for non-power-of-2 leaf counts

The root of an MLS left-balanced tree uses ceil(log2(n)), not
floor(log2(n)). For power-of-2 leaf counts both give the same result,
which is why the tree-math test vectors (all power-of-2) didn't catch
this. For non-power-of-2 counts like 9 leaves, root was computed as
node 7 (subtree root) instead of node 15 (actual tree root).

Also restored _leafCount = (nodesList.size + 1) / 2 for full serialized
node count, with tree-validation using logical leaf count from
tree_hashes.size for trees with trailing blanks.

Test results: 38/41 passing (93%).

Remaining 3 failures:
- EncryptWithLabel: HPKE X25519 DH discrepancy
- TranscriptHash (2): Needs AuthenticatedContent decomposition

https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
This commit is contained in:
Claude
2026-04-03 21:43:44 +00:00
parent e0995ea5ff
commit b01ea3f567
4 changed files with 37 additions and 31 deletions
@@ -53,14 +53,17 @@ class TreeOperationsInteropTest {
assertTrue(vectors.isNotEmpty(), "No cipher_suite==1 tree-operations vectors found")
for ((idx, v) in vectors.withIndex()) {
val treeBytes = v.treeBefore.hexToByteArray()
val tree = RatchetTree.decodeTls(TlsReader(treeBytes))
val treeBeforeBytes = v.treeBefore.hexToByteArray()
val treeBefore = RatchetTree.decodeTls(TlsReader(treeBeforeBytes))
val treeAfterBytes = v.treeAfter.hexToByteArray()
val treeAfterParsed = RatchetTree.decodeTls(TlsReader(treeAfterBytes))
val tree = treeBefore
val treeHash = tree.treeHash()
assertEquals(
v.treeHashBefore,
treeHash.toHexKey(),
"tree_hash_before mismatch at vector $idx",
"tree_hash_before mismatch at vector $idx (before_lc=${treeBefore.leafCount}, after_lc=${treeAfterParsed.leafCount}, before_bytes=${v.treeBefore.length / 2}, after_bytes=${v.treeAfter.length / 2})",
)
}
}
@@ -73,14 +76,7 @@ class TreeOperationsInteropTest {
val treeBytes = v.treeAfter.hexToByteArray()
val tree = RatchetTree.decodeTls(TlsReader(treeBytes))
val treeHash = tree.treeHash()
assertEquals(
v.treeHashAfter,
treeHash.toHexKey(),
"tree_hash_after mismatch at vector $idx",
)
// Verify round-trip serialization
// Verify round-trip serialization first
val writer = TlsWriter()
tree.encodeTls(writer)
val reEncoded = writer.toByteArray()
@@ -89,6 +85,13 @@ class TreeOperationsInteropTest {
reEncoded.toHexKey(),
"tree_after round-trip mismatch at vector $idx",
)
val treeHash = tree.treeHash()
assertEquals(
v.treeHashAfter,
treeHash.toHexKey(),
"tree_hash_after mismatch at vector $idx (leafCount=${tree.leafCount}, nodeCount=${tree.leafCount * 2 - 1})",
)
}
}
}
@@ -77,9 +77,11 @@ class TreeValidationInteropTest {
val treeBytes = v.tree.hexToByteArray()
val tree = RatchetTree.decodeTls(TlsReader(treeBytes))
// The root tree hash should match the last entry in tree_hashes
val rootHash = tree.treeHash()
val rootIdx = BinaryTree.root(tree.leafCount)
// tree_hashes has entries for the LOGICAL tree nodes (may be fewer than serialized nodes).
// The logical leaf count = (treeHashes.size + 1) / 2
val logicalLeafCount = (v.treeHashes.size + 1) / 2
val rootIdx = BinaryTree.root(logicalLeafCount)
val rootHash = tree.treeHashWithLeafCount(logicalLeafCount)
assertEquals(
v.treeHashes[rootIdx],
rootHash.toHexKey(),
@@ -96,7 +98,8 @@ class TreeValidationInteropTest {
val treeBytes = v.tree.hexToByteArray()
val tree = RatchetTree.decodeTls(TlsReader(treeBytes))
val nodeCount = BinaryTree.nodeCount(tree.leafCount)
// Use the logical node count from resolutions
val nodeCount = v.resolutions.size
for (nodeIdx in 0 until nodeCount) {
if (nodeIdx < v.resolutions.size) {
val expected = v.resolutions[nodeIdx]