Skip to content

Commit 0a65fb6

Browse files
committed
LMS: Build the tree for the public key as the authentication path of the current one-time key, so a freshly generated key retains its first signature's path instead of rebuilding the 2^(h-5) leaves below the cached top that the build has just computed and dropped; fresh ancestors within the pinned top and the root are published to the cache as a side effect
1 parent acc84b8 commit 0a65fb6

3 files changed

Lines changed: 92 additions & 2 deletions

File tree

‎core/src/main/java/org/bouncycastle/crypto/params/LMSPrivateKeyParameters.java‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ public class LMSPrivateKeyParameters
5252
// (advanceRetainedPath). Consecutive signatures share most of their path, so a signature costs about
5353
// (h - 5) / 2 + 1 leaf derivations amortised, in place of the 2^(h - 5) it takes to rebuild the path
5454
// below the cached top every time. The worst case (crossing into the other half of the tree) is
55-
// still that rebuild; only a scheduled traversal (BDS) would smooth it.
55+
// still that rebuild; only a scheduled traversal (BDS) would smooth it. A tree built for the public
56+
// key is built in path form (getPublicKey), so a freshly generated key already holds the path of
57+
// its first signature; a decoded key holds none until it signs.
5658
//
5759
// The arrays of both tiers are handed out by reference to contexts and signatures and must never be
5860
// modified or wiped.
@@ -621,12 +623,32 @@ public LMSPublicKeyParameters getPublicKey()
621623
{
622624
if (publicKey == null)
623625
{
626+
//
627+
// With no root cached the whole tree has to be built. Built as the authentication path
628+
// of the current one-time key it costs exactly the same - every leaf and interior node
629+
// once - but leaves that path retained, so the first signature does not rebuild the
630+
// 2^(h - 5) leaves below the cached top that the build has just computed and dropped.
631+
//
632+
if (retained == null && peekRootT() == null && q >= 0 && q < (1 << parameters.getH()))
633+
{
634+
advanceRetainedPath(parameters.getH(), q);
635+
}
636+
624637
publicKey = new LMSPublicKeyParameters(parameters, otsParameters, this.findT(1), I);
625638
}
626639
return publicKey;
627640
}
628641
}
629642

643+
/**
644+
* Whether an authentication path is currently retained. Used by the regression tests that check
645+
* a key built or signed with keeps the path its work produced.
646+
*/
647+
synchronized boolean isPathRetained()
648+
{
649+
return retained != null;
650+
}
651+
630652
byte[] findT(int r)
631653
{
632654
if (r >= maxCacheR)
@@ -709,6 +731,33 @@ private byte[][] advanceRetainedPath(int h, int q)
709731
? LMSEngine.computeNode(tDigest, I, r >> i, child, sibling)
710732
: LMSEngine.computeNode(tDigest, I, r >> i, sibling, child);
711733
}
734+
735+
//
736+
// The fresh ancestors that fall within the pinned top are nodes the cache would otherwise
737+
// compute again, so they are published now; the siblings already were, by findT. Built
738+
// from nothing (fresh == h) this is the whole tree in path form, and the root is then one
739+
// hash away - storing it is what lets getPublicKey come here in place of a plain build.
740+
//
741+
synchronized (tCache)
742+
{
743+
for (int i = 0; i < fresh; ++i)
744+
{
745+
int node = r >> i;
746+
if (node < maxCacheR && tCache[node] == null)
747+
{
748+
tCache[node] = anc[i];
749+
}
750+
}
751+
752+
if (tCache[1] == null)
753+
{
754+
byte[] child = anc[h - 1], sibling = path[h - 1];
755+
756+
tCache[1] = ((r >> (h - 1)) & 1) == 0
757+
? LMSEngine.computeNode(tDigest, I, 1, child, sibling)
758+
: LMSEngine.computeNode(tDigest, I, 1, sibling, child);
759+
}
760+
}
712761
}
713762

714763
retained = new RetainedPath(q, path, anc);

‎core/src/test/java/org/bouncycastle/crypto/params/LMSTests.java‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,47 @@ public void run()
702702
}
703703
}
704704

