diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..dd9d14b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Java / Maven dependencies + - package-ecosystem: "maven" + directory: "/" + target-branch: "main" + schedule: + interval: "weekly" + day: "sunday" + open-pull-requests-limit: 15 + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + groups: + maven-dependencies: + patterns: + - "*" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "main" + schedule: + interval: "weekly" + day: "sunday" + open-pull-requests-limit: 10 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..93c8239 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,74 @@ +name: Build + +on: + push: + branches: master + pull_request: + branches: master + types: [opened, synchronize, reopened] + schedule: + - cron: '41 13 * * 3' + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + codeql: + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: java-kotlin + build-mode: manual + name: 'codeql: ${{ matrix.language }}' + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 6.0.2 + - name: Set up JDK 25 + if: ${{ matrix.language == 'java-kotlin' }} + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: 25 + cache: 'maven' + - name: Initialize CodeQL + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended, security-and-quality + - name: Minimal Maven Build + if: ${{ matrix.language == 'java-kotlin' }} + run: mvn -B package -Dgpg.skip -DskipTests + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + with: + category: "/language:${{matrix.language}}" + + maven: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + main: ${{ steps.main.outputs.main }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: 25 + cache: 'maven' + - name: Maven Verify + run: mvn -B -fae verify -Dgpg.skip \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 9814cb4..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: "CodeQL Advanced" - -on: - push: - branches: master - pull_request: - branches: master - schedule: - - cron: '41 13 * * 3' - -jobs: - analyze: - name: Analyze - runs-on: 'ubuntu-latest' - permissions: - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'java-kotlin' ] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: 21 - cache: 'maven' - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - queries: security-extended, security-and-quality - - - name: Build with Maven - run: mvn -B --file pom.xml -Dmaven.javadoc.skip=true -Dgpg.skip -Denforcer.skip -DskipTests clean package - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml deleted file mode 100644 index f3afdae..0000000 --- a/.github/workflows/maven.yml +++ /dev/null @@ -1,22 +0,0 @@ -# This workflow will build a Java project with Maven -# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven - -name: Java CI with Maven - -on: [push, pull_request] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: 21 - cache: 'maven' - - name: Build with Maven - run: mvn -B verify -Dgpg.skip --file pom.xml diff --git a/README.md b/README.md index b408069..47dae7e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # crypto-utils -Utility library for reading and writing X509 certificates as well as reading, writing and generating RSA and EC keys. Also provides a simple certificate authority implementation and RSA-KEM AES GCM based on BouncyCastle as well as a ECDH-KEM AES GCM implementation based on JEP 452. +Utility library for reading and writing X509 certificates as well as reading, writing and generating RSA and EC keys. Also provides a simple certificate authority and a RFC 9180 Hybrid Public Key Encryption implementation. + [![Java CI with Maven](https://github.com/hhund/crypto-utils/workflows/Java%20CI%20with%20Maven/badge.svg)](https://github.com/hhund/crypto-utils/actions?query=workflow%3A"Java+CI+with+Maven") [![Maven Central](https://maven-badges.sml.io/sonatype-central/de.hs-heilbronn.mi/crypto-utils/badge.svg)](https://maven-badges.sml.io/sonatype-central/de.hs-heilbronn.mi/crypto-utils) @@ -8,6 +9,6 @@ Utility library for reading and writing X509 certificates as well as reading, wr de.hs-heilbronn.mi crypto-utils - 5.2.1 + 6.0.0 ``` diff --git a/pom.xml b/pom.xml index e2fb811..352bc62 100644 --- a/pom.xml +++ b/pom.xml @@ -3,21 +3,21 @@ de.hs-heilbronn.mi crypto-utils - 5.2.1 + 6.0.0-SNAPSHOT crypto-utils - Utility library for reading and writing X509 certificates as well as reading, writing and generating RSA and EC keys. Also provides a simple certificate authority implementation and RSA-KEM AES GCM based on BouncyCastle as well as a ECDH-KEM AES GCM implementation based on JEP 452. + Utility library for reading and writing X509 certificates as well as reading, writing and generating RSA and EC keys. Also provides a simple certificate authority and a RFC 9180 Hybrid Public Key Encryption implementation. https://github.com/hhund/crypto-utils UTF-8 - 21 - 21 - 21 + 25 + 25 + 25 - 2.0.17 - 1.83 - 5.14.1 + 2.0.18 + 1.84 + 5.14.4 @@ -78,21 +78,21 @@ org.apache.logging.log4j log4j-slf4j2-impl - 2.25.2 + 2.26.0 test - + - org.junit.jupiter - junit-jupiter-engine - ${junit.version} - test + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test - org.junit.jupiter - junit-jupiter-params - ${junit.version} - test + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test @@ -101,7 +101,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.14.1 + 3.15.0 -proc:none @@ -111,7 +111,7 @@ org.apache.maven.plugins maven-source-plugin - 3.3.1 + 3.4.0 attach-sources @@ -151,12 +151,12 @@ org.apache.maven.plugins maven-surefire-plugin - 3.5.4 + 3.5.5 org.apache.maven.plugins maven-enforcer-plugin - 3.6.2 + 3.6.3 enforce-maven @@ -182,7 +182,7 @@ org.sonatype.central central-publishing-maven-plugin - 0.9.0 + 0.10.0 true central diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/AbstractKemWrapper.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/AbstractKemWrapper.java new file mode 100644 index 0000000..2b0fb08 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/AbstractKemWrapper.java @@ -0,0 +1,64 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.util.Objects; + +import javax.crypto.DecapsulateException; +import javax.crypto.KEM.Encapsulated; +import javax.crypto.SecretKey; + +public abstract class AbstractKemWrapper implements KemWrapper +{ + private final KemId kemId; + + public AbstractKemWrapper(KemId kemId) + { + this.kemId = Objects.requireNonNull(kemId, "kemId"); + } + + @Override + public final Encapsulated getEncapsulated(PublicKey publicKey, SecureRandom secureRandom) + throws NoSuchAlgorithmException, InvalidKeyException, KeyNotSupportedException + { + if (!kemId.isKeySupported(publicKey)) + throw new KeyNotSupportedException("publicKey not supported"); + + Encapsulated encapsulated = doGetEncapsulated(publicKey, secureRandom, kemId.getSharedSecretLength()); + + if (encapsulated.encapsulation().length != kemId.getEncapsulationLength()) + throw new IllegalStateException("encapsulation.length not " + kemId.getEncapsulationLength()); + + if (encapsulated.key().getEncoded().length != kemId.getSharedSecretLength()) + throw new IllegalStateException("sharedSecret.length not " + kemId.getSharedSecretLength()); + + return encapsulated; + } + + protected abstract Encapsulated doGetEncapsulated(PublicKey publicKey, SecureRandom secureRandom, + int sharedSecretLength) throws NoSuchAlgorithmException, InvalidKeyException; + + @Override + public final SecretKey getSharedSecret(PrivateKey privateKey, byte[] encapsulation) + throws NoSuchAlgorithmException, InvalidKeyException, DecapsulateException, KeyNotSupportedException + { + if (!kemId.isKeySupported(privateKey)) + throw new KeyNotSupportedException("privateKey not supported"); + + if (encapsulation.length != kemId.getEncapsulationLength()) + throw new IllegalStateException("encapsulation.length not " + kemId.getEncapsulationLength()); + + SecretKey sharedSecret = doGetSharedSecret(privateKey, encapsulation, kemId.getSharedSecretLength()); + + if (sharedSecret.getEncoded().length != kemId.getSharedSecretLength()) + throw new IllegalStateException("sharedSecret.length not " + kemId.getSharedSecretLength()); + + return sharedSecret; + } + + protected abstract SecretKey doGetSharedSecret(PrivateKey privateKey, byte[] encapsulation, int sharedSecretLength) + throws NoSuchAlgorithmException, InvalidKeyException, DecapsulateException; +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/AeadId.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/AeadId.java new file mode 100644 index 0000000..706a1f1 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/AeadId.java @@ -0,0 +1,131 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.AlgorithmParameterSpec; +import java.util.Objects; +import java.util.function.BiFunction; + +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.IvParameterSpec; + +public enum AeadId +{ + AES_128_GCM(0x0001, 16, 12, "AES", "AES/GCM/NoPadding", 128, GCMParameterSpec::new), + + AES_256_GCM(0x0002, 32, 12, "AES", "AES/GCM/NoPadding", 128, GCMParameterSpec::new), + + ChaCha20Poly1305(0x0003, 32, 12, "ChaCha20", "ChaCha20-Poly1305", 128, (_, iV) -> new IvParameterSpec(iV)); + + private final int id; + private final int keyLength; + private final int ivLength; + private final String keyAlgorithm; + private final String cipherAlgorithm; + private final int authenticationTagLengthBits; + private final BiFunction cipherAlgorithmParameterSpecFactory; + + AeadId(int id, int keyLenght, int ivLength, String keyAlgorithm, String cipherAlgorithm, + int authenticationTagLengthBits, + BiFunction cipherAlgorithmParameterSpecFactory) + { + this.id = id; + this.keyLength = keyLenght; + this.ivLength = ivLength; + this.keyAlgorithm = keyAlgorithm; + this.cipherAlgorithm = cipherAlgorithm; + this.authenticationTagLengthBits = authenticationTagLengthBits; + this.cipherAlgorithmParameterSpecFactory = cipherAlgorithmParameterSpecFactory; + } + + public int getId() + { + return id; + } + + public byte[] getIdAsI2osp2Bytes() + { + return ByteEncoding.i2osp2(id); + } + + public int getKeyLength() + { + return keyLength; + } + + public byte[] getKeyLengthAsI2osp2Bytes() + { + return ByteEncoding.i2osp2(keyLength); + } + + public int getIvLength() + { + return ivLength; + } + + public byte[] getIvLengthAsI2osp2Bytes() + { + return ByteEncoding.i2osp2(ivLength); + } + + public int getAuthenticationTagLengthBits() + { + return authenticationTagLengthBits; + } + + public String getKeyAlgorithm() + { + return keyAlgorithm; + } + + public void initEncryptionCipher(Cipher cipher, SecretKey key, byte[] iv) + throws NoSuchPaddingException, InvalidKeyException, InvalidAlgorithmParameterException + { + initCipher(cipher, key, iv, Cipher.ENCRYPT_MODE); + } + + public void initDecryptionCipher(Cipher cipher, SecretKey key, byte[] iv) + throws NoSuchPaddingException, InvalidKeyException, InvalidAlgorithmParameterException + { + initCipher(cipher, key, iv, Cipher.DECRYPT_MODE); + } + + private void initCipher(Cipher cipher, SecretKey key, byte[] iv, int mode) + throws InvalidKeyException, InvalidAlgorithmParameterException + { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(iv, "iv"); + if (ivLength != iv.length) + throw new IllegalArgumentException("iv.length not " + ivLength); + + AlgorithmParameterSpec spec = cipherAlgorithmParameterSpecFactory.apply(authenticationTagLengthBits, iv); + cipher.init(mode, key, spec); + } + + public Cipher toCipher() throws NoSuchAlgorithmException, NoSuchPaddingException + { + return Cipher.getInstance(cipherAlgorithm); + } + + public static AeadId from(byte[] value) throws IllegalArgumentException + { + Objects.requireNonNull(value, "value"); + if (value.length != 2) + throw new IllegalArgumentException("value.length not 2"); + + long aeadId = ByteEncoding.os2ip(value); + + if (AES_128_GCM.id == aeadId) + return AES_128_GCM; + else if (AES_256_GCM.id == aeadId) + return AES_256_GCM; + else if (ChaCha20Poly1305.id == aeadId) + return ChaCha20Poly1305; + else + throw new IllegalArgumentException("AeadId not supported"); + } +} \ No newline at end of file diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ByteEncoding.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ByteEncoding.java new file mode 100644 index 0000000..dfb6d64 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ByteEncoding.java @@ -0,0 +1,105 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; + +public final class ByteEncoding +{ + private ByteEncoding() + { + } + + /** + * @param value + * >= 0 and <= 255 + * @return encoded value + * @throws IllegalArgumentException + * if value < 0 or > 255 + */ + public static byte[] i2osp1(int value) + { + if (value < 0 || value > 0xFF) + throw new IllegalArgumentException("value < 0 or value > 255"); + + return new byte[] { (byte) value }; + } + + /** + * @param value + * >= 0 and <= 65535 + * @return encoded value + * @throws IllegalArgumentException + * if value < 0 or > 65535 + */ + public static byte[] i2osp2(int value) + { + if (value < 0 || value > 0xFFFF) + throw new IllegalArgumentException("value < 0 or value > 65535"); + + byte[] output = new byte[2]; + output[0] = (byte) (value >>> 8); + output[1] = (byte) value; + return output; + } + + /** + * @param input + * not null, length < 1 or > 4 + * @return decoded value + * @throws IllegalArgumentException + * if input length < 1 or > 4 + */ + public static long os2ip(byte[] input) + { + Objects.requireNonNull(input, "input"); + + if (input.length < 1 || input.length > 4) + throw new IllegalArgumentException("input.length < 1 or input.length > 4"); + + long value = 0; + for (int j = 0; j < input.length; j++) + value |= ((long) input[j] & 0xff) << (8 * (input.length - 1 - j)); + return value; + } + + public static byte[] concat(byte[]... bytes) + { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Arrays.stream(bytes).forEach(out::writeBytes); + return out.toByteArray(); + } + + /** + * @param expected + * >= 0 + * @param actual + * @throws IllegalArgumentException + * if expected < 0 + * @throws IOException + * if actual < expected + */ + public static void expectRead(int expected, int actual) throws IOException + { + if (expected < 0) + throw new IllegalArgumentException("expected < 0"); + + if (actual < expected) + throw new IOException("Truncated stream"); + } + + /** + * @param value + * @throws IOException + * if value <0 or >255 + */ + public static void throwIfTruncated(int value) throws IOException + { + if (value < 0) + throw new IOException("Truncated stream"); + + if (value > 0xFF) + throw new IOException("value > 255"); + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkLength.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkLength.java new file mode 100644 index 0000000..d89d98e --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkLength.java @@ -0,0 +1,37 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.util.Objects; + +/** + * length(exp) = 1024 * 2^exp for exp ∈ [0, 15] + */ +public enum ChunkLength +{ + KiB_1, KiB_2, KiB_4, KiB_8, KiB_16, KiB_32, KiB_64, KiB_128, KiB_256, KiB_512, MiB_1, MiB_2, MiB_4, MiB_8, MiB_16, MiB_32; + + public static final int BASE = 1024; + + public int getLength() + { + return BASE << ordinal(); + } + + public byte[] getExponentAsI2osp1Byte() + { + return ByteEncoding.i2osp1(ordinal()); + } + + public static ChunkLength from(byte[] value) throws IllegalArgumentException + { + Objects.requireNonNull(value, "value"); + if (value.length != 1) + throw new IllegalArgumentException("value.length not 1"); + + long exponent = ByteEncoding.os2ip(value); + + if (exponent > ChunkLength.values().length - 1) + throw new IllegalArgumentException("Chunk length exponent not supported"); + + return ChunkLength.values()[(int) exponent]; + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkedInputStreamEnumeration.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkedInputStreamEnumeration.java new file mode 100644 index 0000000..8f0981e --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkedInputStreamEnumeration.java @@ -0,0 +1,214 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.util.Enumeration; +import java.util.NoSuchElementException; +import java.util.Objects; + +/** + * This enumeration emits one final empty chunk for empty inputs so that the cryptographic operation can produce final + * authentication output.
+ *
+ * Per chunk initialization vectors are calculated as "baseNonce XOR sequence". The sequence starts at 0 for the first + * chunk is incremented by 1 for every chunk.
+ *
+ * {@link IOException}s throw by the source {@link InputStream} are re-thrown as {@link RuntimeIOException}s and can be + * translated back to {@link IOException}s via {@link SequenceInputStreamForRuntimeIOException}.
+ *
+ * The implementation is not thread-save. + * + * @see SequenceInputStreamForRuntimeIOException + */ +public class ChunkedInputStreamEnumeration implements Enumeration +{ + @FunctionalInterface + public interface CryptOperation + { + InputStream apply(byte[] iv, byte[] sequence, boolean finished, byte[] chunk) + throws IOException, GeneralSecurityException; + } + + private final int chunkLength; + private final byte[] baseNonce; + private final InputStream source; + private final CryptOperation cryptOperation; + + private final byte[] sequence; + + private boolean initialized = false; + private boolean finished = false; + private boolean noElementEmitted = true; + + private byte[] nextChunk; + + private RuntimeIOException pendingException; + + /** + * @param chunkLength + * > 0 + * @param baseNonce + * not null, length > 0 + * @param source + * not null + * @param cryptOperation + * not null + */ + ChunkedInputStreamEnumeration(int chunkLength, byte[] baseNonce, InputStream source, CryptOperation cryptOperation) + { + if (chunkLength <= 0) + throw new IllegalArgumentException("chunkLength <= 0"); + this.chunkLength = chunkLength; + this.baseNonce = Objects.requireNonNull(baseNonce, "baseNonce"); + if (baseNonce.length <= 0) + throw new IllegalArgumentException("baseNonce.length <= 0"); + this.source = Objects.requireNonNull(source, "source"); + this.cryptOperation = Objects.requireNonNull(cryptOperation, "cryptOperation"); + + this.sequence = new byte[baseNonce.length]; + } + + private void ensureInitialized() + { + if (!initialized) + { + loadNextChunk(); + initialized = true; + } + } + + @Override + public boolean hasMoreElements() + { + ensureInitialized(); + + // nextChunk == null && noElementEmitted -> source empty, emit empty final chunk + // nextChunk == null && !noElementEmitted -> true end-of-stream + + return nextChunk != null || noElementEmitted; + } + + @Override + public InputStream nextElement() + { + ensureInitialized(); + + if (pendingException != null) + { + RuntimeIOException e = pendingException; + pendingException = null; + throw e; + } + + if (nextChunk == null && !noElementEmitted) + throw new NoSuchElementException(); + + boolean currentChunkIsFinal = this.finished; + byte[] currentChunk = this.nextChunk; + + if (currentChunk == null) // && noElementEmitted + currentChunk = new byte[0]; + + loadNextChunk(); + + if (this.nextChunk == null) + currentChunkIsFinal = true; + + try + { + InputStream result = cryptOperation.apply(createNonce(), sequence, currentChunkIsFinal, currentChunk); + + incrementSequence(); + + return result; + } + catch (IOException e) + { + throw new RuntimeIOException(e); + } + catch (GeneralSecurityException e) + { + throw new RuntimeIOException(new IOException(e.getMessage(), e)); + } + } + + private void loadNextChunk() + { + if (finished) + { + nextChunk = null; + return; + } + + byte[] buffer = new byte[chunkLength]; + int offset = 0; + + try + { + while (offset < chunkLength) + { + int read = source.read(buffer, offset, chunkLength - offset); + if (read == -1) + break; + + offset += read; + } + + if (offset == 0) + { + finished = true; + nextChunk = null; + + source.close(); + } + else if (offset < chunkLength) + { + byte[] lastChunk = new byte[offset]; + System.arraycopy(buffer, 0, lastChunk, 0, offset); + nextChunk = lastChunk; + finished = true; + + source.close(); + } + else + nextChunk = buffer; + } + catch (IOException e) + { + finished = true; + nextChunk = null; + + try + { + source.close(); + } + catch (IOException closeE) + { + e.addSuppressed(closeE); + } + + pendingException = new RuntimeIOException(e); + } + } + + private byte[] createNonce() + { + byte[] result = new byte[baseNonce.length]; + for (int i = 0; i < baseNonce.length; i++) + result[i] = (byte) (baseNonce[i] ^ sequence[i]); + return result; + } + + private void incrementSequence() + { + if (noElementEmitted) + noElementEmitted = false; + + for (int i = sequence.length - 1; i >= 0; i--) + if (++sequence[i] != 0) + return; + + throw new RuntimeIOException(new IOException("Message limit reached")); + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/DhKemWrapper.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/DhKemWrapper.java new file mode 100644 index 0000000..b1be1cf --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/DhKemWrapper.java @@ -0,0 +1,46 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.util.EnumSet; + +import javax.crypto.DecapsulateException; +import javax.crypto.KEM; +import javax.crypto.KEM.Encapsulated; +import javax.crypto.SecretKey; + +public class DhKemWrapper extends AbstractKemWrapper implements KemWrapper +{ + public static final EnumSet DH_KEMS = EnumSet.of(KemId.DHKEM_P256_HKDF_SHA256, KemId.DHKEM_P384_HKDF_SHA384, + KemId.DHKEM_P521_HKDF_SHA512, KemId.DHKEM_X25519_HKDF_SHA256, KemId.DHKEM_X448_HKDF_SHA512); + + public DhKemWrapper(KemId kemId) + { + super(kemId); + + if (!DH_KEMS.contains(kemId)) + throw new IllegalArgumentException("KemId " + kemId.name() + " not supported"); + } + + @Override + protected Encapsulated doGetEncapsulated(PublicKey publicKey, SecureRandom secureRandom, int sharedSecretLength) + throws NoSuchAlgorithmException, InvalidKeyException + { + return createKem().newEncapsulator(publicKey, secureRandom).encapsulate(); + } + + @Override + protected SecretKey doGetSharedSecret(PrivateKey privateKey, byte[] encapsulation, int sharedSecretLength) + throws NoSuchAlgorithmException, InvalidKeyException, DecapsulateException + { + return createKem().newDecapsulator(privateKey).decapsulate(encapsulation); + } + + private KEM createKem() throws NoSuchAlgorithmException + { + return KEM.getInstance("DHKEM"); + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/Hpke.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/Hpke.java new file mode 100644 index 0000000..7166a5e --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/Hpke.java @@ -0,0 +1,246 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.SequenceInputStream; +import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import javax.crypto.Cipher; +import javax.crypto.DecapsulateException; +import javax.crypto.KEM.Encapsulated; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; + +import de.hsheilbronn.mi.utils.crypto.hpke.KeySchedule.Result; + +/** + * RFC 9180 Hybrid Public Key Encryption implementation with + * support for modes 0 and 1. The encryption produces a wire-format not defined in RFC 9180 for use in the encryption of + * large files.
+ *
+ * The general wire-format is defined as: [header][encapsulation][chunk0]...[chunkN]
+ *
+ * The header format is specified in classes implementing {@link Protocol}. Lengths of the encapsulation are define by + * the RFC, see {@link KemId}.
+ *
+ * Chunks 1 to n-1 have a fixed length. The final chunk may be shorter. The chunk length is defined via + * {@link Protocol#getChunkLength()}.
+ *
+ * The RFC 9180 key-schedule is implemented by {@link KeySchedule}, with additional "info" supplied to the schedule by + * {@link Protocol#getKdfInfo()}.
+ *
+ * Additional authenticated data per chunk is defined by the sequence number and a one byte boolean flag for the final + * chunk, see {@link #createAad(byte[], boolean)}. + */ +public class Hpke +{ + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final ProtocolFactory protocolFactory; + private final SecureRandom secureRandom; + + /** + * Uses {@link #SECURE_RANDOM}. + * + * @param protocolFactory + * not null + */ + public Hpke(ProtocolFactory protocolFactory) + { + this(protocolFactory, SECURE_RANDOM); + } + + /** + * @param protocolFactory + * not null + * @param secureRandom + * not null + */ + public Hpke(ProtocolFactory protocolFactory, SecureRandom secureRandom) + { + this.protocolFactory = Objects.requireNonNull(protocolFactory, "protocolFactory"); + this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom"); + } + + protected KeySchedule createKeySchedule(Protocol protocol) + { + return new KeySchedule(protocol.getMode(), protocol.getKemId(), protocol.getKdfId(), protocol.getAeadId(), + protocol.getKdfInfo(), protocolFactory.getPreSharedKeyProvider()); + } + + protected byte[] createAad(byte[] sequence, boolean finished) + { + return ByteEncoding.concat(sequence, ByteEncoding.i2osp1(finished ? 1 : 0)); + } + + /** + * Encrypts the given plain-text into chunks using the wire-format configure with the given header.
+ *
+ * For an empty plainText stream a single encrypted chunk will be emitted. + * + * @param protocol + * not null + * @param plainText + * not null + * @param publicKey + * not null + * @return crypt-text + * @throws IOException + * @throws NoSuchAlgorithmException + * @throws InvalidKeyException + * @throws NoSuchPaddingException + * @throws InvalidAlgorithmParameterException + * @throws GeneralSecurityException + * @throws KeyNotFoundException + * @throws KeyNotSupportedException + */ + public InputStream encrypt(Protocol protocol, InputStream plainText, PublicKey publicKey) + throws IOException, NoSuchAlgorithmException, InvalidKeyException, NoSuchPaddingException, + InvalidAlgorithmParameterException, GeneralSecurityException, KeyNotFoundException, KeyNotSupportedException + { + Objects.requireNonNull(protocol, "protocol"); + Objects.requireNonNull(plainText, "plainText"); + Objects.requireNonNull(publicKey, "publicKey"); + + Encapsulated encapsulated = protocol.getKemId().toKem().getEncapsulated(publicKey, secureRandom); + + Result keyScheduleResult = createKeySchedule(protocol).executeKeySchedule(encapsulated.key()); + + Cipher cipher = protocol.getAeadId().toCipher(); + + ChunkedInputStreamEnumeration chunks = new ChunkedInputStreamEnumeration(protocol.getChunkLength().getLength(), + keyScheduleResult.baseNonce(), plainText, + (byte[] iv, byte[] sequence, boolean finished, byte[] chunk) -> + { + protocol.getAeadId().initEncryptionCipher(cipher, keyScheduleResult.key(), iv); + + cipher.updateAAD(createAad(sequence, finished)); + + byte[] encrypted = cipher.doFinal(chunk); + + return new ByteArrayInputStream(encrypted); + }); + + return new SequenceInputStream(Collections.enumeration( + List.of(protocolFactory.write(protocol), new ByteArrayInputStream(encapsulated.encapsulation()), + SequenceInputStreamForRuntimeIOException.of(chunks)))); + } + + /** + * Encrypts the given plain-text into chunks using the wire-format configure with the given header.
+ *
+ * For an empty plainText stream a single encrypted chunk will be emitted. + * + * @param protocol + * not null + * @param plainText + * not null + * @param publicKey + * not null + * @param out + * not null + * @throws IOException + * @throws NoSuchAlgorithmException + * @throws InvalidKeyException + * @throws NoSuchPaddingException + * @throws InvalidAlgorithmParameterException + * @throws GeneralSecurityException + * @throws KeyNotFoundException + * @throws KeyNotSupportedException + */ + public void encrypt(Protocol protocol, InputStream plainText, PublicKey publicKey, OutputStream out) + throws IOException, NoSuchAlgorithmException, InvalidKeyException, NoSuchPaddingException, + InvalidAlgorithmParameterException, GeneralSecurityException, KeyNotFoundException, KeyNotSupportedException + { + Objects.requireNonNull(out, "out"); + + encrypt(protocol, plainText, publicKey).transferTo(out); + } + + /** + * Decrypted chunks are emitted as soon as they are successfully decrypted while a later chunk may fail decryption. + * + * @param encrypted + * not null + * @return plain text + * @throws IOException + * @throws NoSuchAlgorithmException + * @throws InvalidKeyException + * @throws DecapsulateException + * @throws NoSuchPaddingException + * @throws InvalidAlgorithmParameterException + * @throws GeneralSecurityException + * @throws KeyNotFoundException + * @throws KeyNotSupportedException + */ + public final InputStream decrypt(InputStream encrypted) throws IOException, NoSuchAlgorithmException, + InvalidKeyException, DecapsulateException, NoSuchPaddingException, InvalidAlgorithmParameterException, + GeneralSecurityException, KeyNotFoundException, KeyNotSupportedException + { + Objects.requireNonNull(encrypted, "encrypted"); + + Protocol protocol = protocolFactory.read(encrypted); + + PrivateKey privateKey = protocolFactory.getReceiverPrivateKeyProvider().retrieve(protocol.getReceiverKeyId()); + + byte[] encapsulation = encrypted.readNBytes(protocol.getKemId().getEncapsulationLength()); + ByteEncoding.expectRead(protocol.getKemId().getEncapsulationLength(), encapsulation.length); + + SecretKey sharedSecret = protocol.getKemId().toKem().getSharedSecret(privateKey, encapsulation); + + Result keyScheduleResult = createKeySchedule(protocol).executeKeySchedule(sharedSecret); + + Cipher cipher = protocol.getAeadId().toCipher(); + + ChunkedInputStreamEnumeration chunks = new ChunkedInputStreamEnumeration( + protocol.getChunkLength().getLength() + (protocol.getAeadId().getAuthenticationTagLengthBits() / 8), + keyScheduleResult.baseNonce(), encrypted, + (byte[] iv, byte[] sequence, boolean finished, byte[] chunk) -> + { + protocol.getAeadId().initDecryptionCipher(cipher, keyScheduleResult.key(), iv); + + cipher.updateAAD(createAad(sequence, finished)); + + byte[] decrypted = cipher.doFinal(chunk); + + return new ByteArrayInputStream(decrypted); + }); + + return SequenceInputStreamForRuntimeIOException.of(chunks); + } + + /** + * Decrypted chunks are emitted as soon as they are successfully decrypted while a later chunk may fail decryption. + * + * @param encrypted + * not null + * @param plainText + * not null + * @throws IOException + * @throws NoSuchAlgorithmException + * @throws InvalidKeyException + * @throws DecapsulateException + * @throws NoSuchPaddingException + * @throws InvalidAlgorithmParameterException + * @throws GeneralSecurityException + * @throws KeyNotFoundException + * @throws KeyNotSupportedException + */ + public final void decrypt(InputStream encrypted, OutputStream plainText) throws IOException, + NoSuchAlgorithmException, InvalidKeyException, DecapsulateException, NoSuchPaddingException, + InvalidAlgorithmParameterException, GeneralSecurityException, KeyNotFoundException, KeyNotSupportedException + { + decrypt(encrypted).transferTo(plainText); + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KdfId.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KdfId.java new file mode 100644 index 0000000..a579181 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KdfId.java @@ -0,0 +1,58 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + +import javax.crypto.KDF; + +public enum KdfId +{ + HKDF_SHA256(0x0001, "HKDF-SHA256"), HKDF_SHA384(0x0002, "HKDF-SHA384"), HKDF_SHA512(0x0003, "HKDF-SHA512"); + + private final int id; + private final String algorithm; + + KdfId(int id, String algorithm) + { + this.algorithm = algorithm; + this.id = id; + } + + public int getId() + { + return id; + } + + public byte[] getIdAsI2osp2Bytes() + { + return ByteEncoding.i2osp2(id); + } + + public String getAlgorithm() + { + return algorithm; + } + + public KDF toKdf() throws NoSuchAlgorithmException + { + return KDF.getInstance(algorithm); + } + + public static KdfId from(byte[] value) throws IllegalArgumentException + { + Objects.requireNonNull(value, "value"); + if (value.length != 2) + throw new IllegalArgumentException("value.length not 2"); + + long kdfId = ByteEncoding.os2ip(value); + + if (HKDF_SHA256.id == kdfId) + return HKDF_SHA256; + else if (HKDF_SHA384.id == kdfId) + return HKDF_SHA384; + else if (HKDF_SHA512.id == kdfId) + return HKDF_SHA512; + else + throw new IllegalArgumentException("KdfId not supported"); + } +} \ No newline at end of file diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KemId.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KemId.java new file mode 100644 index 0000000..0ba29f5 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KemId.java @@ -0,0 +1,194 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.security.AlgorithmParameters; +import java.security.AsymmetricKey; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECKey; +import java.security.interfaces.RSAKey; +import java.security.interfaces.XECKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.InvalidParameterSpecException; +import java.security.spec.NamedParameterSpec; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Predicate; + +import de.hsheilbronn.mi.utils.crypto.keypair.KeyPairGeneratorFactory; + +public enum KemId +{ + DHKEM_P256_HKDF_SHA256(0x0010, 32, 65, DhKemWrapper::new, isEcKey(namedCurve("secp256r1")), + KeyPairGeneratorFactory.secp256r1()), + + DHKEM_P384_HKDF_SHA384(0x0011, 48, 97, DhKemWrapper::new, isEcKey(namedCurve("secp384r1")), + KeyPairGeneratorFactory.secp384r1()), + + DHKEM_P521_HKDF_SHA512(0x0012, 64, 133, DhKemWrapper::new, isEcKey(namedCurve("secp521r1")), + KeyPairGeneratorFactory.secp521r1()), + + DHKEM_X25519_HKDF_SHA256(0x0020, 32, 32, DhKemWrapper::new, isXecKey(NamedParameterSpec.X25519), + KeyPairGeneratorFactory.x25519()), + + DHKEM_X448_HKDF_SHA512(0x0021, 64, 56, DhKemWrapper::new, isXecKey(NamedParameterSpec.X448), + KeyPairGeneratorFactory.x448()), + + /** + * RSA-KEM for 1024 Bit RSA keys. KEM ID 0xFF10 not defined in RFC 9180 and thus not compatible with + * other RFC 9180 implementations.
+ *
+ * Insecure, use only for testing. + * + * @see RsaKemWrapper + */ + RSAKEM_1024_KDF2_SHA256(0xFF10, 32, 128, RsaKemWrapper::new, isRsaKey(1024), KeyPairGeneratorFactory.rsa1024()), + + /** + * RSA-KEM for 2048 Bit RSA keys. KEM ID 0xFF11 not defined in RFC 9180 and thus not compatible with + * other RFC 9180 implementations.
+ *
+ * Not recommended for production use. + * + * @see RsaKemWrapper + */ + RSAKEM_2048_KDF2_SHA256(0xFF11, 32, 256, RsaKemWrapper::new, isRsaKey(2048), KeyPairGeneratorFactory.rsa2048()), + + /** + * RSA-KEM for 3072 Bit RSA keys. KEM ID 0xFF12 not defined in RFC 9180 and thus not compatible with + * other RFC 9180 implementations.
+ *
+ * Not recommended for production use. + * + * @see RsaKemWrapper + */ + RSAKEM_3072_KDF2_SHA512(0xFF12, 64, 384, RsaKemWrapper::new, isRsaKey(3072), KeyPairGeneratorFactory.rsa3072()), + + /** + * RSA-KEM for 4096 Bit RSA keys. KEM ID 0xFF13 not defined in RFC 9180 and thus not compatible with + * other RFC 9180 implementations. + * + * @see RsaKemWrapper + */ + RSAKEM_4096_KDF2_SHA512(0xFF13, 64, 512, RsaKemWrapper::new, isRsaKey(4096), KeyPairGeneratorFactory.rsa4096()); + + private static Predicate isRsaKey(int length) + { + // Some external providers emit valid RSA moduli that are one bit below the nominal key size + return key -> key instanceof RSAKey rsaKey && rsaKey.getModulus().bitLength() >= length - 1 + && rsaKey.getModulus().bitLength() <= length; + } + + private static Predicate isXecKey(NamedParameterSpec curve) + { + return key -> key instanceof XECKey xecKey && xecKey.getParams() instanceof NamedParameterSpec params + && curve.getName().equals(params.getName()); + } + + private static Predicate isEcKey(ECParameterSpec expected) + { + return key -> key instanceof ECKey ecKey && matchesCurve(ecKey.getParams(), expected); + } + + private static boolean matchesCurve(ECParameterSpec actual, ECParameterSpec expected) + { + return actual.getCurve().equals(expected.getCurve()) && actual.getGenerator().equals(expected.getGenerator()) + && actual.getOrder().equals(expected.getOrder()) && actual.getCofactor() == expected.getCofactor(); + } + + private static ECParameterSpec namedCurve(String name) + { + try + { + AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC"); + parameters.init(new ECGenParameterSpec(name)); + return parameters.getParameterSpec(ECParameterSpec.class); + } + catch (NoSuchAlgorithmException | InvalidParameterSpecException e) + { + throw new RuntimeException(e); + } + } + + private final int id; + private final int sharedSecretLength; + private final int encapsulationLength; + private final Function kemWrapperFactory; + private final Predicate keySupported; + private final KeyPairGeneratorFactory keyPairGeneratorFactory; + + KemId(int id, int sharedSecretLength, int encapsulationLength, Function kemWrapperFactory, + Predicate keySupported, KeyPairGeneratorFactory keyPairGeneratorFactory) + { + this.id = id; + this.sharedSecretLength = sharedSecretLength; + this.encapsulationLength = encapsulationLength; + this.kemWrapperFactory = kemWrapperFactory; + this.keySupported = keySupported; + this.keyPairGeneratorFactory = keyPairGeneratorFactory; + } + + public int getId() + { + return id; + } + + public byte[] getIdAsI2osp2Bytes() + { + return ByteEncoding.i2osp2(id); + } + + public int getSharedSecretLength() + { + return sharedSecretLength; + } + + public int getEncapsulationLength() + { + return encapsulationLength; + } + + public KemWrapper toKem() + { + return kemWrapperFactory.apply(this); + } + + public boolean isKeySupported(AsymmetricKey key) + { + return keySupported.test(key); + } + + public KeyPairGeneratorFactory getKeyPairGeneratorFactory() + { + return keyPairGeneratorFactory; + } + + public static KemId from(byte[] value) throws IllegalArgumentException + { + Objects.requireNonNull(value, "value"); + if (value.length != 2) + throw new IllegalArgumentException("value.length not 2"); + + long kemId = ByteEncoding.os2ip(value); + + if (DHKEM_P256_HKDF_SHA256.id == kemId) + return DHKEM_P256_HKDF_SHA256; + else if (DHKEM_P384_HKDF_SHA384.id == kemId) + return DHKEM_P384_HKDF_SHA384; + else if (DHKEM_P521_HKDF_SHA512.id == kemId) + return DHKEM_P521_HKDF_SHA512; + else if (DHKEM_X25519_HKDF_SHA256.id == kemId) + return DHKEM_X25519_HKDF_SHA256; + else if (DHKEM_X448_HKDF_SHA512.id == kemId) + return DHKEM_X448_HKDF_SHA512; + else if (RSAKEM_1024_KDF2_SHA256.id == kemId) + return RSAKEM_1024_KDF2_SHA256; + else if (RSAKEM_2048_KDF2_SHA256.id == kemId) + return RSAKEM_2048_KDF2_SHA256; + else if (RSAKEM_3072_KDF2_SHA512.id == kemId) + return RSAKEM_3072_KDF2_SHA512; + else if (RSAKEM_4096_KDF2_SHA512.id == kemId) + return RSAKEM_4096_KDF2_SHA512; + else + throw new IllegalArgumentException("KemId not supported"); + } +} \ No newline at end of file diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KemWrapper.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KemWrapper.java new file mode 100644 index 0000000..c916eae --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KemWrapper.java @@ -0,0 +1,20 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; + +import javax.crypto.DecapsulateException; +import javax.crypto.KEM.Encapsulated; +import javax.crypto.SecretKey; + +public interface KemWrapper +{ + Encapsulated getEncapsulated(PublicKey publicKey, SecureRandom secureRandom) + throws NoSuchAlgorithmException, InvalidKeyException, KeyNotSupportedException; + + SecretKey getSharedSecret(PrivateKey privateKey, byte[] encapsulation) + throws NoSuchAlgorithmException, InvalidKeyException, DecapsulateException, KeyNotSupportedException; +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyNotFoundException.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyNotFoundException.java new file mode 100644 index 0000000..0c49519 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyNotFoundException.java @@ -0,0 +1,11 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +public final class KeyNotFoundException extends Exception +{ + private static final long serialVersionUID = 1L; + + public KeyNotFoundException(String message) + { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyNotSupportedException.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyNotSupportedException.java new file mode 100644 index 0000000..1926834 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyNotSupportedException.java @@ -0,0 +1,11 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +public final class KeyNotSupportedException extends Exception +{ + private static final long serialVersionUID = 1L; + + public KeyNotSupportedException(String message) + { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyProvider.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyProvider.java new file mode 100644 index 0000000..af608b8 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyProvider.java @@ -0,0 +1,68 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.nio.ByteBuffer; +import java.security.Key; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.stream.Collectors; + +@FunctionalInterface +interface KeyProvider +{ + public static final String RECEIVER_KEY_ID = "ReceiverKeyId"; + public static final String PSK = "PSK"; + + K retrieve(byte[] id) throws KeyNotFoundException; + + static KeyProvider of(String type) + { + return i -> + { + throw notFound(type, i); + }; + } + + static KeyProvider of(String type, byte[] id, K key) + { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(key, "key"); + + return i -> + { + if (Arrays.equals(i, id)) + return key; + else + throw notFound(type, i); + }; + } + + static KeyProvider of(String type, Map map) + { + Objects.requireNonNull(map, "map"); + + final Map cache = map.entrySet().stream().collect( + Collectors.toMap(e -> ByteBuffer.wrap(e.getKey().clone()).asReadOnlyBuffer(), Entry::getValue)); + + return i -> + { + if (i == null) + throw notFound(type, null); + + K key = cache.get(ByteBuffer.wrap(i)); + + if (key != null) + return key; + else + throw notFound(type, i); + }; + } + + static KeyNotFoundException notFound(String type, byte[] id) + { + return new KeyNotFoundException( + type + " with ID " + (id != null ? HexFormat.of().formatHex(id) : "null") + " not found"); + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeySchedule.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeySchedule.java new file mode 100644 index 0000000..5c18a50 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/KeySchedule.java @@ -0,0 +1,89 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.security.InvalidAlgorithmParameterException; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + +import javax.crypto.KDF; +import javax.crypto.SecretKey; +import javax.crypto.spec.HKDFParameterSpec; +import javax.crypto.spec.HKDFParameterSpec.Builder; +import javax.crypto.spec.HKDFParameterSpec.Expand; +import javax.crypto.spec.HKDFParameterSpec.Extract; + +public class KeySchedule +{ + private static final byte[] HPKE = new byte[] { 'H', 'P', 'K', 'E' }; + private static final byte[] HPKE_V1 = new byte[] { 'H', 'P', 'K', 'E', '-', 'v', '1' }; + private static final byte[] PSK_ID_HASH = new byte[] { 'p', 's', 'k', '_', 'i', 'd', '_', 'h', 'a', 's', 'h' }; + private static final byte[] INFO_HASH = new byte[] { 'i', 'n', 'f', 'o', '_', 'h', 'a', 's', 'h' }; + private static final byte[] SECRET = new byte[] { 's', 'e', 'c', 'r', 'e', 't' }; + private static final byte[] KEY = new byte[] { 'k', 'e', 'y' }; + private static final byte[] BASE_NONCE = new byte[] { 'b', 'a', 's', 'e', '_', 'n', 'o', 'n', 'c', 'e' }; + + public record Result(SecretKey key, byte[] baseNonce) + { + } + + private final Mode mode; + private final KemId kemId; + private final KdfId kdfId; + private final AeadId aeadId; + private final byte[] info; + private final PreSharedKeyProvider preSharedKeyProvider; + + public KeySchedule(Mode mode, KemId kemId, KdfId kdfId, AeadId aeadId, byte[] info, + PreSharedKeyProvider preSharedKeyProvider) + { + this.mode = Objects.requireNonNull(mode, "mode"); + this.kemId = Objects.requireNonNull(kemId, "kemId"); + this.kdfId = Objects.requireNonNull(kdfId, "kdfId"); + this.aeadId = Objects.requireNonNull(aeadId, "aeadId"); + this.info = Objects.requireNonNull(info, "info"); + this.preSharedKeyProvider = Objects.requireNonNull(preSharedKeyProvider, "preSharedKeyProvider"); + } + + protected Builder withPsk(Builder secretXSpecBuilder) throws KeyNotFoundException + { + if (mode.isPsk()) + secretXSpecBuilder.addIKM(preSharedKeyProvider.retrieve(mode.getPskId())); + + return secretXSpecBuilder; + } + + public Result executeKeySchedule(SecretKey sharedSecret) + throws NoSuchAlgorithmException, InvalidAlgorithmParameterException, KeyNotFoundException + { + byte[] suiteId = ByteEncoding.concat(HPKE, kemId.getIdAsI2osp2Bytes(), kdfId.getIdAsI2osp2Bytes(), + aeadId.getIdAsI2osp2Bytes()); + + KDF kdfContext = kdfId.toKdf(); + Extract pskIdHashSpec = HKDFParameterSpec.ofExtract().addIKM(HPKE_V1).addIKM(suiteId).addIKM(PSK_ID_HASH) + .addIKM(mode.getPskId()).extractOnly(); + byte[] pskIdHash = kdfContext.deriveData(pskIdHashSpec); + + Extract infoHashSpec = HKDFParameterSpec.ofExtract().addIKM(HPKE_V1).addIKM(suiteId).addIKM(INFO_HASH) + .addIKM(info).extractOnly(); + byte[] infoHash = kdfContext.deriveData(infoHashSpec); + + byte[] keyScheduleContext = ByteEncoding.concat(mode.getValueAsI2osp1Byte(), pskIdHash, infoHash); + + KDF kdf = kdfId.toKdf(); + Extract secretXSpec = withPsk(HKDFParameterSpec.ofExtract().addIKM(HPKE_V1).addIKM(suiteId).addIKM(SECRET)) + .addSalt(sharedSecret).extractOnly(); + SecretKey secretX = kdf.deriveKey("Generic", secretXSpec); + + byte[] keyInfo = ByteEncoding.concat(aeadId.getKeyLengthAsI2osp2Bytes(), HPKE_V1, suiteId, KEY, + keyScheduleContext); + Expand keySpec = HKDFParameterSpec.expandOnly(secretX, keyInfo, aeadId.getKeyLength()); + + byte[] baseNonceInfo = ByteEncoding.concat(aeadId.getIvLengthAsI2osp2Bytes(), HPKE_V1, suiteId, BASE_NONCE, + keyScheduleContext); + Expand baseNonceSpec = HKDFParameterSpec.expandOnly(secretX, baseNonceInfo, aeadId.getIvLength()); + + SecretKey key = kdf.deriveKey(aeadId.getKeyAlgorithm(), keySpec); + byte[] baseNonce = kdf.deriveData(baseNonceSpec); + + return new Result(key, baseNonce); + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/Mode.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/Mode.java new file mode 100644 index 0000000..9956abe --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/Mode.java @@ -0,0 +1,105 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.util.Arrays; +import java.util.Objects; + +public final class Mode +{ + public static final int BASE_VALUE = 0x00; + public static final int PSK_VALUE = 0x01; + + private final int value; + private final byte[] pskId; + + protected Mode(int value, byte[] pskId) + { + this.value = value; + this.pskId = pskId; + } + + /** + * @return mode 0 + */ + public static Mode base() + { + return new Mode(BASE_VALUE, new byte[0]); + } + + /** + * @param pskId + * not null, pskId.length > 0 + * @return mode 1 + */ + public static Mode psk(byte[] pskId) + { + Objects.requireNonNull(pskId, "pskId"); + if (pskId.length <= 0) + throw new IllegalArgumentException("pskId.length <= 0"); + + return new Mode(PSK_VALUE, pskId); + } + + public byte[] getValueAsI2osp1Byte() + { + return ByteEncoding.i2osp1(value); + } + + public byte[] getPskId() + { + return pskId.clone(); + } + + @Override + public int hashCode() + { + final int prime = 31; + int result = 1; + result = prime * result + Objects.hash(value); + result = prime * result + Arrays.hashCode(pskId); + return result; + } + + @Override + public boolean equals(Object obj) + { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Mode other = (Mode) obj; + return value == other.value && Arrays.equals(pskId, other.pskId); + } + + @Override + public String toString() + { + return Integer.toString(value); + } + + public boolean isPsk() + { + return PSK_VALUE == value; + } + + /** + * @param value + * 0x00 or 0x01 + * @param pskId + * null if value = 0x00, not null and length > 0 if + * value = 0x01 + * @return HPKE mode + * @throws IllegalArgumentException + * given value and pskId combination is unexpected or value is not supported + */ + public static Mode from(byte value, byte[] pskId) throws IllegalArgumentException + { + if (BASE_VALUE == value && pskId == null) + return Mode.base(); + else if (PSK_VALUE == value && pskId != null) + return Mode.psk(pskId); + else + throw new IllegalArgumentException("Mode not supported"); + } +} \ No newline at end of file diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/PreSharedKeyProvider.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/PreSharedKeyProvider.java new file mode 100644 index 0000000..18ee31a --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/PreSharedKeyProvider.java @@ -0,0 +1,28 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.util.Map; +import java.util.function.Function; + +import javax.crypto.SecretKey; + +/** + * {@link Function} to retrieve the a pre-shared-key (psk) {@link SecretKey} for a given pskId + */ +@FunctionalInterface +public interface PreSharedKeyProvider extends KeyProvider +{ + static PreSharedKeyProvider of() + { + return KeyProvider. of(PSK)::retrieve; + } + + static PreSharedKeyProvider of(byte[] pskId, SecretKey psk) + { + return KeyProvider.of(PSK, pskId, psk)::retrieve; + } + + static PreSharedKeyProvider of(Map map) + { + return KeyProvider.of(PSK, map)::retrieve; + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/Protocol.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/Protocol.java new file mode 100644 index 0000000..5e3ed0e --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/Protocol.java @@ -0,0 +1,18 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +public interface Protocol +{ + Mode getMode(); + + KemId getKemId(); + + KdfId getKdfId(); + + AeadId getAeadId(); + + ChunkLength getChunkLength(); + + byte[] getReceiverKeyId(); + + byte[] getKdfInfo(); +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolFactory.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolFactory.java new file mode 100644 index 0000000..91c80ea --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolFactory.java @@ -0,0 +1,140 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class ProtocolFactory +{ + public static final byte[] MAGIC = new byte[] { 'H', 'P', 'K', 'E', 'F' }; + + public static final int ROOT_HEADER_LENGTH = MAGIC.length + 1; + + public static interface ProtocolSerializer

+ { + int getVersion(); + + P read(InputStream source) throws IOException; + + Class

getType(); + + byte[] write(P protocol); + } + + public static final ProtocolSerializer V1_SERIALIZER = new ProtocolSerializer<>() + { + @Override + public int getVersion() + { + return ProtocolV1.VERSION; + } + + @Override + public ProtocolV1 read(InputStream source) throws IOException + { + return ProtocolV1.from(source); + } + + @Override + public Class getType() + { + return ProtocolV1.class; + } + + @Override + public byte[] write(ProtocolV1 protocol) + { + return protocol.getCanonicalHeader(); + } + }; + + private final PreSharedKeyProvider preSharedKeyProvider; + private final ReceiverPrivateKeyProvider receiverPrivateKeyProvider; + + private final List> protocolSerializers = new ArrayList<>(); + + /** + * @param preSharedKeyProvider + * not null + * @param receiverPrivateKeyProvider + * not null + */ + public ProtocolFactory(PreSharedKeyProvider preSharedKeyProvider, + ReceiverPrivateKeyProvider receiverPrivateKeyProvider) + { + this(preSharedKeyProvider, receiverPrivateKeyProvider, List.of(V1_SERIALIZER)); + } + + protected ProtocolFactory(PreSharedKeyProvider preSharedKeyProvider, + ReceiverPrivateKeyProvider receiverPrivateKeyProvider, + Collection> protocolSerializers) + { + this.preSharedKeyProvider = Objects.requireNonNull(preSharedKeyProvider, "preSharedKeyProvider"); + this.receiverPrivateKeyProvider = Objects.requireNonNull(receiverPrivateKeyProvider, + "receiverPrivateKeyProvider"); + + if (protocolSerializers != null) + { + this.protocolSerializers.addAll(protocolSerializers); + + if (protocolSerializers.size() != protocolSerializers.stream().mapToInt(ProtocolSerializer::getVersion) + .distinct().count()) + throw new IllegalArgumentException("Multiple protocol serializers for same version"); + } + } + + public Protocol read(InputStream source) throws IOException + { + Objects.requireNonNull(source, "source"); + + byte[] baseHeaderValue = source.readNBytes(ROOT_HEADER_LENGTH); + ByteEncoding.expectRead(ROOT_HEADER_LENGTH, baseHeaderValue.length); + + if (!Arrays.equals(MAGIC, 0, MAGIC.length, baseHeaderValue, 0, MAGIC.length)) + throw new IOException("Protocol not supported"); + + int version = baseHeaderValue[baseHeaderValue.length - 1] & 0xFF; + + Optional> deserializer = protocolSerializers.stream() + .filter(s -> s.getVersion() == version).findFirst(); + if (deserializer.isPresent()) + return deserializer.get().read(source); + else + throw new IOException("Protocol not supported"); + } + + public InputStream write(Protocol protocol) + { + Objects.requireNonNull(protocol, "protocol"); + + Optional> serializer = protocolSerializers.stream() + .filter(s -> s.getType().isInstance(protocol)).findFirst(); + + if (serializer.isPresent()) + { + @SuppressWarnings("unchecked") + ProtocolSerializer protocolSerializer = (ProtocolSerializer) serializer.get(); + + return new ByteArrayInputStream(ByteEncoding.concat(MAGIC, + ByteEncoding.i2osp1(protocolSerializer.getVersion()), protocolSerializer.write(protocol))); + } + else + throw new IllegalArgumentException("Protocol not supported"); + } + + public PreSharedKeyProvider getPreSharedKeyProvider() + { + return preSharedKeyProvider; + } + + public ReceiverPrivateKeyProvider getReceiverPrivateKeyProvider() + { + return receiverPrivateKeyProvider; + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolV1.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolV1.java new file mode 100644 index 0000000..0e856a8 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolV1.java @@ -0,0 +1,205 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +/** + * The wire-format header is defined with fixed length and fixed order:
+ * ["HPKEF", 5 bytes] (magic marker)
+ * [0x01, 1 byte]
+ * [mode (base, psk), 1 byte] - see {@link Mode}
+ * [kem-id, 2 bytes] - see {@link KemId}
+ * [kdf-id, 2 byte] - see {@link KdfId}
+ * [aead-id, 2 byte] - see {@link AeadId}
+ * [chunkLengthExponent, 1 byte] - see {@link ChunkLength}
+ * [receiver-key-id, 32 bytes]
+ * [pre-shared-key-id, 32 bytes] - only if mode = psk
+ *
+ * Uses ["HPKEF"][0x01][chunkLengthExponent] as KDF info value, see {@link #KDF_INFO}.
+ * Supported chunk lengths are defined in {@link ChunkLength}.
+ */ +public class ProtocolV1 implements Protocol +{ + public static final byte VERSION = (byte) 0x01; + + // last byte: chunk length exponent + private static final byte[] KDF_INFO = new byte[] { 'H', 'P', 'K', 'E', 'F', VERSION, 0 }; + + public static final int RECEIVER_KEY_ID_LENGTH = 32; + public static final int PRE_SHARED_KEY_ID_LENGTH = 32; + + public static final int HEADER_BASE_LENGTH = 8 + RECEIVER_KEY_ID_LENGTH; + public static final int HEADER_PSK_LENGTH = HEADER_BASE_LENGTH + PRE_SHARED_KEY_ID_LENGTH; + + private final Mode mode; + private final KemId kemId; + private final KdfId kdfId; + private final AeadId aeadId; + + private final ChunkLength chunkLength; + private final byte[] receiverKeyId; + + private final AtomicReference canonical = new AtomicReference<>(); + + /** + * @param mode + * not null, if mode {@link Mode#PSK_VALUE}: mode.pskId.length = + * {@value #PRE_SHARED_KEY_ID_LENGTH} + * @param kemId + * not null + * @param kdfId + * not null + * @param aeadId + * not null + * @param chunkLength + * not null + * @param receiverKeyId + * not null, length {@value #RECEIVER_KEY_ID_LENGTH} + */ + public ProtocolV1(Mode mode, KemId kemId, KdfId kdfId, AeadId aeadId, ChunkLength chunkLength, byte[] receiverKeyId) + { + Objects.requireNonNull(mode, "mode"); + if (mode.isPsk() && mode.getPskId().length != ProtocolV1.PRE_SHARED_KEY_ID_LENGTH) + throw new IllegalArgumentException("mode.pskId.length not " + ProtocolV1.PRE_SHARED_KEY_ID_LENGTH); + + Objects.requireNonNull(kemId, "kemId"); + Objects.requireNonNull(kdfId, "kdfId"); + Objects.requireNonNull(aeadId, "aeadId"); + Objects.requireNonNull(chunkLength, "chunkLength"); + Objects.requireNonNull(receiverKeyId, "receiverKeyId"); + + if (receiverKeyId.length != RECEIVER_KEY_ID_LENGTH) + throw new IllegalArgumentException("receiverKeyId.length not " + RECEIVER_KEY_ID_LENGTH); + + this.mode = mode; + this.kemId = kemId; + this.kdfId = kdfId; + this.aeadId = aeadId; + this.chunkLength = chunkLength; + this.receiverKeyId = receiverKeyId; + } + + public static ProtocolV1 from(InputStream source) throws IOException + { + int modeValue = source.read(); + ByteEncoding.throwIfTruncated(modeValue); + + int remainingHeaderLength; + if (Mode.BASE_VALUE == (byte) modeValue) + remainingHeaderLength = HEADER_BASE_LENGTH - 1; + else if (Mode.PSK_VALUE == (byte) modeValue) + remainingHeaderLength = HEADER_PSK_LENGTH - 1; + else + throw new IOException("Mode not supported"); + + byte[] remainingHeader = source.readNBytes(remainingHeaderLength); + ByteEncoding.expectRead(remainingHeaderLength, remainingHeader.length); + + ByteBuffer buffer = ByteBuffer.wrap(remainingHeader); + + byte[] kemIdValue = new byte[2]; + byte[] kdfIdValue = new byte[2]; + byte[] aeadIdValue = new byte[2]; + byte[] chunkLengthValue = new byte[1]; + byte[] receiverKeyId = new byte[ProtocolV1.RECEIVER_KEY_ID_LENGTH]; + + buffer.get(kemIdValue).get(kdfIdValue).get(aeadIdValue).get(chunkLengthValue).get(receiverKeyId); + + byte[] pskId; + if (Mode.PSK_VALUE == modeValue) + { + pskId = new byte[ProtocolV1.PRE_SHARED_KEY_ID_LENGTH]; + buffer.get(pskId); + } + else + pskId = null; + + try + { + Mode mode = Mode.from((byte) modeValue, pskId); + KemId kemId = KemId.from(kemIdValue); + KdfId kdfId = KdfId.from(kdfIdValue); + AeadId aeadId = AeadId.from(aeadIdValue); + ChunkLength chunkLength = ChunkLength.from(chunkLengthValue); + + return new ProtocolV1(mode, kemId, kdfId, aeadId, chunkLength, receiverKeyId); + } + catch (IllegalArgumentException e) + { + throw new IOException(e.getMessage(), e); + } + } + + public byte[] getCanonicalHeader() + { + byte[] c = canonical.get(); + if (c == null) + canonical.compareAndSet(null, toCanonical()); + + return canonical.get(); + } + + private byte[] toCanonical() + { + ByteBuffer buffer = ByteBuffer.allocate(mode.isPsk() ? HEADER_PSK_LENGTH : HEADER_BASE_LENGTH); + buffer.put(mode.getValueAsI2osp1Byte()); + buffer.put(kemId.getIdAsI2osp2Bytes()); + buffer.put(kdfId.getIdAsI2osp2Bytes()); + buffer.put(aeadId.getIdAsI2osp2Bytes()); + buffer.put(chunkLength.getExponentAsI2osp1Byte()); + buffer.put(receiverKeyId); + + if (mode.isPsk()) + buffer.put(mode.getPskId()); + + return buffer.array(); + } + + @Override + public Mode getMode() + { + return mode; + } + + @Override + public KemId getKemId() + { + return kemId; + } + + @Override + public KdfId getKdfId() + { + return kdfId; + } + + @Override + public AeadId getAeadId() + { + return aeadId; + } + + @Override + public ChunkLength getChunkLength() + { + return chunkLength; + } + + @Override + public byte[] getReceiverKeyId() + { + return receiverKeyId.clone(); + } + + @Override + public byte[] getKdfInfo() + { + byte[] kdfInfo = KDF_INFO.clone(); + kdfInfo[KDF_INFO.length - 1] = chunkLength.getExponentAsI2osp1Byte()[0]; + + return kdfInfo; + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ReceiverPrivateKeyProvider.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ReceiverPrivateKeyProvider.java new file mode 100644 index 0000000..be8c171 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/ReceiverPrivateKeyProvider.java @@ -0,0 +1,27 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.security.PrivateKey; +import java.util.Map; +import java.util.function.Function; + +/** + * {@link Function} to retrieve the receiver {@link PrivateKey} for a given receiverKeyId + */ +@FunctionalInterface +public interface ReceiverPrivateKeyProvider extends KeyProvider +{ + static ReceiverPrivateKeyProvider of() + { + return KeyProvider. of(RECEIVER_KEY_ID)::retrieve; + } + + static ReceiverPrivateKeyProvider of(byte[] receiverKeyId, PrivateKey receiverKey) + { + return KeyProvider.of(RECEIVER_KEY_ID, receiverKeyId, receiverKey)::retrieve; + } + + static ReceiverPrivateKeyProvider of(Map map) + { + return KeyProvider.of(RECEIVER_KEY_ID, map)::retrieve; + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/RsaKemWrapper.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/RsaKemWrapper.java new file mode 100644 index 0000000..d324d04 --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/RsaKemWrapper.java @@ -0,0 +1,101 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.interfaces.RSAPublicKey; +import java.util.function.Supplier; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.DecapsulateException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.KEM.Encapsulated; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.bouncycastle.crypto.DataLengthException; +import org.bouncycastle.crypto.DerivationFunction; +import org.bouncycastle.crypto.SecretWithEncapsulation; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.digests.SHA512Digest; +import org.bouncycastle.crypto.generators.KDF2BytesGenerator; +import org.bouncycastle.crypto.kems.RSAKEMGenerator; +import org.bouncycastle.crypto.params.KDFParameters; +import org.bouncycastle.crypto.params.RSAKeyParameters; + +/** + * Custom KEM implementation for KEM IDs {@link KemId#RSAKEM_1024_KDF2_SHA256}, {@link KemId#RSAKEM_2048_KDF2_SHA256}, + * {@link KemId#RSAKEM_3072_KDF2_SHA512} and {@link KemId#RSAKEM_4096_KDF2_SHA512}. Not defined in RFC 9180 and thus not + * compatible with other RFC 9180 implementations.
+ *
+ * Uses RSA Key Encapsulation Mechanism (RSA-KEM) from ISO 18033-2 via {@link RSAKEMGenerator} for encapsulation and a + * raw RSA (r = c^d mod n) operation for decapsulation. The shared secret is derived via + * {@link KDF2BytesGenerator} and {@link SHA256Digest} for 1024 and 2048 bit RSA keys, or {@link SHA512Digest} for 3072 + * and 4096 bit RSA keys. + */ +public class RsaKemWrapper extends AbstractKemWrapper implements KemWrapper +{ + private static final String SHARED_SECRET_KEY_ALGORITHM = "Generic"; + + private final Supplier derivationFunctionFactory; + + public RsaKemWrapper(KemId kemId) + { + super(kemId); + + derivationFunctionFactory = switch (kemId) + { + case RSAKEM_1024_KDF2_SHA256, RSAKEM_2048_KDF2_SHA256 -> () -> new KDF2BytesGenerator(new SHA256Digest()); + case RSAKEM_3072_KDF2_SHA512, RSAKEM_4096_KDF2_SHA512 -> () -> new KDF2BytesGenerator(new SHA512Digest()); + + default -> throw new IllegalArgumentException("KemId " + kemId.name() + " not supported"); + }; + } + + @Override + protected Encapsulated doGetEncapsulated(PublicKey publicKey, SecureRandom secureRandom, int sharedSecretLength) + throws NoSuchAlgorithmException, InvalidKeyException + { + RSAPublicKey rsaPublicKey = (RSAPublicKey) publicKey; + + RSAKeyParameters rsaKeyParameters = new RSAKeyParameters(false, rsaPublicKey.getModulus(), + rsaPublicKey.getPublicExponent()); + + RSAKEMGenerator encapsulator = new RSAKEMGenerator(sharedSecretLength, derivationFunctionFactory.get(), + secureRandom); + SecretWithEncapsulation encapsulated = encapsulator.generateEncapsulated(rsaKeyParameters); + + return new Encapsulated(new SecretKeySpec(encapsulated.getSecret(), SHARED_SECRET_KEY_ALGORITHM), + encapsulated.getEncapsulation(), null); + } + + @Override + protected SecretKey doGetSharedSecret(PrivateKey privateKey, byte[] encapsulation, int sharedSecretLength) + throws NoSuchAlgorithmException, InvalidKeyException, DecapsulateException + { + try + { + // no padding to get equivalent operation for: r = c^d mod n + Cipher cipher = Cipher.getInstance("RSA/ECB/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, privateKey); + byte[] r = cipher.doFinal(encapsulation); + + DerivationFunction kdf = derivationFunctionFactory.get(); + kdf.init(new KDFParameters(r, null)); + + byte[] secret = new byte[sharedSecretLength]; + kdf.generateBytes(secret, 0, secret.length); + + return new SecretKeySpec(secret, SHARED_SECRET_KEY_ALGORITHM); + } + catch (InvalidKeyException | DataLengthException | NoSuchAlgorithmException | NoSuchPaddingException + | IllegalBlockSizeException | BadPaddingException | IllegalArgumentException e) + { + throw new DecapsulateException(e.getMessage(), e); + } + } +} diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/RuntimeIOException.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/RuntimeIOException.java new file mode 100644 index 0000000..051d50b --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/RuntimeIOException.java @@ -0,0 +1,22 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.io.IOException; +import java.util.Objects; + +public final class RuntimeIOException extends RuntimeException +{ + private static final long serialVersionUID = 1L; + + public RuntimeIOException(IOException cause) + { + super(Objects.requireNonNull(cause, "cause")); + } + + /** + * @return the wrapped {@link IOException} + */ + public IOException asIOException() + { + return (IOException) super.getCause(); + } +} \ No newline at end of file diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/SequenceInputStreamForRuntimeIOException.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/SequenceInputStreamForRuntimeIOException.java new file mode 100644 index 0000000..26bf2fd --- /dev/null +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/hpke/SequenceInputStreamForRuntimeIOException.java @@ -0,0 +1,63 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.SequenceInputStream; +import java.util.Enumeration; + +public final class SequenceInputStreamForRuntimeIOException extends SequenceInputStream +{ + public static final SequenceInputStream of(ChunkedInputStreamEnumeration enumeration) throws IOException + { + return withRuntimeIOException(() -> new SequenceInputStreamForRuntimeIOException(enumeration)); + } + + /** + * @param e + * not null + * @throws RuntimeIOException + * if errors occur during the peek operation of the {@link SequenceInputStream} constructor an thus + * reading of the first element + */ + private SequenceInputStreamForRuntimeIOException(Enumeration e) + { + super(e); + } + + @Override + public int read() throws IOException + { + return withRuntimeIOException(() -> super.read()); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException + { + return withRuntimeIOException(() -> super.read(b, off, len)); + } + + @Override + public long transferTo(OutputStream out) throws IOException + { + return withRuntimeIOException(() -> super.transferTo(out)); + } + + @FunctionalInterface + private static interface SupplierWithIOException + { + T get() throws IOException; + } + + private static T withRuntimeIOException(SupplierWithIOException withRuntimeIOException) throws IOException + { + try + { + return withRuntimeIOException.get(); + } + catch (RuntimeIOException e) + { + throw e.asIOException(); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/hsheilbronn/mi/utils/crypto/keypair/KeyPairGeneratorFactory.java b/src/main/java/de/hsheilbronn/mi/utils/crypto/keypair/KeyPairGeneratorFactory.java index 099fd7b..d341e05 100644 --- a/src/main/java/de/hsheilbronn/mi/utils/crypto/keypair/KeyPairGeneratorFactory.java +++ b/src/main/java/de/hsheilbronn/mi/utils/crypto/keypair/KeyPairGeneratorFactory.java @@ -140,7 +140,7 @@ public static KeyPairGeneratorFactory rsa4096() /** * @param keySize - * >= 1204, % 1024 == 0 + * >= 1024, % 1024 == 0 * @return RSA {@link KeyPairGeneratorFactory} for {@link CertificateAuthority} */ public static KeyPairGeneratorFactory rsa(int keySize) diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 2562499..0c022cc 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -7,6 +7,7 @@ exports de.hsheilbronn.mi.utils.crypto.ca; exports de.hsheilbronn.mi.utils.crypto.cert; exports de.hsheilbronn.mi.utils.crypto.context; + exports de.hsheilbronn.mi.utils.crypto.hpke; exports de.hsheilbronn.mi.utils.crypto.io; exports de.hsheilbronn.mi.utils.crypto.kem; exports de.hsheilbronn.mi.utils.crypto.keypair; diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/AbstractKemWrapperTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/AbstractKemWrapperTest.java new file mode 100644 index 0000000..a00b256 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/AbstractKemWrapperTest.java @@ -0,0 +1,116 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import java.security.InvalidKeyException; +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; + +import javax.crypto.DecapsulateException; +import javax.crypto.KEM.Encapsulated; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.Test; + +import de.hsheilbronn.mi.utils.crypto.keypair.KeyPairGeneratorFactory; + +public class AbstractKemWrapperTest +{ + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private static AbstractKemWrapper createTestWrapper(KemId kemId, int testEncapsulationLength, + int testSharedSecretLength) + { + return new AbstractKemWrapper(kemId) + { + @Override + protected Encapsulated doGetEncapsulated(PublicKey publicKey, SecureRandom secureRandom, + int sharedSecretLength) throws NoSuchAlgorithmException, InvalidKeyException + { + return new Encapsulated(createKey(testSharedSecretLength), new byte[testEncapsulationLength], null); + } + + @Override + protected SecretKey doGetSharedSecret(PrivateKey privateKey, byte[] encapsulation, int sharedSecretLength) + throws NoSuchAlgorithmException, InvalidKeyException, DecapsulateException + { + return createKey(testSharedSecretLength); + } + + private SecretKeySpec createKey(int sharedSecretLength) + { + return new SecretKeySpec(new byte[sharedSecretLength], "Generic"); + } + }; + } + + @Test + void testKeyNotSupported() throws Exception + { + KemId kemId = KemId.DHKEM_P256_HKDF_SHA256; + AbstractKemWrapper wrapper = createTestWrapper(kemId, kemId.getEncapsulationLength(), + kemId.getSharedSecretLength()); + KeyPair notOkKeyPair = KeyPairGeneratorFactory.secp384r1().initialize().generateKeyPair(); + KeyPair okKeyPair = kemId.getKeyPairGeneratorFactory().initialize().generateKeyPair(); + + assertDoesNotThrow(() -> wrapper.getEncapsulated(okKeyPair.getPublic(), SECURE_RANDOM)); + KeyNotSupportedException e = assertThrowsExactly(KeyNotSupportedException.class, + () -> wrapper.getEncapsulated(notOkKeyPair.getPublic(), SECURE_RANDOM)); + assertEquals("publicKey not supported", e.getMessage()); + + assertDoesNotThrow( + () -> wrapper.getSharedSecret(okKeyPair.getPrivate(), new byte[kemId.getEncapsulationLength()])); + e = assertThrowsExactly(KeyNotSupportedException.class, + () -> wrapper.getSharedSecret(notOkKeyPair.getPrivate(), new byte[kemId.getEncapsulationLength()])); + assertEquals("privateKey not supported", e.getMessage()); + } + + @Test + void testBadEncapsulationLength() throws Exception + { + KemId kemId = KemId.DHKEM_P256_HKDF_SHA256; + KeyPair okKeyPair = kemId.getKeyPairGeneratorFactory().initialize().generateKeyPair(); + + assertDoesNotThrow(() -> createTestWrapper(kemId, kemId.getEncapsulationLength(), kemId.getSharedSecretLength()) + .getEncapsulated(okKeyPair.getPublic(), SECURE_RANDOM)); + IllegalStateException isE = assertThrowsExactly(IllegalStateException.class, + () -> createTestWrapper(kemId, 0, kemId.getSharedSecretLength()).getEncapsulated(okKeyPair.getPublic(), + SECURE_RANDOM)); + assertEquals("encapsulation.length not " + kemId.getEncapsulationLength(), isE.getMessage()); + + assertDoesNotThrow(() -> createTestWrapper(kemId, kemId.getEncapsulationLength(), kemId.getSharedSecretLength()) + .getSharedSecret(okKeyPair.getPrivate(), new byte[kemId.getEncapsulationLength()])); + + IllegalStateException iaE = assertThrowsExactly(IllegalStateException.class, + () -> createTestWrapper(kemId, kemId.getEncapsulationLength(), kemId.getSharedSecretLength()) + .getSharedSecret(okKeyPair.getPrivate(), new byte[0])); + assertEquals("encapsulation.length not " + kemId.getEncapsulationLength(), iaE.getMessage()); + } + + @Test + void testBadSharedSecretLength() throws Exception + { + KemId kemId = KemId.DHKEM_P256_HKDF_SHA256; + KeyPair okKeyPair = kemId.getKeyPairGeneratorFactory().initialize().generateKeyPair(); + assertDoesNotThrow(() -> createTestWrapper(kemId, kemId.getEncapsulationLength(), kemId.getSharedSecretLength()) + .getEncapsulated(okKeyPair.getPublic(), SECURE_RANDOM)); + IllegalStateException isE = assertThrowsExactly(IllegalStateException.class, + () -> createTestWrapper(kemId, kemId.getEncapsulationLength(), kemId.getSharedSecretLength() - 1) + .getEncapsulated(okKeyPair.getPublic(), SECURE_RANDOM)); + assertEquals("sharedSecret.length not " + kemId.getSharedSecretLength(), isE.getMessage()); + + assertDoesNotThrow(() -> createTestWrapper(kemId, kemId.getEncapsulationLength(), kemId.getSharedSecretLength()) + .getSharedSecret(okKeyPair.getPrivate(), new byte[kemId.getEncapsulationLength()])); + + IllegalStateException iaE = assertThrowsExactly(IllegalStateException.class, + () -> createTestWrapper(kemId, kemId.getEncapsulationLength(), kemId.getSharedSecretLength() - 1) + .getSharedSecret(okKeyPair.getPrivate(), new byte[kemId.getEncapsulationLength()])); + assertEquals("sharedSecret.length not " + kemId.getSharedSecretLength(), iaE.getMessage()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/AeadIdTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/AeadIdTest.java new file mode 100644 index 0000000..92ad108 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/AeadIdTest.java @@ -0,0 +1,133 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import java.security.AlgorithmParameters; +import java.security.spec.InvalidParameterSpecException; +import java.util.stream.Stream; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.IvParameterSpec; + +import org.bouncycastle.util.Arrays; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class AeadIdTest +{ + private static Stream forTestFrom() + { + return Stream.of(Arguments.of(AeadId.AES_128_GCM, 0x0001), Arguments.of(AeadId.AES_256_GCM, 0x0002), + Arguments.of(AeadId.ChaCha20Poly1305, 0x0003)); + } + + @ParameterizedTest + @MethodSource("forTestFrom") + void testFrom(AeadId expected, int id) throws Exception + { + assertEquals(expected, AeadId.from(new byte[] { (byte) (id >>> 8), (byte) id })); + } + + private static Stream forTestFromInvalid() + { + return Stream.of(Arguments.of(null, NullPointerException.class, "value"), + Arguments.of(new byte[0], IllegalArgumentException.class, "value.length not 2"), + Arguments.of(new byte[1], IllegalArgumentException.class, "value.length not 2"), + Arguments.of(new byte[2], IllegalArgumentException.class, "AeadId not supported")); + } + + @ParameterizedTest + @MethodSource("forTestFromInvalid") + void testFromInvalid(byte[] invalid, Class exceptionClass, String exceptionMessage) + throws Exception + { + Exception exception = assertThrowsExactly(exceptionClass, () -> AeadId.from(invalid)); + assertEquals(exceptionMessage, exception.getMessage()); + } + + @FunctionalInterface + private static interface CipherAlgorithmParametersEvaluator + { + void evaluate(AlgorithmParameters params, int expectedAuthTagLengthBits, byte[] expectedIv) + throws InvalidParameterSpecException; + } + + private static Stream forTestGetter() + { + CipherAlgorithmParametersEvaluator gcmEvaluator = (params, expectedAuthTagLengthBits, expectedIv) -> + { + GCMParameterSpec parameterSpec = params.getParameterSpec(GCMParameterSpec.class); + assertNotNull(parameterSpec); + assertEquals(expectedAuthTagLengthBits, parameterSpec.getTLen()); + assertArrayEquals(expectedIv, parameterSpec.getIV()); + }; + + CipherAlgorithmParametersEvaluator chaCha20Poly1305Evaluator = (params, _, expectedIv) -> + { + IvParameterSpec parameterSpec = params.getParameterSpec(IvParameterSpec.class); + assertArrayEquals(expectedIv, parameterSpec.getIV()); + }; + + return Stream.of( + Arguments.of(0x0001, "AES", "AES/GCM/NoPadding", 16, 12, 128, "GCM", gcmEvaluator, AeadId.AES_128_GCM), + Arguments.of(0x0002, "AES", "AES/GCM/NoPadding", 32, 12, 128, "GCM", gcmEvaluator, AeadId.AES_256_GCM), + Arguments.of(0x0003, "ChaCha20", "ChaCha20-Poly1305", 32, 12, 128, "ChaCha20-Poly1305", + chaCha20Poly1305Evaluator, AeadId.ChaCha20Poly1305)); + } + + @ParameterizedTest + @MethodSource("forTestGetter") + void testGetter(int expectedId, String expectedKeyAlgorithm, String expectedCipherAlgorithm, int expectedKeyLength, + int expectedIvLength, int expectedAuthTagLengthBits, String cipherParameterAlgorithmName, + CipherAlgorithmParametersEvaluator cipherAlgorithmParametersEvaluator, AeadId aeadId) throws Exception + { + assertEquals(expectedId, aeadId.getId()); + assertArrayEquals(new byte[] { (byte) (expectedId >>> 8), (byte) expectedId }, aeadId.getIdAsI2osp2Bytes()); + assertEquals(expectedKeyAlgorithm, aeadId.getKeyAlgorithm()); + assertEquals(expectedKeyLength, aeadId.getKeyLength()); + assertArrayEquals(new byte[] { (byte) (expectedKeyLength >>> 8), (byte) expectedKeyLength }, + aeadId.getKeyLengthAsI2osp2Bytes()); + assertEquals(expectedIvLength, aeadId.getIvLength()); + assertArrayEquals(new byte[] { (byte) (expectedIvLength >>> 8), (byte) expectedIvLength }, + aeadId.getIvLengthAsI2osp2Bytes()); + assertEquals(expectedAuthTagLengthBits, aeadId.getAuthenticationTagLengthBits()); + + Cipher cipher = aeadId.toCipher(); + assertNotNull(cipher); + assertEquals(expectedCipherAlgorithm, cipher.getAlgorithm()); + + KeyGenerator keyGen = KeyGenerator.getInstance(aeadId.getKeyAlgorithm()); + keyGen.init(aeadId.getKeyLength() * 8); + SecretKey secretKey = keyGen.generateKey(); + byte[] iv = new byte[aeadId.getIvLength()]; + Arrays.fill(iv, (byte) 0xAB); + + assertDoesNotThrow(() -> aeadId.initEncryptionCipher(cipher, secretKey, iv)); + assertNotNull(cipher.getIV()); + assertArrayEquals(iv, cipher.getIV()); + assertEquals(cipherParameterAlgorithmName, cipher.getParameters().getAlgorithm()); + IllegalArgumentException e = assertThrowsExactly(IllegalArgumentException.class, + () -> aeadId.initEncryptionCipher(cipher, secretKey, new byte[0])); + assertEquals("iv.length not " + aeadId.getIvLength(), e.getMessage()); + + cipherAlgorithmParametersEvaluator.evaluate(cipher.getParameters(), expectedAuthTagLengthBits, iv); + + assertDoesNotThrow(() -> aeadId.initDecryptionCipher(cipher, secretKey, iv)); + assertNotNull(cipher.getIV()); + assertArrayEquals(iv, cipher.getIV()); + assertEquals(cipherParameterAlgorithmName, cipher.getParameters().getAlgorithm()); + e = assertThrowsExactly(IllegalArgumentException.class, + () -> aeadId.initDecryptionCipher(cipher, secretKey, new byte[0])); + assertEquals("iv.length not " + aeadId.getIvLength(), e.getMessage()); + + cipherAlgorithmParametersEvaluator.evaluate(cipher.getParameters(), expectedAuthTagLengthBits, iv); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ByteEncodingTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ByteEncodingTest.java new file mode 100644 index 0000000..0989679 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ByteEncodingTest.java @@ -0,0 +1,144 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +public class ByteEncodingTest +{ + @Test + void testI2osp1() throws Exception + { + IllegalArgumentException e = assertThrowsExactly(IllegalArgumentException.class, + () -> ByteEncoding.i2osp1(Integer.MIN_VALUE)); + assertEquals("value < 0 or value > 255", e.getMessage()); + e = assertThrowsExactly(IllegalArgumentException.class, () -> ByteEncoding.i2osp1(-1)); + assertEquals("value < 0 or value > 255", e.getMessage()); + + byte[] b00 = ByteEncoding.i2osp1(0x00); + assertArrayEquals(new byte[1], b00); + byte[] b01 = ByteEncoding.i2osp1(0x01); + assertArrayEquals(new byte[] { (byte) 0x01 }, b01); + byte[] bFF = ByteEncoding.i2osp1(0xFF); + assertArrayEquals(new byte[] { (byte) 0xFF }, bFF); + + e = assertThrowsExactly(IllegalArgumentException.class, () -> ByteEncoding.i2osp1(0xFF + 1)); + assertEquals("value < 0 or value > 255", e.getMessage()); + e = assertThrowsExactly(IllegalArgumentException.class, () -> ByteEncoding.i2osp1(Integer.MAX_VALUE)); + assertEquals("value < 0 or value > 255", e.getMessage()); + } + + @Test + void testI2osp2() throws Exception + { + IllegalArgumentException e = assertThrowsExactly(IllegalArgumentException.class, + () -> ByteEncoding.i2osp2(Integer.MIN_VALUE)); + assertEquals("value < 0 or value > 65535", e.getMessage()); + e = assertThrowsExactly(IllegalArgumentException.class, () -> ByteEncoding.i2osp2(-1)); + assertEquals("value < 0 or value > 65535", e.getMessage()); + + byte[] b0000 = ByteEncoding.i2osp2(0x0000); + assertArrayEquals(new byte[2], b0000); + byte[] b0001 = ByteEncoding.i2osp2(0x0001); + assertArrayEquals(new byte[] { (byte) 0x00, (byte) 0x01 }, b0001); + byte[] bFFFF = ByteEncoding.i2osp2(0xFFFF); + assertArrayEquals(new byte[] { (byte) 0xFF, (byte) 0xFF }, bFFFF); + + e = assertThrowsExactly(IllegalArgumentException.class, () -> ByteEncoding.i2osp2(0xFFFF + 1)); + assertEquals("value < 0 or value > 65535", e.getMessage()); + e = assertThrowsExactly(IllegalArgumentException.class, () -> ByteEncoding.i2osp2(Integer.MAX_VALUE)); + assertEquals("value < 0 or value > 65535", e.getMessage()); + } + + @Test + void testOs2ip() throws Exception + { + assertThrowsExactly(NullPointerException.class, () -> ByteEncoding.os2ip(null)); + + IllegalArgumentException e = assertThrowsExactly(IllegalArgumentException.class, + () -> ByteEncoding.os2ip(new byte[0])); + assertEquals("input.length < 1 or input.length > 4", e.getMessage()); + + long i1_00 = ByteEncoding.os2ip(new byte[1]); + assertEquals(0x00, i1_00); + long i1_01 = ByteEncoding.os2ip(new byte[] { (byte) 0x01 }); + assertEquals(0x01, i1_01); + long i1_FF = ByteEncoding.os2ip(new byte[] { (byte) 0xFF }); + assertEquals(0xFF, i1_FF); + + long i2_0000 = ByteEncoding.os2ip(new byte[2]); + assertEquals(0x0000, i2_0000); + long i2_0001 = ByteEncoding.os2ip(new byte[] { (byte) 0x00, (byte) 0x01 }); + assertEquals(0x0001, i2_0001); + long i2_FFFF = ByteEncoding.os2ip(new byte[] { (byte) 0xFF, (byte) 0xFF }); + assertEquals(0xFFFF, i2_FFFF); + + long i3_000000 = ByteEncoding.os2ip(new byte[3]); + assertEquals(0x000000, i3_000000); + long i3_000001 = ByteEncoding.os2ip(new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x01 }); + assertEquals(0x000001, i3_000001); + long i3_FFFFFF = ByteEncoding.os2ip(new byte[] { (byte) 0xFF, (byte) 0xFF, (byte) 0xFF }); + assertEquals(0xFFFFFF, i3_FFFFFF); + + long i4_00000000 = ByteEncoding.os2ip(new byte[4]); + assertEquals(0x000000, i4_00000000); + long i4_00000001 = ByteEncoding.os2ip(new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x01 }); + assertEquals(0x000001, i4_00000001); + long i4_FFFFFFFF = ByteEncoding.os2ip(new byte[] { (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF }); + assertEquals(0xFFFFFFFFL, i4_FFFFFFFF); + + e = assertThrowsExactly(IllegalArgumentException.class, () -> ByteEncoding.os2ip(new byte[5])); + assertEquals("input.length < 1 or input.length > 4", e.getMessage()); + } + + @Test + void testConcat() throws Exception + { + assertThrowsExactly(NullPointerException.class, () -> ByteEncoding.concat((byte[]) null)); + assertThrowsExactly(NullPointerException.class, () -> ByteEncoding.concat(new byte[0], (byte[]) null)); + assertThrowsExactly(NullPointerException.class, + () -> ByteEncoding.concat(new byte[0], (byte[]) null, new byte[0])); + + assertArrayEquals(new byte[0], ByteEncoding.concat(new byte[0])); + assertArrayEquals(new byte[0], ByteEncoding.concat(new byte[0], new byte[0])); + assertArrayEquals(new byte[1], ByteEncoding.concat(new byte[0], new byte[1])); + assertArrayEquals(new byte[1], ByteEncoding.concat(new byte[1], new byte[0])); + assertArrayEquals(new byte[2], ByteEncoding.concat(new byte[1], new byte[1])); + assertArrayEquals(new byte[] { (byte) 0x01, (byte) 0x02, (byte) 0x03 }, ByteEncoding + .concat(new byte[] { (byte) 0x01 }, new byte[] { (byte) 0x02 }, new byte[] { (byte) 0x03 })); + } + + @Test + void testExpectRead() throws Exception + { + assertDoesNotThrow(() -> ByteEncoding.expectRead(0, 0)); + IOException e = assertThrowsExactly(IOException.class, () -> ByteEncoding.expectRead(1, 0)); + assertEquals(e.getMessage(), "Truncated stream"); + e = assertThrowsExactly(IOException.class, () -> ByteEncoding.expectRead(1, -1)); + assertEquals(e.getMessage(), "Truncated stream"); + IllegalArgumentException i = assertThrowsExactly(IllegalArgumentException.class, + () -> ByteEncoding.expectRead(-1, 0)); + assertEquals(i.getMessage(), "expected < 0"); + } + + @Test + void testThrowIfTruncated() throws Exception + { + IOException e = assertThrowsExactly(IOException.class, () -> ByteEncoding.throwIfTruncated(Integer.MIN_VALUE)); + assertEquals(e.getMessage(), "Truncated stream"); + e = assertThrowsExactly(IOException.class, () -> ByteEncoding.throwIfTruncated(-1)); + assertEquals(e.getMessage(), "Truncated stream"); + + assertDoesNotThrow(() -> ByteEncoding.throwIfTruncated(0)); + assertDoesNotThrow(() -> ByteEncoding.throwIfTruncated(1)); + assertDoesNotThrow(() -> ByteEncoding.throwIfTruncated(0xFF)); + + e = assertThrowsExactly(IOException.class, () -> ByteEncoding.throwIfTruncated(Integer.MAX_VALUE)); + assertEquals(e.getMessage(), "value > 255"); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkLengthTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkLengthTest.java new file mode 100644 index 0000000..0a60da8 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkLengthTest.java @@ -0,0 +1,51 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import java.util.EnumSet; + +import org.junit.jupiter.api.Test; + +public class ChunkLengthTest +{ + @Test + void testEncodeDecode() throws Exception + { + EnumSet.allOf(ChunkLength.class).forEach(cl -> + { + byte[] encoded = cl.getExponentAsI2osp1Byte(); + assertNotNull(encoded); + ChunkLength decoded = ChunkLength.from(encoded); + assertNotNull(decoded); + assertEquals(cl, decoded); + }); + } + + @Test + void testGetLenght() throws Exception + { + ChunkLength[] values = ChunkLength.values(); + assertEquals(16, values.length); + + for (int i = 0; i < values.length; i++) + { + int expectedLength = (int) (ChunkLength.BASE * Math.pow(2, i)); + assertEquals(expectedLength, values[i].getLength()); + } + } + + @Test + void testFromInvalid() throws Exception + { + IllegalArgumentException e = assertThrowsExactly(IllegalArgumentException.class, + () -> ChunkLength.from(new byte[0])); + assertEquals("value.length not 1", e.getMessage()); + e = assertThrowsExactly(IllegalArgumentException.class, + () -> ChunkLength.from(new byte[] { (byte) ChunkLength.values().length })); + assertEquals("Chunk length exponent not supported", e.getMessage()); + e = assertThrowsExactly(IllegalArgumentException.class, () -> ChunkLength.from(new byte[] { (byte) 0xFF })); + assertEquals("Chunk length exponent not supported", e.getMessage()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkedInputStreamEnumerationTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkedInputStreamEnumerationTest.java new file mode 100644 index 0000000..a9a0433 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ChunkedInputStreamEnumerationTest.java @@ -0,0 +1,361 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import de.hsheilbronn.mi.utils.crypto.hpke.ChunkedInputStreamEnumeration.CryptOperation; + +public class ChunkedInputStreamEnumerationTest +{ + @Test + void testConstructor() throws Exception + { + CryptOperation op = (_, _, _, _) -> null; + byte[] baseNonce = new byte[1]; + InputStream source = InputStream.nullInputStream(); + + assertDoesNotThrow(() -> new ChunkedInputStreamEnumeration(1, baseNonce, source, op)); + assertThrowsExactly(IllegalArgumentException.class, + () -> new ChunkedInputStreamEnumeration(Integer.MIN_VALUE, baseNonce, source, op)); + assertThrowsExactly(IllegalArgumentException.class, + () -> new ChunkedInputStreamEnumeration(-1, baseNonce, source, op)); + assertThrowsExactly(IllegalArgumentException.class, + () -> new ChunkedInputStreamEnumeration(0, baseNonce, source, op)); + + assertThrowsExactly(NullPointerException.class, () -> new ChunkedInputStreamEnumeration(1, null, source, op)); + assertThrowsExactly(IllegalArgumentException.class, + () -> new ChunkedInputStreamEnumeration(1, new byte[0], source, op)); + assertThrowsExactly(NullPointerException.class, + () -> new ChunkedInputStreamEnumeration(1, baseNonce, null, op)); + assertThrowsExactly(NullPointerException.class, + () -> new ChunkedInputStreamEnumeration(1, baseNonce, source, null)); + } + + @Test + void testChunking() throws Exception + { + CryptOperation op = (iv, sequence, finished, chunk) -> + { + assertNotNull(iv); + assertEquals(2, iv.length); + assertNotNull(sequence); + assertEquals(2, sequence.length); + assertNotNull(chunk); + assertEquals(1, chunk.length); + + ByteBuffer b = ByteBuffer.allocate(2 + 2 + 1 + 1); + + b.put(iv); + b.put(sequence); + b.put(finished ? (byte) 0x01 : (byte) 0x00); + b.put(chunk); + + return new ByteArrayInputStream(b.array()); + }; + byte[] baseNonce = new byte[] { (byte) 0xFF, (byte) 0x00 }; + InputStream source = new ByteArrayInputStream(new byte[] { (byte) 0xAA, (byte) 0xBB }); + + ChunkedInputStreamEnumeration e = new ChunkedInputStreamEnumeration(1, baseNonce, source, op); + ArrayList results = Collections.list(e); + assertEquals(2, results.size()); + assertArrayEquals(HexFormat.of().parseHex("FF00000000AA"), results.get(0).readAllBytes()); + assertArrayEquals(HexFormat.of().parseHex("FF01000101BB"), results.get(1).readAllBytes()); + } + + @Test + void testChunkingEmptyInput() throws Exception + { + CryptOperation op = (iv, sequence, finished, chunk) -> + { + assertNotNull(iv); + assertEquals(2, iv.length); + assertNotNull(sequence); + assertEquals(2, sequence.length); + assertNotNull(chunk); + assertEquals(0, chunk.length); + + ByteBuffer b = ByteBuffer.allocate(2 + 2 + 1); + + b.put(iv); + b.put(sequence); + b.put(finished ? (byte) 0x01 : (byte) 0x00); + + return new ByteArrayInputStream(b.array()); + }; + byte[] baseNonce = new byte[] { (byte) 0xFF, (byte) 0x00 }; + InputStream source = InputStream.nullInputStream(); + + ChunkedInputStreamEnumeration e = new ChunkedInputStreamEnumeration(1, baseNonce, source, op); + ArrayList results = Collections.list(e); + assertEquals(1, results.size()); + assertArrayEquals(HexFormat.of().parseHex("FF00000001"), results.get(0).readAllBytes()); + } + + @Test + void testChunkingSourceShorterThenChunkLenght() throws Exception + { + CryptOperation op = (iv, sequence, finished, chunk) -> + { + assertNotNull(iv); + assertEquals(2, iv.length); + assertNotNull(sequence); + assertEquals(2, sequence.length); + assertNotNull(chunk); + assertTrue(chunk.length > 0); + + ByteBuffer b = ByteBuffer.allocate(2 + 2 + 1 + chunk.length); + + b.put(iv); + b.put(sequence); + b.put(finished ? (byte) 0x01 : (byte) 0x00); + b.put(chunk); + + return new ByteArrayInputStream(b.array()); + }; + byte[] baseNonce = new byte[] { (byte) 0xFF, (byte) 0x00 }; + InputStream source = new ByteArrayInputStream(new byte[] { (byte) 0xAA, (byte) 0xBB, (byte) 0xCC }); + + ChunkedInputStreamEnumeration e = new ChunkedInputStreamEnumeration(2, baseNonce, source, op); + ArrayList results = Collections.list(e); + assertEquals(2, results.size()); + assertArrayEquals(HexFormat.of().parseHex("FF00000000AABB"), results.get(0).readAllBytes()); + assertArrayEquals(HexFormat.of().parseHex("FF01000101CC"), results.get(1).readAllBytes()); + } + + @Test + void testChunkingSourceThrowsIOException() throws Exception + { + final int chunkLength = 2; + final IOException readException = new IOException("simmulated failed read"); + final IOException closeException = new IOException("simmulated failed close"); + + CryptOperation op = (_, _, _, _) -> InputStream.nullInputStream(); + byte[] baseNonce = new byte[] { (byte) 0xFF, (byte) 0x00 }; + InputStream source = new ByteArrayInputStream(new byte[] { (byte) 0xAA, (byte) 0xBB, (byte) 0xCC }); + + InputStream sourceWrapper = new InputStream() + { + int counter = 0; + + @Override + public int read(byte[] b, int off, int len) throws IOException + { + if (counter >= chunkLength) + throw readException; + + int i = source.read(b, off, len); + counter += i; + return i; + } + + @Override + public int read() throws IOException + { + throw new IOException("Unexpected call of read() in test"); + } + + @Override + public void close() throws IOException + { + throw closeException; + } + }; + + ChunkedInputStreamEnumeration enumeration = new ChunkedInputStreamEnumeration(chunkLength, baseNonce, + sourceWrapper, op); + assertDoesNotThrow(() -> enumeration.hasMoreElements()); + assertDoesNotThrow(() -> enumeration.nextElement()); + assertDoesNotThrow(() -> enumeration.hasMoreElements()); + RuntimeIOException e = assertThrowsExactly(RuntimeIOException.class, () -> enumeration.nextElement()); + assertNotNull(e.asIOException()); + assertEquals(readException, e.asIOException()); + assertNotNull(e.asIOException().getSuppressed()); + assertEquals(1, e.asIOException().getSuppressed().length); + assertEquals(closeException, e.asIOException().getSuppressed()[0]); + } + + private static final HexFormat HEX = HexFormat.of(); + + private static record TestVector(AeadId aeadId, SecretKey key, byte[] baseNonce, Map data) + { + static TestVector of(AeadId aeadId, String keyHex, String baseNonceHex, Map data) + { + SecretKeySpec key = new SecretKeySpec(HEX.parseHex(keyHex), aeadId.getKeyAlgorithm()); + return new TestVector(aeadId, key, HEX.parseHex(baseNonceHex), data); + } + } + + private static record TestVectorData(byte[] aad, byte[] nonce, byte[] ct) + { + static TestVectorData of(String aadHex, String nonceHex, String ctHex) + { + return new TestVectorData(HEX.parseHex(aadHex), HEX.parseHex(nonceHex), HEX.parseHex(ctHex)); + } + } + + // Test Data from https://www.rfc-editor.org/rfc/rfc9180.html#name-test-vectors + + private static final byte[] PT = HexFormat.of() + .parseHex("4265617574792069732074727574682c20747275746820626561757479"); + + private static final String aad0 = "436f756e742d30"; + private static final String aad1 = "436f756e742d31"; + private static final String aad2 = "436f756e742d32"; + private static final String aad4 = "436f756e742d34"; + private static final String aad255 = "436f756e742d323535"; + private static final String aad256 = "436f756e742d323536"; + + // A.1.1.1. Encryptions + private static final Map A1_DATA = Map.of(0, + TestVectorData.of(aad0, "56d890e5accaaf011cff4b7d", + "f938558b5d72f1a23810b4be2ab4f84331acc02fc97babc53a52ae8218a355a96d8770ac83d07bea87e13c512a"), + 1, + TestVectorData.of(aad1, "56d890e5accaaf011cff4b7c", + "af2d7e9ac9ae7e270f46ba1f975be53c09f8d875bdc8535458c2494e8a6eab251c03d0c22a56b8ca42c2063b84"), + 2, + TestVectorData.of(aad2, "56d890e5accaaf011cff4b7f", + "498dfcabd92e8acedc281e85af1cb4e3e31c7dc394a1ca20e173cb72516491588d96a19ad4a683518973dcc180"), + 4, + TestVectorData.of(aad4, "56d890e5accaaf011cff4b79", + "583bd32bc67a5994bb8ceaca813d369bca7b2a42408cddef5e22f880b631215a09fc0012bc69fccaa251c0246d"), + 255, + TestVectorData.of(aad255, "56d890e5accaaf011cff4b82", + "7175db9717964058640a3a11fb9007941a5d1757fda1a6935c805c21af32505bf106deefec4a49ac38d71c9e0a"), + 256, TestVectorData.of(aad256, "56d890e5accaaf011cff4a7d", + "957f9800542b0b8891badb026d79cc54597cb2d225b54c00c5238c25d05c30e3fbeda97d2e0e1aba483a2df9f2")); + + // A.2.1.1. Encryptions + private static final Map A2_DATA = Map.of(0, + TestVectorData.of(aad0, "5c4d98150661b848853b547f", + "1c5250d8034ec2b784ba2cfd69dbdb8af406cfe3ff938e131f0def8c8b60b4db21993c62ce81883d2dd1b51a28"), + 1, + TestVectorData.of(aad1, "5c4d98150661b848853b547e", + "6b53c051e4199c518de79594e1c4ab18b96f081549d45ce015be002090bb119e85285337cc95ba5f59992dc98c"), + 2, + TestVectorData.of(aad2, "5c4d98150661b848853b547d", + "71146bd6795ccc9c49ce25dda112a48f202ad220559502cef1f34271e0cb4b02b4f10ecac6f48c32f878fae86b"), + 4, + TestVectorData.of(aad4, "5c4d98150661b848853b547b", + "63357a2aa291f5a4e5f27db6baa2af8cf77427c7c1a909e0b37214dd47db122bb153495ff0b02e9e54a50dbe16"), + 255, + TestVectorData.of(aad255, "5c4d98150661b848853b5480", + "18ab939d63ddec9f6ac2b60d61d36a7375d2070c9b683861110757062c52b8880a5f6b3936da9cd6c23ef2a95c"), + 256, TestVectorData.of(aad256, "5c4d98150661b848853b557f", + "7a4a13e9ef23978e2c520fd4d2e757514ae160cd0cd05e556ef692370ca53076214c0c40d4c728d6ed9e727a5b")); + + // A.6.1.1. Encryptions + private static final Map A6_DATA = Map.of(0, + TestVectorData.of(aad0, "55ff7a7d739c69f44b25447b", + "170f8beddfe949b75ef9c387e201baf4132fa7374593dfafa90768788b7b2b200aafcc6d80ea4c795a7c5b841a"), + 1, + TestVectorData.of(aad1, "55ff7a7d739c69f44b25447a", + "d9ee248e220ca24ac00bbbe7e221a832e4f7fa64c4fbab3945b6f3af0c5ecd5e16815b328be4954a05fd352256"), + 2, + TestVectorData.of(aad2, "55ff7a7d739c69f44b254479", + "142cf1e02d1f58d9285f2af7dcfa44f7c3f2d15c73d460c48c6e0e506a3144bae35284e7e221105b61d24e1c7a"), + 4, + TestVectorData.of(aad4, "55ff7a7d739c69f44b25447f", + "3bb3a5a07100e5a12805327bf3b152df728b1c1be75a9fd2cb2bf5eac0cca1fb80addb37eb2a32938c7268e3e5"), + 255, + TestVectorData.of(aad255, "55ff7a7d739c69f44b254484", + "4f268d0930f8d50b8fd9d0f26657ba25b5cb08b308c92e33382f369c768b558e113ac95a4c70dd60909ad1adc7"), + 256, TestVectorData.of(aad256, "55ff7a7d739c69f44b25457b", + "dbbfc44ae037864e75f136e8b4b4123351d480e6619ae0e0ae437f036f2f8f1ef677686323977a1ccbb4b4f16a")); + + private static final TestVector A1 = TestVector.of(AeadId.AES_128_GCM, "4531685d41d65f03dc48f6b8302c05b0", + "56d890e5accaaf011cff4b7d", A1_DATA); + private static final TestVector A2 = TestVector.of(AeadId.ChaCha20Poly1305, + "ad2744de8e17f4ebba575b3f5f5a8fa1f69c2a07f6e7500bc60ca6e3e3ec1c91", "5c4d98150661b848853b547f", A2_DATA); + private static final TestVector A6 = TestVector.of(AeadId.AES_256_GCM, + "751e346ce8f0ddb2305c8a2a85c70d5cf559c53093656be636b9406d4d7d1b70", "55ff7a7d739c69f44b25447b", A6_DATA); + + private static Stream forTestWithRfcTestVector() throws KeyNotFoundException + { + return Stream.of(A1, A2, A6).map(Arguments::of); + } + + @ParameterizedTest + @MethodSource("forTestWithRfcTestVector") + void testWithRfcTestVector(TestVector vector) throws Exception + { + AeadId aeadId = vector.aeadId(); + AtomicInteger counter = new AtomicInteger(); + + Cipher cipher = aeadId.toCipher(); + CryptOperation op = (iv, _, _, chunk) -> + { + int c = counter.getAndIncrement(); + + Optional.ofNullable(vector.data().get(c)).map(TestVectorData::nonce) + .ifPresent(n -> assertArrayEquals(n, iv)); + + aeadId.initEncryptionCipher(cipher, vector.key(), iv); + Optional.ofNullable(vector.data().get(c)).map(TestVectorData::aad).ifPresent(a -> cipher.updateAAD(a)); + + return new ByteArrayInputStream(cipher.doFinal(chunk)); + }; + + InputStream source = new SequenceInputStream(Collections + .enumeration(IntStream.rangeClosed(0, 256).mapToObj(_ -> PT).map(ByteArrayInputStream::new).toList())); + + ChunkedInputStreamEnumeration e = new ChunkedInputStreamEnumeration(PT.length, vector.baseNonce(), source, op); + List results = Collections.list(e).stream().map(readAllBytes()).toList(); + assertEquals(257, results.size()); + + vector.data().forEach((i, data) -> assertArrayEquals(data.ct(), results.get(i))); + } + + private Function readAllBytes() + { + return in -> + { + try + { + return in.readAllBytes(); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + }; + } + + @Test + void testSequenceLimit() throws Exception + { + ChunkedInputStreamEnumeration en = new ChunkedInputStreamEnumeration(1, new byte[1], + new ByteArrayInputStream(new byte[0xFF + 1]), (_, _, _, _) -> null); + + RuntimeIOException e = assertThrowsExactly(RuntimeIOException.class, () -> Collections.list(en)); + assertEquals("Message limit reached", e.asIOException().getMessage()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/DhKemWrapperTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/DhKemWrapperTest.java new file mode 100644 index 0000000..ac151db --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/DhKemWrapperTest.java @@ -0,0 +1,201 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.KeyPair; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.stream.Stream; + +import javax.crypto.DecapsulateException; +import javax.crypto.KEM.Encapsulated; +import javax.crypto.SecretKey; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import de.hsheilbronn.mi.utils.crypto.keypair.KeyPairGeneratorFactory; + +public class DhKemWrapperTest +{ + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private static Stream forTestConstructor() + { + return EnumSet.complementOf(DhKemWrapper.DH_KEMS).stream().map(Arguments::of); + } + + @ParameterizedTest + @MethodSource("forTestConstructor") + void testConstructor(KemId kemId) throws Exception + { + IllegalArgumentException e = assertThrowsExactly(IllegalArgumentException.class, () -> new DhKemWrapper(kemId)); + assertEquals("KemId " + kemId.name() + " not supported", e.getMessage()); + } + + private static Stream kemVariants() + { + return Stream.of( + Arguments.of(KemId.DHKEM_P256_HKDF_SHA256, + KeyPairGeneratorFactory.secp256r1().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_P384_HKDF_SHA384, + KeyPairGeneratorFactory.secp384r1().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_P521_HKDF_SHA512, + KeyPairGeneratorFactory.secp521r1().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_X25519_HKDF_SHA256, + KeyPairGeneratorFactory.x25519().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_X448_HKDF_SHA512, + KeyPairGeneratorFactory.x448().initialize().generateKeyPair())); + } + + @ParameterizedTest + @MethodSource("kemVariants") + void testTruncatedEnc(KemId kemId, KeyPair keyPair) throws Exception + { + DhKemWrapper w = new DhKemWrapper(kemId); + Encapsulated encapsulated = w.getEncapsulated(keyPair.getPublic(), SECURE_RANDOM); + + assertNotNull(encapsulated); + + SecretKey sKey = encapsulated.key(); + assertNotNull(sKey); + assertEquals("Generic", sKey.getAlgorithm()); + assertNotNull(sKey.getEncoded()); + + byte[] encapsulation = encapsulated.encapsulation(); + assertNotNull(encapsulation); + assertEquals(kemId.getEncapsulationLength(), encapsulation.length); + + for (int i = 0; i <= encapsulation.length; i++) + { + try + { + w.getSharedSecret(keyPair.getPrivate(), Arrays.copyOfRange(encapsulation, 0, encapsulation.length - i)); + assertEquals(0, i); // only not truncated stream ok + } + catch (IllegalStateException e) + { + assertEquals("encapsulation.length not " + kemId.getEncapsulationLength(), e.getMessage()); + } + } + } + + private static Stream forTestModifiedDheEnc() + { + return Stream.of( + Arguments.of(KemId.DHKEM_P256_HKDF_SHA256, + KeyPairGeneratorFactory.secp256r1().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_P384_HKDF_SHA384, + KeyPairGeneratorFactory.secp384r1().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_P521_HKDF_SHA512, + KeyPairGeneratorFactory.secp521r1().initialize().generateKeyPair())); + } + + @ParameterizedTest + @MethodSource("forTestModifiedDheEnc") + void testModifiedDheEnc(KemId kemId, KeyPair keyPair) throws Exception + { + DhKemWrapper w = new DhKemWrapper(kemId); + Encapsulated encapsulated = w.getEncapsulated(keyPair.getPublic(), SECURE_RANDOM); + + assertNotNull(encapsulated); + + SecretKey sKey = encapsulated.key(); + assertNotNull(sKey); + assertEquals("Generic", sKey.getAlgorithm()); + assertNotNull(sKey.getEncoded()); + + byte[] encapsulation = encapsulated.encapsulation(); + assertNotNull(encapsulation); + assertEquals(kemId.getEncapsulationLength(), encapsulation.length); + + assertEquals(encapsulation[0], (byte) 0x04); + + encapsulation[0] ^= 0x01; + DecapsulateException e = assertThrows(DecapsulateException.class, + () -> w.getSharedSecret(keyPair.getPrivate(), encapsulation)); + assertEquals("Cannot decapsulate", e.getMessage()); + assertNotNull(e.getCause()); + assertEquals(IOException.class, e.getCause().getClass()); + + encapsulation[0] = (byte) 0x04; + encapsulation[1] ^= 0x01; + e = assertThrows(DecapsulateException.class, () -> w.getSharedSecret(keyPair.getPrivate(), encapsulation)); + assertEquals("Cannot decapsulate", e.getMessage()); + assertNotNull(e.getCause()); + assertEquals(InvalidKeyException.class, e.getCause().getClass()); + } + + private static Stream forTestModifiedDhxEnc() + { + return Stream.of( + Arguments.of(KemId.DHKEM_X25519_HKDF_SHA256, + KeyPairGeneratorFactory.x25519().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_X448_HKDF_SHA512, + KeyPairGeneratorFactory.x448().initialize().generateKeyPair())); + } + + @ParameterizedTest + @MethodSource("forTestModifiedDhxEnc") + void testModifiedDhxEnc(KemId kemId, KeyPair keyPair) throws Exception + { + DhKemWrapper w = new DhKemWrapper(kemId); + Encapsulated encapsulated = w.getEncapsulated(keyPair.getPublic(), SECURE_RANDOM); + + assertNotNull(encapsulated); + + SecretKey sKey = encapsulated.key(); + assertNotNull(sKey); + assertEquals("Generic", sKey.getAlgorithm()); + assertNotNull(sKey.getEncoded()); + + byte[] encapsulation = encapsulated.encapsulation(); + assertNotNull(encapsulation); + assertEquals(kemId.getEncapsulationLength(), encapsulation.length); + + encapsulation[0] ^= 0x01; + + for (int i = 0; i < encapsulation.length; i++) + { + encapsulation[i] ^= 0x01; + SecretKey sharedSecret = w.getSharedSecret(keyPair.getPrivate(), encapsulation); + + assertNotSame(sKey, sharedSecret); + } + } + + @ParameterizedTest + @MethodSource("kemVariants") + void testTwoCallsResultInDifferentEncapsulationsAndKeys(KemId kemId, KeyPair keyPair) throws Exception + { + DhKemWrapper w = new DhKemWrapper(kemId); + + Encapsulated e1 = w.getEncapsulated(keyPair.getPublic(), SECURE_RANDOM); + assertNotNull(e1); + assertNotNull(e1.encapsulation()); + assertNotNull(e1.key()); + + Encapsulated e2 = w.getEncapsulated(keyPair.getPublic(), SECURE_RANDOM); + assertNotNull(e2); + assertNotNull(e2.encapsulation()); + assertNotNull(e2.key()); + + assertFalse(Arrays.equals(e1.encapsulation(), e2.encapsulation())); + assertFalse(Arrays.equals(e1.key().getEncoded(), e2.key().getEncoded())); + + SecretKey sharedSecret1 = w.getSharedSecret(keyPair.getPrivate(), e1.encapsulation()); + assertArrayEquals(e1.key().getEncoded(), sharedSecret1.getEncoded()); + SecretKey sharedSecret2 = w.getSharedSecret(keyPair.getPrivate(), e2.encapsulation()); + assertArrayEquals(e2.key().getEncoded(), sharedSecret2.getEncoded()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/HpkeDemo.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/HpkeDemo.java new file mode 100644 index 0000000..3c8081f --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/HpkeDemo.java @@ -0,0 +1,30 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.io.InputStream; +import java.io.OutputStream; +import java.security.KeyPair; + +import org.junit.jupiter.api.Test; + +public class HpkeDemo +{ + private static final int GIB_1 = 1024 * 1024 * 1024; + + @Test + void v1WriteReadBaseModeDemo() throws Exception + { + byte[] receiverKeyId = new byte[ProtocolV1.RECEIVER_KEY_ID_LENGTH]; + KemId kemId = KemId.DHKEM_X25519_HKDF_SHA256; + KeyPair keyPair = kemId.getKeyPairGeneratorFactory().initialize().generateKeyPair(); + PreSharedKeyProvider preSharedKeyProvider = PreSharedKeyProvider.of(); + ReceiverPrivateKeyProvider receiverPrivateKeyProvider = ReceiverPrivateKeyProvider.of(receiverKeyId, + keyPair.getPrivate()); + ProtocolV1 protocol = new ProtocolV1(Mode.base(), kemId, KdfId.HKDF_SHA256, AeadId.AES_128_GCM, + ChunkLength.MiB_1, receiverKeyId); + ProtocolFactory protocolFactory = new ProtocolFactory(preSharedKeyProvider, receiverPrivateKeyProvider); + Hpke hpke = new Hpke(protocolFactory); + + InputStream encrypted = hpke.encrypt(protocol, new ZeroInputStream(GIB_1), keyPair.getPublic()); + hpke.decrypt(encrypted, OutputStream.nullOutputStream()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/HpkeTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/HpkeTest.java new file mode 100644 index 0000000..a317289 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/HpkeTest.java @@ -0,0 +1,461 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.crypto.AEADBadTagException; +import javax.crypto.DecapsulateException; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.hsheilbronn.mi.utils.crypto.hpke.ProtocolFactory.ProtocolSerializer; + +public class HpkeTest +{ + private static final Logger logger = LoggerFactory.getLogger(HpkeTest.class); + + private static final long TWO_KIB = 2L * 1024; + + private static final byte[] PSK_ID = Sha256.digest("Test Pre Shared Key ID".getBytes(StandardCharsets.US_ASCII)); + private static final SecretKey PSK = new SecretKeySpec(new byte[] { 'T', 'e', 's', 't', ' ', 'P', 'S', 'K' }, + "Generic"); + private static final PreSharedKeyProvider PRE_SHARED_KEY_PROVIDER = PreSharedKeyProvider.of(PSK_ID, PSK); + + private static final byte[] RECEIVER_KEY_ID = Sha256 + .digest("Test Receiver Key ID".getBytes(StandardCharsets.US_ASCII)); + + private static record ProtocolAndKeyPair(Protocol protocol, KeyPair keyPair) + { + @Override + public final String toString() + { + return Stream + .of("Mode " + protocol.getMode(), protocol.getKemId().name(), protocol.getKdfId().name(), + protocol.getAeadId().name(), protocol.getChunkLength().name()) + .collect(Collectors.joining(", ")); + } + + Arguments toArguments(String plainText) + { + return Arguments.argumentSet(toString() + ", plainText: \"" + plainText + "\"", this, plainText); + } + + Arguments toArguments() + { + return Arguments.argumentSet(toString(), this); + } + } + + private static Stream forTestEncryptDecryptInputStream() throws KeyNotFoundException + { + List modes = List.of(Mode.base(), Mode.psk(PSK_ID)); + KemId[] kemIds = EnumSet.of(KemId.RSAKEM_1024_KDF2_SHA256, KemId.RSAKEM_2048_KDF2_SHA256, + KemId.RSAKEM_3072_KDF2_SHA512, KemId.RSAKEM_4096_KDF2_SHA512).toArray(KemId[]::new); + KdfId[] kdfIds = KdfId.values(); + AeadId[] aeadIds = AeadId.values(); + ChunkLength[] chunkLengths = ChunkLength.values(); + + String plainText0 = ""; + String plainText1 = "Foo Bar Baz"; + + return modes.stream().flatMap(mode -> + { + return Stream.of(kemIds).flatMap(kemId -> + { + KeyPair keyPair = kemId.getKeyPairGeneratorFactory().initialize().generateKeyPair(); + + return Stream.of(kdfIds).flatMap(kdfId -> + { + return Stream.of(aeadIds).flatMap(aeadId -> + { + return Stream.of(chunkLengths).flatMap(chunklength -> + { + return Stream.of(plainText0, plainText1).map(plainText -> + { + return new ProtocolAndKeyPair( + new ProtocolV1(mode, kemId, kdfId, aeadId, chunklength, RECEIVER_KEY_ID), + keyPair).toArguments(plainText); + }); + }); + }); + }); + }); + }); + } + + @ParameterizedTest + @MethodSource("forTestEncryptDecryptInputStream") + void testEncryptDecryptInputStream(ProtocolAndKeyPair protocolAndKeyPair, String plainText) throws Exception + { + final Hpke hpke = new Hpke(new ProtocolFactory(PRE_SHARED_KEY_PROVIDER, + ReceiverPrivateKeyProvider.of(RECEIVER_KEY_ID, protocolAndKeyPair.keyPair().getPrivate()))); + + byte[] plainTextBytes = plainText.getBytes(StandardCharsets.UTF_8); + byte[] encrypted = hpke.encrypt(protocolAndKeyPair.protocol(), new ByteArrayInputStream(plainTextBytes), + protocolAndKeyPair.keyPair().getPublic()).readAllBytes(); + + logger.debug("{}, plaintText: \"{}\" - encrypted.length: {}", protocolAndKeyPair.toString(), plainText, + encrypted.length); + + InputStream decryptedStream = hpke.decrypt(new ByteArrayInputStream(encrypted)); + assertNotNull(decryptedStream); + + byte[] decrypted = decryptedStream.readAllBytes(); + + assertArrayEquals(plainTextBytes, decrypted); + } + + private static Stream forDecryptionTruncatedStreamTest() throws KeyNotFoundException + { + BiConsumer aesErrorHandler = (i, e) -> + { + if (i > 0 && i <= 1024 && e.getCause() instanceof AEADBadTagException badTag) + assertEquals("Tag mismatch", badTag.getMessage()); + else if (i > 1024 && i <= 1024 + 15 && e.getCause() instanceof AEADBadTagException badTag) + assertEquals("Input data too short to contain an expected tag length of 16bytes", badTag.getMessage()); + else if (i > 1024 + 15 && i <= 2048 + 16 && e.getCause() instanceof AEADBadTagException badTag) + assertEquals("Tag mismatch", badTag.getMessage()); + else if (i > 2048 + 16 && i <= 2048 + 16 + 16 && e.getCause() instanceof AEADBadTagException badTag) + assertEquals("Input data too short to contain an expected tag length of 16bytes", badTag.getMessage()); + else if (i > 2048 + 16 + 16) + assertEquals("Truncated stream", e.getMessage()); + else + fail("Truncated by " + i, e); + }; + + BiConsumer chaCha20ErrorHandler = (i, e) -> + { + if (i > 0 && i <= 1024 && e.getCause() instanceof AEADBadTagException badTag) + assertEquals("Tag mismatch", badTag.getMessage()); + else if (i > 1024 && i <= 1024 + 15 && e.getCause() instanceof AEADBadTagException badTag) + assertEquals("Input too short - need tag", badTag.getMessage()); + else if (i > 1024 + 15 && i <= 2048 + 16 && e.getCause() instanceof AEADBadTagException badTag) + assertEquals("Tag mismatch", badTag.getMessage()); + else if (i > 2048 + 16 && i <= 2048 + 16 + 16 && e.getCause() instanceof AEADBadTagException badTag) + assertEquals("Input too short - need tag", badTag.getMessage()); + else if (i > 2048 + 16 + 16) + assertEquals("Truncated stream", e.getMessage()); + else + fail("Truncated by " + i, e); + }; + + return Stream.of(AeadId.values()).map(aeadId -> + { + KemId kemId = KemId.RSAKEM_1024_KDF2_SHA256; + KeyPair keyPair = kemId.getKeyPairGeneratorFactory().initialize().generateKeyPair(); + ProtocolV1 header = new ProtocolV1(handleException(() -> Mode.psk(PSK_ID)), kemId, KdfId.HKDF_SHA256, + aeadId, ChunkLength.KiB_1, RECEIVER_KEY_ID); + ProtocolAndKeyPair hkp = new ProtocolAndKeyPair(header, keyPair); + + return Arguments.of(hkp, AeadId.ChaCha20Poly1305.equals(aeadId) ? chaCha20ErrorHandler : aesErrorHandler); + }); + } + + public interface SupplierWithException + { + T get() throws Exception; + } + + private static Mode handleException(SupplierWithException modeSupplier) + { + try + { + return modeSupplier.get(); + } + catch (Exception e) + { + throw new RuntimeException(e); + } + } + + @ParameterizedTest + @MethodSource("forDecryptionTruncatedStreamTest") + void decryptionTruncatedStreamTest(ProtocolAndKeyPair headerAndKeyPair, + BiConsumer errorHandler) throws Exception + { + final Hpke hpke = new Hpke(new ProtocolFactory(PRE_SHARED_KEY_PROVIDER, + ReceiverPrivateKeyProvider.of(RECEIVER_KEY_ID, headerAndKeyPair.keyPair().getPrivate()))); + + ByteArrayOutputStream encrypted = new ByteArrayOutputStream(); + hpke.encrypt(headerAndKeyPair.protocol(), new ZeroInputStream(TWO_KIB), headerAndKeyPair.keyPair().getPublic(), + encrypted); + + byte[] encryptedBytes = encrypted.toByteArray(); + assertNotNull(encryptedBytes); + + for (int i = 0; i <= encryptedBytes.length; i++) + { + try + { + hpke.decrypt(new ByteArrayInputStream(encryptedBytes, 0, encryptedBytes.length - i), + OutputStream.nullOutputStream()); + + assertEquals(0, i); // only not truncated stream ok + } + catch (IOException e) + { + errorHandler.accept(i, e); + } + catch (GeneralSecurityException e) + { + fail(e); + } + } + } + + private static Stream forTestModifiedMessages() throws KeyNotFoundException + { + List modes = List.of(Mode.base(), Mode.psk(PSK_ID)); + KemId[] kemIds = EnumSet.of(KemId.RSAKEM_1024_KDF2_SHA256, KemId.RSAKEM_2048_KDF2_SHA256, + KemId.RSAKEM_3072_KDF2_SHA512, KemId.RSAKEM_4096_KDF2_SHA512).toArray(KemId[]::new); + KdfId[] kdfIds = KdfId.values(); + AeadId[] aeadIds = AeadId.values(); + ChunkLength[] chunkLengths = ChunkLength.values(); + + return modes.stream().flatMap(mode -> + { + return Stream.of(kemIds).flatMap(kemId -> + { + KeyPair keyPair = kemId.getKeyPairGeneratorFactory().initialize().generateKeyPair(); + + return Stream.of(kdfIds).flatMap(kdfId -> + { + return Stream.of(aeadIds).flatMap(aeadId -> + { + return Stream.of(chunkLengths).map(chunkLength -> + { + return new ProtocolAndKeyPair( + new ProtocolV1(mode, kemId, kdfId, aeadId, chunkLength, RECEIVER_KEY_ID), keyPair) + .toArguments(); + }); + }); + }); + }); + }); + + // KeyPair keyPair = kemIds[0].getKeyPairGeneratorFactory().initialize().generateKeyPair(); + // return Stream.of(new ProtocolAndKeyPair( + // new ProtocolV1(modes.get(0), kemIds[0], kdfIds[0], aeadIds[0], chunkLengths[0], RECEIVER_KEY_ID), + // keyPair).toArguments()); + } + + private static final class TestProtocol extends ProtocolV1 + { + public TestProtocol(Mode mode, KemId kemId, KdfId kdfId, AeadId aeadId, ChunkLength chunkLength, + byte[] receiverKeyId) + { + super(mode, kemId, kdfId, aeadId, chunkLength, receiverKeyId); + } + + public static TestProtocol from(InputStream source) throws IOException + { + ProtocolV1 v1 = ProtocolV1.from(source); + return new TestProtocol(v1.getMode(), v1.getKemId(), v1.getKdfId(), v1.getAeadId(), v1.getChunkLength(), + v1.getReceiverKeyId()); + } + + @Override + public byte[] getKdfInfo() + { + byte[] kdfInfo = super.getKdfInfo(); + kdfInfo[kdfInfo.length - 2] = 0x02; + return kdfInfo; + } + } + + private static final ProtocolSerializer TEST_SERIALIZER = new ProtocolSerializer<>() + { + @Override + public int getVersion() + { + return 0x02; + } + + @Override + public TestProtocol read(InputStream source) throws IOException + { + return TestProtocol.from(source); + } + + @Override + public Class getType() + { + return TestProtocol.class; + } + + @Override + public byte[] write(TestProtocol protocol) + { + return protocol.getCanonicalHeader(); + } + }; + + @ParameterizedTest + @MethodSource("forTestModifiedMessages") + void testModifiedMessages(ProtocolAndKeyPair protocolAndKeyPair) throws Exception + { + ProtocolFactory protocolFactory = new ProtocolFactory(PRE_SHARED_KEY_PROVIDER, + ReceiverPrivateKeyProvider.of(RECEIVER_KEY_ID, protocolAndKeyPair.keyPair().getPrivate()), + List.of(ProtocolFactory.V1_SERIALIZER, TEST_SERIALIZER)) + { + }; + Hpke hpke = new Hpke(protocolFactory); + + ZeroInputStream source = new ZeroInputStream(protocolAndKeyPair.protocol().getChunkLength().getLength() + 1); + byte[] encrypted = hpke.encrypt(protocolAndKeyPair.protocol(), source, protocolAndKeyPair.keyPair().getPublic()) + .readAllBytes(); + + logger.debug("{}", protocolAndKeyPair.toString()); + + assertDoesNotThrow(() -> hpke.decrypt(new ByteArrayInputStream(encrypted), OutputStream.nullOutputStream())); + + AtomicInteger index = new AtomicInteger(); + + // Magic + byte[] m0 = encrypted.clone(); + m0[index.get()] = (byte) (m0[index.get()] ^ (byte) 0x01); + expectException(hpke, m0, IOException.class); + index.getAndUpdate(i -> i += 5); + + // Version + byte[] m5Invalid = encrypted.clone(); + m5Invalid[index.get()] = (byte) 0xFF; + expectException(hpke, m5Invalid, IOException.class); + byte[] m5Version2 = encrypted.clone(); + m5Version2[index.get()] = (byte) 0x02; + expectException(hpke, m5Version2, IOException.class); + index.getAndUpdate(i -> i += 1); + + // Mode + byte[] m6invalid = encrypted.clone(); + m6invalid[index.get()] = (byte) 0xFF; + expectException(hpke, m6invalid, IOException.class); + byte[] m6other = encrypted.clone(); + m6other[index + .get()] = (byte) (protocolAndKeyPair.protocol().getMode().isPsk() ? Mode.BASE_VALUE : Mode.PSK_VALUE); + expectException(hpke, m6other, IOException.class, KeyNotFoundException.class, DecapsulateException.class); + index.getAndUpdate(i -> i += 1); + + // KemId + byte[] m7invalid = encrypted.clone(); + m7invalid[index.get()] = (byte) 0xFE; + m7invalid[index.get() + 1] = (byte) 0xFF; + expectException(hpke, m7invalid, IOException.class); + EnumSet.complementOf(EnumSet.of(protocolAndKeyPair.protocol().getKemId())).stream() + .map(KemId::getIdAsI2osp2Bytes).forEach(other -> + { + byte[] m7other = encrypted.clone(); + m7other[index.get()] = other[0]; + m7other[index.get() + 1] = other[1]; + expectException(hpke, m7other, KeyNotSupportedException.class); + }); + index.getAndUpdate(i -> i += 2); + + // KdfId + byte[] m9invalid = encrypted.clone(); + m9invalid[index.get()] = (byte) 0xFF; + m9invalid[index.get() + 1] = (byte) 0xFF; + expectException(hpke, m9invalid, IOException.class); + EnumSet.complementOf(EnumSet.of(protocolAndKeyPair.protocol().getKdfId())).stream() + .map(KdfId::getIdAsI2osp2Bytes).forEach(other -> + { + byte[] m9other = encrypted.clone(); + m9other[index.get()] = other[0]; + m9other[index.get() + 1] = other[1]; + expectException(hpke, m9other, IOException.class); + }); + index.getAndUpdate(i -> i += 2); + + // AeadId + byte[] m11invalid = encrypted.clone(); + m11invalid[index.get()] = (byte) 0xFF; + m11invalid[index.get() + 1] = (byte) 0xFF; + expectException(hpke, m11invalid, IOException.class); + EnumSet.complementOf(EnumSet.of(protocolAndKeyPair.protocol().getAeadId())).stream() + .map(AeadId::getIdAsI2osp2Bytes).forEach(other -> + { + byte[] m11other = encrypted.clone(); + m11other[index.get()] = other[0]; + m11other[index.get() + 1] = other[1]; + expectException(hpke, m11other, IOException.class); + }); + index.getAndUpdate(i -> i += 2); + + // Chunk length + byte[] m13invalid = encrypted.clone(); + m13invalid[index.get()] = (byte) 0xFF; + expectException(hpke, m13invalid, IOException.class); + EnumSet.complementOf(EnumSet.of(protocolAndKeyPair.protocol().getChunkLength())).stream() + .map(ChunkLength::getExponentAsI2osp1Byte).forEach(other -> + { + byte[] m11other = encrypted.clone(); + m11other[index.get()] = other[0]; + expectException(hpke, m11other, IOException.class); + }); + index.getAndUpdate(i -> i += 1); + + // Receiver key ID + byte[] m14 = encrypted.clone(); + m14[index.get()] = (byte) (m14[index.get()] ^ (byte) 0x01); + expectException(hpke, m14, KeyNotFoundException.class); + index.getAndUpdate(i -> i += ProtocolV1.RECEIVER_KEY_ID_LENGTH); + + // Pre shared key + if (protocolAndKeyPair.protocol().getMode().isPsk()) + { + byte[] m46 = encrypted.clone(); + m46[index.get()] = (byte) (m46[index.get()] ^ (byte) 0x01); + expectException(hpke, m46, KeyNotFoundException.class); + index.getAndUpdate(i -> i += ProtocolV1.PRE_SHARED_KEY_ID_LENGTH); + } + + // Encapsulation + byte[] mEnc = encrypted.clone(); + mEnc[index.get()] = (byte) (mEnc[index.get()] ^ (byte) 0x01); + expectException(hpke, mEnc, IOException.class, DecapsulateException.class); + index.getAndUpdate(i -> i += protocolAndKeyPair.protocol().getKemId().getEncapsulationLength()); + + // First Chunk + byte[] mC0 = encrypted.clone(); + mC0[index.get()] = (byte) (mC0[index.get()] ^ (byte) 0x01); + expectException(hpke, mC0, IOException.class); + } + + private void expectException(Hpke hpke, byte[] modified, Class... expected) + { + try + { + hpke.decrypt(new ByteArrayInputStream(modified), OutputStream.nullOutputStream()); + fail("Exception expected"); + } + catch (IOException | GeneralSecurityException | KeyNotFoundException | KeyNotSupportedException e) + { + if (!List.of(expected).contains(e.getClass())) + fail("Exception of type " + e.getClass().getName() + " (message: " + e.getMessage() + ") not expected"); + } + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KdfIdTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KdfIdTest.java new file mode 100644 index 0000000..fbd34ef --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KdfIdTest.java @@ -0,0 +1,67 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import java.util.stream.Stream; + +import javax.crypto.KDF; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class KdfIdTest +{ + private static Stream forTestFrom() + { + return Stream.of(Arguments.of(KdfId.HKDF_SHA256, 0x0001), Arguments.of(KdfId.HKDF_SHA384, 0x0002), + Arguments.of(KdfId.HKDF_SHA512, 0x0003)); + } + + @ParameterizedTest + @MethodSource("forTestFrom") + void testFrom(KdfId expected, int id) throws Exception + { + assertEquals(expected, KdfId.from(new byte[] { (byte) (id >>> 8), (byte) id })); + } + + private static Stream forTestFromInvalid() + { + return Stream.of(Arguments.of(null, NullPointerException.class, "value"), + Arguments.of(new byte[0], IllegalArgumentException.class, "value.length not 2"), + Arguments.of(new byte[1], IllegalArgumentException.class, "value.length not 2"), + Arguments.of(new byte[2], IllegalArgumentException.class, "KdfId not supported")); + } + + @ParameterizedTest + @MethodSource("forTestFromInvalid") + void testFromInvalid(byte[] invalid, Class exceptionClass, String exceptionMessage) + throws Exception + { + Exception exception = assertThrowsExactly(exceptionClass, () -> KdfId.from(invalid)); + assertEquals(exceptionMessage, exception.getMessage()); + } + + private static Stream forTestGetter() + { + return Stream.of(Arguments.of(0x0001, "HKDF-SHA256", KdfId.HKDF_SHA256), + Arguments.of(0x0002, "HKDF-SHA384", KdfId.HKDF_SHA384), + Arguments.of(0x0003, "HKDF-SHA512", KdfId.HKDF_SHA512)); + } + + @ParameterizedTest + @MethodSource("forTestGetter") + void testGetter(int expectedId, String expectedAlgorithm, KdfId kdfId) throws Exception + { + assertEquals(expectedId, kdfId.getId()); + assertArrayEquals(new byte[] { (byte) (expectedId >>> 8), (byte) expectedId }, kdfId.getIdAsI2osp2Bytes()); + assertEquals(expectedAlgorithm, kdfId.getAlgorithm()); + + KDF kdf = kdfId.toKdf(); + assertNotNull(kdf); + assertEquals(expectedAlgorithm, kdf.getAlgorithm()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KemIdTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KemIdTest.java new file mode 100644 index 0000000..dede8a1 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KemIdTest.java @@ -0,0 +1,121 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.KeyPair; +import java.util.EnumSet; +import java.util.stream.Stream; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import de.hsheilbronn.mi.utils.crypto.keypair.KeyPairGeneratorFactory; + +public class KemIdTest +{ + private static Stream forTestIsKeySupported() + { + return Stream.of( + Arguments.of(KemId.DHKEM_P256_HKDF_SHA256, + KeyPairGeneratorFactory.secp256r1().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_P384_HKDF_SHA384, + KeyPairGeneratorFactory.secp384r1().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_P521_HKDF_SHA512, + KeyPairGeneratorFactory.secp521r1().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_X25519_HKDF_SHA256, + KeyPairGeneratorFactory.x25519().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_X448_HKDF_SHA512, + KeyPairGeneratorFactory.x448().initialize().generateKeyPair()), + Arguments.of(KemId.RSAKEM_1024_KDF2_SHA256, + KeyPairGeneratorFactory.rsa1024().initialize().generateKeyPair()), + Arguments.of(KemId.RSAKEM_2048_KDF2_SHA256, + KeyPairGeneratorFactory.rsa2048().initialize().generateKeyPair()), + Arguments.of(KemId.RSAKEM_3072_KDF2_SHA512, + KeyPairGeneratorFactory.rsa3072().initialize().generateKeyPair()), + Arguments.of(KemId.RSAKEM_4096_KDF2_SHA512, + KeyPairGeneratorFactory.rsa4096().initialize().generateKeyPair())); + } + + @ParameterizedTest + @MethodSource("forTestIsKeySupported") + void testIsKeySupported(KemId positive, KeyPair pair) throws Exception + { + assertTrue(positive.isKeySupported(pair.getPrivate())); + assertTrue(positive.isKeySupported(pair.getPublic())); + + EnumSet.complementOf(EnumSet.of(positive)).forEach(n -> + { + assertFalse(n.isKeySupported(pair.getPrivate()), n.name()); + assertFalse(n.isKeySupported(pair.getPublic()), n.name()); + }); + } + + private static Stream forTestFrom() + { + return Stream.of(Arguments.of(KemId.DHKEM_P256_HKDF_SHA256, 0x0010), + Arguments.of(KemId.DHKEM_P384_HKDF_SHA384, 0x0011), Arguments.of(KemId.DHKEM_P521_HKDF_SHA512, 0x0012), + Arguments.of(KemId.DHKEM_X25519_HKDF_SHA256, 0x0020), + Arguments.of(KemId.DHKEM_X448_HKDF_SHA512, 0x0021), Arguments.of(KemId.RSAKEM_1024_KDF2_SHA256, 0xFF10), + Arguments.of(KemId.RSAKEM_2048_KDF2_SHA256, 0xFF11), + Arguments.of(KemId.RSAKEM_3072_KDF2_SHA512, 0xFF12), + Arguments.of(KemId.RSAKEM_4096_KDF2_SHA512, 0xFF13)); + } + + @ParameterizedTest + @MethodSource("forTestFrom") + void testFrom(KemId expected, int id) throws Exception + { + assertEquals(expected, KemId.from(new byte[] { (byte) (id >>> 8), (byte) id })); + } + + private static Stream forTestFromInvalid() + { + return Stream.of(Arguments.of(null, NullPointerException.class, "value"), + Arguments.of(new byte[0], IllegalArgumentException.class, "value.length not 2"), + Arguments.of(new byte[1], IllegalArgumentException.class, "value.length not 2"), + Arguments.of(new byte[2], IllegalArgumentException.class, "KemId not supported")); + } + + @ParameterizedTest + @MethodSource("forTestFromInvalid") + void testFromInvalid(byte[] invalid, Class exceptionClass, String exceptionMessage) + throws Exception + { + Exception exception = assertThrowsExactly(exceptionClass, () -> KemId.from(invalid)); + assertEquals(exceptionMessage, exception.getMessage()); + } + + private static Stream forTestGetter() + { + return Stream.of(Arguments.of(0x0010, 32, 65, DhKemWrapper.class, KemId.DHKEM_P256_HKDF_SHA256), + Arguments.of(0x0011, 48, 97, DhKemWrapper.class, KemId.DHKEM_P384_HKDF_SHA384), + Arguments.of(0x0012, 64, 133, DhKemWrapper.class, KemId.DHKEM_P521_HKDF_SHA512), + Arguments.of(0x0020, 32, 32, DhKemWrapper.class, KemId.DHKEM_X25519_HKDF_SHA256), + Arguments.of(0x0021, 64, 56, DhKemWrapper.class, KemId.DHKEM_X448_HKDF_SHA512), + Arguments.of(0xFF10, 32, 128, RsaKemWrapper.class, KemId.RSAKEM_1024_KDF2_SHA256), + Arguments.of(0xFF11, 32, 256, RsaKemWrapper.class, KemId.RSAKEM_2048_KDF2_SHA256), + Arguments.of(0xFF12, 64, 384, RsaKemWrapper.class, KemId.RSAKEM_3072_KDF2_SHA512), + Arguments.of(0xFF13, 64, 512, RsaKemWrapper.class, KemId.RSAKEM_4096_KDF2_SHA512)); + } + + @ParameterizedTest + @MethodSource("forTestGetter") + void testGetter(int expectedId, int expectedSharedSecretLength, int expectedEncapsulationLength, + Class expectedKemWrapperClass, KemId kemId) throws Exception + { + assertEquals(expectedId, kemId.getId()); + assertArrayEquals(new byte[] { (byte) (expectedId >>> 8), (byte) expectedId }, kemId.getIdAsI2osp2Bytes()); + assertEquals(expectedSharedSecretLength, kemId.getSharedSecretLength()); + assertEquals(expectedEncapsulationLength, kemId.getEncapsulationLength()); + + KemWrapper kem = kemId.toKem(); + assertNotNull(kem); + assertEquals(expectedKemWrapperClass, kem.getClass()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KemWrapperTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KemWrapperTest.java new file mode 100644 index 0000000..4b71715 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KemWrapperTest.java @@ -0,0 +1,71 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.security.KeyPair; +import java.security.SecureRandom; +import java.util.stream.Stream; + +import javax.crypto.KEM.Encapsulated; +import javax.crypto.SecretKey; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import de.hsheilbronn.mi.utils.crypto.keypair.KeyPairGeneratorFactory; + +public class KemWrapperTest +{ + private static final SecureRandom SECURE_RANDDOM = new SecureRandom(); + + private static Stream forTestKem() + { + return Stream.of( + Arguments.of(KemId.DHKEM_P256_HKDF_SHA256, + KeyPairGeneratorFactory.secp256r1().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_P384_HKDF_SHA384, + KeyPairGeneratorFactory.secp384r1().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_P521_HKDF_SHA512, + KeyPairGeneratorFactory.secp521r1().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_X25519_HKDF_SHA256, + KeyPairGeneratorFactory.x25519().initialize().generateKeyPair()), + Arguments.of(KemId.DHKEM_X448_HKDF_SHA512, + KeyPairGeneratorFactory.x448().initialize().generateKeyPair()), + Arguments.of(KemId.RSAKEM_1024_KDF2_SHA256, + KeyPairGeneratorFactory.rsa1024().initialize().generateKeyPair()), + Arguments.of(KemId.RSAKEM_2048_KDF2_SHA256, + KeyPairGeneratorFactory.rsa2048().initialize().generateKeyPair()), + Arguments.of(KemId.RSAKEM_3072_KDF2_SHA512, + KeyPairGeneratorFactory.rsa3072().initialize().generateKeyPair()), + Arguments.of(KemId.RSAKEM_4096_KDF2_SHA512, + KeyPairGeneratorFactory.rsa4096().initialize().generateKeyPair())); + } + + @ParameterizedTest + @MethodSource("forTestKem") + void testKem(KemId kemId, KeyPair keyPair) throws Exception + { + KemWrapper kem = kemId.toKem(); + assertNotNull(kem); + + Encapsulated encapsulated = kem.getEncapsulated(keyPair.getPublic(), SECURE_RANDDOM); + assertNotNull(encapsulated); + + byte[] encapsulation = encapsulated.encapsulation(); + assertNotNull(encapsulation); + assertEquals(kemId.getEncapsulationLength(), encapsulation.length); + + SecretKey sharedSecretSender = encapsulated.key(); + assertNotNull(sharedSecretSender); + assertEquals(kemId.getSharedSecretLength(), sharedSecretSender.getEncoded().length); + + SecretKey sharedSecretReceiver = kem.getSharedSecret(keyPair.getPrivate(), encapsulation); + assertNotNull(sharedSecretReceiver); + assertEquals(kemId.getSharedSecretLength(), sharedSecretReceiver.getEncoded().length); + + assertArrayEquals(sharedSecretSender.getEncoded(), sharedSecretReceiver.getEncoded()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyProviderTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyProviderTest.java new file mode 100644 index 0000000..f2ba37f --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyProviderTest.java @@ -0,0 +1,110 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.util.Map; +import java.util.stream.Stream; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import de.hsheilbronn.mi.utils.crypto.keypair.KeyPairGeneratorFactory; + +public class KeyProviderTest +{ + private static final byte[] PSK_ID_1 = Sha256 + .digest("Test Pre Shared Key ID 1".getBytes(StandardCharsets.US_ASCII)); + private static final byte[] PSK_ID_2 = Sha256 + .digest("Test Pre Shared Key ID 2".getBytes(StandardCharsets.US_ASCII)); + private static final SecretKey PSK_1 = new SecretKeySpec( + new byte[] { 'T', 'e', 's', 't', ' ', 'P', 'S', 'K', ' ', '1' }, "Generic"); + private static final SecretKey PSK_2 = new SecretKeySpec( + new byte[] { 'T', 'e', 's', 't', ' ', 'P', 'S', 'K', ' ', '2' }, "Generic"); + + private static final KeyPairGenerator RK_GENERATOR = KeyPairGeneratorFactory.rsa1024().initialize(); + + private static final byte[] RK_ID_1 = Sha256.digest("Test Receiver Key ID 1".getBytes(StandardCharsets.US_ASCII)); + private static final PrivateKey RK_1 = RK_GENERATOR.generateKeyPair().getPrivate(); + private static final byte[] RK_ID_2 = Sha256.digest("Test Receiver Key ID 2".getBytes(StandardCharsets.US_ASCII)); + private static final PrivateKey RK_2 = RK_GENERATOR.generateKeyPair().getPrivate(); + + private static Stream forTestOf() + { + return Stream.of(Arguments.of(PreSharedKeyProvider.of(), KeyProvider.PSK, PSK_ID_1, PSK_ID_2), + Arguments.of(ReceiverPrivateKeyProvider.of(), KeyProvider.RECEIVER_KEY_ID, RK_ID_1, RK_ID_2)); + } + + @ParameterizedTest + @MethodSource("forTestOf") + void testOf(KeyProvider provider, String type, byte[] id1, byte[] id2) throws Exception + { + testNotFound(provider, type, id1); + testNotFound(provider, type, id2); + testNotFound(provider, type, new byte[0]); + testNotFound(provider, type, null); + } + + private static Stream forTestOf1() + { + return Stream.of( + Arguments.of(PreSharedKeyProvider.of(PSK_ID_1, PSK_1), KeyProvider.PSK, PSK_ID_1, PSK_1, PSK_ID_2), + Arguments.of(ReceiverPrivateKeyProvider.of(RK_ID_1, RK_1), KeyProvider.RECEIVER_KEY_ID, RK_ID_1, RK_1, + RK_ID_2)); + } + + @ParameterizedTest + @MethodSource("forTestOf1") + void testOf1(KeyProvider provider, String type, byte[] id1, K key1, byte[] id2) throws Exception + { + testFound(provider, id1, key1); + + testNotFound(provider, type, id2); + testNotFound(provider, type, new byte[0]); + testNotFound(provider, type, null); + } + + private static Stream forTestOf2() + { + return Stream.of( + Arguments.of(PreSharedKeyProvider.of(Map.of(PSK_ID_1, PSK_1, PSK_ID_2, PSK_2)), KeyProvider.PSK, + PSK_ID_1, PSK_1, PSK_ID_2, PSK_2), + Arguments.of(ReceiverPrivateKeyProvider.of(Map.of(RK_ID_1, RK_1, RK_ID_2, RK_2)), + KeyProvider.RECEIVER_KEY_ID, RK_ID_1, RK_1, RK_ID_2, RK_2)); + } + + @ParameterizedTest + @MethodSource("forTestOf2") + void testOf2(KeyProvider provider, String type, byte[] id1, K key1, byte[] id2, K key2) + throws Exception + { + testFound(provider, id1, key1); + testFound(provider, id2, key2); + + testNotFound(provider, type, new byte[0]); + testNotFound(provider, type, null); + } + + private void testFound(KeyProvider provider, byte[] pskId, K expected) + throws KeyNotFoundException + { + Key k = provider.retrieve(pskId); + assertNotNull(k); + assertEquals(expected, k); + } + + private void testNotFound(KeyProvider provider, String type, byte[] pskId) + { + KeyNotFoundException e = assertThrowsExactly(KeyNotFoundException.class, () -> provider.retrieve(pskId)); + assertEquals(KeyProvider.notFound(type, pskId).getMessage(), e.getMessage()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyScheduleTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyScheduleTest.java new file mode 100644 index 0000000..6806ba9 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/KeyScheduleTest.java @@ -0,0 +1,232 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigInteger; +import java.security.AlgorithmParameters; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPrivateKeySpec; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.InvalidParameterSpecException; +import java.security.spec.NamedParameterSpec; +import java.security.spec.XECPrivateKeySpec; +import java.util.HexFormat; +import java.util.stream.Stream; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import de.hsheilbronn.mi.utils.crypto.hpke.KeySchedule.Result; + +public class KeyScheduleTest +{ + private static record Rfc9180TestData(String name, Mode mode, KemId kemId, KdfId kdfId, AeadId aeadId, + PrivateKey skRm, byte[] enc, byte[] sharedSecret, byte[] key, byte[] baseNonce) + { + public static Rfc9180TestData withX25519(String name, Mode mode, KemId kemId, KdfId kdfId, AeadId aeadId, + String skRm, String enc, String sharedSecret, String key, String baseNonce) + { + return new Rfc9180TestData(name, mode, kemId, kdfId, aeadId, toX25519PrivateKey(skRm), + HexFormat.of().parseHex(enc), HexFormat.of().parseHex(sharedSecret), HexFormat.of().parseHex(key), + HexFormat.of().parseHex(baseNonce)); + } + + private static PrivateKey toX25519PrivateKey(String hex) + { + try + { + XECPrivateKeySpec spec = new XECPrivateKeySpec(NamedParameterSpec.X25519, HexFormat.of().parseHex(hex)); + return KeyFactory.getInstance("X25519").generatePrivate(spec); + } + catch (InvalidKeySpecException | NoSuchAlgorithmException e) + { + throw new RuntimeException(e); + } + } + + public static Rfc9180TestData withSecp256r1(String name, Mode mode, KemId kemId, KdfId kdfId, AeadId aeadId, + String skRm, String enc, String sharedSecret, String key, String baseNonce) + { + return new Rfc9180TestData(name, mode, kemId, kdfId, aeadId, toEcPrivateKey("secp256r1", skRm), + HexFormat.of().parseHex(enc), HexFormat.of().parseHex(sharedSecret), HexFormat.of().parseHex(key), + HexFormat.of().parseHex(baseNonce)); + } + + public static Rfc9180TestData withSecp521r1(String name, Mode mode, KemId kemId, KdfId kdfId, AeadId aeadId, + String skRm, String enc, String sharedSecret, String key, String baseNonce) + { + return new Rfc9180TestData(name, mode, kemId, kdfId, aeadId, toEcPrivateKey("secp521r1", skRm), + HexFormat.of().parseHex(enc), HexFormat.of().parseHex(sharedSecret), HexFormat.of().parseHex(key), + HexFormat.of().parseHex(baseNonce)); + } + + private static PrivateKey toEcPrivateKey(String curve, String hex) + { + try + { + AlgorithmParameters params = AlgorithmParameters.getInstance("EC"); + params.init(new ECGenParameterSpec(curve)); + ECParameterSpec ecParams = params.getParameterSpec(ECParameterSpec.class); + ECPrivateKeySpec spec = new ECPrivateKeySpec(new BigInteger(1, HexFormat.of().parseHex(hex)), ecParams); + return KeyFactory.getInstance("EC").generatePrivate(spec); + } + catch (InvalidKeySpecException | NoSuchAlgorithmException | InvalidParameterSpecException e) + { + throw new RuntimeException(e); + } + } + + @Override + public final String toString() + { + return name; + } + } + + // Test Data from https://www.rfc-editor.org/rfc/rfc9180.html#name-test-vectors + + private static final byte[] INFO = HexFormat.of().parseHex("4f6465206f6e2061204772656369616e2055726e"); + + private static final byte[] PSK_ID = HexFormat.of().parseHex("456e6e796e20447572696e206172616e204d6f726961"); + private static final SecretKey PSK = new SecretKeySpec( + HexFormat.of().parseHex("0247fd33b913760fa1fa51e1892d9f307fbe65eb171e8132c2af18555a738b82"), "Generic"); + private static final PreSharedKeyProvider PSK_PROVIDER = PreSharedKeyProvider.of(PSK_ID, PSK); + + private static final Rfc9180TestData A11 = Rfc9180TestData.withX25519("A.1.1", Mode.base(), + KemId.DHKEM_X25519_HKDF_SHA256, KdfId.HKDF_SHA256, AeadId.AES_128_GCM, + "4612c550263fc8ad58375df3f557aac531d26850903e55a9f23f21d8534e8ac8", + "37fda3567bdbd628e88668c3c8d7e97d1d1253b6d4ea6d44c150f741f1bf4431", + "fe0e18c9f024ce43799ae393c7e8fe8fce9d218875e8227b0187c04e7d2ea1fc", // + "4531685d41d65f03dc48f6b8302c05b0", "56d890e5accaaf011cff4b7d"); + private static final Rfc9180TestData A12 = Rfc9180TestData.withX25519("A.1.2", Mode.psk(PSK_ID), + KemId.DHKEM_X25519_HKDF_SHA256, KdfId.HKDF_SHA256, AeadId.AES_128_GCM, + "c5eb01eb457fe6c6f57577c5413b931550a162c71a03ac8d196babbd4e5ce0fd", + "0ad0950d9fb9588e59690b74f1237ecdf1d775cd60be2eca57af5a4b0471c91b", + "727699f009ffe3c076315019c69648366b69171439bd7dd0807743bde76986cd", // + "15026dba546e3ae05836fc7de5a7bb26", "9518635eba129d5ce0914555"); + + private static final Rfc9180TestData A21 = Rfc9180TestData.withX25519("A.2.1", Mode.base(), + KemId.DHKEM_X25519_HKDF_SHA256, KdfId.HKDF_SHA256, AeadId.ChaCha20Poly1305, + "8057991eef8f1f1af18f4a9491d16a1ce333f695d4db8e38da75975c4478e0fb", + "1afa08d3dec047a643885163f1180476fa7ddb54c6a8029ea33f95796bf2ac4a", + "0bbe78490412b4bbea4812666f7916932b828bba79942424abb65244930d69a7", + "ad2744de8e17f4ebba575b3f5f5a8fa1f69c2a07f6e7500bc60ca6e3e3ec1c91", "5c4d98150661b848853b547f"); + private static final Rfc9180TestData A22 = Rfc9180TestData.withX25519("A.2.2", Mode.psk(PSK_ID), + KemId.DHKEM_X25519_HKDF_SHA256, KdfId.HKDF_SHA256, AeadId.ChaCha20Poly1305, + "77d114e0212be51cb1d76fa99dd41cfd4d0166b08caa09074430a6c59ef17879", + "2261299c3f40a9afc133b969a97f05e95be2c514e54f3de26cbe5644ac735b04", + "4be079c5e77779d0215b3f689595d59e3e9b0455d55662d1f3666ec606e50ea7", + "600d2fdb0313a7e5c86a9ce9221cd95bed069862421744cfb4ab9d7203a9c019", "112e0465562045b7368653e7"); + + private static final Rfc9180TestData A31 = Rfc9180TestData.withSecp256r1("A.3.1", Mode.base(), + KemId.DHKEM_P256_HKDF_SHA256, KdfId.HKDF_SHA256, AeadId.AES_128_GCM, + "f3ce7fdae57e1a310d87f1ebbde6f328be0a99cdbcadf4d6589cf29de4b8ffd2", + "04a92719c6195d5085104f469a8b9814d5838ff72b60501e2c4466e5e67b325" + + "ac98536d7b61a1af4b78e5b7f951c0900be863c403ce65c9bfcb9382657222d18c4", + "c0d26aeab536609a572b07695d933b589dcf363ff9d93c93adea537aeabb8cb8", // + "868c066ef58aae6dc589b6cfdd18f97e", "4e0bc5018beba4bf004cca59"); + private static final Rfc9180TestData A32 = Rfc9180TestData.withSecp256r1("A.3.2", Mode.psk(PSK_ID), + KemId.DHKEM_P256_HKDF_SHA256, KdfId.HKDF_SHA256, AeadId.AES_128_GCM, + "438d8bcef33b89e0e9ae5eb0957c353c25a94584b0dd59c991372a75b43cb661", + "04305d35563527bce037773d79a13deabed0e8e7cde61eecee403496959e89e" + + "4d0ca701726696d1485137ccb5341b3c1c7aaee90a4a02449725e744b1193b53b5f", + "2e783ad86a1beae03b5749e0f3f5e9bb19cb7eb382f2fb2dd64c99f15ae0661b", // + "55d9eb9d26911d4c514a990fa8d57048", "b595dc6b2d7e2ed23af529b1"); + + private static final Rfc9180TestData A41 = Rfc9180TestData.withSecp256r1("A.4.1", Mode.base(), + KemId.DHKEM_P256_HKDF_SHA256, KdfId.HKDF_SHA512, AeadId.AES_128_GCM, + "3ac8530ad1b01885960fab38cf3cdc4f7aef121eaa239f222623614b4079fb38", + "0493ed86735bdfb978cc055c98b45695ad7ce61ce748f4dd63c525a3b8d53a1" + + "5565c6897888070070c1579db1f86aaa56deb8297e64db7e8924e72866f9a472580", + "02f584736390fc93f5b4ad039826a3fa08e9911bd1215a3db8e8791ba533cafd", // + "090ca96e5f8aa02b69fac360da50ddf9", "9c995e621bf9a20c5ca45546"); + private static final Rfc9180TestData A42 = Rfc9180TestData.withSecp256r1("A.4.2", Mode.psk(PSK_ID), + KemId.DHKEM_P256_HKDF_SHA256, KdfId.HKDF_SHA512, AeadId.AES_128_GCM, + "bc6f0b5e22429e5ff47d5969003f3cae0f4fec50e23602e880038364f33b8522", + "04a307934180ad5287f95525fe5bc6244285d7273c15e061f0f2efb211c3505" + + "7f3079f6e0abae200992610b25f48b63aacfcb669106ddee8aa023feed301901371", + "2912aacc6eaebd71ff715ea50f6ef3a6637856b2a4c58ea61e0c3fc159e3bc16", // + "0b910ba8d9cfa17e5f50c211cb32839a", "0c29e714eb52de5b7415a1b7"); + + private static final Rfc9180TestData A51 = Rfc9180TestData.withSecp256r1("A.5.1", Mode.base(), + KemId.DHKEM_P256_HKDF_SHA256, KdfId.HKDF_SHA256, AeadId.ChaCha20Poly1305, + "a4d1c55836aa30f9b3fbb6ac98d338c877c2867dd3a77396d13f68d3ab150d3b", + "04c07836a0206e04e31d8ae99bfd549380b072a1b1b82e563c935c095827824" + + "fc1559eac6fb9e3c70cd3193968994e7fe9781aa103f5b50e934b5b2f387e381291", + "806520f82ef0b03c823b7fc524b6b55a088f566b9751b89551c170f4113bd850", + "a8f45490a92a3b04d1dbf6cf2c3939ad8bfc9bfcb97c04bffe116730c9dfe3fc", "726b4390ed2209809f58c693"); + private static final Rfc9180TestData A52 = Rfc9180TestData.withSecp256r1("A.5.2", Mode.psk(PSK_ID), + KemId.DHKEM_P256_HKDF_SHA256, KdfId.HKDF_SHA256, AeadId.ChaCha20Poly1305, + "12ecde2c8bc2d5d7ed2219c71f27e3943d92b344174436af833337c557c300b3", + "04f336578b72ad7932fe867cc4d2d44a718a318037a0ec271163699cee653fa" + + "805c1fec955e562663e0c2061bb96a87d78892bff0cc0bad7906c2d998ebe1a7246", + "ac4f260dce4db6bf45435d9c92c0e11cfdd93743bd3075949975974cc2b3d79e", + "6d61cb330b7771168c8619498e753f16198aad9566d1f1c6c70e2bc1a1a8b142", "0de7655fb65e1cd51a38864e"); + + private static final Rfc9180TestData A61 = Rfc9180TestData.withSecp521r1("A.6.1", Mode.base(), + KemId.DHKEM_P521_HKDF_SHA512, KdfId.HKDF_SHA512, AeadId.AES_256_GCM, + "01462680369ae375e4b3791070a7458ed527842f6a98a79ff5e0d4cbde83c2" + + "7196a3916956655523a6a2556a7af62c5cadabe2ef9da3760bb21e005202f7b24628" // + + "47", + "040138b385ca16bb0d5fa0c0665fbbd7e69e3ee29f63991d3e9b5fa740aab89" + + "00aaeed46ed73a49055758425a0ce36507c54b29cc5b85a5cee6bae0cf1c21f2731e" + + "ce2013dc3fb7c8d21654bb161b463962ca19e8c654ff24c94dd2898de12051f1ed06" + + "92237fb02b2f8d1dc1c73e9b366b529eb436e98a996ee522aef863dd5739d2f29b0", + "776ab421302f6eff7d7cb5cb1adaea0cd50872c71c2d63c30c4f1" + + "d5e43653336fef33b103c67e7a98add2d3b66e2fda95b5b2a667aa9dac7e59cc1d46" // + + "d30e818", + "751e346ce8f0ddb2305c8a2a85c70d5cf559c53093656be636b9406d4d7d1b70", "55ff7a7d739c69f44b25447b"); + private static final Rfc9180TestData A62 = Rfc9180TestData.withSecp521r1("A.6.2", Mode.psk(PSK_ID), + KemId.DHKEM_P521_HKDF_SHA512, KdfId.HKDF_SHA512, AeadId.AES_256_GCM, + "011bafd9c7a52e3e71afbdab0d2f31b03d998a0dc875dd7555c63560e142bd" + + "e264428de03379863b4ec6138f813fa009927dc5d15f62314c56d4e7ff2b485753eb" // + + "72", + "040085eff0835cc84351f32471d32aa453cdc1f6418eaaecf1c2824210eb1d4" + + "8d0768b368110fab21407c324b8bb4bec63f042cfa4d0868d19b760eb4beba1bff79" + + "3b30036d2c614d55730bd2a40c718f9466faf4d5f8170d22b6df98dfe0c067d02b34" + + "9ae4a142e0c03418f0a1479ff78a3db07ae2c2e89e5840f712c174ba2118e90fdcb", + "0d52de997fdaa4797720e8b1bebd3df3d03c4cf38cc8c1398168d" + + "36c3fc7626428c9c254dd3f9274450909c64a5b3acbe45e2d850a2fd69ac0605fe5c" // + + "8a057a5", + "f764a5a4b17e5d1ffba6e699d65560497ebaea6eb0b0d9010a6d979e298a39ff", "479afdf3546ddba3a9841f38"); + + private static Stream forTestExecuteKeySchedule() + { + return Stream.of(Arguments.of(A11), Arguments.of(A12), Arguments.of(A21), Arguments.of(A22), Arguments.of(A31), + Arguments.of(A32), Arguments.of(A41), Arguments.of(A42), Arguments.of(A51), Arguments.of(A52), + Arguments.of(A61), Arguments.of(A62)); + } + + @ParameterizedTest + @MethodSource("forTestExecuteKeySchedule") + void testExecuteKeySchedule(Rfc9180TestData testData) throws Exception + { + KemWrapper kem = testData.kemId().toKem(); + assertNotNull(kem); + + SecretKey sharedSecret = kem.getSharedSecret(testData.skRm(), testData.enc()); + assertNotNull(sharedSecret); + assertArrayEquals(testData.sharedSecret(), sharedSecret.getEncoded()); + + KeySchedule keySchedule = new KeySchedule(testData.mode(), testData.kemId(), testData.kdfId(), + testData.aeadId(), INFO, PSK_PROVIDER); + + Result result = keySchedule.executeKeySchedule(sharedSecret); + assertNotNull(result); + + assertNotNull(result.key()); + assertNotNull(result.baseNonce()); + + assertArrayEquals(testData.key(), result.key().getEncoded()); + assertArrayEquals(testData.baseNonce(), result.baseNonce()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ModeTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ModeTest.java new file mode 100644 index 0000000..517f0f0 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ModeTest.java @@ -0,0 +1,100 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class ModeTest +{ + private static final byte[] PSK_ID = Sha256.digest("Test PSK Identifier".getBytes(StandardCharsets.US_ASCII)); + + @Test + void testPskFactoryMethods() throws Exception + { + assertDoesNotThrow(() -> Mode.base()); + assertDoesNotThrow(() -> Mode.psk(PSK_ID)); + assertThrowsExactly(NullPointerException.class, () -> Mode.psk(null)); + + IllegalArgumentException e = assertThrowsExactly(IllegalArgumentException.class, () -> Mode.psk(new byte[0])); + assertEquals("pskId.length <= 0", e.getMessage()); + } + + @Test + @SuppressWarnings("unlikely-arg-type") // equals string + void testBase() throws Exception + { + Mode base = Mode.base(); + assertArrayEquals(new byte[] { (byte) 0x00 }, base.getValueAsI2osp1Byte()); + assertArrayEquals(new byte[0], base.getPskId()); + + assertFalse(base.isPsk()); + + assertEquals(base.hashCode(), Mode.base().hashCode()); + assertTrue(base.equals(base)); + assertTrue(base.equals(Mode.base())); + assertFalse(base.equals(Mode.psk(PSK_ID))); + + assertFalse(base.equals(null)); + assertFalse(base.equals("")); + } + + @Test + @SuppressWarnings("unlikely-arg-type") // equals string + void testPsk() throws Exception + { + Mode psk = Mode.psk(PSK_ID); + assertArrayEquals(new byte[] { (byte) 0x01 }, psk.getValueAsI2osp1Byte()); + assertArrayEquals(PSK_ID, psk.getPskId()); + + assertTrue(psk.isPsk()); + + assertEquals(psk.hashCode(), Mode.psk(PSK_ID).hashCode()); + assertTrue(psk.equals(psk)); + assertTrue(psk.equals(Mode.psk(PSK_ID))); + assertFalse(psk.equals(Mode.psk(new byte[1]))); + assertFalse(psk.equals(Mode.base())); + + assertFalse(psk.equals(null)); + assertFalse(psk.equals("")); + } + + private static Stream forTestFrom() + { + return Stream.of(Arguments.of(Mode.base(), 0x00, null), Arguments.of(Mode.psk(PSK_ID), 0x01, PSK_ID)); + } + + @ParameterizedTest + @MethodSource("forTestFrom") + void testFrom(Mode expected, int id, byte[] pskId) throws Exception + { + assertEquals(expected, Mode.from((byte) id, pskId)); + } + + private static Stream forTestFromInvalid() + { + return Stream.of(Arguments.of(0xFF, PSK_ID, IllegalArgumentException.class, "Mode not supported"), + Arguments.of(Mode.PSK_VALUE, null, IllegalArgumentException.class, "Mode not supported"), + Arguments.of(Mode.BASE_VALUE, new byte[0], IllegalArgumentException.class, "Mode not supported"), + Arguments.of(Mode.PSK_VALUE, new byte[0], IllegalArgumentException.class, "pskId.length <= 0")); + } + + @ParameterizedTest + @MethodSource("forTestFromInvalid") + void testFromInvalid(int invalid, byte[] pskId, Class exceptionClass, String exceptionMessage) + throws Exception + { + Exception exception = assertThrowsExactly(exceptionClass, () -> Mode.from((byte) invalid, pskId)); + assertEquals(exceptionMessage, exception.getMessage()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolFactoryTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolFactoryTest.java new file mode 100644 index 0000000..a09e770 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolFactoryTest.java @@ -0,0 +1,233 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import de.hsheilbronn.mi.utils.crypto.hpke.ProtocolFactory.ProtocolSerializer; + +public class ProtocolFactoryTest +{ + private static final class TestProtocol implements Protocol + { + @Override + public Mode getMode() + { + return null; + } + + @Override + public KemId getKemId() + { + return null; + } + + @Override + public KdfId getKdfId() + { + return null; + } + + @Override + public AeadId getAeadId() + { + return null; + } + + @Override + public ChunkLength getChunkLength() + { + return null; + } + + @Override + public byte[] getKdfInfo() + { + return null; + } + + @Override + public byte[] getReceiverKeyId() + { + return null; + } + } + + private static final ProtocolSerializer TEST_SERIALIZER = new ProtocolSerializer() + { + @Override + public int getVersion() + { + return 0xFF; + } + + @Override + public TestProtocol read(InputStream stream) throws IOException + { + return new TestProtocol(); + } + + @Override + public Class getType() + { + return TestProtocol.class; + } + + @Override + public byte[] write(TestProtocol protocol) + { + return new byte[0]; + } + }; + + private static Stream forTestConstructor() + { + return Stream.of( + Arguments.of((Supplier) () -> new ProtocolFactory(PreSharedKeyProvider.of(), + ReceiverPrivateKeyProvider.of()), null, null), + Arguments.of((Supplier) () -> new ProtocolFactory(PreSharedKeyProvider.of(), + ReceiverPrivateKeyProvider.of(), null), null, null), + Arguments.of((Supplier) () -> new ProtocolFactory(PreSharedKeyProvider.of(), + ReceiverPrivateKeyProvider.of(), List.of()), null, null), + Arguments.of((Supplier) () -> new ProtocolFactory(PreSharedKeyProvider.of(), + ReceiverPrivateKeyProvider.of(), List.of(TEST_SERIALIZER)), null, null), + Arguments.of((Supplier) () -> new ProtocolFactory(null, + ReceiverPrivateKeyProvider.of(), List.of()), NullPointerException.class, + "preSharedKeyProvider"), + Arguments.of((Supplier) () -> new ProtocolFactory(PreSharedKeyProvider.of(), null, + List.of()), NullPointerException.class, "receiverPrivateKeyProvider"), + Arguments.of( + (Supplier) () -> new ProtocolFactory(PreSharedKeyProvider.of(), + ReceiverPrivateKeyProvider.of(), List.of(TEST_SERIALIZER, TEST_SERIALIZER)), + IllegalArgumentException.class, "Multiple protocol serializers for same version")); + } + + @MethodSource("forTestConstructor") + @ParameterizedTest + void testConstructor(Supplier constructor, Class expectedException, + String expectedExceptionMessage) throws Exception + { + if (expectedException == null) + assertDoesNotThrow(() -> constructor.get()); + else + { + Exception e = assertThrowsExactly(expectedException, () -> constructor.get()); + assertEquals(expectedExceptionMessage, e.getMessage()); + } + } + + @Test + void testWriteReadTestProtocol() throws Exception + { + ProtocolFactory factory = new ProtocolFactory(PreSharedKeyProvider.of(), ReceiverPrivateKeyProvider.of(), + List.of(TEST_SERIALIZER)) + { + }; + + InputStream stream = factory.write(new TestProtocol()); + assertNotNull(stream); + + byte[] header = stream.readAllBytes(); + + assertEquals(ProtocolFactory.ROOT_HEADER_LENGTH, header.length); + assertArrayEquals(ByteEncoding.concat(ProtocolFactory.MAGIC, new byte[] { (byte) 0xFF }), header); + + Protocol read = factory.read(new ByteArrayInputStream(header)); + assertNotNull(read); + assertTrue(read instanceof TestProtocol); + } + + @Test + void testWriteReadV1Protocol() throws Exception + { + ProtocolFactory factory = new ProtocolFactory(PreSharedKeyProvider.of(), ReceiverPrivateKeyProvider.of()); + + InputStream stream = factory.write(new ProtocolV1(Mode.base(), KemId.DHKEM_P256_HKDF_SHA256, KdfId.HKDF_SHA256, + AeadId.AES_128_GCM, ChunkLength.KiB_1, new byte[ProtocolV1.RECEIVER_KEY_ID_LENGTH])); + assertNotNull(stream); + + byte[] header = stream.readAllBytes(); + + assertEquals(ProtocolFactory.ROOT_HEADER_LENGTH + ProtocolV1.HEADER_BASE_LENGTH, header.length); + + Protocol read = factory.read(new ByteArrayInputStream(header)); + assertNotNull(read); + assertTrue(read instanceof ProtocolV1); + } + + @Test + void testWriteReadTestAndV1Protocol() throws Exception + { + ProtocolFactory factory = new ProtocolFactory(PreSharedKeyProvider.of(), ReceiverPrivateKeyProvider.of(), + List.of(TEST_SERIALIZER, ProtocolFactory.V1_SERIALIZER)) + { + }; + + InputStream streamV1 = factory.write(new ProtocolV1(Mode.base(), KemId.DHKEM_P256_HKDF_SHA256, + KdfId.HKDF_SHA256, AeadId.AES_128_GCM, ChunkLength.KiB_1, new byte[ProtocolV1.RECEIVER_KEY_ID_LENGTH])); + assertNotNull(streamV1); + + byte[] headerV1 = streamV1.readAllBytes(); + + assertEquals(ProtocolFactory.ROOT_HEADER_LENGTH + ProtocolV1.HEADER_BASE_LENGTH, headerV1.length); + + Protocol readV1 = factory.read(new ByteArrayInputStream(headerV1)); + assertNotNull(readV1); + assertTrue(readV1 instanceof ProtocolV1); + + InputStream streamTest = factory.write(new TestProtocol()); + assertNotNull(streamTest); + + byte[] headerTest = streamTest.readAllBytes(); + + assertEquals(ProtocolFactory.ROOT_HEADER_LENGTH, headerTest.length); + assertArrayEquals(ByteEncoding.concat(ProtocolFactory.MAGIC, new byte[] { (byte) 0xFF }), headerTest); + + Protocol readTest = factory.read(new ByteArrayInputStream(headerTest)); + assertNotNull(readTest); + assertTrue(readTest instanceof TestProtocol); + } + + @Test + void testReadWriteNull() throws Exception + { + ProtocolFactory factory = new ProtocolFactory(PreSharedKeyProvider.of(), ReceiverPrivateKeyProvider.of()); + + NullPointerException e = assertThrowsExactly(NullPointerException.class, () -> factory.write(null)); + assertEquals("protocol", e.getMessage()); + e = assertThrowsExactly(NullPointerException.class, () -> factory.read(null)); + assertEquals("source", e.getMessage()); + } + + @Test + void testProtocolNotSupported() throws Exception + { + ProtocolFactory factory = new ProtocolFactory(PreSharedKeyProvider.of(), ReceiverPrivateKeyProvider.of()); + + IllegalArgumentException iaE = assertThrowsExactly(IllegalArgumentException.class, + () -> factory.write(new TestProtocol())); + assertEquals("Protocol not supported", iaE.getMessage()); + + IOException ioE = assertThrowsExactly(IOException.class, () -> factory.read(new ByteArrayInputStream( + ByteEncoding.concat(ProtocolFactory.MAGIC, new byte[] { (byte) TEST_SERIALIZER.getVersion() })))); + assertEquals("Protocol not supported", ioE.getMessage()); + ioE = assertThrowsExactly(IOException.class, () -> factory.read(new ByteArrayInputStream(ByteEncoding + .concat(new byte[ProtocolFactory.MAGIC.length], new byte[] { (byte) TEST_SERIALIZER.getVersion() })))); + assertEquals("Protocol not supported", ioE.getMessage()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolV1Test.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolV1Test.java new file mode 100644 index 0000000..3a55af8 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ProtocolV1Test.java @@ -0,0 +1,231 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.EnumSet; +import java.util.HexFormat; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ProtocolV1Test +{ + private static final Logger logger = LoggerFactory.getLogger(ProtocolV1Test.class); + + private static final byte[] PSK_ID = Sha256.digest("Test PSK Identifier".getBytes(StandardCharsets.US_ASCII)); + private static final byte[] RECEIVER_KEY_IDENTIFIER = Sha256 + .digest("Test Receiver Key Identifier".getBytes(StandardCharsets.US_ASCII)); + + private static final ProtocolV1 V1_BASE = new ProtocolV1(Mode.base(), KemId.DHKEM_X25519_HKDF_SHA256, + KdfId.HKDF_SHA256, AeadId.AES_128_GCM, ChunkLength.KiB_1, RECEIVER_KEY_IDENTIFIER); + private static final byte[] V1_BASE_BYTE_ARRAY = ByteEncoding.concat(Mode.base().getValueAsI2osp1Byte(), + KemId.DHKEM_X25519_HKDF_SHA256.getIdAsI2osp2Bytes(), KdfId.HKDF_SHA256.getIdAsI2osp2Bytes(), + AeadId.AES_128_GCM.getIdAsI2osp2Bytes(), ChunkLength.KiB_1.getExponentAsI2osp1Byte(), + RECEIVER_KEY_IDENTIFIER); + + private static final ProtocolV1 V1_PSK = new ProtocolV1(Mode.psk(PSK_ID), KemId.DHKEM_P521_HKDF_SHA512, + KdfId.HKDF_SHA512, AeadId.ChaCha20Poly1305, ChunkLength.MiB_1, RECEIVER_KEY_IDENTIFIER); + private static final byte[] V1_PSK_BYTE_ARRAY = ByteEncoding.concat(Mode.psk(PSK_ID).getValueAsI2osp1Byte(), + KemId.DHKEM_P521_HKDF_SHA512.getIdAsI2osp2Bytes(), KdfId.HKDF_SHA512.getIdAsI2osp2Bytes(), + AeadId.ChaCha20Poly1305.getIdAsI2osp2Bytes(), ChunkLength.MiB_1.getExponentAsI2osp1Byte(), + RECEIVER_KEY_IDENTIFIER, PSK_ID); + + private static Stream forTestConstructorExceptions() + { + return Stream.of( + Arguments.of( + (Executable) () -> new ProtocolV1(Mode.base(), KemId.DHKEM_X25519_HKDF_SHA256, + KdfId.HKDF_SHA256, AeadId.AES_128_GCM, ChunkLength.KiB_1, RECEIVER_KEY_IDENTIFIER), + null, null), + Arguments.of( + (Executable) () -> new ProtocolV1(Mode.psk(PSK_ID), KemId.DHKEM_P521_HKDF_SHA512, + KdfId.HKDF_SHA512, AeadId.ChaCha20Poly1305, ChunkLength.KiB_1, RECEIVER_KEY_IDENTIFIER), + null, null), + Arguments.of( + (Executable) () -> new ProtocolV1(Mode.psk(new byte[1]), KemId.DHKEM_P521_HKDF_SHA512, + KdfId.HKDF_SHA512, AeadId.ChaCha20Poly1305, ChunkLength.KiB_1, RECEIVER_KEY_IDENTIFIER), + IllegalArgumentException.class, "mode.pskId.length not " + ProtocolV1.PRE_SHARED_KEY_ID_LENGTH), + Arguments.of( + (Executable) () -> new ProtocolV1(null, KemId.DHKEM_X25519_HKDF_SHA256, KdfId.HKDF_SHA256, + AeadId.AES_128_GCM, ChunkLength.KiB_1, RECEIVER_KEY_IDENTIFIER), + NullPointerException.class, "mode"), + Arguments.of((Executable) () -> new ProtocolV1(Mode.base(), null, KdfId.HKDF_SHA256, AeadId.AES_128_GCM, + ChunkLength.KiB_1, RECEIVER_KEY_IDENTIFIER), NullPointerException.class, "kemId"), + Arguments.of( + (Executable) () -> new ProtocolV1(Mode.base(), KemId.DHKEM_X25519_HKDF_SHA256, null, + AeadId.AES_128_GCM, ChunkLength.KiB_1, RECEIVER_KEY_IDENTIFIER), + NullPointerException.class, "kdfId"), + Arguments.of( + (Executable) () -> new ProtocolV1(Mode.base(), KemId.DHKEM_X25519_HKDF_SHA256, + KdfId.HKDF_SHA256, null, ChunkLength.KiB_1, RECEIVER_KEY_IDENTIFIER), + NullPointerException.class, "aeadId"), + Arguments.of( + (Executable) () -> new ProtocolV1(Mode.base(), KemId.DHKEM_X25519_HKDF_SHA256, + KdfId.HKDF_SHA256, AeadId.AES_128_GCM, null, RECEIVER_KEY_IDENTIFIER), + NullPointerException.class, "chunkLength"), + Arguments.of( + (Executable) () -> new ProtocolV1(Mode.base(), KemId.DHKEM_X25519_HKDF_SHA256, + KdfId.HKDF_SHA256, AeadId.AES_128_GCM, ChunkLength.KiB_1, null), + NullPointerException.class, "receiverKeyId"), + Arguments.of( + (Executable) () -> new ProtocolV1(Mode.base(), KemId.DHKEM_X25519_HKDF_SHA256, + KdfId.HKDF_SHA256, AeadId.AES_128_GCM, ChunkLength.KiB_1, new byte[0]), + IllegalArgumentException.class, + "receiverKeyId.length not " + ProtocolV1.RECEIVER_KEY_ID_LENGTH), + Arguments.of( + (Executable) () -> new ProtocolV1(Mode.base(), KemId.DHKEM_X25519_HKDF_SHA256, + KdfId.HKDF_SHA256, AeadId.AES_128_GCM, ChunkLength.KiB_1, + new byte[ProtocolV1.RECEIVER_KEY_ID_LENGTH + 1]), + IllegalArgumentException.class, + "receiverKeyId.length not " + ProtocolV1.RECEIVER_KEY_ID_LENGTH)); + } + + @ParameterizedTest + @MethodSource("forTestConstructorExceptions") + void testConstructorExceptions(Executable executable, Class expectedExcpetion, + String expectedMessage) throws Exception + { + if (expectedExcpetion == null) + assertDoesNotThrow(executable); + else + { + Exception e = assertThrowsExactly(expectedExcpetion, executable); + assertEquals(expectedMessage, e.getMessage()); + } + } + + private static Stream forTestWriteReadHeader() + { + return Stream.of(Arguments.of(V1_BASE, V1_BASE_BYTE_ARRAY, ChunkLength.KiB_1), + Arguments.of(V1_PSK, V1_PSK_BYTE_ARRAY, ChunkLength.MiB_1)); + } + + @ParameterizedTest + @MethodSource("forTestWriteReadHeader") + void testWriteReadHeaderStream(ProtocolV1 protocol, byte[] expected, ChunkLength chunkLength) throws Exception + { + assertEquals(chunkLength, protocol.getChunkLength()); + + byte[] header = protocol.getCanonicalHeader(); + assertArrayEquals(header, protocol.getCanonicalHeader()); + + logger.debug("Actual: {}", HexFormat.of().formatHex(header)); + logger.debug("Expected: {}", HexFormat.of().formatHex(expected)); + + assertArrayEquals(expected, header); + + ProtocolV1 readProtocol = ProtocolV1.from(new ByteArrayInputStream(expected)); + + assertEquals(protocol.getAeadId(), readProtocol.getAeadId()); + assertEquals(protocol.getChunkLength(), readProtocol.getChunkLength()); + assertEquals(protocol.getKdfId(), readProtocol.getKdfId()); + assertEquals(protocol.getKemId(), readProtocol.getKemId()); + assertEquals(protocol.getMode(), readProtocol.getMode()); + assertArrayEquals(protocol.getReceiverKeyId(), readProtocol.getReceiverKeyId()); + } + + private static Stream forTestReadInvalidHeadersFromStream() + { + return Stream.concat( + Stream.of( + Arguments.of(ByteEncoding.concat(Mode.base().getValueAsI2osp1Byte(), + KemId.DHKEM_X25519_HKDF_SHA256.getIdAsI2osp2Bytes(), + KdfId.HKDF_SHA256.getIdAsI2osp2Bytes(), AeadId.AES_128_GCM.getIdAsI2osp2Bytes(), + ChunkLength.KiB_1.getExponentAsI2osp1Byte()), "Truncated stream"), + Arguments.of( + ByteEncoding.concat(new byte[] { (byte) 0xFF }, + KemId.DHKEM_X25519_HKDF_SHA256.getIdAsI2osp2Bytes(), + KdfId.HKDF_SHA256.getIdAsI2osp2Bytes(), AeadId.AES_128_GCM.getIdAsI2osp2Bytes(), + ChunkLength.KiB_1.getExponentAsI2osp1Byte(), RECEIVER_KEY_IDENTIFIER), + "Mode not supported"), + Arguments.of( + ByteEncoding.concat(Mode.base().getValueAsI2osp1Byte(), + KemId.DHKEM_X25519_HKDF_SHA256.getIdAsI2osp2Bytes(), + KdfId.HKDF_SHA256.getIdAsI2osp2Bytes(), AeadId.AES_128_GCM.getIdAsI2osp2Bytes(), + new byte[] { (byte) 0xFF }, RECEIVER_KEY_IDENTIFIER), + "Chunk length exponent not supported"), + Arguments.of( + ByteEncoding.concat(Mode.base().getValueAsI2osp1Byte(), + KemId.DHKEM_X25519_HKDF_SHA256.getIdAsI2osp2Bytes(), + KdfId.HKDF_SHA256.getIdAsI2osp2Bytes(), AeadId.AES_128_GCM.getIdAsI2osp2Bytes(), + new byte[] { (byte) 0x10 }, RECEIVER_KEY_IDENTIFIER), + "Chunk length exponent not supported"), + Arguments.of( + ByteEncoding.concat(Mode.base().getValueAsI2osp1Byte(), + new byte[] { (byte) 0xFF, (byte) 0x00 }, KdfId.HKDF_SHA256.getIdAsI2osp2Bytes(), + AeadId.AES_128_GCM.getIdAsI2osp2Bytes(), + ChunkLength.KiB_1.getExponentAsI2osp1Byte(), RECEIVER_KEY_IDENTIFIER), + "KemId not supported"), + Arguments.of(ByteEncoding.concat(Mode.base().getValueAsI2osp1Byte(), + KemId.DHKEM_X25519_HKDF_SHA256.getIdAsI2osp2Bytes(), + new byte[] { (byte) 0xFF, (byte) 0xFF }, AeadId.AES_128_GCM.getIdAsI2osp2Bytes(), + ChunkLength.KiB_1.getExponentAsI2osp1Byte(), RECEIVER_KEY_IDENTIFIER), + "KdfId not supported"), + Arguments.of( + ByteEncoding.concat(Mode.base().getValueAsI2osp1Byte(), + KemId.DHKEM_X25519_HKDF_SHA256.getIdAsI2osp2Bytes(), + KdfId.HKDF_SHA256.getIdAsI2osp2Bytes(), new byte[] { (byte) 0xFF, (byte) 0xFF }, + ChunkLength.KiB_1.getExponentAsI2osp1Byte(), RECEIVER_KEY_IDENTIFIER), + "AeadId not supported")), + Stream.concat(IntStream.range(1, V1_BASE_BYTE_ARRAY.length).mapToObj(trunc -> + { + byte[] truncated = new byte[V1_BASE_BYTE_ARRAY.length - trunc]; + ByteBuffer.wrap(V1_PSK_BYTE_ARRAY).get(truncated); + return Arguments.argumentSet("Mode Base, truncated by " + trunc, truncated, "Truncated stream"); + }), IntStream.range(1, V1_PSK_BYTE_ARRAY.length).mapToObj(trunc -> + { + byte[] truncated = new byte[V1_PSK_BYTE_ARRAY.length - trunc]; + ByteBuffer.wrap(V1_PSK_BYTE_ARRAY).get(truncated); + return Arguments.argumentSet("Mode PSK, truncated by " + trunc, truncated, "Truncated stream"); + }))); + } + + @ParameterizedTest + @MethodSource("forTestReadInvalidHeadersFromStream") + void testReadInvalidHeadersFromStream(byte[] invalid, String exceptionMessage) throws Exception + { + Exception exception = assertThrowsExactly(IOException.class, + () -> ProtocolV1.from(new ByteArrayInputStream(invalid))); + assertEquals(exceptionMessage, exception.getMessage()); + } + + private static Stream forTestGetChunkSize() + { + return EnumSet.allOf(ChunkLength.class).stream().map(Arguments::of); + } + + @ParameterizedTest + @MethodSource("forTestGetChunkSize") + void testGetChunkSize(ChunkLength chunkLength) + { + ProtocolV1 protocol = new ProtocolV1(Mode.base(), KemId.DHKEM_X25519_HKDF_SHA256, KdfId.HKDF_SHA256, + AeadId.AES_128_GCM, chunkLength, RECEIVER_KEY_IDENTIFIER); + + assertEquals(chunkLength, protocol.getChunkLength()); + } + + @Test + void testGetKdfInfo() throws Exception + { + ProtocolV1 protocol = new ProtocolV1(Mode.base(), KemId.DHKEM_X25519_HKDF_SHA256, KdfId.HKDF_SHA256, + AeadId.AES_128_GCM, ChunkLength.KiB_1, RECEIVER_KEY_IDENTIFIER); + + assertNotNull(protocol.getKdfInfo()); + assertArrayEquals(new byte[] { 'H', 'P', 'K', 'E', 'F', (byte) 0x01, (byte) 0x00 }, protocol.getKdfInfo()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/RsaKemWrapperTest.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/RsaKemWrapperTest.java new file mode 100644 index 0000000..3b359be --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/RsaKemWrapperTest.java @@ -0,0 +1,202 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import java.security.InvalidKeyException; +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.interfaces.RSAPrivateKey; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.stream.Stream; + +import javax.crypto.DecapsulateException; +import javax.crypto.KEM.Encapsulated; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.bouncycastle.crypto.DerivationFunction; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.digests.SHA512Digest; +import org.bouncycastle.crypto.generators.KDF2BytesGenerator; +import org.bouncycastle.crypto.kems.RSAKEMExtractor; +import org.bouncycastle.crypto.params.RSAKeyParameters; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import de.hsheilbronn.mi.utils.crypto.keypair.KeyPairGeneratorFactory; + +public class RsaKemWrapperTest +{ + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private static Stream forTestConstructor() + { + return EnumSet.complementOf(EnumSet.of(KemId.RSAKEM_1024_KDF2_SHA256, KemId.RSAKEM_2048_KDF2_SHA256, + KemId.RSAKEM_3072_KDF2_SHA512, KemId.RSAKEM_4096_KDF2_SHA512)).stream().map(Arguments::of); + } + + @ParameterizedTest + @MethodSource("forTestConstructor") + void testConstructor(KemId kemId) throws Exception + { + IllegalArgumentException e = assertThrowsExactly(IllegalArgumentException.class, + () -> new RsaKemWrapper(kemId)); + assertEquals("KemId " + kemId.name() + " not supported", e.getMessage()); + } + + private static Stream kemVariants() + { + KDF2BytesGenerator kdf2Sha256 = new KDF2BytesGenerator(new SHA256Digest()); + KDF2BytesGenerator kdf2Sha512 = new KDF2BytesGenerator(new SHA512Digest()); + + return Stream.of( + Arguments.of(KemId.RSAKEM_1024_KDF2_SHA256, + KeyPairGeneratorFactory.rsa1024().initialize().generateKeyPair(), kdf2Sha256), + Arguments.of(KemId.RSAKEM_2048_KDF2_SHA256, + KeyPairGeneratorFactory.rsa2048().initialize().generateKeyPair(), kdf2Sha256), + Arguments.of(KemId.RSAKEM_3072_KDF2_SHA512, + KeyPairGeneratorFactory.rsa3072().initialize().generateKeyPair(), kdf2Sha512), + Arguments.of(KemId.RSAKEM_4096_KDF2_SHA512, + KeyPairGeneratorFactory.rsa4096().initialize().generateKeyPair(), kdf2Sha512)); + } + + @ParameterizedTest + @MethodSource("kemVariants") + void testAgainstBouncyCastleImplementation(KemId kemId, KeyPair keyPair, DerivationFunction kdf) throws Exception + { + RsaKemWrapper w = new RsaKemWrapper(kemId); + Encapsulated encapsulated = w.getEncapsulated(keyPair.getPublic(), SECURE_RANDOM); + + assertNotNull(encapsulated); + + SecretKey sKey = encapsulated.key(); + assertNotNull(sKey); + assertEquals("Generic", sKey.getAlgorithm()); + assertNotNull(sKey.getEncoded()); + + byte[] encapsulation = encapsulated.encapsulation(); + assertNotNull(encapsulation); + assertEquals(kemId.getEncapsulationLength(), encapsulation.length); + + SecretKey rKey = w.getSharedSecret(keyPair.getPrivate(), encapsulation); + assertNotNull(rKey); + assertEquals("Generic", rKey.getAlgorithm()); + assertNotNull(rKey.getEncoded()); + + assertEquals(sKey, rKey); + + SecretKey rKeyBC = bouncyCastledoGetSecretKey(keyPair.getPrivate(), encapsulation, + kemId.getSharedSecretLength(), kdf); + assertNotNull(rKeyBC); + assertEquals("Generic", rKeyBC.getAlgorithm()); + assertNotNull(rKeyBC.getEncoded()); + + assertEquals(sKey, rKeyBC); + } + + private SecretKey bouncyCastledoGetSecretKey(PrivateKey privateKey, byte[] encapsulation, int sharedSecretLength, + DerivationFunction kdf) throws NoSuchAlgorithmException, InvalidKeyException, DecapsulateException + { + RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) privateKey; + + RSAKeyParameters rsaKeyParameters = new RSAKeyParameters(true, rsaPrivateKey.getModulus(), + rsaPrivateKey.getPrivateExponent()); + + RSAKEMExtractor decapsulator = new RSAKEMExtractor(rsaKeyParameters, sharedSecretLength, kdf); + + return new SecretKeySpec(decapsulator.extractSecret(encapsulation), "Generic"); + } + + @ParameterizedTest + @MethodSource("kemVariants") + void testTruncatedEnc(KemId kemId, KeyPair keyPair) throws Exception + { + RsaKemWrapper w = new RsaKemWrapper(kemId); + Encapsulated encapsulated = w.getEncapsulated(keyPair.getPublic(), SECURE_RANDOM); + + assertNotNull(encapsulated); + + SecretKey sKey = encapsulated.key(); + assertNotNull(sKey); + assertEquals("Generic", sKey.getAlgorithm()); + assertNotNull(sKey.getEncoded()); + + byte[] encapsulation = encapsulated.encapsulation(); + assertNotNull(encapsulation); + assertEquals(kemId.getEncapsulationLength(), encapsulation.length); + + for (int i = 0; i <= encapsulation.length; i++) + { + try + { + w.getSharedSecret(keyPair.getPrivate(), Arrays.copyOfRange(encapsulation, 0, encapsulation.length - i)); + assertEquals(0, i); // only not truncated stream ok + } + catch (IllegalStateException e) + { + assertEquals("encapsulation.length not " + kemId.getEncapsulationLength(), e.getMessage()); + } + } + } + + @ParameterizedTest + @MethodSource("kemVariants") + void testModifiedEnc(KemId kemId, KeyPair keyPair) throws Exception + { + RsaKemWrapper w = new RsaKemWrapper(kemId); + Encapsulated encapsulated = w.getEncapsulated(keyPair.getPublic(), SECURE_RANDOM); + + assertNotNull(encapsulated); + + SecretKey sKey = encapsulated.key(); + assertNotNull(sKey); + assertEquals("Generic", sKey.getAlgorithm()); + assertNotNull(sKey.getEncoded()); + + byte[] encapsulation = encapsulated.encapsulation(); + assertNotNull(encapsulation); + assertEquals(kemId.getEncapsulationLength(), encapsulation.length); + + for (int i = 0; i < encapsulation.length; i++) + { + encapsulation[i] ^= 0x01; + SecretKey sharedSecret = w.getSharedSecret(keyPair.getPrivate(), encapsulation); + + assertNotSame(sKey, sharedSecret); + } + } + + @ParameterizedTest + @MethodSource("kemVariants") + void testTwoCallsResultInDifferentEncapsulationsAndKeys(KemId kemId, KeyPair keyPair) throws Exception + { + RsaKemWrapper w = new RsaKemWrapper(kemId); + + Encapsulated e1 = w.getEncapsulated(keyPair.getPublic(), SECURE_RANDOM); + assertNotNull(e1); + assertNotNull(e1.encapsulation()); + assertNotNull(e1.key()); + + Encapsulated e2 = w.getEncapsulated(keyPair.getPublic(), SECURE_RANDOM); + assertNotNull(e2); + assertNotNull(e2.encapsulation()); + assertNotNull(e2.key()); + + assertFalse(Arrays.equals(e1.encapsulation(), e2.encapsulation())); + assertFalse(Arrays.equals(e1.key().getEncoded(), e2.key().getEncoded())); + + SecretKey sharedSecret1 = w.getSharedSecret(keyPair.getPrivate(), e1.encapsulation()); + assertArrayEquals(e1.key().getEncoded(), sharedSecret1.getEncoded()); + SecretKey sharedSecret2 = w.getSharedSecret(keyPair.getPrivate(), e2.encapsulation()); + assertArrayEquals(e2.key().getEncoded(), sharedSecret2.getEncoded()); + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/Sha256.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/Sha256.java new file mode 100644 index 0000000..aefff08 --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/Sha256.java @@ -0,0 +1,24 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public final class Sha256 +{ + private Sha256() + { + } + + public static byte[] digest(byte[] bytes) + { + try + { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return digest.digest(bytes); + } + catch (NoSuchAlgorithmException e) + { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ZeroInputStream.java b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ZeroInputStream.java new file mode 100644 index 0000000..3155d7c --- /dev/null +++ b/src/test/java/de/hsheilbronn/mi/utils/crypto/hpke/ZeroInputStream.java @@ -0,0 +1,61 @@ +package de.hsheilbronn.mi.utils.crypto.hpke; + +import java.io.IOException; +import java.io.InputStream; + +public final class ZeroInputStream extends InputStream +{ + private final long size; + private long position = 0; + + public ZeroInputStream(long size) + { + this.size = size; + } + + @Override + public int read() throws IOException + { + if (position >= size) + return -1; + + position++; + return 0; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException + { + if (position >= size) + return -1; + + int bytesToRead = (int) Math.min(len, size - position); + + for (int i = 0; i < bytesToRead; i++) + b[off + i] = 0; + + position += bytesToRead; + return bytesToRead; + } + + @Override + public long skip(long n) throws IOException + { + long skipped = Math.min(n, size - position); + position += skipped; + return skipped; + } + + @Override + public int available() throws IOException + { + long remaining = size - position; + return remaining > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) remaining; + } + + @Override + public void close() throws IOException + { + // nothing to do + } +}