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