From 6c5b0ca7414413008f4ec33aef1a966707744e36 Mon Sep 17 00:00:00 2001 From: Muzzammil Shahid Date: Wed, 28 Feb 2024 18:53:31 +0500 Subject: [PATCH] Implement SecretBox (#7) * Add secret box * add tests * improve error messages * refactor code * add output variant of box and boxOpen * suggested renaming * sort imports * renaming tweaks --- build.gradle | 1 + .../java/io/xconn/cryptobox/SecretBox.java | 114 ++++++++++++++++++ src/main/java/io/xconn/cryptobox/Util.java | 15 +++ .../io/xconn/cryptobox/SecretBoxTest.java | 85 +++++++++++++ .../java/io/xconn/cryptobox/UtilTest.java | 19 +++ 5 files changed, 234 insertions(+) create mode 100644 src/main/java/io/xconn/cryptobox/SecretBox.java create mode 100644 src/main/java/io/xconn/cryptobox/Util.java create mode 100644 src/test/java/io/xconn/cryptobox/SecretBoxTest.java create mode 100644 src/test/java/io/xconn/cryptobox/UtilTest.java diff --git a/build.gradle b/build.gradle index cab964a..0d722f0 100644 --- a/build.gradle +++ b/build.gradle @@ -10,6 +10,7 @@ repositories { } dependencies { + implementation 'org.bouncycastle:bcprov-jdk18on:1.77' testImplementation platform('org.junit:junit-bom:5.9.1') testImplementation 'org.junit.jupiter:junit-jupiter' } diff --git a/src/main/java/io/xconn/cryptobox/SecretBox.java b/src/main/java/io/xconn/cryptobox/SecretBox.java new file mode 100644 index 0000000..250788e --- /dev/null +++ b/src/main/java/io/xconn/cryptobox/SecretBox.java @@ -0,0 +1,114 @@ +package io.xconn.cryptobox; + +import java.security.MessageDigest; +import java.util.Arrays; + +import org.bouncycastle.crypto.engines.XSalsa20Engine; +import org.bouncycastle.crypto.macs.Poly1305; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.crypto.params.ParametersWithIV; + +public class SecretBox { + + static int MAC_SIZE = 16; + + public static byte[] box(byte[] message, byte[] privateKey) { + checkLength(privateKey, Util.SECRET_KEY_LEN); + + byte[] nonce = Util.generateRandomBytesArray(Util.NONCE_SIZE); + byte[] output = new byte[message.length + MAC_SIZE + Util.NONCE_SIZE]; + box(output, nonce, message, privateKey); + + return output; + } + + public static void box(byte[] output, byte[] message, byte[] privateKey) { + checkLength(privateKey, Util.SECRET_KEY_LEN); + + byte[] nonce = Util.generateRandomBytesArray(Util.NONCE_SIZE); + box(output, nonce, message, privateKey); + } + + static void box(byte[] output, byte[] nonce, byte[] plaintext, byte[] privateKey) { + checkLength(nonce, Util.NONCE_SIZE); + + XSalsa20Engine cipher = new XSalsa20Engine(); + Poly1305 mac = new Poly1305(); + + cipher.init(true, new ParametersWithIV(new KeyParameter(privateKey), nonce)); + byte[] subKey = new byte[Util.SECRET_KEY_LEN]; + cipher.processBytes(subKey, 0, Util.SECRET_KEY_LEN, subKey, 0); + byte[] cipherWithoutNonce = new byte[plaintext.length + mac.getMacSize()]; + cipher.processBytes(plaintext, 0, plaintext.length, cipherWithoutNonce, mac.getMacSize()); + + // hash the ciphertext + mac.init(new KeyParameter(subKey)); + mac.update(cipherWithoutNonce, mac.getMacSize(), plaintext.length); + mac.doFinal(cipherWithoutNonce, 0); + + System.arraycopy(nonce, 0, output, 0, nonce.length); + System.arraycopy(cipherWithoutNonce, 0, output, nonce.length, cipherWithoutNonce.length); + } + + + public static byte[] boxOpen(byte[] ciphertext, byte[] privateKey) { + checkLength(privateKey, Util.SECRET_KEY_LEN); + + byte[] nonce = Arrays.copyOfRange(ciphertext, 0, Util.NONCE_SIZE); + byte[] message = Arrays.copyOfRange(ciphertext, Util.NONCE_SIZE, + ciphertext.length); + byte[] plainText = new byte[message.length - MAC_SIZE]; + boxOpen(plainText, nonce, message, privateKey); + + return plainText; + } + + public static void boxOpen(byte[] output, byte[] ciphertext, byte[] privateKey) { + checkLength(privateKey, Util.SECRET_KEY_LEN); + + byte[] nonce = Arrays.copyOfRange(ciphertext, 0, Util.NONCE_SIZE); + byte[] message = Arrays.copyOfRange(ciphertext, Util.NONCE_SIZE, + ciphertext.length); + + boxOpen(output, nonce, message, privateKey); + } + + static void boxOpen(byte[] output, byte[] nonce, byte[] ciphertext, byte[] privateKey) { + checkLength(nonce, Util.NONCE_SIZE); + + XSalsa20Engine cipher = new XSalsa20Engine(); + Poly1305 mac = new Poly1305(); + + cipher.init(false, new ParametersWithIV(new KeyParameter(privateKey), nonce)); + byte[] sk = new byte[Util.SECRET_KEY_LEN]; + cipher.processBytes(sk, 0, sk.length, sk, 0); + + // hash ciphertext + mac.init(new KeyParameter(sk)); + int len = Math.max(ciphertext.length - mac.getMacSize(), 0); + mac.update(ciphertext, mac.getMacSize(), len); + byte[] calculatedMAC = new byte[mac.getMacSize()]; + mac.doFinal(calculatedMAC, 0); + + // extract mac + final byte[] presentedMAC = new byte[mac.getMacSize()]; + System.arraycopy( + ciphertext, 0, presentedMAC, 0, Math.min(ciphertext.length, mac.getMacSize())); + + if (!MessageDigest.isEqual(calculatedMAC, presentedMAC)) { + throw new IllegalArgumentException("Invalid MAC"); + } + + + cipher.processBytes(ciphertext, mac.getMacSize(), output.length, output, 0); + } + + static void checkLength(byte[] data, int size) { + if (data == null) + throw new NullPointerException("Input array is null."); + else if (data.length != size) { + throw new IllegalArgumentException("Invalid array length: " + data.length + + ". Length should be " + size); + } + } +} diff --git a/src/main/java/io/xconn/cryptobox/Util.java b/src/main/java/io/xconn/cryptobox/Util.java new file mode 100644 index 0000000..53aa1aa --- /dev/null +++ b/src/main/java/io/xconn/cryptobox/Util.java @@ -0,0 +1,15 @@ +package io.xconn.cryptobox; + +import java.security.SecureRandom; + +public class Util { + public static final int NONCE_SIZE = 24; + public static final int SECRET_KEY_LEN = 32; + + static byte[] generateRandomBytesArray(int size) { + byte[] randomBytes = new byte[size]; + SecureRandom random = new SecureRandom(); + random.nextBytes(randomBytes); + return randomBytes; + } +} diff --git a/src/test/java/io/xconn/cryptobox/SecretBoxTest.java b/src/test/java/io/xconn/cryptobox/SecretBoxTest.java new file mode 100644 index 0000000..8168298 --- /dev/null +++ b/src/test/java/io/xconn/cryptobox/SecretBoxTest.java @@ -0,0 +1,85 @@ +package io.xconn.cryptobox; + +import org.bouncycastle.util.encoders.Hex; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import static io.xconn.cryptobox.SecretBox.MAC_SIZE; +import static io.xconn.cryptobox.SecretBox.box; +import static io.xconn.cryptobox.SecretBox.boxOpen; +import static io.xconn.cryptobox.SecretBox.checkLength; + +public class SecretBoxTest { + + private static byte[] privateKey; + + @BeforeAll + public static void setUp() { + privateKey = Hex.decode("cd281cb85a967c5fc249b31c1c6503a181841526182d4f6e63c81e4213a45fb7"); + } + + @Test + public void testEncryptAndDecrypt() { + byte[] message = "Hello, World!".getBytes(); + byte[] encrypted = box(message, privateKey); + byte[] decrypted = boxOpen(encrypted, privateKey); + assertArrayEquals(message, decrypted); + } + + @Test + public void testEncryptAndDecryptOutput() { + byte[] message = "Hello, World!".getBytes(); + byte[] encrypted = new byte[Util.NONCE_SIZE + MAC_SIZE + message.length]; + box(encrypted, message, privateKey); + byte[] decrypted = new byte[message.length]; + boxOpen(decrypted, encrypted, privateKey); + assertArrayEquals(message, decrypted); + } + + @Test + public void testEncryptAndDecryptWithNonce() { + byte[] nonce = Util.generateRandomBytesArray(Util.NONCE_SIZE); + byte[] message = "Hello, World!".getBytes(); + byte[] encrypted = new byte[message.length + Util.NONCE_SIZE + MAC_SIZE]; + box(encrypted, nonce, message, privateKey); + byte[] decrypted = boxOpen(encrypted, privateKey); + assertArrayEquals(message, decrypted); + } + + @Test + public void testEncryptAndDecryptWithInvalidMAC() { + byte[] message = "Hello, World!".getBytes(); + byte[] encrypted = box(message, privateKey); + encrypted[encrypted.length - 1] ^= 0xFF; // Modify last byte + assertThrows(IllegalArgumentException.class, () -> boxOpen(encrypted, privateKey)); + } + + @Test + public void testEncryptAndDecryptWithInvalidNonce() { + byte[] message = "Hello, World!".getBytes(); + byte[] encrypted = box(message, privateKey); + encrypted[0] ^= 0xFF; // Modify first byte + assertThrows(IllegalArgumentException.class, () -> boxOpen(encrypted, privateKey)); + } + + @Test + public void testEncryptAndDecryptWithModifiedCiphertext() { + byte[] message = "Hello, World!".getBytes(); + byte[] encrypted = box(message, privateKey); + encrypted[Util.NONCE_SIZE + 1] ^= 0xFF; // Modify the byte next to nonce + assertThrows(IllegalArgumentException.class, () -> boxOpen(encrypted, privateKey)); + } + + @Test + void testCheckLength() { + assertThrows(NullPointerException.class, () -> checkLength(null, 16)); + + byte[] data = new byte[16]; + checkLength(data, 16); + + assertThrows(IllegalArgumentException.class, () -> checkLength(data, 32)); + } +} diff --git a/src/test/java/io/xconn/cryptobox/UtilTest.java b/src/test/java/io/xconn/cryptobox/UtilTest.java new file mode 100644 index 0000000..3b246e4 --- /dev/null +++ b/src/test/java/io/xconn/cryptobox/UtilTest.java @@ -0,0 +1,19 @@ +package io.xconn.cryptobox; + + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +public class UtilTest { + + @Test + public void testGenerateRandomBytesArray() { + int size = 32; + byte[] randomBytes = Util.generateRandomBytesArray(size); + + assertNotNull(randomBytes); + assertEquals(size, randomBytes.length); + } +}