Skip to content

XMSS^MT: tie the carried-over traversal state to the previous subtree in the index check - #2560

Open
AlexLaroche wants to merge 1 commit into
bcgit:mainfrom
AlexLaroche:xmssmt-subtree-rollback
Open

AlexLaroche wants to merge 1 commit into
bcgit:mainfrom
AlexLaroche:xmssmt-subtree-rollback

Conversation

@AlexLaroche

Copy link
Copy Markdown
Contributor

Summary

The XMSS^MT index check added for github #2414 (BDSStateMap.validateIndex) makes one allowance. Where a layer's expected leaf is 0, it also accepts a traversal state on the layer's last leaf, because the state carried over from the previous subtree legitimately stays there until the signer rebuilds it.

The allowance only checks that the state is on a last leaf. It doesn't check that this is the previous subtree's last leaf. As a result, a key saved on the last leaf of a subtree, whose index was then moved back to the first leaf of that same subtree, still passes. That key signs a second message under a one-time key it has already used, and both signatures verify.

Reproduced with bcprov-jdk18on 1.86 (through PrivateKeyFactory, in both the legacy and the RFC 9802 PKCS#8 forms) and with main:

Parameters Saved at Index changed to Before this change
h=4 d=2 SHA-256 2 0 refused
h=4 d=2 SHA-256 3 (last leaf of subtree 0) 0 accepted, one-time key 0 reused
h=4 d=2 SHA-256 7 (last leaf of subtree 1) 4 accepted, one-time key 4 reused
SHA2_20/4_256 30 0 refused
SHA2_20/4_256 31 (last leaf of subtree 0) 0 accepted, one-time key 0 reused

Change

  • New check in BDS: isLastLeafOfTree(publicSeed, secretKeySeed, layer, tree) decides whether a state on the last leaf belongs to a given subtree.
    • The last leaf is a right node, so the first node of its authentication path is the leaf before it.
    • That leaf is recomputed from the seeds, at that subtree's address, and compared in constant time.
    • The cost is one WOTS+ public key and one L-tree, and it is paid only when the allowance applies.
  • New overloads: BDSStateMap.validate(XMSSMTParameters, long, byte[], byte[]) and validateIndex(XMSSMTParameters, long, byte[], byte[]) take the key's seeds. With the seeds, they accept the carried-over state only when it belongs to subtree tree - 1, and refuse it at tree 0.
  • Old overloads kept: the two-argument forms were already public in 1.86, so they stay, with the old behaviour, and are @deprecated.
  • Callers moved to the seeded forms:
    • the XMSSMTPrivateKeyParameters constructor (both the decode and the builder paths);
    • toByteArray() and rollKey();
    • XMSSEngine before signing;
    • XmssKeyUtil before writing PKCS#8 (its copy of the secret seed is cleared afterwards).
  • Docs: a release-notes entry, plus a note on the allowance in docs/claude/conventions.md.

Tests

  • New regression test: XMSSMTPrivateKeyTests.testIndexRollbackToStartOfSubtreeRejected.
    • It moves h=4/d=2 keys back from 3 to 0 and from 7 to 4. Both the raw encoding and PKCS#8 must refuse the result.
    • The keys as saved must still decode both ways and sign verifiably.
    • It fails on main without this change ("no exception on index 3 moved back to 0").
  • Existing tests: all 224 tests in org.bouncycastle.crypto.signers.xmss.AllTests pass. They include testEveryIndexRoundTrips and the index enumeration tests, which decode at every index of a key and so exercise the allowance at each legitimate boundary.

… the last leaf of a subtree only when it belongs to the previous subtree, so a key moved back to the first leaf of the subtree it was saved at the end of is refused rather than reusing a one-time key.
@dghgit

dghgit commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Please include a statement saying you reviewed the code. See CONTRIBUTING.md for details.

@AlexLaroche

Copy link
Copy Markdown
Contributor Author

Contribution statement

Use of generative AI. I used Claude Code (Anthropic's coding agent, model Claude Opus) to help with this pull request. It helped find and reproduce the issue, write the change and its regression test, run the existing tests, and write this description. I chose what to submit and I am responsible for it.

Review. I have reviewed every line of this change, including the tests and the release note, and I understand what it does and why. I checked that the new test fails on main and passes with the change, and that the existing tests listed in the description pass.

Representations under CONTRIBUTING.md. By submitting this pull request I make the representations in the project's contributing guidelines, including the following:

  • I hold the rights needed to release this contribution under the Bouncy Castle License. To the best of my knowledge it will not give rise to third-party intellectual property claims against the Legion of the Bouncy Castle Inc. or its recipients.
  • To the extent any part of it is protected by copyright, I am its author or am otherwise authorised to release it under the Bouncy Castle License.
  • Where any part was generated with AI tools and is not protected by copyright, I do not represent that part as owned intellectual property, and I understand that the Legion of the Bouncy Castle Inc. accepts it on that basis.
  • I have complied with Anthropic's terms of use for Claude Code. Those terms assign to me whatever rights Anthropic has in the outputs and place no restriction on contributing them under the Bouncy Castle License.
  • To the best of my knowledge, the contribution does not reproduce or derive from third-party material in a way that would infringe anyone's intellectual property rights. The code was written against the existing Bouncy Castle sources in this repository.

@dghgit dghgit self-assigned this Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants