Skip to content

Commit 9a76686

Browse files
committed
Append the LMS private key tree cache as optional trailing data under an unchanged version 0 rather than a version 1 encoding, and cache the top six levels rather than seven, so releases predating the cache still read the key and it costs half the bytes, relates to github #2365.
1 parent c9566f0 commit 9a76686

3 files changed

Lines changed: 42 additions & 23 deletions

File tree

‎core/src/main/java/org/bouncycastle/pqc/crypto/lms/LMSPrivateKeyParameters.java‎

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public class LMSPrivateKeyParameters
1717
implements LMSContextBasedSigner
1818
{
1919
private static CacheKey T1 = new CacheKey(1);
20-
private static CacheKey[] internedKeys = new CacheKey[129];
20+
private static CacheKey[] internedKeys = new CacheKey[64];
2121

2222
static
2323
{
@@ -109,10 +109,9 @@ else if (src instanceof DataInputStream)
109109
*/
110110

111111

112-
int version = dIn.readInt();
113-
if (version != 0 && version != 1)
112+
if (dIn.readInt() != 0)
114113
{
115-
throw new IllegalStateException("expected version 0 or 1 lms private key");
114+
throw new IllegalStateException("expected version 0 lms private key");
116115
}
117116

118117
int sigType = dIn.readInt();
@@ -147,11 +146,15 @@ else if (src instanceof DataInputStream)
147146
LMSPrivateKeyParameters key = new LMSPrivateKeyParameters(parameter, otsParameter, q, I, maxQ, masterSecret);
148147

149148
//
150-
// A version 1 encoding appends a cache of the top of the Merkle tree (see getEncoded).
151-
// Priming it here means the first signature made after the key is decoded does not have
152-
// to rebuild the whole tree, which otherwise costs about as much as key generation.
149+
// Anything after the master secret is a cache of the top of the Merkle tree (see
150+
// getEncoded). Priming it here means the first signature made after the key is decoded
151+
// does not have to rebuild the whole tree, which otherwise costs about as much as key
152+
// generation. The cache is optional trailing data rather than a new version so that
153+
// releases predating it still read the key - they stop at the master secret and ignore
154+
// what follows - at the cost of it being absent rather than malformed when a stream
155+
// supplies no more bytes.
153156
//
154-
if (version == 1)
157+
if (dIn.available() > 0)
155158
{
156159
int cacheCount = dIn.readInt();
157160
if (cacheCount < 0 || cacheCount >= internedKeys.length)
@@ -512,29 +515,31 @@ public byte[] getEncoded()
512515
// It is implementation dependent.
513516
//
514517
// Format:
515-
// version u32 (1; version 0 - without the tree cache - is still accepted on read)
518+
// version u32 (0)
516519
// type u32
517520
// otstype u32
518521
// I u8x16
519522
// q u32
520523
// maxQ u32
521524
// master secret Length u32
522525
// master secret u8[]
523-
// tree cache node count u32 (n; the top-of-tree nodes 1..n)
524-
// tree cache nodes u8[] (n * getSigParameters().getM() bytes)
526+
// tree cache node count u32 (n; the top-of-tree nodes 1..n) - optional
527+
// tree cache nodes u8[] (n * getSigParameters().getM() bytes) - optional
525528
//
526529
// The tree cache carries the top of the Merkle tree so that the first signature made after
527530
// the key is decoded does not have to rebuild the whole tree - which otherwise costs about
528531
// as much as key generation (see github #2365). The nodes are a deterministic function of I,
529532
// the master secret and the parameters and are independent of q, so persisting them leaks
530-
// nothing the (already encoded) master secret does not. A key written by this method cannot
531-
// be read by releases before 1.86; those releases reject the version field.
533+
// nothing the (already encoded) master secret does not. The cache is appended after the
534+
// master secret rather than announced by a new version number, so a key written by this
535+
// method is still readable by releases that predate it: their decoder returns at the end of
536+
// the master secret and never looks at the trailing bytes.
532537
//
533538

534539
int cacheTop = Math.min(internedKeys.length, maxCacheR);
535540

536541
Composer composer = Composer.compose()
537-
.u32str(1) // version
542+
.u32str(0) // version
538543
.u32str(parameters.getType()) // type
539544
.u32str(otsParameters.getType()) // ots type
540545
.bytes(I) // I at 16 bytes

‎core/src/test/java/org/bouncycastle/pqc/crypto/lms/LMSTests.java‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package org.bouncycastle.pqc.crypto.lms;
22

33
import java.io.IOException;
4+
import java.security.SecureRandom;
45

56
import junit.framework.TestCase;
67
import org.bouncycastle.util.Arrays;
8+
import org.bouncycastle.util.Pack;
79
import org.bouncycastle.util.encoders.Hex;
810

911
public class LMSTests
@@ -195,11 +197,13 @@ public void testTreeCachePersistence()
195197

196198
byte[] enc = privateKey.getEncoded();
197199

198-
// version 1: 72 byte legacy body + u32 node count + (cacheTop - 1) nodes of m bytes each.
200+
// 72 byte body + u32 node count + (cacheTop - 1) nodes of m bytes each. The version stays 0
201+
// and the cache is appended as trailing data, so releases that predate it still read the
202+
// key - they stop at the master secret - and simply do not see the cache.
199203
assertEquals(0, enc[0]);
200204
assertEquals(0, enc[1]);
201205
assertEquals(0, enc[2]);
202-
assertEquals(1, enc[3]);
206+
assertEquals(0, enc[3]);
203207
assertEquals(72 + 4 + (cacheTop - 1) * m, enc.length);
204208

205209
// Decoding primes the cache - this is the fix; without it the decoded key's cache is empty
@@ -214,7 +218,8 @@ public void testTreeCachePersistence()
214218
LMSPrivateKeyParameters fresh = LMS.generateKeys(sigParams, otsParams, 0, I, seed);
215219
assertTrue(Arrays.areEqual(sigFromDecoded.getEncoded(), LMS.generateSign(fresh, msg).getEncoded()));
216220

217-
// Legacy version 0 encodings (no cache) must still decode and sign correctly.
221+
// An encoding with no trailing cache - what an older release writes - must still decode
222+
// and sign correctly.
218223
byte[] legacyEnc = Composer.compose()
219224
.u32str(0)
220225
.u32str(sigParams.getType())
@@ -228,7 +233,7 @@ public void testTreeCachePersistence()
228233
assertEquals(72, legacyEnc.length);
229234

230235
LMSPrivateKeyParameters legacy = LMSPrivateKeyParameters.getInstance(legacyEnc);
231-
assertFalse("version 0 encoding carries no cache", legacy.isTreeCachePrimed());
236+
assertFalse("encoding without trailing data carries no cache", legacy.isTreeCachePrimed());
232237
assertTrue(LMS.verifySignature(publicKey, LMS.generateSign(legacy, msg), msg));
233238
}
234239

@@ -292,11 +297,20 @@ public void testMalformedPrivateKeyTreeCache()
292297

293298
LMSigParameters sigParams = LMSigParameters.lms_sha256_n32_h10;
294299
LMOtsParameters otsParams = LMOtsParameters.sha256_n32_w4;
295-
int cacheCountLimit = 128;
296300
int m = sigParams.getM();
297301

302+
//
303+
// The number of nodes cached is capped by LMSPrivateKeyParameters' interned-key table, so
304+
// read it off a freshly generated key of the same parameters rather than hard-coding it -
305+
// the limit moves if that table is resized.
306+
//
307+
LMSKeyPairGenerator limitGen = new LMSKeyPairGenerator();
308+
limitGen.init(new LMSKeyGenerationParameters(new LMSParameters(sigParams, otsParams), new SecureRandom()));
309+
byte[] sampleEnc = ((LMSPrivateKeyParameters)limitGen.generateKeyPair().getPrivate()).getEncoded();
310+
int cacheCountLimit = Pack.bigEndianToInt(sampleEnc, 40 + m);
311+
298312
byte[] atLimit = Composer.compose()
299-
.u32str(1)
313+
.u32str(0)
300314
.u32str(sigParams.getType())
301315
.u32str(otsParams.getType())
302316
.bytes(I)
@@ -310,7 +324,7 @@ public void testMalformedPrivateKeyTreeCache()
310324
assertTrue(LMSPrivateKeyParameters.getInstance(atLimit).isTreeCachePrimed());
311325

312326
byte[] beyondLimit = Composer.compose()
313-
.u32str(1)
327+
.u32str(0)
314328
.u32str(sigParams.getType())
315329
.u32str(otsParams.getType())
316330
.bytes(I)
@@ -331,7 +345,7 @@ public void testMalformedPrivateKeyTreeCache()
331345
}
332346

333347
byte[] truncated = Composer.compose()
334-
.u32str(1)
348+
.u32str(0)
335349
.u32str(sigParams.getType())
336350
.u32str(otsParams.getType())
337351
.bytes(I)

‎docs/releasenotes.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ <h3>2.1.2 Defects Fixed</h3>
7676
<li>ArmoredInputStream.read() returned read(), calling itself, on reaching the ASCII-armor checksum line. The running CRC-24 is not reset between lines and the checksum was not required to be the last thing before the armor tail, so repeating one line whose value matches - "=twTO", the base64 of the CRC-24 initial value, which needs no data bytes at all - cost a stack frame per line and threw StackOverflowError from about 360KB of plain ASCII. Being an Error it escaped the catch (IOException) callers put around PGP parsing, and PGPUtil.getDecoderStream reaches it on the first read of any untrusted armored message. A second checksum is now rejected, per RFC 9580 sec. 6.2, which places it once immediately before the tail; the armor tail still clears the flag, so concatenated messages are unaffected.</li>
7777
<li>X500Name.hashCode() threw a NullPointerException for a name containing an RDN decoded from an empty SET, which any peer can encode: AbstractX500NameStyle.calculateHashCode took the RDN's first AttributeTypeAndValue without a null check, while its two siblings for the same case - areEqual for equals(), and IETFUtils.appendRDN for toString() - both already had one, so hashCode was the only one of the three that failed. An empty RDN now contributes nothing to the hash, matching what toString renders for it. X500Name.hashCode() was additionally marking the value calculated before computing it, so once a style had thrown, every later call quietly returned 0 - a single loud failure degrading into a silent source of hash collisions; the flag is now set after the value is in hand, matching the j2me copy of the class, which had not drifted.</li>
7878
<li>HSSSigner.init and LMSSigner.init assigned only the key for the mode being set and left the other one from a previous init in place, so a signer initialised for verification still held the private key from an earlier signing init and would sign with it, and one initialised for signing still held the public key and would verify. Both keys are now cleared on every init, and calling the signer in the mode it was not initialised for raises IllegalStateException naming which init is missing rather than working off the stale key or failing later as a NullPointerException; the verification check sits ahead of the existing catch-all so a missing init is reported rather than folded into "signature did not verify". The two lightweight tests that asserted an exhausted key shard were reaching generateSignature through a signer left in verify mode and now re-initialise for signing first, which is what they meant to test. Note the same init pattern remains in the other older signers under pqc.crypto - XMSS, XMSS^MT, Falcon, Picnic, Rainbow, Dilithium, SPHINCS-256 and SPHINCS+ - while every signer added since (AIMer, Faest, HAETAE, Hawk, Mayo, MQOM, QRUOV, SDitH, SLH-DSA, Snova, SQIsign, UOV) already clears both.</li>
79-
<li>LMS private-key encodings now preserve a bounded top-of-tree cache alongside the existing seed material, so a decoded LMS/HSS private key avoids rebuilding the Merkle tree before its first signature while continuing to accept the previous version-0 encoding. LMS private-key decoding also now rejects unknown LMS and LM-OTS type codes with IOException rather than leaking a NullPointerException. The persisted cache is deterministic from the already-encoded master secret and is capped to the existing LMS tree-cache bound (github #2365).</li>
79+
<li>LMS private-key encodings now preserve a bounded top-of-tree cache alongside the existing seed material, so a decoded LMS/HSS private key avoids rebuilding the Merkle tree before its first signature - measured at about thirty times faster for the h10 and h15 parameter sets. The cache is appended after the master secret rather than announced by a new version number, so the encoding stays version 0 and remains readable by releases that predate it: their decoder stops at the master secret and never looks at the trailing bytes, giving them a working key without the cache. An encoding carrying no cache is still accepted, and a truncated or over-large one is still rejected. The cache holds the top six levels of the tree, which takes an sha256_n32 private key from 72 to 2092 bytes. LMS private-key decoding also now rejects unknown LMS and LM-OTS type codes with IOException rather than leaking a NullPointerException. The persisted cache is deterministic from the already-encoded master secret, so it discloses nothing that secret does not (github #2365).</li>
8080
<li>XMSSSigner.init and XMSSMTSigner.init assigned only the key for the mode being set. generateSignature already refused to run unless the signer had been initialised for signing, but verifySignature carried no equivalent check, so a signer re-initialised for signing still verified against the public key left behind by an earlier verification init - and returned true rather than failing. The public key is now cleared on a signing init, and verifySignature opens with a single check that reports an IllegalStateException both for that wrong mode and for a signer never initialised at all - the latter previously returned false for XMSS, because the NullPointerException the absent public key raised was swallowed by the catch that keeps a malformed signature from propagating, and threw NullPointerException for XMSS^MT. The private key is deliberately left in place on a verification init, unlike the HSSSigner and LMSSigner change above: getUpdatedPrivateKey() synchronizes on it, so clearing it would turn sign, then verify, then collect the advanced key state - a legitimate sequence for these stateful schemes, and the one the JCA layer uses - into a NullPointerException. Falcon and SPHINCS-256 were checked and are unaffected, holding a single key field that a re-init overwrites rather than separate private and public fields.</li>
8181
<li>The PQC PrivateKeyFactory (org.bouncycastle.pqc.crypto.util) chose its algorithm branch with a subtree test on the algorithm identifier's OID arc, algOID.on(arc), which matches every leaf below the arc - including leaves the corresponding Utils parameters table has no entry for. The branch then passed the null lookup result straight into a *PrivateKeyParameters constructor, so a PKCS#8 PrivateKeyInfo naming such an OID escaped createKey with a NullPointerException past its declared throws IOException for fourteen of the eighteen arcs, and for the other four - Classic McEliece, NTRU LPRime, Streamlined NTRU Prime and SNOVA, whose constructors only store the parameters - createKey returned a key object carrying null parameters, deferring the failure to whatever used the key. Every arc was reachable with an arbitrary undeclared leaf; concretely, the OQS interop arc 1.3.9999.6 carries sixteen hybrid (ECDSA-P256 / RSA-3072 / P-384 / P-521 combined with SPHINCS+) OIDs that are declared in BCObjectIdentifiers but deliberately not implemented, for example p256_sphincs_sha2_128f_simple (1.3.9999.6.4.14). Each branch now dispatches on the parameters table itself, Utils.&lt;alg&gt;Params.containsKey(algOID) - the predicate the same method already used for SLH-DSA and MQOM - so an OID with no parameters falls through to the unrecognised-algorithm terminal, which now throws IOException rather than RuntimeException, matching PublicKeyFactory whose terminal has always been an IOException. On the public-key side the sixteen hybrid OIDs are not in the converter table at all and were already rejected cleanly, but five registrations there route to a converter whose parameters lookup cannot succeed - the bare SPHINCS+ arc, the OQS round-3 OID 1.3.9999.6.4.10, and the vestigial Kyber-AES OIDs - and those leaked the same NullPointerException; the SPHINCS+ and ML-KEM converters now reject an unmapped OID with IOException. Keys naming a mapped OID are unaffected, and the jdk1.4 and jdk1.1 legacy overlays of both factories carry the same fix.</li>
8282
<li>EdEC KeyFactorySpi.engineGeneratePublic could read the algorithm discriminator byte from a malformed X509EncodedKeySpec before checking the encoding length, so very short Ed25519/Ed448/X25519/X448 public-key encodings leaked ArrayIndexOutOfBoundsException, and malformed full-length encodings could leak runtime exceptions from the key-parameter constructors instead of the KeyFactory contract's InvalidKeySpecException. The base implementation and the jdk1.4, jdk1.11 and jdk1.15 legacy/multi-release overlays now guard the fixed-offset fast path and wrap malformed EdEC public-key decode failures as InvalidKeySpecException while preserving valid key import.</li>

0 commit comments

Comments
 (0)