Why One-Time Signature Keys Must Never Be Reused

Hash-based signature schemes such as Lamport and Winternitz build signatures from one-time keys. A signature is made by revealing part of the private key, so each key can safely sign exactly one message. Signing a second message with the same key reveals more of it, and an observer can combine the revealed parts into a signature the signer never produced.

This article shows the mechanism with a four-bit Lamport-style model built on Sphere10 Framework's hashing and random-number APIs. It then looks at what the rule means for a signing service, and at how the Framework's stateful signature schemes expose the choice of one-time key.

A one-time signing key reserved for one signature and then retired.
A one-time signing key reserved for one signature and then retired.
💡 In short. See with a small Lamport-style model how signing twice with a one-time key exposes enough secrets to forge a signature, and what that means for stateful signing.
⚠️ The four-bit model is for teaching only. It does not hash a real message into a full-length digest or implement a standardized parameter set. Do not use it to protect data.

1. How a Lamport signature works

The private key holds two random secrets for every bit of the message, one for the value 0 and one for the value 1. The public key holds the hash of every secret.


secrets[position][bit] = Tools.Crypto.GenerateCryptographicallyRandomBytes(32);
publicValues[position][bit] = Hashers.Hash(CHF.SHA2_256, secrets[position][bit]);

To sign, the signer reveals one secret per bit position, chosen by the value of that message bit.


private static byte[][] Sign(int digest, byte[][][] secrets) {
	var signature = new byte[4][];
	for (var position = 0; position < 4; position++) {
		signature[position] = secrets[position][(digest >> position) & 1];
	}
	return signature;
}

The verifier hashes each revealed secret and compares it with the public value for that position and bit.


private static bool Verify(int digest, byte[][] signature, byte[][][] publicValues) {
	for (var position = 0; position < 4; position++) {
		var expected = publicValues[position][(digest >> position) & 1];
		if (!Hashers.Hash(CHF.SHA2_256, signature[position]).SequenceEqual(expected))
			return false;
	}
	return true;
}

After one signature, half of the private key is public. That is safe because the other half is still secret, and forging a different message needs at least one secret from that half.

The layout below shows the model's eight secrets[position][bit] values after signing 0101. The top row holds the secrets for bit value 0 and the bottom row those for bit value 1.

Diagram source
---
config:
  packet:
    bitsPerRow: 4
    bitWidth: 110
    showBits: false
---
packet-beta
0: "[0][0] secret"
1: "[1][0] revealed"
2: "[2][0] secret"
3: "[3][0] revealed"
4: "[0][1] revealed"
5: "[1][1] secret"
6: "[2][1] revealed"
7: "[3][1] secret"

2. What a second signature reveals

A second signature with the same key reveals the secrets selected by the second message. Wherever the two messages differ, both secrets for that position are now public.

The sample signs 0101 and then 1010. The two values differ in every bit, so the two signatures together reveal the whole private key.

Position Revealed by 0101 Revealed by 1010 Observer holds
0 secret for 1 secret for 0 both
1 secret for 0 secret for 1 both
2 secret for 1 secret for 0 both
3 secret for 0 secret for 1 both

An observer can now assemble a signature for any four-bit value. The sample builds one for 1111, which was never signed, by taking the secret for 1 at each position from whichever signature revealed it.


var forged = new byte[4][];
for (var position = 0; position < 4; position++) {
	forged[position] = ((0b0101 >> position) & 1) == 1 ? first[position] : second[position];
}
Require(Verify(0b1111, forged, publicValues), "Expected reused-key forgery did not verify.");

The forged signature for 1111 therefore has this layout, one revealed secret per position.

No hash was broken. The forgery uses only authentic secrets that the signer published, which is why a stronger hash function does not help. The verifier cannot detect the problem either, because each signature it sees is valid on its own.

Diagram source
---
config:
  packet:
    bitsPerRow: 4
    bitWidth: 110
    showBits: false
---
packet-beta
0: "[0][1] from sig 1"
1: "[1][1] from sig 2"
2: "[2][1] from sig 1"
3: "[3][1] from sig 2"

The complementary pair makes the exposure complete. Two messages that differ in fewer bits reveal fewer pairs and allow fewer forgeries, but any reuse weakens the key.

3. Design signing state with the scheme

Practical schemes place many one-time keys under one long-lived public key, and each signature consumes one of them. That makes the signer stateful. It must record which one-time keys are spent, and that record is as critical as the private key itself.

The following situations commonly reintroduce a spent key.

  • A crash between signing and saving state. Reserve the key index durably before releasing the signature, and never after.
  • Restoring a backup or cloning a signer. A restored copy carries an old index and will reuse keys the original has already spent.
  • Concurrent workers. Two workers that read the same index sign with the same key. Allocation needs a single authority.
  • Retrying a failed delivery. Resend the signature that was already produced. Do not sign again.

Sphere10 Framework makes this choice explicit in its signature API. The AMS schemes (WAMS and WAMSSharp) are stateful, and the signerNonce argument selects the one-time key used for a signature.


var privateKey = Signers.GeneratePrivateKey(DSS.PQC_WAMS);
var publicKey = Signers.DerivePublicKey(DSS.PQC_WAMS, privateKey, signerNonce: 0);
var signature = Signers.Sign(DSS.PQC_WAMS, privateKey, message, signerNonce: 0);
var valid = Signers.Verify(DSS.PQC_WAMS, signature, message, publicKey);

The caller owns the nonce, so the caller owns the state. Persisting and advancing it safely is the application's job. Stateless schemes such as ECDSA ignore the argument.

ℹ️ RFC 8554 specifies LMS/HSS, RFC 8391 specifies XMSS, and NIST SP 800-208 sets the approved parameter sets and state-management requirements. The Framework's WOTS and WAMS implementations are reference implementations and make no claim of compliance with those standards.

4. Get the package and run the sample

The complete MIT-licensed project targets .NET 10 and needs only the core package.


dotnet add package Sphere10.Framework --version 3.1.3
dotnet run -c Release


Both intentionally reused-key signatures verify: passed
First signature alone fails for unsigned 1111: passed
Unsigned 1111 verifies after combining exposed alternatives: passed
Altered revealed secret rejected by public hashes: passed

The existing Lamport explanation and Winternitz explanation develop the constructions further, and the Framework crypto guide documents the supplied primitives.


sample.zip
Download the complete worked example and build instructions (ZIP).

For the broader Sphere 10 development toolkit, see the Sphere10 Framework overview .

Author: Herman Schoenfeld