From 567a420aacb4c41b415e0413a4b347ec416bb7e9 Mon Sep 17 00:00:00 2001 From: Matt Sicker Date: Sun, 6 Dec 2020 19:39:48 -0600 Subject: [PATCH] Add initial vault operations This implementation aims to be compatible with libsodium ristretto255 signcryption and XChaCha20-Poly1305. This relates to #5 with an enhanced implementation of #1. --- .../src/test/java/dev/o1c/spi/VaultTest.java | 80 +++++ .../dev/o1c/spi/XChaCha20Poly1305Test.java | 32 ++ java8/pom.xml | 4 + java8/src/main/java/dev/o1c/spi/Vault.java | 302 ++++++++++++++++++ .../java/dev/o1c/spi/XChaCha20Poly1305.java | 113 +++++++ pom.xml | 5 + 6 files changed, 536 insertions(+) create mode 100644 java15/src/test/java/dev/o1c/spi/VaultTest.java create mode 100644 java15/src/test/java/dev/o1c/spi/XChaCha20Poly1305Test.java create mode 100644 java8/src/main/java/dev/o1c/spi/Vault.java create mode 100644 java8/src/main/java/dev/o1c/spi/XChaCha20Poly1305.java diff --git a/java15/src/test/java/dev/o1c/spi/VaultTest.java b/java15/src/test/java/dev/o1c/spi/VaultTest.java new file mode 100644 index 0000000..7a84e6e --- /dev/null +++ b/java15/src/test/java/dev/o1c/spi/VaultTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2020 Matt Sicker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.o1c.spi; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.Security; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class VaultTest { + + @BeforeAll + static void beforeAll() { + Security.addProvider(new BouncyCastleProvider()); + } + + @AfterAll + static void afterAll() { + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME); + } + + @Test + void smokeTest() { + var vault = new Vault(); + var alice = vault.generateKeyPair(); + var aliceId = "Alice".getBytes(StandardCharsets.UTF_8); + var bob = vault.generateKeyPair(); + var bobId = "Robert".getBytes(StandardCharsets.UTF_8); + var context = getClass().getName().getBytes(StandardCharsets.UTF_8); + var msg = "Ristretto is traditionally a short shot of espresso coffee made with the normal amount of ground coffee " + + "but extracted with about half the amount of water in the same amount of time by using a finer grind. " + + "This produces a concentrated shot of coffee per volume. Just pulling a normal shot short will produce a " + + "weaker shot and is not a Ristretto as some believe."; + var data = msg.getBytes(StandardCharsets.UTF_8); + var sealedData = vault.seal(alice.getPrivate(), aliceId, bob.getPublic(), bobId, context, data); + var actual = vault.unseal(alice.getPublic(), aliceId, bob.getPrivate(), bobId, context, sealedData); + assertArrayEquals(data, actual); + } + + @Test + @Disabled("needs test vectors") + void compatTest() { + var vault = new Vault(); + var aliceKey = ByteOps.fromHex("TODO"); + var alice = vault.parsePrivateKey(aliceKey); + var aliceId = "TODO".getBytes(StandardCharsets.UTF_8); + var bobKey = ByteOps.fromHex("TODO"); + var bob = vault.parsePrivateKey(bobKey); + var bobId = "TODO".getBytes(StandardCharsets.UTF_8); + var context = "TODO".getBytes(StandardCharsets.UTF_8); + var msg = "Ristretto is traditionally a short shot of espresso coffee made with the normal amount of ground coffee " + + "but extracted with about half the amount of water in the same amount of time by using a finer grind. " + + "This produces a concentrated shot of coffee per volume. Just pulling a normal shot short will produce a " + + "weaker shot and is not a Ristretto as some believe."; + var sealed = ByteOps.fromHex("TODO"); + var unsealed = vault.unseal(alice.getPublic(), aliceId, bob.getPrivate(), bobId, context, sealed); + assertEquals(msg, new String(unsealed, StandardCharsets.UTF_8)); + } +} diff --git a/java15/src/test/java/dev/o1c/spi/XChaCha20Poly1305Test.java b/java15/src/test/java/dev/o1c/spi/XChaCha20Poly1305Test.java new file mode 100644 index 0000000..271873a --- /dev/null +++ b/java15/src/test/java/dev/o1c/spi/XChaCha20Poly1305Test.java @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Matt Sicker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.o1c.spi; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class XChaCha20Poly1305Test { + @Test + void subkeyDerivation() { + var key = ByteOps.fromHex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + var nonce = ByteOps.fromHex("000000090000004a0000000031415927"); + var expectedKey = ByteOps.fromHex("82413b4227b27bfed30e42508a877d73a0f9e4d58a74a853c12ec41326d3ecdc"); + var actualKey = XChaCha20Poly1305.calculateSubKey(key, nonce); + assertArrayEquals(expectedKey, actualKey); + } +} diff --git a/java8/pom.xml b/java8/pom.xml index 234fabd..206adc1 100644 --- a/java8/pom.xml +++ b/java8/pom.xml @@ -38,6 +38,10 @@ org.bouncycastle bcprov-jdk15on + + cafe.cryptography + curve25519-elisabeth + net.i2p.crypto eddsa diff --git a/java8/src/main/java/dev/o1c/spi/Vault.java b/java8/src/main/java/dev/o1c/spi/Vault.java new file mode 100644 index 0000000..67b0e10 --- /dev/null +++ b/java8/src/main/java/dev/o1c/spi/Vault.java @@ -0,0 +1,302 @@ +/* + * Copyright 2020 Matt Sicker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.o1c.spi; + +import cafe.cryptography.curve25519.CompressedRistretto; +import cafe.cryptography.curve25519.Constants; +import cafe.cryptography.curve25519.InvalidEncodingException; +import cafe.cryptography.curve25519.RistrettoElement; +import cafe.cryptography.curve25519.RistrettoGeneratorTable; +import cafe.cryptography.curve25519.Scalar; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.ShortBufferException; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Objects; + +public class Vault { + private final SecureRandom secureRandom; + + public Vault() { + try { + secureRandom = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { + throw new InvalidProviderException(e); + } + } + + public KeyPair generateKeyPair() { + byte[] key = new byte[KEY_SIZE]; + secureRandom.nextBytes(key); + return parsePrivateKey(key); + } + + KeyPair parsePrivateKey(byte[] key) { + VaultPrivateKey privateKey = new VaultPrivateKey(Scalar.fromBytesModOrder(key)); + VaultPublicKey publicKey = privateKey.generatePublicKey(); + return new KeyPair(publicKey, privateKey); + } + + public byte[] seal( + PrivateKey senderKey, byte[] senderId, PublicKey recipientKey, byte[] recipientId, byte[] context, byte[] data) { + if (!(senderKey instanceof VaultPrivateKey) || !(recipientKey instanceof VaultPublicKey)) { + throw new IllegalArgumentException("Only vault key pairs are allowed"); + } + Objects.requireNonNull(senderId); + Objects.requireNonNull(recipientId); + Objects.requireNonNull(context); + Objects.requireNonNull(data); + if (senderId.length > 255) { + throw new IllegalArgumentException("Sender id can only be up to 255 bytes"); + } + if (recipientId.length > 255) { + throw new IllegalArgumentException("Recipient id can only be up to 255 bytes"); + } + if (context.length > 255) { + throw new IllegalArgumentException("Context data can only be up to 255 bytes"); + } + if (data.length > MAX_BYTES_PER_SEAL) { + throw new IllegalArgumentException("Can only seal up to 1 GB of data per invocation"); + } + return seal((VaultPrivateKey) senderKey, senderId, (VaultPublicKey) recipientKey, recipientId, context, data); + } + + private byte[] seal( + VaultPrivateKey sender, byte[] senderId, VaultPublicKey recipient, byte[] recipientId, byte[] context, + byte[] data) { + MessageDigest digest = getFullDigest(); + digest.update(NONCE); + digest.update(sender.getEncoded()); + digest.update(recipient.getEncoded()); + byte[] noise = new byte[32]; + secureRandom.nextBytes(noise); + digest.update(noise); + digest.update(data); + byte[] nonce = digest.digest(); + Scalar ephemeralPrivateKey = Scalar.fromBytesModOrderWide(nonce); + RistrettoElement ephemeralPublicKey = Constants.RISTRETTO_GENERATOR_TABLE.multiply(ephemeralPrivateKey); + byte[] rBuf = ephemeralPublicKey.compress().toByteArray(); + RistrettoElement kp = + recipient.table.multiply(Scalar.fromBits(rBuf).multiplyAndAdd(sender.value, ephemeralPrivateKey)); + byte[] k = kp.compress().toByteArray(); + + digest = getReducedDigest(); + digest.update(SHARED_KEY); + digest.update(k); + digestVariableLengthBuffer(digest, senderId); + digestVariableLengthBuffer(digest, recipientId); + digestVariableLengthBuffer(digest, context); + byte[] key = digest.digest(); + + digest = getFullDigest(); + digest.update(SIGN_KEY); + digest.update(rBuf); + digestVariableLengthBuffer(digest, senderId); + digestVariableLengthBuffer(digest, recipientId); + digestVariableLengthBuffer(digest, context); + + byte[] iv = new byte[IV_SIZE]; + secureRandom.nextBytes(iv); + Cipher cipher = XChaCha20Poly1305.cryptWith(true, key, iv); + cipher.updateAAD(context); + int ciphertextLength = IV_SIZE + cipher.getOutputSize(data.length); + byte[] sealed = Arrays.copyOf(iv, ciphertextLength + SIG_SIZE); + try { + cipher.doFinal(data, 0, data.length, sealed, IV_SIZE); + } catch (ShortBufferException | IllegalBlockSizeException | BadPaddingException e) { + throw new IllegalStateException(e); + } + digest.update(sealed, 0, ciphertextLength); + Scalar challenge = Scalar.fromBytesModOrderWide(digest.digest()); + byte[] sBuf = challenge.multiply(sender.value).subtract(ephemeralPrivateKey).toByteArray(); + System.arraycopy(rBuf, 0, sealed, ciphertextLength, rBuf.length); + System.arraycopy(sBuf, 0, sealed, ciphertextLength + rBuf.length, sBuf.length); + return sealed; + } + + public byte[] unseal( + PublicKey senderKey, byte[] senderId, PrivateKey recipientKey, byte[] recipientId, byte[] context, + byte[] sealedData) { + if (!(senderKey instanceof VaultPublicKey) || !(recipientKey instanceof VaultPrivateKey)) { + throw new IllegalArgumentException("Only vault key pairs are allowed"); + } + Objects.requireNonNull(senderId); + Objects.requireNonNull(recipientId); + Objects.requireNonNull(context); + Objects.requireNonNull(sealedData); + if (senderId.length > 255) { + throw new IllegalArgumentException("Sender id can only be up to 255 bytes"); + } + if (recipientId.length > 255) { + throw new IllegalArgumentException("Recipient id can only be up to 255 bytes"); + } + if (context.length > 255) { + throw new IllegalArgumentException("Context data can only be up to 255 bytes"); + } + if (sealedData.length < IV_SIZE + TAG_SIZE + SIG_SIZE) { + throw new IllegalArgumentException("Sealed data is missing metadata"); + } + return unseal((VaultPublicKey) senderKey, senderId, (VaultPrivateKey) recipientKey, recipientId, context, sealedData); + } + + private byte[] unseal( + VaultPublicKey sender, byte[] senderId, VaultPrivateKey recipient, byte[] recipientId, byte[] context, + byte[] sealed) { + byte[] rBuf = Arrays.copyOfRange(sealed, sealed.length - SIG_SIZE, sealed.length - KEY_SIZE); + byte[] sBuf = Arrays.copyOfRange(sealed, sealed.length - KEY_SIZE, sealed.length); + + RistrettoElement r; + try { + r = new CompressedRistretto(rBuf).decompress(); + } catch (InvalidEncodingException e) { + throw new InvalidSealException(e); + } + Scalar rs = Scalar.fromBits(rBuf); + byte[] k = sender.table.multiply(rs).add(r).multiply(recipient.value).compress().toByteArray(); + MessageDigest digest = getReducedDigest(); + digest.update(SHARED_KEY); + digest.update(k); + digestVariableLengthBuffer(digest, senderId); + digestVariableLengthBuffer(digest, recipientId); + digestVariableLengthBuffer(digest, context); + byte[] key = digest.digest(); + byte[] iv = Arrays.copyOf(sealed, IV_SIZE); + + digest = getFullDigest(); + digest.update(SIGN_KEY); + digest.update(rBuf); + digestVariableLengthBuffer(digest, senderId); + digestVariableLengthBuffer(digest, recipientId); + digestVariableLengthBuffer(digest, context); + digest.update(sealed, 0, sealed.length - SIG_SIZE); + Scalar s = Scalar.fromCanonicalBytes(sBuf); + RistrettoElement expected = Constants.RISTRETTO_GENERATOR_TABLE.multiply(s).add(r); + RistrettoElement actual = sender.table.multiply(Scalar.fromBytesModOrderWide(digest.digest())); + if (expected.ctEquals(actual) == 0) { + throw new InvalidSealException("Signature mismatch"); + } + Cipher cipher = XChaCha20Poly1305.cryptWith(false, key, iv); + cipher.updateAAD(context); + try { + return cipher.doFinal(sealed, IV_SIZE, sealed.length - SIG_SIZE - IV_SIZE); + } catch (IllegalBlockSizeException e) { + throw new IllegalStateException(e); + } catch (BadPaddingException e) { + throw new InvalidSealException(e); + } + } + + private static final int KEY_SIZE = 32; + private static final int SIG_SIZE = 64; + private static final int TAG_SIZE = 16; + private static final int IV_SIZE = 24; + private static final int MAX_BYTES_PER_SEAL = 1 << 30; + // libsodium uses Blake2b by default, though Blake3 is now available, but the Java libraries are pretty meh + private static final String FULL_DIGEST_ALGORITHM = "BLAKE2B-512"; + private static final String REDUCED_DIGEST_ALGORITHM = "BLAKE2B-256"; + private static final String ALGORITHM = "SignCrypt25519"; + private static final byte[] SHARED_KEY = "shared_key".getBytes(StandardCharsets.UTF_8); + private static final byte[] SIGN_KEY = "sign_key".getBytes(StandardCharsets.UTF_8); + private static final byte[] NONCE = "nonce".getBytes(StandardCharsets.UTF_8); + + private static MessageDigest getFullDigest() { + try { + return MessageDigest.getInstance(FULL_DIGEST_ALGORITHM); + } catch (NoSuchAlgorithmException e) { + throw new InvalidProviderException(e); + } + } + + private static MessageDigest getReducedDigest() { + try { + return MessageDigest.getInstance(REDUCED_DIGEST_ALGORITHM); + } catch (NoSuchAlgorithmException e) { + throw new InvalidProviderException(e); + } + } + + private static void digestVariableLengthBuffer(MessageDigest digest, byte[] buf) { + digest.update((byte) (buf.length & 0xff)); + digest.update(buf); + } + + private static class VaultPrivateKey implements PrivateKey { + private final Scalar value; + + private VaultPrivateKey(Scalar value) { + this.value = value; + } + + @Override + public String getAlgorithm() { + return ALGORITHM; + } + + @Override + public String getFormat() { + return "RAW"; + } + + @Override + public byte[] getEncoded() { + return value.toByteArray(); + } + + VaultPublicKey generatePublicKey() { + return new VaultPublicKey(Constants.RISTRETTO_GENERATOR_TABLE.multiply(value)); + } + } + + private static class VaultPublicKey implements PublicKey { + private final RistrettoElement value; + private transient RistrettoGeneratorTable table; + + private VaultPublicKey(RistrettoElement value) { + this.value = value; + table = new RistrettoGeneratorTable(value); + } + + private Object readResolve() { + table = new RistrettoGeneratorTable(value); + return this; + } + + @Override + public String getAlgorithm() { + return ALGORITHM; + } + + @Override + public String getFormat() { + return "RAW"; + } + + @Override + public byte[] getEncoded() { + return value.compress().toByteArray(); + } + } +} diff --git a/java8/src/main/java/dev/o1c/spi/XChaCha20Poly1305.java b/java8/src/main/java/dev/o1c/spi/XChaCha20Poly1305.java new file mode 100644 index 0000000..8b94816 --- /dev/null +++ b/java8/src/main/java/dev/o1c/spi/XChaCha20Poly1305.java @@ -0,0 +1,113 @@ +/* + * Copyright 2020 Matt Sicker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.o1c.spi; + +import dev.o1c.spi.ByteOps; +import dev.o1c.spi.InvalidProviderException; +import org.bouncycastle.util.Arrays; + +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +// https://tools.ietf.org/html/rfc8439 +// https://tools.ietf.org/html/draft-irtf-cfrg-xchacha-03 +class XChaCha20Poly1305 { + private static final int KEY_SIZE = 32; + private static final int[] ENGINE_STATE_HEADER = + ByteOps.unpackIntsLE("expand 32-byte k".getBytes(StandardCharsets.US_ASCII), 0, 4); + + static Cipher cryptWith(boolean forEncryption, byte[] key, byte[] nonce) { + Cipher cipher = getChaCha20Poly1305(); + byte[] hNonce = Arrays.copyOfRange(nonce, 0, 16); + byte[] sNonce = Arrays.copyOfRange(nonce, 12, nonce.length); + ByteOps.packIntLE(0, sNonce, 0); + SecretKey subkey = new SecretKeySpec(calculateSubKey(key, hNonce), "XChaCha20-Poly1305"); + IvParameterSpec iv = new IvParameterSpec(sNonce); + try { + cipher.init(forEncryption ? Cipher.ENCRYPT_MODE : Cipher.DECRYPT_MODE, subkey, iv); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException(e); + } + return cipher; + } + + static byte[] calculateSubKey(byte[] key, byte[] nonce) { + int[] state = Arrays.copyOf(ENGINE_STATE_HEADER, 16); + ByteOps.unpackIntsLE(key, 0, 8, state, 4); + ByteOps.unpackIntsLE(nonce, 0, 4, state, 12); + chaChaBlock(state); + byte[] subkey = new byte[KEY_SIZE]; + ByteOps.packIntsLE(state, 0, 4, subkey, 0); + ByteOps.packIntsLE(state, 12, 4, subkey, 16); + return subkey; + } + + private static void chaChaBlock(int[] state) { + for (int i = 0; i < 10; i++) { + columnRound(state); + diagonalRound(state); + } + } + + private static void columnRound(int[] state) { + quarterRound(state, 0, 4, 8, 12); + quarterRound(state, 1, 5, 9, 13); + quarterRound(state, 2, 6, 10, 14); + quarterRound(state, 3, 7, 11, 15); + } + + private static void diagonalRound(int[] state) { + quarterRound(state, 0, 5, 10, 15); + quarterRound(state, 1, 6, 11, 12); + quarterRound(state, 2, 7, 8, 13); + quarterRound(state, 3, 4, 9, 14); + } + + private static void quarterRound(int[] state, int a, int b, int c, int d) { + state[a] += state[b]; + state[d] = shiftLeftRotate(state[d] ^ state[a], 16); + + state[c] += state[d]; + state[b] = shiftLeftRotate(state[b] ^ state[c], 12); + + state[a] += state[b]; + state[d] = shiftLeftRotate(state[d] ^ state[a], 8); + + state[c] += state[d]; + state[b] = shiftLeftRotate(state[b] ^ state[c], 7); + } + + // val <<< len + private static int shiftLeftRotate(int val, int len) { + return (val << len) | (val >>> -len); + } + + private static Cipher getChaCha20Poly1305() { + try { + return Cipher.getInstance("ChaCha20-Poly1305"); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + throw new InvalidProviderException(e); + } + } +} diff --git a/pom.xml b/pom.xml index 67d09d1..f184cb3 100644 --- a/pom.xml +++ b/pom.xml @@ -91,6 +91,11 @@ bcprov-jdk15on 1.67 + + cafe.cryptography + curve25519-elisabeth + 0.1.0 + net.i2p.crypto eddsa