705+
/**
706+
* Building the tree for the public key computes every node on the current one-time key's path, so
707+
* the key keeps that path rather than rebuilding it for the first signature. A decoded key has no
708+
* such path until it signs, and one decoded from a legacy encoding with no tree cache gets its root
709+
* from that first signature's path rather than from a second full build.
710+
*/
711+
public void testTreeBuildRetainsFirstPath()
712+
throws Exception
713+
{
714+
LMSigParameters sigParams = LMSigParameters.lms_sha256_n32_h5;
715+
LMOtsParameters otsParams = LMOtsParameters.sha256_n32_w8;
716+
byte[] I = Hex.decode("d08fabd4a2091ff0a8cb4ed834e74534");
717+
byte[] seed = Hex.decode("558b8966c48ae9cb898b423c83443aae014a72f1b1ab5cc85cf1d892903b5439");
718+
byte[] msg = Strings.toByteArray("first path");
719+
720+
LMSPrivateKeyParameters key = lmsKey(sigParams, otsParams, 0, I, seed);
721+
assertFalse(key.isPathRetained());
722+
LMSPublicKeyParameters pub = key.getPublicKey();
723+
assertTrue("tree build should leave the first path retained", key.isPathRetained());
724+
assertTrue(key.isTreeCachePrimed());
725+
byte[] sig = sign(key, msg);
726+
assertEquals(0, Pack.bigEndianToInt(sig, 0));
727+
assertTrue(verify(pub, sig, msg));
728+
729+
LMSPrivateKeyParameters decoded = LMSPrivateKeyParameters.getInstance(key.getEncoded());
730+
assertTrue(decoded.isTreeCachePrimed());
731+
assertFalse("nothing to retain from a decode", decoded.isPathRetained());
732+
sig = sign(decoded, msg);
733+
assertEquals(1, Pack.bigEndianToInt(sig, 0));
734+
assertTrue(verify(pub, sig, msg));
735+
assertTrue(decoded.isPathRetained());
736+
737+
LMSPrivateKeyParameters legacy = LMSPrivateKeyParameters.getInstance(coreKey(sigParams, otsParams, I, seed, 2, 32));
738+
assertFalse(legacy.isTreeCachePrimed());
739+
sig = sign(legacy, msg);
740+
assertEquals(2, Pack.bigEndianToInt(sig, 0));
741+
assertTrue(verify(pub, sig, msg));
742+
assertTrue("the first path should have supplied the root", legacy.isTreeCachePrimed());
743+
assertEquals(pub, legacy.getPublicKey());
744+
}
745+
705746
private static LMSPrivateKeyParameters generateKey(LMSigParameters sigParams, LMOtsParameters otsParams)
706747
{
707748
SecureRandom random = new SecureRandom();

‎docs/releasenotes.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Date: 2026, TBD
2626

2727
### 2.1.3 Additional Features and Functionality
2828

29-
- The promoted LMS private key (org.bouncycastle.crypto.params.LMSPrivateKeyParameters) keeps its Merkle tree in two bounded tiers in place of the WeakHashMap that held every node it ever computed: the top 63 nodes - the same ones its encoding persists - in a fixed array for the life of the key, and the authentication path of the last one-time key signed with, together with that leaf's ancestors, advanced under the key's lock as each index is claimed. The old map keyed every node below the top on objects nothing retained, so a leaf was collectable the moment it was inserted and the map's hit rate below the top depended on when the collector next ran, while its table still grew to 2^(h+1) entries during a tree build. A run of consecutive signatures now costs about (h - 5) / 2 + 1 leaf derivations each rather than either a cache hit or a 2^(h - 5) rebuild, with the rebuild remaining as the worst case at a half-tree crossing; shards and repositioned keys inherit the parent's retained path, and the encoding is unchanged. The deprecated org.bouncycastle.pqc.crypto.lms copy is untouched.
29+
- The promoted LMS private key (org.bouncycastle.crypto.params.LMSPrivateKeyParameters) keeps its Merkle tree in two bounded tiers in place of the WeakHashMap that held every node it ever computed: the top 63 nodes - the same ones its encoding persists - in a fixed array for the life of the key, and the authentication path of the last one-time key signed with, together with that leaf's ancestors, advanced under the key's lock as each index is claimed. The old map keyed every node below the top on objects nothing retained, so a leaf was collectable the moment it was inserted and the map's hit rate below the top depended on when the collector next ran, while its table still grew to 2^(h+1) entries during a tree build. A run of consecutive signatures now costs about (h - 5) / 2 + 1 leaf derivations each rather than either a cache hit or a 2^(h - 5) rebuild, with the rebuild remaining as the worst case at a half-tree crossing; shards and repositioned keys inherit the parent's retained path, and the encoding is unchanged. The tree built for the public key is built in path form, so a freshly generated key already holds the path of its first signature rather than rebuilding it - that signature went from 2.6 s to about 1 ms at h=15. The deprecated org.bouncycastle.pqc.crypto.lms copy is untouched.
3030

3131
### 2.1.4 Additional Notes
3232

0 commit comments

Comments
 (0)