From d9bcc3384aeb2da0756dd70afe82b966975a68f7 Mon Sep 17 00:00:00 2001 From: RFC8452 Date: Sun, 1 Mar 2020 00:45:27 +1300 Subject: [PATCH] Release 1.0.9 --- .gitignore | 6 + LICENSE | 19 + README.md | 18 + pom.xml | 170 +++ src/main/java/org/rfc8452/aead/AEAD.java | 19 + src/main/java/org/rfc8452/aead/AesGcmSiv.java | 208 +++ .../java/org/rfc8452/aead/ByteOperations.java | 24 + .../java/org/rfc8452/aead/Conversion.java | 30 + .../java/org/rfc8452/aead/AesGcmSivTest.java | 77 ++ .../java/org/rfc8452/aead/RFC8452Test.java | 1210 +++++++++++++++++ 10 files changed, 1781 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/org/rfc8452/aead/AEAD.java create mode 100644 src/main/java/org/rfc8452/aead/AesGcmSiv.java create mode 100644 src/main/java/org/rfc8452/aead/ByteOperations.java create mode 100644 src/main/java/org/rfc8452/aead/Conversion.java create mode 100644 src/test/java/org/rfc8452/aead/AesGcmSivTest.java create mode 100644 src/test/java/org/rfc8452/aead/RFC8452Test.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3db363 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +*.iml +META-INF/ +target/ +settings.xml +release.properties diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6065763 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019, 2020 RFC8452 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7184cee --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +AES-GCM-SIV Authenticated Encryption with Associated Data +--------------------------------------------------------- + +AES-GCM-SIV AEAD implementation. + +Nonce Misuse-Resistant Authenticated Encryption. + +From RFC8452: + + Some AEADs, including AES-GCM, suffer catastrophic failures + of confidentiality and/or integrity when two distinct messages are + encrypted with the same key and nonce. + + Nonce misuse-resistant AEADs do not suffer from this problem. For + this class of AEADs, encrypting two messages with the same nonce only + discloses whether the messages were equal or not. + +See [RFC8432](https://tools.ietf.org/html/rfc8452) for more details. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..c3ba77e --- /dev/null +++ b/pom.xml @@ -0,0 +1,170 @@ + + + 4.0.0 + + org.rfc8452.aead + AEAD + 1.0.9 + + AEAD + AES-GCM-SIV AEAD Implementation - Nonce Misuse-Resistant Authenticated Encryption + + https://github.com/RFC8452/AEAD + jar + + + 1.8 + 1.8 + + + + + org.junit.jupiter + junit-jupiter + 5.5.2 + test + + + org.junit.jupiter + junit-jupiter-api + 5.5.2 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.5.2 + test + + + org.rfc8452.authenticator + Polyval + 1.3.29 + + + + + + MIT + https://opensource.org/licenses/MIT + + + + + + rfc8452@protonmail.ch + + + + + scm:git:https://github.com/RFC8452/AEAD.git + scm:git:https://github.com/RFC8452/AEAD.git + https://github.com/RFC8452/AEAD/tree/master + AEAD-1.0.9 + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-release-plugin + 3.0.0-M1 + + true + false + release + deploy + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.0.1 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + sign-artifacts + verify + + sign + + + + --pinentry-mode + loopback + + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + ossrh + https://oss.sonatype.org/ + true + + + + maven-surefire-plugin + 3.0.0-M4 + + + maven-failsafe-plugin + 3.0.0-M4 + + + org.apache.maven.plugins + maven-jar-plugin + 3.2.0 + + + + com.rfc8452.authenticator + + + + + + + + diff --git a/src/main/java/org/rfc8452/aead/AEAD.java b/src/main/java/org/rfc8452/aead/AEAD.java new file mode 100644 index 0000000..7b1e525 --- /dev/null +++ b/src/main/java/org/rfc8452/aead/AEAD.java @@ -0,0 +1,19 @@ +package org.rfc8452.aead; + +import java.security.GeneralSecurityException; + +public interface AEAD { + + byte[] seal(byte[] plaintext, byte[] aad, byte[] nonce) throws GeneralSecurityException; + byte[] open(byte[] ciphertext, byte[] aad, byte[] nonce) throws GeneralSecurityException; + + byte[] seal(byte[] plaintext, byte[] aad) throws GeneralSecurityException; + byte[] open(byte[] ciphertext, byte[] aad) throws GeneralSecurityException; + + String seal(String plaintextHexString, String aadHexString, String nonceHexString) + throws GeneralSecurityException; + String open(String ciphertextHexString, String aadHexString, String nonceHexString) + throws GeneralSecurityException; + + void resetKey() throws GeneralSecurityException; +} diff --git a/src/main/java/org/rfc8452/aead/AesGcmSiv.java b/src/main/java/org/rfc8452/aead/AesGcmSiv.java new file mode 100644 index 0000000..322a97b --- /dev/null +++ b/src/main/java/org/rfc8452/aead/AesGcmSiv.java @@ -0,0 +1,208 @@ +package org.rfc8452.aead; + +import org.rfc8452.authenticator.Polyval; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.SecureRandom; +import java.util.Arrays; + +public class AesGcmSiv implements AEAD +{ + static final int AES_BLOCK_SIZE = 16; + private static final int NONCE_BYTE_LENGTH = 12; + + private final byte[] key; + + public AesGcmSiv(final byte[] key) + { + this.key = key; + } + + public AesGcmSiv(final String keyHexString) + { + this.key = Conversion.hexStringToBytes(keyHexString); + } + + private static Cipher initAesEcbNoPaddingCipher(final byte[] key) throws GeneralSecurityException + { + if (!((key.length == 16) || (key.length == 32))) + { + throw new InvalidKeyException( + String.format("Key length is %d, expected length is 16 or 32 bytes", key.length)); + } + final Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES")); + return cipher; + } + + @Override + public byte[] seal(final byte[] plaintext, final byte[] aad, final byte[] nonce) throws GeneralSecurityException + { + final byte[] authenticationKey = deriveKey(key, nonce, 0, 1); + final byte[] encryptionKey = deriveKey(key, nonce, 2, key.length == 16 ? 3 : 5); + final byte[] tag = getTag(encryptionKey, authenticationKey, plaintext, aad, nonce); + final byte[] ciphertext = aesCtr(encryptionKey, tag, plaintext); + final byte[] tagWithCiphertext = new byte[tag.length + ciphertext.length]; + System.arraycopy(tag, 0, tagWithCiphertext, plaintext.length, tag.length); + System.arraycopy(ciphertext, 0, tagWithCiphertext, 0, ciphertext.length); + return tagWithCiphertext; + } + + public byte[] seal(final byte[] plaintext, final byte[] aad) throws GeneralSecurityException + { + final byte[] nonce = new byte[NONCE_BYTE_LENGTH]; + SecureRandom.getInstanceStrong().nextBytes(nonce); + + final byte[] ciphertext = seal(plaintext, aad, nonce); + final byte[] nonceWithCipherText = new byte[nonce.length + ciphertext.length]; + System.arraycopy(nonce, 0, nonceWithCipherText, 0, nonce.length); + System.arraycopy(ciphertext, 0, nonceWithCipherText, nonce.length, ciphertext.length); + return nonceWithCipherText; + } + + @Override + public String seal(final String plaintextHexString, final String aadHexString, final String nonceHexString) + throws GeneralSecurityException + { + final byte[] plaintext = Conversion.hexStringToBytes(plaintextHexString); + final byte[] aad = Conversion.hexStringToBytes(aadHexString); + final byte[] nonce = Conversion.hexStringToBytes(nonceHexString); + return Conversion.bytesToHexString(seal(plaintext, aad, nonce)); + } + + @Override + public byte[] open(final byte[] ciphertext, final byte[] aad, final byte[] nonce) throws GeneralSecurityException + { + final byte[] tag = new byte[AES_BLOCK_SIZE]; + final byte[] plainText = new byte[ciphertext.length - AES_BLOCK_SIZE]; + System.arraycopy(ciphertext, 0, plainText, 0, plainText.length); + System.arraycopy(ciphertext, plainText.length, tag, 0, tag.length); + + final byte[] authenticationKey = deriveKey(key, nonce, 0, 1); + final byte[] encryptionKey = deriveKey(key, nonce, 2, key.length == 16 ? 3 : 5); + + final byte[] deciphered = aesCtr(encryptionKey, tag, plainText); + final byte[] actual = getTag(encryptionKey, authenticationKey, deciphered, aad, nonce); + + if (Arrays.equals(tag, actual)) + { + return deciphered; + } + return null; + } + + @Override + public byte[] open(final byte[] nonceWithCiphertext, final byte[] aad) throws GeneralSecurityException + { + if (nonceWithCiphertext.length < NONCE_BYTE_LENGTH) + { + return null; + } + final byte[] nonce = new byte[NONCE_BYTE_LENGTH]; + final byte[] ciphertext = new byte[nonceWithCiphertext.length - NONCE_BYTE_LENGTH]; + System.arraycopy(nonceWithCiphertext, 0, nonce, 0, nonce.length); + System.arraycopy(nonceWithCiphertext, nonce.length, ciphertext, 0, ciphertext.length); + return open(ciphertext, aad, nonce); + } + + @Override + public String open(final String ciphertextHexString, + final String aadHexString, final String nonceHexString) + throws GeneralSecurityException + { + final byte[] ciphertext = Conversion.hexStringToBytes(ciphertextHexString); + final byte[] aad = Conversion.hexStringToBytes(aadHexString); + final byte[] nonce = Conversion.hexStringToBytes(nonceHexString); + final byte[] plaintext = open(ciphertext, aad, nonce); + if (null == plaintext) + { + return null; + } + return Conversion.bytesToHexString(plaintext); + } + + private static byte[] getTag(final byte[] encryptionKey, final byte[] authenticationKey, + final byte[] plaintext, final byte[] aad, final byte[] nonce) + throws GeneralSecurityException + { + if (nonce.length != NONCE_BYTE_LENGTH) + { + throw new GeneralSecurityException("Expected nonce length is 12 bytes"); + } + final byte[] aadPlaintextLengths = new byte[AES_BLOCK_SIZE]; + ByteOperations.inPlaceUpdate(aadPlaintextLengths, (long) aad.length * 8, 0); + ByteOperations.inPlaceUpdate(aadPlaintextLengths, (long) plaintext.length * 8, 8); + + final Polyval authenticator = new Polyval(authenticationKey); + authenticator.update(aad); + authenticator.update(plaintext); + authenticator.update(aadPlaintextLengths); + final byte[] digest = authenticator.digest(); + for (int i = 0; i < nonce.length; i++) + { + digest[i] ^= nonce[i]; + } + digest[digest.length - 1] &= (byte) ~0x80; + final Cipher cipher = initAesEcbNoPaddingCipher(encryptionKey); + cipher.update(digest, 0, digest.length, digest, 0); + return digest; + } + + private static byte[] deriveKey(final byte[] parentKey, final byte[] nonce, + final int counterStartValue, final int counterEndValue) + throws GeneralSecurityException + { + if (nonce.length != NONCE_BYTE_LENGTH) + { + throw new GeneralSecurityException("Expected nonce length is 12 bytes"); + } + final Cipher cipher = initAesEcbNoPaddingCipher(parentKey); + final byte[] counter = new byte[AES_BLOCK_SIZE]; + System.arraycopy(nonce, 0, counter, counter.length - nonce.length, nonce.length); + final int counterLength = (counterEndValue - counterStartValue + 1) * 8; + final byte[] key = new byte[counterLength]; + final byte[] block = new byte[AES_BLOCK_SIZE]; + for (int i = counterStartValue; i <= counterEndValue; i++) + { + ByteOperations.inPlaceUpdate(counter, i); + cipher.update(counter, 0, AES_BLOCK_SIZE, block, 0); + System.arraycopy(block, 0, key, (i - counterStartValue) * 8, 8); + } + return key; + } + + private static byte[] aesCtr(final byte[] encryptionKey, final byte[] tag, final byte[] input) + throws GeneralSecurityException + { + final byte[] result = new byte[input.length]; + final byte[] counter = Arrays.copyOf(tag, tag.length); + final byte[] key = new byte[AES_BLOCK_SIZE]; + final Cipher cipher = initAesEcbNoPaddingCipher(encryptionKey); + counter[counter.length - 1] |= (byte) 0x80; + for (int i = 0; i < input.length; i += AES_BLOCK_SIZE) + { + cipher.update(counter, 0, counter.length, key, 0); + for (int j = 0; j < Math.min(AES_BLOCK_SIZE, input.length - i); j++) + { + result[i + j] = (byte) (input[i + j] ^ key[j]); + } + for (int k=0; k < 4; k++) + { + if (++counter[k] != 0) + { + break; + } + } + } + return result; + } + + @Override + public void resetKey() throws GeneralSecurityException + { + SecureRandom.getInstanceStrong().nextBytes(key); + } +} diff --git a/src/main/java/org/rfc8452/aead/ByteOperations.java b/src/main/java/org/rfc8452/aead/ByteOperations.java new file mode 100644 index 0000000..6097613 --- /dev/null +++ b/src/main/java/org/rfc8452/aead/ByteOperations.java @@ -0,0 +1,24 @@ +package org.rfc8452.aead; + +public class ByteOperations +{ + static void inPlaceUpdate(byte[] b, final int n) + { + b[0] = (byte) n; + b[1] = (byte) (n >> 8); + b[2] = (byte) (n >> 16); + b[3] = (byte) (n >> 24); + } + + static void inPlaceUpdate(byte[] b, final long n, final int offset) + { + b[offset] = (byte) n; + b[1 + offset] = (byte) (n >> 8); + b[2 + offset] = (byte) (n >> 16); + b[3 + offset] = (byte) (n >> 24); + b[4 + offset] = (byte) (n >> 32); + b[5 + offset] = (byte) (n >> 40); + b[6 + offset] = (byte) (n >> 48); + b[7 + offset] = (byte) (n >> 56); + } +} diff --git a/src/main/java/org/rfc8452/aead/Conversion.java b/src/main/java/org/rfc8452/aead/Conversion.java new file mode 100644 index 0000000..7444c6e --- /dev/null +++ b/src/main/java/org/rfc8452/aead/Conversion.java @@ -0,0 +1,30 @@ +package org.rfc8452.aead; + +import java.util.Formatter; +import java.util.Scanner; + +public class Conversion +{ + + static byte[] hexStringToBytes(final String hexString) + { + byte[] bytes = new byte[hexString.length() / 2]; + for (int i = 0; i < hexString.length() / 2; i++) + { + String chunk = hexString.substring(i * 2, i * 2 + 2); + bytes[i] = (byte) new Scanner(chunk).nextInt(16); + } + return bytes; + } + + static String bytesToHexString(final byte[] bytes) + { + Formatter formatter = new Formatter(); + for (byte b : bytes) + { + formatter.format("%02x", b); + } + return formatter.toString(); + } + +} diff --git a/src/test/java/org/rfc8452/aead/AesGcmSivTest.java b/src/test/java/org/rfc8452/aead/AesGcmSivTest.java new file mode 100644 index 0000000..1087d83 --- /dev/null +++ b/src/test/java/org/rfc8452/aead/AesGcmSivTest.java @@ -0,0 +1,77 @@ +package org.rfc8452.aead; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.security.GeneralSecurityException; + +class AesGcmSivTest +{ + + @Test + void shouldProduceDecipheredThatEqualsToInitialPlaintext16S() throws GeneralSecurityException { + AEAD aead = new AesGcmSiv("00000000000000000000000000000000"); + final byte[] plaintext = new byte[] {1, 1}; + final byte[] aad = new byte[] {2, 2}; + final byte[] ciphertext = aead.seal(plaintext, aad); + final byte[] deciphered = aead.open(ciphertext, aad); + Assertions.assertArrayEquals(plaintext, deciphered); + } + + @Test + void shouldProduceDecipheredThatEqualsToInitialPlaintext16B() throws GeneralSecurityException { + AEAD aead = new AesGcmSiv(new byte[] { + 1, 1, 1, 1, + 1, 1, 1, 1, + 1, 1, 1, 1, + 1, 1, 1, 1 + }); + final byte[] plaintext = new byte[] {1, 1}; + final byte[] aad = new byte[] {2, 2}; + final byte[] ciphertext = aead.seal(plaintext, aad); + final byte[] deciphered = aead.open(ciphertext, aad); + Assertions.assertArrayEquals(plaintext, deciphered); + } + + @Test + void shouldProduceDecipheredThatEqualsToInitialPlaintext32B() throws GeneralSecurityException { + AEAD aead = new AesGcmSiv(new byte[] { + 1, 1, 1, 1, + 1, 1, 1, 1, + 1, 1, 1, 1, + 1, 1, 1, 1, + + 1, 1, 1, 1, + 1, 1, 1, 1, + 1, 1, 1, 1, + 1, 1, 1, 1 + }); + final byte[] plaintext = new byte[] {1, 1}; + final byte[] aad = new byte[] {2, 2}; + final byte[] ciphertext = aead.seal(plaintext, aad); + final byte[] deciphered = aead.open(ciphertext, aad); + Assertions.assertArrayEquals(plaintext, deciphered); + } + + @Test + void shouldFailOnInvalidAad() throws GeneralSecurityException { + AEAD aead = new AesGcmSiv("01000000000000000000000000000000"); + final byte[] plaintext = new byte[] {1, 1}; + final byte[] aad = new byte[] {2, 2}; + final byte[] invalidAad = new byte[] {3, 3}; + final byte[] deciphered = aead.open(aead.seal(plaintext, aad), invalidAad); + Assertions.assertNull(deciphered); + } + + @Test + void shouldForgetKeyAfterKeyReset() throws GeneralSecurityException { + AEAD aead = new AesGcmSiv("00000000000000000000000000000000"); + final byte[] plaintext = new byte[] {1, 1}; + final byte[] aad = new byte[] {2, 2}; + final byte[] ciphertext = aead.seal(plaintext, aad); + aead.resetKey(); + final byte[] deciphered = aead.open(ciphertext, aad); + Assertions.assertNull(deciphered); + } + +} \ No newline at end of file diff --git a/src/test/java/org/rfc8452/aead/RFC8452Test.java b/src/test/java/org/rfc8452/aead/RFC8452Test.java new file mode 100644 index 0000000..f127e49 --- /dev/null +++ b/src/test/java/org/rfc8452/aead/RFC8452Test.java @@ -0,0 +1,1210 @@ +package org.rfc8452.aead; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.security.GeneralSecurityException; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +public class RFC8452Test +{ + @ParameterizedTest + @MethodSource("rfc8452C1TestVectors") + void shouldSealAsSpecifiedInRFC8452C1TestVectors(final TestVector testVector) throws GeneralSecurityException + { + final String actual = new AesGcmSiv(testVector.key).seal( + testVector.plaintext, testVector.aad, testVector.nonce); + final String expected = testVector.result; + Assertions.assertEquals(expected, actual); + } + + @ParameterizedTest + @MethodSource("rfc8452C2TestVectors") + void shouldSealAsSpecifiedInRFC8452C2TestVectors(final TestVector testVector) throws GeneralSecurityException + { + final String actual = new AesGcmSiv(testVector.key).seal( + testVector.plaintext, testVector.aad, testVector.nonce); + final String expected = testVector.result; + Assertions.assertEquals(expected, actual); + } + + @ParameterizedTest + @MethodSource("rfc8452C3TestVectors") + void shouldSealAsSpecifiedInRFC8452C3TestVectors(final TestVector testVector) throws GeneralSecurityException + { + final String actual = new AesGcmSiv(testVector.key).seal( + testVector.plaintext, testVector.aad, testVector.nonce); + final String expected = testVector.result; + Assertions.assertEquals(expected, actual); + } + + @ParameterizedTest + @MethodSource("rfc8452C1TestVectors") + void shouldOpenAsSpecifiedInRFC8452C1TestVectors(final TestVector testVector) throws GeneralSecurityException + { + final String actual = new AesGcmSiv(testVector.key).open(testVector.result, testVector.aad, testVector.nonce); + final String expected = testVector.plaintext; + Assertions.assertEquals(expected, actual); + } + + @ParameterizedTest + @MethodSource("rfc8452C2TestVectors") + void shouldOpenAsSpecifiedInRFC8452C2TestVectors(final TestVector testVector) throws GeneralSecurityException + { + final String actual = new AesGcmSiv(testVector.key).open(testVector.result, testVector.aad, testVector.nonce); + final String expected = testVector.plaintext; + Assertions.assertEquals(expected, actual); + } + + @ParameterizedTest + @MethodSource("rfc8452C3TestVectors") + void shouldOpenAsSpecifiedInRFC8452C3TestVectors(final TestVector testVector) throws GeneralSecurityException + { + final String actual = new AesGcmSiv(testVector.key).open(testVector.result, testVector.aad, testVector.nonce); + final String expected = testVector.plaintext; + Assertions.assertEquals(expected, actual); + } + + public static Stream rfc8452C1TestVectors() + { + return parseSpec(RFC8452_C1); + } + + public static Stream rfc8452C2TestVectors() + { + return parseSpec(RFC8452_C2); + } + + public static Stream rfc8452C3TestVectors() + { + return parseSpec(RFC8452_C3); + } + + static class TestVector + { + public String plaintext; + public String key; + public String aad; + public String nonce; + public String result; + + public TestVector(Map item) + { + item.forEach((k, v) -> { + if (k.startsWith("Plaintext")) plaintext = v; + if (k.startsWith("Key")) key = v; + if (k.startsWith("AAD")) aad = v; + if (k.startsWith("Nonce")) nonce = v; + if (k.startsWith("Result")) result = v; + }); + } + } + + private static Stream parseSpec(final String[] spec) + { + final String regex = "\\s{2}((?