Implementation of a post-quantum threshold signature scheme based on LMS (Leighton-Micali Signature), following the k-of-k One-Time Threshold Signatures scheme described in:
John Kelsey, Nathalie Lang, and Stefan Lucks, Turning Hash-Based Signatures into Distributed Signatures and Threshold Signatures: Delegate Your Signing Capability, and Distribute it Among Trustees. IACR Communications in Cryptology, vol. 2, no. 2, Jul 07, 2025, doi: 10.62056/a6ksudy6b
The end goal is for signatures produced by the threshold scheme to be indistinguishable from standard LMS signatures, directly verifiable with OpenSSL 4.
- Overview
- Project Structure
- Protocol Design
- Main Data Structures
- Implementation
- Verification with OpenSSL
- Benchmarking
- Testing
- Requirements
- Build and Run
The scheme implements a k-of-k threshold signature over LMS/LM-OTS. The core idea is that no individual trustee holds the LMS private key; instead, the Dealer distributes the sensitive information XOR-masked across all trustees. Only if the k trustees of a coalition collaborate can the final signature be reconstructed — a standard LMS object verifiable by any RFC 8554-compliant implementation.
The project includes two variants of the protocol:
- Flat variant (
KK_Setup/KK_Sign1/KK_Sign2/KK_Aggregator_Sign): a single fixed coalition ofktrustees, all required for every signature. This is theℓ-of-ℓcase described in the KLL paper, kept in the code as a reference baseline. - Sharded variant (
ShardSetup/ShardSign1/ShardSign2/AggregatorSign): each KeyID of the LMS tree is assigned to a different coalition of trustees via a coalition listCL, enabling real threshold/distributed signatures without requiring every trustee for every signature. This is the variant used byProtocolRunner.mainand the one recommended for actual use.
Security stems from two properties:
- One-time use: each
(K[t], KeyID)pair is used exactly once. The Trustee rejects any reuse attempt (viausedKeyIDsin the flat variant, and by consuming the entry from itskeylistin the sharded variant). - Round authentication: in Round 2, the Trustee verifies via
KK_Auththat the received randomizerRis consistent with its own share, preventing substitution attacks by the Aggregator.
threshold-hbs/
├── src/
│ ├── main/java/es/uma/nicslab/hbs/
│ │ ├── lms/
│ │ │ ├── LM_OTS_WITH_CHAIN.java # Generation of the full Winternitz chain (SK[i][j])
│ │ │ ├── LMOtsChain.java # Container for the full chain
│ │ │ ├── LMSHashUtils.java # Auxiliary LMS hash functions (H, computeH)
│ │ │ ├── LMSSerializer.java # Converts a ThresholdSignature to the RFC 8554 wire format
│ │ │ └── ... # Remaining Bouncy Castle LMS/HSS classes
│ │ ├── model/
│ │ │ ├── CRV.java # Masked values: R, CHK, PATH, SK
│ │ │ ├── Round1Msg.java # Round 1 message: (R_t, CHK_t)
│ │ │ ├── Round2Msg.java # Round 2 message: (Z_t, PATH_t)
│ │ │ ├── SetupDealer.java # Result of ShardSetup: trustees + board
│ │ │ └── ThresholdSignature.java # Final assembled signature: (R, PATH, Z)
│ │ ├── protocol/
│ │ │ ├── PublicBulletinBoard.java # CRV, CL, public key — public information shared by all parties
│ │ │ └── ProtocolRunner.java # Orchestrates the full protocol (sharded variant)
│ │ ├── roles/
│ │ │ ├── Dealer.java # KK_Setup / ShardSetup
│ │ │ ├── Trustee.java # KK_Sign1/KK_Auth/KK_Sign2 and ShardSign1/ShardSign2
│ │ │ └── Aggregator.java # KK_Aggregator_Sign and AggregatorSign
│ │ ├── bench/
│ │ │ └── Benchmark.java # Benchmarking harness (setup, signing, verification)
│ │ └── util/
│ │ ├── PRF.java # PRF built on KMAC-256 with 5 domain-separation labels
│ │ ├── ByteUtils.java # XOR, concat, intToBytes, constantTimeEquals
│ │ └── LMSExporterOpenSSL.java # Exports keys and signatures for OpenSSL
│ └── test/java/es/uma/nicslab/hbs/
│ └── roles/
│ ├── AggregatorTest.java
│ ├── TrusteeTest.java
│ └── DealerTest.java
├── LMSOpenSSL/
│ ├── cert.pem
│ ├── lmspub.pem
│ ├── message.bin
│ └── sig.file
├── ThresholdOpenSSL/
│ ├── lmspubthreshold.pem
│ ├── messagethreshold.bin
│ └── sigthreshold.file
├── bench/
│ └── results/ # Raw benchmarking logs (see Benchmarking section)
├── pom.xml
└── README.md
| Role | Responsibility | Secret knowledge |
|---|---|---|
| Dealer | Runs KK_Setup/ShardSetup exactly once. Generates and publishes the CRV (and, with sharding, the CL) on the PublicBulletinBoard. Takes no further part after setup. |
The keys K[1..k] of every trustee (only during setup) |
| Trustee | Takes part in the two signing rounds, for the KeyIDs of its coalition(s). | Only its own PRF key K[t] |
| Aggregator | Coordinates the two rounds, reconstructs the values via XOR and assembles the final signature. | The (public) CRV and the trustees' round messages. Knows no K[t]. |
Dealer ──KK_Setup / ShardSetup──► CRV (+ CL) on the PublicBulletinBoard
┌─── Round 1 ───────────────────────────────────────┐
│ Aggregator ──(KeyID, M)──► Trustee t │
│ Trustee t ──(R_t, CHK_t)──► Aggregator │
└───────────────────────────────────────────────────┘
┌─── Round 2 ───────────────────────────────────────┐
│ Aggregator ──(R, CHK'[t])──► Trustee t │
│ Trustee t ──(Z_t, PATH_t)──► Aggregator │
└───────────────────────────────────────────────────┘
Aggregator assembles (R, PATH, Z) → standard LMS signature, verifiable with OpenSSL
Round 1 in detail:
- The Aggregator sends
(KeyID, M)to each Trusteetbelonging to the coalition for thatKeyID. - The Trustee checks that
KeyIDhas not been used before (one-time protection) and that no other signature is in progress. - Each Trustee computes
R_t = PRF^R_{K[t]}(KeyID, n)andCHK_t = PRF^CHK_{K[t]}(KeyID, |CHK|)and returns them to the Aggregator. - The Aggregator reconstructs
R = CRV.R ⊕ R_1 ⊕ ... ⊕ R_kandCHK = CRV.CHK ⊕ CHK_1 ⊕ ... ⊕ CHK_k.
Round 2 in detail:
- The Aggregator sends
(R, CHK[t])to each Trusteet(whereCHK[t]is thet-th block of the reconstructedCHKarray). - The Trustee runs
KK_Auth: it checks thatPRF^AUTH_{K[t]}(KeyID, R, n) == CHK[t]. If it fails, it returns⊥. - If authentication succeeds, the Trustee computes the message hash
h, computes its share of the signatureZ_tby walking the Winternitz chains overSK_t[i][j], and its share of the Merkle pathPATH_t = PRF^PATH_{K[t]}(KeyID, |PATH|). - The Aggregator reconstructs
PATH = CRV.PATH ⊕ PATH_1 ⊕ ... ⊕ PATH_kand assemblesZby applying the Winternitz chains overCRV.SKand combining with theZ_tshares.
In the flat variant, the k trustees passed to Dealer take part in every signature. With sharding, ShardSetup additionally takes an int[][] CL of length D (the number of KeyIDs in the tree), where CL[keyID] is the array of trustee indices authorized to sign with that particular KeyID.
- Each
TrusteecallsTrusteeSetup(trusteeIndex, CL)after setup, which builds itskeylist: the subset of KeyIDs it takes part in. ShardSign1only proceeds if the requested KeyID is in the trustee'skeylist, and it consumes it from the list upon use (the one-time protection specific to this variant).Aggregator.AggregatorSign(message, keyID)obtains the coalition fromboard.getCL()[keyID]automatically — the caller does not choose which trustees take part; theCLpublished at setup does.- Signing with one KeyID does not consume a trustee's availability for other KeyIDs belonging to different coalitions: one-time state is tracked per KeyID, not globally.
The CRV holds the original LMS values, XOR-masked with the shares of every trustee in the corresponding coalition. It is public information (published on the PublicBulletinBoard), but it reveals no secret, since recovering any original value requires all the shares of the coalition at once.
| Field | Type | Content |
|---|---|---|
R |
byte[] |
R ⊕ R_1 ⊕ ... ⊕ R_k |
CHK |
byte[] |
concat(CHK[1..k]) ⊕ CHK_1 ⊕ ... ⊕ CHK_k |
PATH |
byte[] |
concat(PATH[1..h]) ⊕ PATH_1 ⊕ ... ⊕ PATH_k |
SK |
byte[][][] |
SK[i][j] ⊕ SK_1[i][j] ⊕ ... ⊕ SK_k[i][j] |
Where R is the LM-OTS randomizer, CHK is the trustees' authentication value, PATH is the Merkle authentication path, and SK[i][j] is the j-th element of Winternitz chain i. With sharding, k is the size of the coalition assigned to that particular KeyID (CL[keyID].length), which can vary from one KeyID to another.
The PRF is implemented with KMAC-256 using five domain-separation labels, to guarantee that outputs from different uses are independent of one another even though they share the same key K[t] and the same KeyID.
| Label | Invocation | Use |
|---|---|---|
R |
PRF^R_{K[t]}(KeyID, n) |
Randomizer share R_t |
CHAIN |
PRF^CHAIN_{K[t]}(KeyID, i, j, n) |
Winternitz chain share SK_t[i][j] |
CHK |
PRF^CHK_{K[t]}(KeyID, |CHK|) |
Verification-value share CHK_t |
PATH |
PRF^PATH_{K[t]}(KeyID, |PATH|) |
Merkle path share PATH_t |
AUTH |
PRF^AUTH_{K[t]}(KeyID, R, n) |
Randomizer authentication value in Round 2 |
KK_Setup (flat variant) and ShardSetup (sharded variant) each run exactly once, during the initialization phase. ShardSetup additionally takes the CL, and repeats the KK_Setup process for each of the D KeyIDs of the tree, using only the corresponding coalition CL[keyID] at each iteration.
For each KeyID, the Dealer:
- Computes
CHK[t] = PRF^AUTH_{K[t]}(KeyID, R, n)for each trustee in the coalition (original authentication values). - Generates the shares of each component by invoking the PRF with the corresponding label.
- Masks each original component via XOR with all its shares:
CRV.X = X ⊕ X_1 ⊕ ... ⊕ X_k. - Publishes the resulting
CRV(and, with sharding, theCL) on thePublicBulletinBoard.
After setup, the Dealer takes no further part in the protocol, and the keys K[t] must be distributed to each trustee over a secure channel.
Each Trustee keeps minimal state between rounds: the active keyID and the message M currently in progress. This state is cleared when Round 2 completes, or on any error (including a failed authentication).
Protections implemented:
- One-time enforcement: the
usedKeyIDsset (flat variant) or thekeylistbuilt byTrusteeSetup(sharded variant) track/consume theKeyIDs that have been processed. Any reuse attempt returns⊥. - Signing state: if
current != nullwhen a newKK_Sign1/ShardSign1arrives, it is rejected (two signatures cannot overlap). - Round authentication (
KK_Auth): before proceeding with Round 2, the Trustee recomputesPRF^AUTH_{K[t]}(KeyID, R, n)and compares it against theCHK[t]received from the Aggregator. This prevents a malicious Aggregator from forcing the Trustee to sign with anRdifferent from the one committed to in Round 1.
The Aggregator orchestrates the full protocol in a single method (KK_Aggregator_Sign(message, keyID) in the flat variant, AggregatorSign(message, keyID) with sharding):
- Round 1: invokes
KK_Sign1/ShardSign1on each Trustee in the coalition and collects(R_t, CHK_t). ReconstructsRand theCHKarray via XOR with the CRV. - Round 2: invokes
KK_Sign2/ShardSign2(R, CHK[t])on each Trustee and collects(Z_t, PATH_t). ReconstructsPATHvia XOR with the CRV. - Final assembly: computes the message hash
h, applies the Winternitz chains overCRV.SKto obtainZ_CRV, and combines:Z = Z_CRV ⊕ Z_1 ⊕ ... ⊕ Z_k. - Returns a
ThresholdSignature(R, PATH, Z), a standard LMS/LM-OTS object verifiable with OpenSSL.
If any trustee returns ⊥ in either round, the Aggregator aborts and returns null.
Signatures produced by the threshold scheme are indistinguishable from standard LMS signatures and directly verifiable with OpenSSL 4.
# Verify signature
openssl pkeyutl -verify -in message.bin -sigfile sig.file -inkey lmspub.pem -pubin
# Inspect the X.509 certificate
openssl x509 -in cert.pem -text -noout
# Extract the public key from the certificate
openssl x509 -in cert.pem -pubkey -noout > public_key_from_cert.pem
# Check the public key format
openssl pkey -in lmspub.pem -text -noout -pubin
Expected output:
Signature Verified Successfully
openssl pkeyutl -verify \
-in ThresholdOpenSSL/messagethreshold.bin \
-sigfile ThresholdOpenSSL/sigthreshold.file \
-inkey ThresholdOpenSSL/lmspubthreshold.pem -pubin
es.uma.nicslab.hbs.bench.Benchmark measures the cost of ShardSetup (per KeyID), of a full signature (AggregatorSign) and of its verification, for several values of w (Winternitz parameter) and k (coalition size), alongside a plain (non-threshold) LMS baseline. It has no JUnit dependency; it runs directly as a main.
# Compile and run the benchmark
mvn compile
java -cp target/classes es.uma.nicslab.hbs.bench.Benchmark
Methodology: 8 warm-up iterations and 15 repetitions per configuration, reporting mean ± standard deviation. Raw logs for each run (with a hardware/JDK/parameters header) are kept under bench/results/.
Three JUnit 5 test classes under src/test/java/es/uma/nicslab/hbs/roles/:
DealerTest— setup validation (CRV dimensions, errors from a malformedCLor out-of-range coalitions).TrusteeTest— the protocol's state machine (rejecting an out-of-coalition KeyID, overlapping signatures, an invalidCHK, KeyID reuse) and an end-to-end case with ak=1coalition.AggregatorTest— the full signing flow across different coalitions, cryptographic verification against BouncyCastle's LMS verifier, and one-time protection at the coalition level.
| Component | Minimum version |
|---|---|
| Java (JDK) | 17 |
| Maven | 3.6 |
Bouncy Castle (bcprov-jdk18on / bcpkix-jdk18on) |
1.84 (included in pom.xml) |
| OpenSSL | 4.0 |
# Build and run tests
mvn clean test
# Run the benchmark
mvn compile
java -cp target/classes es.uma.nicslab.hbs.bench.Benchmark