621fe9c8c5
Fred (third member of a three-member group, `leafIndex = 2`) crashed
with `StackOverflowError` on ART / NPE on JVM the moment he tried to
send his first application message. The recursion / null is in
`SecretTree.getNodeSecret`, which assumes `nodeIndex` is always a
direct child of `BinaryTree.parent(nodeIndex)`:
val parentIdx = BinaryTree.parent(nodeIndex, BinaryTree.nodeCount(leafCount))
val parentSecret = getNodeSecret(parentIdx)
val leftIdx = BinaryTree.left(parentIdx)
val rightIdx = BinaryTree.right(parentIdx)
treeSecrets[leftIdx] = leftSecret
treeSecrets[rightIdx] = rightSecret
return treeSecrets[nodeIndex]!!
That invariant holds only for power-of-two trees. For Fred (leafCount
= 3, nodeCount = 5), `BinaryTree.parent(4, 5)` walks via
`parentInRange(5, 5)` and returns **3** (the root) — skipping the
virtual intermediate node 5 that would normally sit between node 4
and the root in a 4-leaf tree. But `left(3) = 1` and `right(3) = 5`;
neither is 4. The cache fills entries 1 and 5 from the root's secret,
node 4's entry is never populated, and the final
`treeSecrets[nodeIndex]!!` throws NPE. On Android the error path
instead re-enters `getNodeSecret` until the stack is exhausted.
The fix walks **down** from the root to the target leaf, picking
left or right at each step based on `targetNode < currentNode`
(holds in left-balanced MLS trees) and using `BinaryTree.left` /
`BinaryTree.right` which correctly descend through virtual
intermediate nodes. The node-level cache is no longer needed —
`getOrInitSender` caches the per-sender ratchet state once, so
re-deriving the leaf secret each time a new sender is initialized
costs a handful of HKDF calls at worst.
Side effects (all in the right direction):
- Re-enabled 6 previously `@Ignore`d tests in
`MlsGroupLifecycleTest` and `MlsGroupEdgeCaseTest` that exercised
exactly the three-member / non-full-tree path this fix addresses:
`testThreeMemberGroup_SequentialAdditions`,
`testExternalJoin_ZaraJoinsViaGroupInfo`,
`testExternalJoin_ExporterSecretsAgree`,
`testSigningKeyRotation_EpochAdvances`,
`testEncryptDecryptAfterSigningKeyRotation`,
`testPskProposal_EpochAdvancesWithPsk`. They now pass.
- `testEmptyCommit_AdvancesEpoch` and
`testMultipleEpochTransitions_EncryptDecryptStillWorks` remain
`@Ignore`d — they hit a **different** pre-existing bug where
Alice's and Bob's exporter secrets diverge after a no-proposal
commit (commit with only an UpdatePath). Unrelated to the
non-full-tree fix; tracked separately. I retagged them with an
explanatory note instead of the old "see
testThreeMemberGroup_SequentialAdditions" reference.
- Fixed an unrelated typo in
`testMultipleEpochTransitions_EncryptDecryptStillWorks` that
asserted `7L` and then `6L` for the same epoch value.
Regression test:
- `testFredEncryptsAfterJoiningThreeMemberGroup` creates a
three-member group via the production path (Alice adds Bob, then
adds Fred; Bob processes the add-Fred commit), then has Fred
encrypt an application message and asserts both Alice and Bob can
decrypt it. Without the fix this throws NPE / stack-overflows at
`encrypt()`.
https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL