From 9ad14d9c2bfa3d5e9a4eed9e1dcd3263abec09c6 Mon Sep 17 00:00:00 2001 From: dilaraacetin Date: Sun, 27 Sep 2026 14:50:44 +0300 Subject: [PATCH 1/2] feat(ciphers): add Winternitz one-time signature (WOTS) --- .../ciphers/WinternitzSignature.java | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 src/main/java/com/thealgorithms/ciphers/WinternitzSignature.java diff --git a/src/main/java/com/thealgorithms/ciphers/WinternitzSignature.java b/src/main/java/com/thealgorithms/ciphers/WinternitzSignature.java new file mode 100644 index 000000000000..e4239b170adb --- /dev/null +++ b/src/main/java/com/thealgorithms/ciphers/WinternitzSignature.java @@ -0,0 +1,227 @@ +package com.thealgorithms.ciphers; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +/** + * Winternitz one-time signatures (WOTS) are a hash-based post-quantum signature scheme and a + * size-optimized version of the Lamport signature. + * + *

The message digest is split into base-{@code w} digits and each digit is signed with a hash + * chain: secret {@code sk[i]} is hashed {@code m[i]} times, while the public key is the chain end + * after {@code w - 1} hashes. A larger {@code w} gives smaller signatures but more hashing + * (133, 67 and 34 values for {@code w} = 4, 16 and 256). + * + *

Security relies only on the one-wayness of the hash function, which is why the scheme is + * considered quantum-resistant. A checksum over the digits is signed too, so an attacker cannot + * hash revealed values further to forge a signature. Each key pair can sign only one message, + * since a second signature reveals enough chain values to forge new ones. + * + *

WOTS is the building block of XMSS and SPHINCS+, which use the WOTS+ variant with per-step + * bitmasks. This implementation is educational and must not be used in production. + * + *

Reference: Wikipedia: Hash-based cryptography + * + * @author dilaraacetin + * @see LamportSignature + */ +public final class WinternitzSignature { + + // SHA-256 output length in bytes + private static final int HASH_BYTES = 32; + private static final int DEFAULT_W = 16; + + private final Parameters parameters; + private final byte[][] privateKey; + private final byte[][] publicKey; + private boolean used; + + /** + * Generates a new key pair with the default Winternitz parameter {@code w = 16}. + */ + public WinternitzSignature() { + this(DEFAULT_W); + } + + /** + * Generates a new key pair with the given Winternitz parameter. + * + * @param w the Winternitz parameter; must be 4, 16 or 256 + * @throws IllegalArgumentException if {@code w} is not a supported value + */ + public WinternitzSignature(int w) { + this.parameters = Parameters.forW(w); + SecureRandom secureRandom = new SecureRandom(); + privateKey = new byte[parameters.len()][HASH_BYTES]; + publicKey = new byte[parameters.len()][]; + for (int i = 0; i < parameters.len(); i++) { + secureRandom.nextBytes(privateKey[i]); + publicKey[i] = chain(privateKey[i], w - 1); + } + } + + /** + * Returns a copy of the public key. + * + * @return {@code len} hash chain end values, each 32 bytes long + */ + public byte[][] getPublicKey() { + return deepCopy(publicKey); + } + + /** + * Signs a message. A key pair can sign only one message. + * + * @param message the message to sign + * @return the signature: {@code len} values of 32 bytes each (67 values for {@code w = 16}) + * @throws IllegalArgumentException if the message is null + * @throws IllegalStateException if this key pair has already been used to sign a message + */ + public byte[][] sign(byte[] message) { + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + if (used) { + throw new IllegalStateException("This Winternitz key pair can only sign one message"); + } + used = true; + + int[] digits = messageDigits(hash(message), parameters); + byte[][] signature = new byte[parameters.len()][]; + for (int i = 0; i < parameters.len(); i++) { + signature[i] = chain(privateKey[i], digits[i]); + } + return signature; + } + + /** + * Verifies a signature against a public key. Using a different {@code w} than the signer + * causes a size mismatch and an {@link IllegalArgumentException}. + * + * @param message the signed message + * @param signature the signature to check + * @param publicKey the public key of the signer + * @param w the Winternitz parameter used to create the key pair; must be 4, 16 or 256 + * @return true if the signature is valid for the message and public key, false otherwise + * @throws IllegalArgumentException if an argument is null, {@code w} is unsupported, or the + * signature or public key is malformed + */ + public static boolean verify(byte[] message, byte[][] signature, byte[][] publicKey, int w) { + Parameters params = Parameters.forW(w); + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + validateShape(signature, params.len(), "signature"); + validateShape(publicKey, params.len(), "publicKey"); + + int[] digits = messageDigits(hash(message), params); + for (int i = 0; i < params.len(); i++) { + // complete the chain: m[i] + (w - 1 - m[i]) = w - 1 steps + byte[] chainEnd = chain(signature[i], w - 1 - digits[i]); + if (!MessageDigest.isEqual(chainEnd, publicKey[i])) { + return false; + } + } + return true; + } + + /** + * Parameters derived from {@code w}: {@code len1} message chains and {@code len2} checksum chains. + */ + record Parameters(int w, int log2w, int len1, int len2) { + + static Parameters forW(int w) { + if (w != 4 && w != 16 && w != 256) { + throw new IllegalArgumentException("w must be 4, 16 or 256, got " + w); + } + int log2w = Integer.numberOfTrailingZeros(w); + // len1 = ceil(8n / log2(w)) + int len1 = (8 * HASH_BYTES + log2w - 1) / log2w; + // len2 = floor(log2(len1 * (w - 1)) / log2(w)) + 1 + int maxChecksumBits = 32 - Integer.numberOfLeadingZeros(len1 * (w - 1)); + int len2 = (maxChecksumBits - 1) / log2w + 1; + return new Parameters(w, log2w, len1, len2); + } + + int len() { + return len1 + len2; + } + } + + /** + * Converts a digest into {@code len1} base-{@code w} message digits followed by {@code len2} + * checksum digits. Shared by sign and verify. + */ + private static int[] messageDigits(byte[] digest, Parameters params) { + int[] digits = new int[params.len()]; + int mask = params.w() - 1; + + // split the digest into log2(w)-bit digits + int bitsLeft = 0; + int currentByte = 0; + int byteIndex = 0; + for (int i = 0; i < params.len1(); i++) { + if (bitsLeft == 0) { + currentByte = digest[byteIndex++] & 0xFF; // unsigned byte + bitsLeft = 8; + } + bitsLeft -= params.log2w(); + digits[i] = (currentByte >> bitsLeft) & mask; + } + + // checksum C = sum(w - 1 - m[i]) + int checksum = 0; + for (int i = 0; i < params.len1(); i++) { + checksum += mask - digits[i]; + } + + // write C as len2 base-w digits, most significant first + for (int i = params.len() - 1; i >= params.len1(); i--) { + digits[i] = checksum & mask; + checksum >>>= params.log2w(); + } + return digits; + } + + /** + * Hashes {@code x} {@code iterations} times; returns a copy of {@code x} for zero iterations. + */ + private static byte[] chain(byte[] x, int iterations) { + byte[] result = x.clone(); + for (int i = 0; i < iterations; i++) { + result = hash(result); + } + return result; + } + + private static byte[] hash(byte[] data) { + try { + return MessageDigest.getInstance("SHA-256").digest(data); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError("SHA-256 is required by the Java SE specification", e); + } + } + + private static void validateShape(byte[][] values, int expectedLength, String name) { + if (values == null) { + throw new IllegalArgumentException(name + " must not be null"); + } + if (values.length != expectedLength) { + throw new IllegalArgumentException(name + " must contain exactly " + expectedLength + " values, got " + values.length); + } + for (byte[] value : values) { + if (value == null || value.length != HASH_BYTES) { + throw new IllegalArgumentException(name + " values must be exactly " + HASH_BYTES + " bytes long"); + } + } + } + + private static byte[][] deepCopy(byte[][] values) { + byte[][] copy = new byte[values.length][]; + for (int i = 0; i < values.length; i++) { + copy[i] = values[i].clone(); + } + return copy; + } +} From 0341291aa889ca160c8bab7fabbb6296e78393f8 Mon Sep 17 00:00:00 2001 From: dilaraacetin Date: Sun, 27 Sep 2026 14:51:09 +0300 Subject: [PATCH 2/2] test(ciphers): add tests for Winternitz one-time signature --- .../ciphers/WinternitzSignatureTest.java | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src/test/java/com/thealgorithms/ciphers/WinternitzSignatureTest.java diff --git a/src/test/java/com/thealgorithms/ciphers/WinternitzSignatureTest.java b/src/test/java/com/thealgorithms/ciphers/WinternitzSignatureTest.java new file mode 100644 index 000000000000..908bf9b53855 --- /dev/null +++ b/src/test/java/com/thealgorithms/ciphers/WinternitzSignatureTest.java @@ -0,0 +1,178 @@ +package com.thealgorithms.ciphers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class WinternitzSignatureTest { + + private static final byte[] MESSAGE = "hello winternitz".getBytes(StandardCharsets.UTF_8); + + @Test + void testValidSignatureVerifies() { + WinternitzSignature keyPair = new WinternitzSignature(); + + byte[][] signature = keyPair.sign(MESSAGE); + + assertTrue(WinternitzSignature.verify(MESSAGE, signature, keyPair.getPublicKey(), 16)); + } + + @ParameterizedTest + @ValueSource(ints = {4, 16, 256}) + void testSignAndVerifyForEverySupportedW(int w) { + WinternitzSignature keyPair = new WinternitzSignature(w); + + byte[][] signature = keyPair.sign(MESSAGE); + + assertTrue(WinternitzSignature.verify(MESSAGE, signature, keyPair.getPublicKey(), w)); + } + + @ParameterizedTest + @CsvSource({"4, 128, 5", "16, 64, 3", "256, 32, 2"}) + void testParametersAreDerivedFromW(int w, int expectedLen1, int expectedLen2) { + WinternitzSignature.Parameters parameters = WinternitzSignature.Parameters.forW(w); + + assertEquals(expectedLen1, parameters.len1()); + assertEquals(expectedLen2, parameters.len2()); + assertEquals(expectedLen1 + expectedLen2, parameters.len()); + } + + @Test + void testDefaultSignatureHas67Values() { + WinternitzSignature keyPair = new WinternitzSignature(); + + byte[][] signature = keyPair.sign(MESSAGE); + + assertEquals(67, signature.length); + assertEquals(67, keyPair.getPublicKey().length); + for (byte[] value : signature) { + assertEquals(32, value.length); + } + } + + @Test + void testTamperedMessageFailsVerification() { + WinternitzSignature keyPair = new WinternitzSignature(); + byte[] message = MESSAGE.clone(); + byte[][] signature = keyPair.sign(message); + + message[0] ^= 0x01; + + assertFalse(WinternitzSignature.verify(message, signature, keyPair.getPublicKey(), 16)); + } + + @Test + void testTamperedSignatureFailsVerification() { + WinternitzSignature keyPair = new WinternitzSignature(); + byte[][] signature = keyPair.sign(MESSAGE); + + signature[10][0] ^= 0x01; + + assertFalse(WinternitzSignature.verify(MESSAGE, signature, keyPair.getPublicKey(), 16)); + } + + @Test + void testDifferentPublicKeyDoesNotVerify() { + WinternitzSignature keyPair1 = new WinternitzSignature(); + WinternitzSignature keyPair2 = new WinternitzSignature(); + + byte[][] signature = keyPair1.sign(MESSAGE); + + assertFalse(WinternitzSignature.verify(MESSAGE, signature, keyPair2.getPublicKey(), 16)); + } + + @Test + void testVerifyingWithDifferentWThrowsException() { + WinternitzSignature keyPair = new WinternitzSignature(16); + byte[][] signature = keyPair.sign(MESSAGE); + byte[][] publicKey = keyPair.getPublicKey(); + + assertThrows(IllegalArgumentException.class, () -> WinternitzSignature.verify(MESSAGE, signature, publicKey, 4)); + } + + @Test + void testSecondSignatureThrowsException() { + WinternitzSignature keyPair = new WinternitzSignature(); + byte[] second = "second message".getBytes(StandardCharsets.UTF_8); + + keyPair.sign(MESSAGE); + + assertThrows(IllegalStateException.class, () -> keyPair.sign(second)); + } + + @Test + void testNullAndMalformedInput() { + WinternitzSignature keyPair = new WinternitzSignature(); + byte[][] publicKey = keyPair.getPublicKey(); + byte[][] signature = keyPair.sign(MESSAGE); + byte[][] tooShort = Arrays.copyOf(signature, 66); + byte[][] wrongValueLength = signature.clone(); + wrongValueLength[0] = new byte[31]; + byte[][] nullValue = signature.clone(); + nullValue[0] = null; + + assertThrows(IllegalArgumentException.class, () -> new WinternitzSignature().sign(null)); + assertThrows(IllegalArgumentException.class, () -> WinternitzSignature.verify(null, signature, publicKey, 16)); + assertThrows(IllegalArgumentException.class, () -> WinternitzSignature.verify(MESSAGE, null, publicKey, 16)); + assertThrows(IllegalArgumentException.class, () -> WinternitzSignature.verify(MESSAGE, tooShort, publicKey, 16)); + assertThrows(IllegalArgumentException.class, () -> WinternitzSignature.verify(MESSAGE, wrongValueLength, publicKey, 16)); + assertThrows(IllegalArgumentException.class, () -> WinternitzSignature.verify(MESSAGE, nullValue, publicKey, 16)); + assertThrows(IllegalArgumentException.class, () -> WinternitzSignature.verify(MESSAGE, signature, null, 16)); + assertThrows(IllegalArgumentException.class, () -> WinternitzSignature.verify(MESSAGE, signature, tooShort, 16)); + } + + @ParameterizedTest + @ValueSource(ints = {0, 3, 15, -16, 2, 8, 512}) + void testUnsupportedWThrowsException(int w) { + assertThrows(IllegalArgumentException.class, () -> new WinternitzSignature(w)); + assertThrows(IllegalArgumentException.class, () -> WinternitzSignature.verify(MESSAGE, new byte[67][32], new byte[67][32], w)); + } + + @Test + void testModifyingReturnedPublicKeyDoesNotChangeInternalState() { + WinternitzSignature keyPair = new WinternitzSignature(); + byte[][] signature = keyPair.sign(MESSAGE); + + byte[][] exposed = keyPair.getPublicKey(); + exposed[0][0] ^= 0x01; + exposed[1] = new byte[32]; + + assertTrue(WinternitzSignature.verify(MESSAGE, signature, keyPair.getPublicKey(), 16)); + } + + @Test + void testEmptyAndLongMessages() { + byte[] empty = new byte[0]; + byte[] longMessage = new byte[8192]; + for (int i = 0; i < longMessage.length; i++) { + longMessage[i] = (byte) i; + } + WinternitzSignature keyPair1 = new WinternitzSignature(); + WinternitzSignature keyPair2 = new WinternitzSignature(); + + byte[][] emptySignature = keyPair1.sign(empty); + byte[][] longSignature = keyPair2.sign(longMessage); + + assertTrue(WinternitzSignature.verify(empty, emptySignature, keyPair1.getPublicKey(), 16)); + assertTrue(WinternitzSignature.verify(longMessage, longSignature, keyPair2.getPublicKey(), 16)); + } + + @Test + void testDifferentMessagesProduceDifferentSignatures() { + WinternitzSignature keyPair1 = new WinternitzSignature(); + WinternitzSignature keyPair2 = new WinternitzSignature(); + + byte[][] signature1 = keyPair1.sign("first message".getBytes(StandardCharsets.UTF_8)); + byte[][] signature2 = keyPair2.sign("second message".getBytes(StandardCharsets.UTF_8)); + + assertFalse(Arrays.deepEquals(signature1, signature2)); + } +}