diff --git a/src/main/java/com/thealgorithms/ciphers/HorsSignature.java b/src/main/java/com/thealgorithms/ciphers/HorsSignature.java new file mode 100644 index 000000000000..64731cde76d2 --- /dev/null +++ b/src/main/java/com/thealgorithms/ciphers/HorsSignature.java @@ -0,0 +1,211 @@ +package com.thealgorithms.ciphers; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +/** + * HORS (Hash to Obtain Random Subset) is a hash-based few-time signature scheme by Reyzin and + * Reyzin (2002). + * + *
The private key consists of {@code t = 2^τ} random 32-byte secrets and the public key contains + * their SHA-256 hashes. To sign, the SHA-256 digest of the message is split into {@code k} chunks of + * {@code τ} bits (most significant bit first); each chunk is an index into the key, and the secrets + * at those {@code k} indices form the signature. The verifier derives the same indices and checks + * that each revealed secret hashes to the matching public-key value. The same index may appear more + * than once, as in the original scheme. Signing is deterministic. + * + *
HORS is a few-time scheme: each signature reveals {@code k} secrets, so after {@code r} + * signatures an attacker knows at most {@code r·k} of them. If all indices of another message fall + * into that set, its signature can be forged, so security decreases with every signature. + * + *
HORST compresses the large public key with a Merkle tree. FORS, used in SPHINCS+ and SLH-DSA + * (FIPS 205), uses a forest of trees instead, which also removes the repeated-index weakness. This + * implementation is educational and must not be used in production. + * + *
References: Reyzin and Reyzin, Better than BiBa, + * Wikipedia: Hash-based cryptography + * + * @author dilaraacetin + * @see LamportSignature + * @see WinternitzSignature + * @see MerkleSignatureScheme + */ +public final class HorsSignature { + + // SHA-256 output length in bytes + private static final int HASH_BYTES = 32; + private static final int DIGEST_BITS = 256; + private static final int MIN_T = 16; + private static final int MAX_T = 65536; + private static final int DEFAULT_T = 1024; + private static final int DEFAULT_K = 16; + + private final int k; + private final int tau; + private final byte[][] privateKey; + private final byte[][] publicKey; + + /** + * Generates a new key pair with {@code t = 1024} and {@code k = 16}. + */ + public HorsSignature() { + this(DEFAULT_T, DEFAULT_K); + } + + /** + * Generates a new key pair. + * + * @param t the number of secrets; a power of two between 16 and 65536 + * @param k the number of secrets revealed per signature; {@code k·log2(t)} must not exceed 256 + * @throws IllegalArgumentException if {@code t} or {@code k} is not supported + */ + public HorsSignature(int t, int k) { + this.tau = tauOf(t); + validateK(k, tau); + this.k = k; + SecureRandom secureRandom = new SecureRandom(); + privateKey = new byte[t][HASH_BYTES]; + publicKey = new byte[t][]; + for (int i = 0; i < t; i++) { + secureRandom.nextBytes(privateKey[i]); + publicKey[i] = hash(privateKey[i]); + } + } + + /** + * Returns a copy of the public key. + * + * @return {@code t} hash values, each 32 bytes long + */ + public byte[][] getPublicKey() { + return deepCopy(publicKey); + } + + /** + * Returns the number of secrets revealed per signature. Like {@code t}, it is part of the public + * key and is needed for verification. + * + * @return the parameter {@code k} + */ + public int getK() { + return k; + } + + /** + * Signs a message. Every signature reveals {@code k} secrets, so a key pair should only sign a + * few messages. + * + * @param message the message to sign + * @return the signature: {@code k} secrets of 32 bytes each + * @throws IllegalArgumentException if the message is null + */ + public byte[][] sign(byte[] message) { + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + int[] indices = messageIndices(hash(message), k, tau); + byte[][] signature = new byte[k][]; + for (int j = 0; j < k; j++) { + signature[j] = privateKey[indices[j]].clone(); + } + return signature; + } + + /** + * Verifies a signature against a public key. {@code t} is taken from the public key length. + * {@code k} is part of the public key and must be supplied by the verifier: taking it from the + * signature would let an attacker submit a shorter signature that reveals fewer secrets. + * + * @param message the signed message + * @param signature the signature to check + * @param publicKey the public key of the signer + * @param k the number of secrets per signature used by the signer (see {@link #getK()}) + * @return true if the signature is valid for the message and public key, false otherwise + * @throws IllegalArgumentException if an argument is null, the public key length is not a + * supported {@code t}, {@code k} is not supported for this {@code t}, a value is not 32 + * bytes long, or the signature does not contain exactly {@code k} values + */ + public static boolean verify(byte[] message, byte[][] signature, byte[][] publicKey, int k) { + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + validateValues(publicKey, "publicKey"); + validateValues(signature, "signature"); + int tau = tauOf(publicKey.length); + validateK(k, tau); + if (signature.length != k) { + throw new IllegalArgumentException("signature must contain exactly " + k + " values, got " + signature.length); + } + + int[] indices = messageIndices(hash(message), k, tau); + for (int j = 0; j < k; j++) { + if (!MessageDigest.isEqual(hash(signature[j]), publicKey[indices[j]])) { + return false; + } + } + return true; + } + + /** + * Splits the first {@code k·τ} bits of a digest into {@code k} unsigned {@code τ}-bit indices, + * most significant bit first. Shared by sign and verify. + */ + static int[] messageIndices(byte[] digest, int k, int tau) { + if ((long) k * tau > digest.length * 8L) { + throw new IllegalArgumentException("digest is too short for " + k + " indices of " + tau + " bits"); + } + int[] indices = new int[k]; + for (int j = 0; j < k; j++) { + int index = 0; + for (int i = 0; i < tau; i++) { + int bit = j * tau + i; + // bit 0 is the most significant bit of digest[0]; & 0xFF reads the byte as unsigned + index = (index << 1) | (((digest[bit / 8] & 0xFF) >> (7 - bit % 8)) & 1); + } + indices[j] = index; + } + return indices; + } + + private static int tauOf(int t) { + if (t < MIN_T || t > MAX_T || (t & (t - 1)) != 0) { + throw new IllegalArgumentException("t must be a power of two between " + MIN_T + " and " + MAX_T + ", got " + t); + } + return Integer.numberOfTrailingZeros(t); + } + + private static void validateK(int k, int tau) { + // k > DIGEST_BITS / tau is the same as k * tau > DIGEST_BITS, but cannot overflow + if (k < 1 || k > DIGEST_BITS / tau) { + throw new IllegalArgumentException("k must be at least 1 and k * log2(t) at most " + DIGEST_BITS + ", got k = " + k); + } + } + + private static void validateValues(byte[][] values, String name) { + if (values == null) { + throw new IllegalArgumentException(name + " must not be null"); + } + 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[] 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 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; + } +} diff --git a/src/test/java/com/thealgorithms/ciphers/HorsSignatureTest.java b/src/test/java/com/thealgorithms/ciphers/HorsSignatureTest.java new file mode 100644 index 000000000000..bb22a24a2a62 --- /dev/null +++ b/src/test/java/com/thealgorithms/ciphers/HorsSignatureTest.java @@ -0,0 +1,201 @@ +package com.thealgorithms.ciphers; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class HorsSignatureTest { + + private static final byte[] MESSAGE = "hello hors".getBytes(StandardCharsets.UTF_8); + + @Test + void testValidSignatureVerifies() { + HorsSignature keyPair = new HorsSignature(); + + byte[][] signature = keyPair.sign(MESSAGE); + + assertTrue(HorsSignature.verify(MESSAGE, signature, keyPair.getPublicKey(), keyPair.getK())); + } + + @Test + void testSameKeySignsSeveralMessages() { + HorsSignature keyPair = new HorsSignature(); + byte[][] publicKey = keyPair.getPublicKey(); + + for (int i = 0; i < 5; i++) { + byte[] message = ("message " + i).getBytes(StandardCharsets.UTF_8); + byte[][] signature = keyPair.sign(message); + + assertTrue(HorsSignature.verify(message, signature, publicKey, keyPair.getK())); + } + } + + @Test + void testSigningIsDeterministic() { + HorsSignature keyPair = new HorsSignature(); + + assertArrayEquals(keyPair.sign(MESSAGE), keyPair.sign(MESSAGE)); + } + + @Test + void testTamperedMessageFailsVerification() { + HorsSignature keyPair = new HorsSignature(); + byte[][] signature = keyPair.sign(MESSAGE); + byte[] tampered = MESSAGE.clone(); + + tampered[0] ^= 0x01; + + assertFalse(HorsSignature.verify(tampered, signature, keyPair.getPublicKey(), keyPair.getK())); + } + + @Test + void testTamperedSignatureFailsVerification() { + HorsSignature keyPair = new HorsSignature(); + byte[][] signature = keyPair.sign(MESSAGE); + + signature[3][0] ^= 0x01; + + assertFalse(HorsSignature.verify(MESSAGE, signature, keyPair.getPublicKey(), keyPair.getK())); + } + + @Test + void testDifferentPublicKeyDoesNotVerify() { + HorsSignature keyPair1 = new HorsSignature(); + HorsSignature keyPair2 = new HorsSignature(); + + byte[][] signature = keyPair1.sign(MESSAGE); + + assertFalse(HorsSignature.verify(MESSAGE, signature, keyPair2.getPublicKey(), keyPair1.getK())); + } + + @Test + void testMessageIndicesWithByteAlignedTau() { + byte[] digest = new byte[32]; + digest[0] = (byte) 0xFF; + digest[1] = 0x00; + digest[2] = (byte) 0x80; + digest[3] = 0x7F; + + assertArrayEquals(new int[] {255, 0, 128, 127}, HorsSignature.messageIndices(digest, 4, 8)); + } + + @Test + void testMessageIndicesAcrossByteBoundaries() { + byte[] first = new byte[32]; + first[0] = (byte) 0xFF; + first[1] = (byte) 0xC0; + byte[] second = new byte[32]; + second[1] = 0x7F; + second[2] = (byte) 0xF0; + + // 11111111 11|000000 0000 -> [1023, 0] + assertArrayEquals(new int[] {1023, 0}, HorsSignature.messageIndices(first, 2, 10)); + // 00000000 01|111111 1111 -> [1, 1023] + assertArrayEquals(new int[] {1, 1023}, HorsSignature.messageIndices(second, 2, 10)); + } + + @Test + void testSignatureRevealsSecretsAtDerivedIndices() throws NoSuchAlgorithmException { + HorsSignature keyPair = new HorsSignature(); + byte[][] publicKey = keyPair.getPublicKey(); + + byte[][] signature = keyPair.sign(MESSAGE); + + int[] indices = HorsSignature.messageIndices(sha256(MESSAGE), 16, 10); + byte[][] expectedHashes = new byte[16][]; + byte[][] actualHashes = new byte[signature.length][]; + int[] expectedLengths = new int[16]; + int[] actualLengths = new int[signature.length]; + Arrays.fill(expectedLengths, 32); + for (int j = 0; j < signature.length; j++) { + actualHashes[j] = sha256(signature[j]); + actualLengths[j] = signature[j].length; + } + for (int j = 0; j < 16; j++) { + expectedHashes[j] = publicKey[indices[j]]; + } + + assertArrayEquals(expectedLengths, actualLengths); + assertArrayEquals(expectedHashes, actualHashes); + } + + @ParameterizedTest + @CsvSource({"16, 4", "256, 32", "1024, 16", "4096, 21"}) + void testSignAndVerifyForDifferentParameters(int t, int k) { + HorsSignature keyPair = new HorsSignature(t, k); + + byte[][] signature = keyPair.sign(MESSAGE); + + assertArrayEquals(new int[] {k, k, t}, new int[] {signature.length, keyPair.getK(), keyPair.getPublicKey().length}); + assertTrue(HorsSignature.verify(MESSAGE, signature, keyPair.getPublicKey(), k)); + } + + @ParameterizedTest + @CsvSource({"0, 16", "8, 2", "100, 4", "131072, 4", "1024, 0", "1024, 26", "1024, -1", "1024, 2147483647"}) + void testUnsupportedParametersThrowException(int t, int k) { + assertThrows(IllegalArgumentException.class, () -> new HorsSignature(t, k)); + } + + @Test + void testNullAndMalformedInput() { + HorsSignature keyPair = new HorsSignature(16, 4); + byte[][] publicKey = keyPair.getPublicKey(); + byte[][] signature = keyPair.sign(MESSAGE); + byte[][] nullValue = signature.clone(); + nullValue[0] = null; + byte[][] shortValue = signature.clone(); + shortValue[0] = new byte[31]; + byte[][] tooManyValues = new byte[65][32]; + + assertThrows(IllegalArgumentException.class, () -> keyPair.sign(null)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(null, signature, publicKey, 4)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, null, publicKey, 4)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, nullValue, publicKey, 4)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, shortValue, publicKey, 4)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, new byte[0][], publicKey, 4)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, tooManyValues, publicKey, 4)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, signature, null, 4)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, signature, new byte[15][32], 4)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, signature, nullValue, 4)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, signature, publicKey, 3)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, signature, publicKey, 0)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, tooManyValues, publicKey, 65)); + assertThrows(IllegalArgumentException.class, () -> HorsSignature.messageIndices(new byte[1], 2, 8)); + } + + @Test + void testTruncatedSignatureIsRejected() { + HorsSignature keyPair = new HorsSignature(); + byte[][] truncated = Arrays.copyOf(keyPair.sign(MESSAGE), 1); + + assertThrows(IllegalArgumentException.class, () -> HorsSignature.verify(MESSAGE, truncated, keyPair.getPublicKey(), keyPair.getK())); + } + + @Test + void testModifyingReturnedArraysDoesNotChangeInternalState() { + HorsSignature keyPair = new HorsSignature(); + byte[][] expectedPublicKey = keyPair.getPublicKey(); + byte[][] expectedSignature = keyPair.sign(MESSAGE); + + keyPair.getPublicKey()[0][0] ^= 0x01; + keyPair.getPublicKey()[1] = new byte[32]; + keyPair.sign(MESSAGE)[0][0] ^= 0x01; + + assertArrayEquals(expectedPublicKey, keyPair.getPublicKey()); + assertArrayEquals(expectedSignature, keyPair.sign(MESSAGE)); + assertTrue(HorsSignature.verify(MESSAGE, expectedSignature, keyPair.getPublicKey(), keyPair.getK())); + } + + private static byte[] sha256(byte[] data) throws NoSuchAlgorithmException { + return MessageDigest.getInstance("SHA-256").digest(data); + } +}