diff --git a/.github/scripts/verify_assertions_basic.sh b/.github/scripts/verify_assertions_basic.sh new file mode 100755 index 00000000..51a472b4 --- /dev/null +++ b/.github/scripts/verify_assertions_basic.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "basic assertions" +printf 'here is some data to encrypt' > data + +ASSERTIONS='[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"}}]' + +java -jar target/cmdline.jar \ + --client-id=opentdf-sdk \ + --client-secret=secret \ + --platform-endpoint=http://localhost:8080 \ + -h \ + encrypt \ + --kas-url=http://localhost:8080 \ + --mime-type=text/plain \ + --with-assertions="$ASSERTIONS" \ + --autoconfigure=false \ + -f data \ + -m 'here is some metadata' > test.tdf + +java -jar target/cmdline.jar \ + --client-id=opentdf-sdk \ + --client-secret=secret \ + --platform-endpoint=http://localhost:8080 \ + -h \ + decrypt \ + -f test.tdf > decrypted + +if ! diff -q data decrypted; then + printf 'decrypted data is incorrect [%s]\n' "$(< decrypted)" + exit 1 +fi + diff --git a/.github/scripts/verify_assertions_hs256.sh b/.github/scripts/verify_assertions_hs256.sh new file mode 100755 index 00000000..65f01007 --- /dev/null +++ b/.github/scripts/verify_assertions_hs256.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "hs256 assertions" +printf 'here is some data to encrypt' > data + +HS256_KEY=$(openssl rand -base64 32) +SIGNED_ASSERTIONS_HS256='[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"},"signingKey":{"alg":"HS256","key":"'"$HS256_KEY"'"}}]' +SIGNED_ASSERTION_VERIFICATON_HS256='{"keys":{"assertion1":{"alg":"HS256","key":"'"$HS256_KEY"'"}}}' + +java -jar target/cmdline.jar \ + --client-id=opentdf-sdk \ + --client-secret=secret \ + --platform-endpoint=http://localhost:8080 \ + -h \ + encrypt \ + --kas-url=http://localhost:8080 \ + --mime-type=text/plain \ + --with-assertions="$SIGNED_ASSERTIONS_HS256" \ + --autoconfigure=false \ + -f data \ + -m 'here is some metadata' > test.tdf + +java -jar target/cmdline.jar \ + --client-id=opentdf-sdk \ + --client-secret=secret \ + --platform-endpoint=http://localhost:8080 \ + -h \ + decrypt \ + --with-assertion-verification-keys="$SIGNED_ASSERTION_VERIFICATON_HS256" \ + -f test.tdf > decrypted + +if ! diff -q data decrypted; then + printf 'decrypted data is incorrect [%s]\n' "$(< decrypted)" + exit 1 +fi diff --git a/.github/scripts/verify_assertions_rs256.sh b/.github/scripts/verify_assertions_rs256.sh new file mode 100755 index 00000000..52d964a1 --- /dev/null +++ b/.github/scripts/verify_assertions_rs256.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "rs256 assertions" +printf 'here is some data to encrypt' > data + +openssl genpkey -algorithm RSA -out rs_private_key.pem -pkeyopt rsa_keygen_bits:2048 +openssl rsa -pubout -in rs_private_key.pem -out rs_public_key.pem + +RS256_PRIVATE_KEY=$(awk '{printf "%s\\n", $0}' rs_private_key.pem) +RS256_PUBLIC_KEY=$(awk '{printf "%s\\n", $0}' rs_public_key.pem) +SIGNED_ASSERTIONS_RS256='[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"},"signingKey":{"alg":"RS256","key":"'"$RS256_PRIVATE_KEY"'"}}]' +SIGNED_ASSERTION_VERIFICATON_RS256='{"keys":{"assertion1":{"alg":"RS256","key":"'"$RS256_PUBLIC_KEY"'"}}}' + +java -jar target/cmdline.jar \ + --client-id=opentdf-sdk \ + --client-secret=secret \ + --platform-endpoint=http://localhost:8080 \ + -h \ + encrypt \ + --kas-url=http://localhost:8080 \ + --mime-type=text/plain \ + --with-assertions "$SIGNED_ASSERTIONS_RS256" \ + --autoconfigure=false \ + -f data \ + -m 'here is some metadata' > test.tdf + +java -jar target/cmdline.jar \ + --client-id=opentdf-sdk \ + --client-secret=secret \ + --platform-endpoint=http://localhost:8080 \ + -h \ + decrypt \ + --with-assertion-verification-keys "$SIGNED_ASSERTION_VERIFICATON_RS256" \ + -f test.tdf > decrypted + +if ! diff -q data decrypted; then + printf 'decrypted data is incorrect [%s]\n' "$(< decrypted)" + exit 1 +fi + diff --git a/.github/scripts/verify_cmdline_roundtrip.sh b/.github/scripts/verify_cmdline_roundtrip.sh new file mode 100755 index 00000000..6ce47fd5 --- /dev/null +++ b/.github/scripts/verify_cmdline_roundtrip.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +printf 'here is some data to encrypt' > data + +java -jar target/cmdline.jar \ + --client-id=opentdf-sdk \ + --client-secret=secret \ + --platform-endpoint=http://localhost:8080 \ + -h \ + encrypt \ + --kas-url=http://localhost:8080 \ + --mime-type=text/plain \ + --attr https://example.com/attr/attr1/value/value1 \ + --autoconfigure=false \ + -f data \ + -m 'here is some metadata' > test.tdf + +java -jar target/cmdline.jar \ + --client-id=opentdf-sdk \ + --client-secret=secret \ + --platform-endpoint=http://localhost:8080 \ + -h \ + decrypt \ + -f test.tdf > decrypted + +java -jar target/cmdline.jar \ + --client-id=opentdf-sdk \ + --client-secret=secret \ + --platform-endpoint=http://localhost:8080 \ + -h \ + metadata \ + -f test.tdf > metadata + +if ! diff -q data decrypted; then + printf 'decrypted data is incorrect [%s]\n' "$(< decrypted)" + exit 1 +fi + +if [ "$(< metadata)" != 'here is some metadata' ]; then + printf 'metadata is incorrect [%s]\n' "$(< metadata)" + exit 1 +fi + diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 2c7eedd7..36acfebd 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -92,7 +92,13 @@ jobs: BUF_INPUT_HTTPS_PASSWORD: ${{ secrets.PERSONAL_ACCESS_TOKEN_OPENTDF }} run: mvn clean --batch-mode clean generate-sources - name: Tests and enforcer (fips) - run: mvn --batch-mode test enforcer:enforce -P 'fips,!non-fips' -Dmaven.antrun.skip + run: | + # install the sdk-fips-bouncycastle jar so that FIPS mode tests work + mvn --batch-mode install -pl sdk-fips-bouncycastle -am \ + -Dmaven.antrun.skip \ + -Dmaven.test.skip + mvn --batch-mode test enforcer:enforce -P 'fips,!non-fips' \ + -Dmaven.antrun.skip - name: Tests with coverage and javadoc (non-fips) env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -104,7 +110,15 @@ jobs: -P 'coverage,non-fips,!fips' platform-integration: + name: "Platform Integration ${{ matrix.label }}" runs-on: ubuntu-22.04 + strategy: + matrix: + include: + - label: "" + maven_profile: "" + - label: " (FIPS)" + maven_profile: "-P 'fips,!non-fips'" steps: - name: Checkout Java SDK uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -117,12 +131,6 @@ jobs: java-version: "17" distribution: "temurin" server-id: github - - name: Build java SDK - run: | - mvn --batch-mode clean install -DskipTests - env: - BUF_INPUT_HTTPS_USERNAME: opentdf-bot - BUF_INPUT_HTTPS_PASSWORD: ${{ secrets.PERSONAL_ACCESS_TOKEN_OPENTDF }} - name: Check out and start up platform with deps/containers id: run-platform @@ -137,121 +145,26 @@ jobs: grpcurl -plaintext localhost:8080 list && \ grpcurl -plaintext localhost:8080 kas.AccessService/PublicKey - - name: Validate the SDK through the command line interface + - name: Build java SDK${{ matrix.label }} run: | - printf 'here is some data to encrypt' > data - - java -jar target/cmdline.jar \ - --client-id=opentdf-sdk \ - --client-secret=secret \ - --platform-endpoint=http://localhost:8080 \ - -h\ - encrypt --kas-url=http://localhost:8080 --mime-type=text/plain --attr https://example.com/attr/attr1/value/value1 --autoconfigure=false -f data -m 'here is some metadata' > test.tdf - - java -jar target/cmdline.jar \ - --client-id=opentdf-sdk \ - --client-secret=secret \ - --platform-endpoint=http://localhost:8080 \ - -h\ - decrypt -f test.tdf > decrypted - - java -jar target/cmdline.jar \ - --client-id=opentdf-sdk \ - --client-secret=secret \ - --platform-endpoint=http://localhost:8080 \ - -h\ - metadata -f test.tdf > metadata - - if ! diff -q data decrypted; then - printf 'decrypted data is incorrect [%s]' "$(< decrypted)" - exit 1 - fi + mvn --batch-mode clean install -pl cmdline -am ${{ matrix.maven_profile }} -DskipTests + env: + BUF_INPUT_HTTPS_USERNAME: opentdf-bot + BUF_INPUT_HTTPS_PASSWORD: ${{ secrets.PERSONAL_ACCESS_TOKEN_OPENTDF }} - if [ "$(< metadata)" != 'here is some metadata' ]; then - printf 'metadata is incorrect [%s]\n' "$(< metadata)" - exit 1 - fi + - name: Validate the SDK through the command line interface${{ matrix.label }} + run: | + ../.github/scripts/verify_cmdline_roundtrip.sh working-directory: cmdline - - name: Encrypt/Decrypt Assertions + - name: Encrypt/Decrypt Assertions${{ matrix.label }} run: | - echo "basic assertions" - echo 'here is some data to encrypt' > data - - ASSERTIONS='[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"}}]' - - java -jar target/cmdline.jar \ - --client-id=opentdf-sdk \ - --client-secret=secret \ - --platform-endpoint=http://localhost:8080 \ - -h\ - encrypt --kas-url=http://localhost:8080 --mime-type=text/plain --with-assertions=$ASSERTIONS --autoconfigure=false -f data -m 'here is some metadata' > test.tdf - - java -jar target/cmdline.jar \ - --client-id=opentdf-sdk \ - --client-secret=secret \ - --platform-endpoint=http://localhost:8080 \ - -h\ - decrypt -f test.tdf > decrypted - - if ! diff -q data decrypted; then - printf 'decrypted data is incorrect [%s]' "$(< decrypted)" - exit 1 - fi - - HS256_KEY=$(openssl rand -base64 32) - openssl genpkey -algorithm RSA -out rs_private_key.pem -pkeyopt rsa_keygen_bits:2048 - openssl rsa -pubout -in rs_private_key.pem -out rs_public_key.pem - RS256_PRIVATE_KEY=$(awk '{printf "%s\\n", $0}' rs_private_key.pem) - RS256_PUBLIC_KEY=$(awk '{printf "%s\\n", $0}' rs_public_key.pem) - SIGNED_ASSERTIONS_HS256='[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"},"signingKey":{"alg":"HS256","key":"'$HS256_KEY'"}}]' - SIGNED_ASSERTION_VERIFICATON_HS256='{"keys":{"assertion1":{"alg":"HS256","key":"'$HS256_KEY'"}}}' - SIGNED_ASSERTIONS_RS256='[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"},"signingKey":{"alg":"RS256","key":"'$RS256_PRIVATE_KEY'"}}]' - SIGNED_ASSERTION_VERIFICATON_RS256='{"keys":{"assertion1":{"alg":"RS256","key":"'$RS256_PUBLIC_KEY'"}}}' - - echo "hs256 assertions" - - java -jar target/cmdline.jar \ - --client-id=opentdf-sdk \ - --client-secret=secret \ - --platform-endpoint=http://localhost:8080 \ - -h\ - encrypt --kas-url=http://localhost:8080 --mime-type=text/plain --with-assertions="$SIGNED_ASSERTIONS_HS256" --autoconfigure=false -f data -m 'here is some metadata' > test.tdf - - java -jar target/cmdline.jar \ - --client-id=opentdf-sdk \ - --client-secret=secret \ - --platform-endpoint=http://localhost:8080 \ - -h\ - decrypt --with-assertion-verification-keys="$SIGNED_ASSERTION_VERIFICATON_HS256" -f test.tdf > decrypted - - if ! diff -q data decrypted; then - printf 'decrypted data is incorrect [%s]' "$(< decrypted)" - exit 1 - fi - - echo "rs256 assertions" - - java -jar target/cmdline.jar \ - --client-id=opentdf-sdk \ - --client-secret=secret \ - --platform-endpoint=http://localhost:8080 \ - -h\ - encrypt --kas-url=http://localhost:8080 --mime-type=text/plain --with-assertions "$SIGNED_ASSERTIONS_RS256" --autoconfigure=false -f data -m 'here is some metadata' > test.tdf - - java -jar target/cmdline.jar \ - --client-id=opentdf-sdk \ - --client-secret=secret \ - --platform-endpoint=http://localhost:8080 \ - -h\ - decrypt --with-assertion-verification-keys "$SIGNED_ASSERTION_VERIFICATON_RS256" -f test.tdf > decrypted - - if ! diff -q data decrypted; then - printf 'decrypted data is incorrect [%s]' "$(< decrypted)" - exit 1 - fi + ../.github/scripts/verify_assertions_basic.sh + ../.github/scripts/verify_assertions_hs256.sh + ../.github/scripts/verify_assertions_rs256.sh working-directory: cmdline + platform-xtest: permissions: contents: read diff --git a/cmdline/pom.xml b/cmdline/pom.xml index 3f82579a..b7061f0a 100644 --- a/cmdline/pom.xml +++ b/cmdline/pom.xml @@ -78,4 +78,19 @@ ${project.version} + + + fips + + false + + + + io.opentdf.platform + sdk-fips-bouncycastle + ${project.version} + + + + diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index 685f8782..af9b2b1c 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -10,7 +10,6 @@ import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; -import java.security.cert.X509Certificate; import java.text.ParseException; import com.google.gson.JsonSyntaxException; import io.opentdf.platform.sdk.AssertionConfig; @@ -23,7 +22,6 @@ import picocli.CommandLine.HelpCommand; import picocli.CommandLine.Option; -import javax.net.ssl.X509TrustManager; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; @@ -63,7 +61,6 @@ class Versions { @CommandLine.Command(name = "tdf", subcommands = { HelpCommand.class }, version = "{\"version\":\"" + Versions.SDK + "\",\"tdfSpecVersion\":\"" + Versions.TDF_SPEC + "\"}") class Command { - @Option(names = { "-V", "--version" }, versionHelp = true, description = "display version info") boolean versionInfoRequested; diff --git a/cmdline/src/main/resources/log4j2.xml b/cmdline/src/main/resources/log4j2.xml index da185a60..51a5a102 100644 --- a/cmdline/src/main/resources/log4j2.xml +++ b/cmdline/src/main/resources/log4j2.xml @@ -6,9 +6,9 @@ - - - + + + diff --git a/pom.xml b/pom.xml index 6ce3488c..4323ef13 100644 --- a/pom.xml +++ b/pom.xml @@ -181,7 +181,7 @@ maven-surefire-plugin - 3.0.0 + 3.5.6 maven-jar-plugin @@ -286,6 +286,7 @@ develop sdk + sdk-fips-bouncycastle cmdline examples diff --git a/sdk-fips-bouncycastle/pom.xml b/sdk-fips-bouncycastle/pom.xml new file mode 100644 index 00000000..1cd4df8b --- /dev/null +++ b/sdk-fips-bouncycastle/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + io.opentdf.platform + sdk-pom + 0.15.0 + + sdk-fips-bouncycastle + io.opentdf.platform:sdk-fips-bouncycastle + BouncyCastle FIPS-backed HkdfProvider SPI implementation (FIPS 140-approved HKDF via bc-fips). + jar + + UTF-8 + + + + io.opentdf.platform + sdk + ${project.version} + + + + org.bouncycastle + bc-fips + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + 3.27.7 + test + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.6.0 + true + + true + published + + central + + + + + org.apache.maven.plugins + maven-deploy-plugin + 3.1.2 + + + default-deploy + none + + + + + + diff --git a/sdk-fips-bouncycastle/src/main/java/io/opentdf/platform/sdk/fips/bouncycastle/BouncyCastleFipsHkdfProvider.java b/sdk-fips-bouncycastle/src/main/java/io/opentdf/platform/sdk/fips/bouncycastle/BouncyCastleFipsHkdfProvider.java new file mode 100644 index 00000000..6e7c734c --- /dev/null +++ b/sdk-fips-bouncycastle/src/main/java/io/opentdf/platform/sdk/fips/bouncycastle/BouncyCastleFipsHkdfProvider.java @@ -0,0 +1,32 @@ +package io.opentdf.platform.sdk.fips.bouncycastle; + +import io.opentdf.platform.sdk.HkdfProvider; +import org.bouncycastle.crypto.KDFCalculator; +import org.bouncycastle.crypto.fips.FipsKDF; + +/** + * FIPS 140-approved {@link HkdfProvider} backed by the BouncyCastle FIPS KDF API. + * Discovered at runtime via {@code META-INF/services/io.opentdf.platform.sdk.HkdfProvider}. + */ +public final class BouncyCastleFipsHkdfProvider implements HkdfProvider { + + @Override + public byte[] computeHKDF(byte[] salt, byte[] secret) { + if (secret == null) { + throw new NullPointerException("secret must not be null"); + } + // RFC 5869 ยง2.2: if salt is absent, use a zeroed buffer of HashLen bytes. + byte[] effectiveSalt = (salt == null || salt.length == 0) ? new byte[32] : salt; + var key = FipsKDF.HKDF_KEY_BUILDER + .withPrf(FipsKDF.AgreementKDFPRF.SHA256_HMAC) + .withSalt(effectiveSalt) + .build(secret); + + var factory = new FipsKDF.AgreementOperatorFactory(); + KDFCalculator kdfCalculator = factory.createKDFCalculator( + FipsKDF.HKDF.withPRF(FipsKDF.AgreementKDFPRF.SHA256_HMAC).using(key.getKey())); + byte[] hkdf = new byte[32]; + kdfCalculator.generateBytes(hkdf); + return hkdf; + } +} diff --git a/sdk-fips-bouncycastle/src/main/resources/META-INF/services/io.opentdf.platform.sdk.HkdfProvider b/sdk-fips-bouncycastle/src/main/resources/META-INF/services/io.opentdf.platform.sdk.HkdfProvider new file mode 100644 index 00000000..bc38f948 --- /dev/null +++ b/sdk-fips-bouncycastle/src/main/resources/META-INF/services/io.opentdf.platform.sdk.HkdfProvider @@ -0,0 +1 @@ +io.opentdf.platform.sdk.fips.bouncycastle.BouncyCastleFipsHkdfProvider diff --git a/sdk-fips-bouncycastle/src/test/java/io/opentdf/platform/sdk/fips/bouncycastle/BouncyCastleFipsHkdfProviderTest.java b/sdk-fips-bouncycastle/src/test/java/io/opentdf/platform/sdk/fips/bouncycastle/BouncyCastleFipsHkdfProviderTest.java new file mode 100644 index 00000000..95b98fcc --- /dev/null +++ b/sdk-fips-bouncycastle/src/test/java/io/opentdf/platform/sdk/fips/bouncycastle/BouncyCastleFipsHkdfProviderTest.java @@ -0,0 +1,63 @@ +package io.opentdf.platform.sdk.fips.bouncycastle; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +class BouncyCastleFipsHkdfProviderTest { + + private final BouncyCastleFipsHkdfProvider provider = new BouncyCastleFipsHkdfProvider(); + + @Test + void computeHKDF_returns32Bytes() { + byte[] salt = "test-salt".getBytes(StandardCharsets.UTF_8); + byte[] secret = "test-secret".getBytes(StandardCharsets.UTF_8); + + byte[] result = provider.computeHKDF(salt, secret); + + assertThat(result).hasSize(32); + } + + @Test + void computeHKDF_isDeterministic() { + byte[] salt = "salt".getBytes(StandardCharsets.UTF_8); + byte[] secret = "secret".getBytes(StandardCharsets.UTF_8); + + byte[] first = provider.computeHKDF(salt, secret); + byte[] second = provider.computeHKDF(salt, secret); + + assertThat(first).isEqualTo(second); + } + + @Test + void computeHKDF_nullSaltMatchesEmptySalt() { + byte[] secret = "secret".getBytes(StandardCharsets.UTF_8); + + byte[] withNull = provider.computeHKDF(null, secret); + byte[] withEmpty = provider.computeHKDF(new byte[0], secret); + + assertThat(withNull).isEqualTo(withEmpty); + } + + @Test + void computeHKDF_nullSaltMatchesZeroSalt() { + byte[] secret = "secret".getBytes(StandardCharsets.UTF_8); + + byte[] withNull = provider.computeHKDF(null, secret); + byte[] withZero = provider.computeHKDF(new byte[32], secret); + + assertThat(withNull).isEqualTo(withZero); + } + + @Test + void computeHKDF_throwsOnNullSecret() { + byte[] salt = "salt".getBytes(StandardCharsets.UTF_8); + + assertThatNullPointerException() + .isThrownBy(() -> provider.computeHKDF(salt, null)) + .withMessage("secret must not be null"); + } +} diff --git a/sdk/pom.xml b/sdk/pom.xml index de6b7618..c8ffddaa 100644 --- a/sdk/pom.xml +++ b/sdk/pom.xml @@ -420,7 +420,6 @@ src/main/kotlin - target/generated-sources @@ -496,18 +495,37 @@ false - -Djava.security.properties=${project.basedir}/src/test/resources/java.security.fips.test + -Djava.security.properties==${project.basedir}/src/test/resources/java.security.fips.test -Dorg.bouncycastle.fips.approved_only=true -Djavax.net.ssl.trustStore=${project.basedir}/src/test/resources/empty-fips-truststore.bcfks -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=BCFKS + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + + io.opentdf.platform + sdk-fips-bouncycastle + ${project.version} + + + + + + org.bouncycastle bc-fips - runtime + test org.bouncycastle bctls-fips - runtime + test org.bouncycastle diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AsymDecryption.java b/sdk/src/main/java/io/opentdf/platform/sdk/AsymDecryption.java index b76d1523..23ae25fe 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/AsymDecryption.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/AsymDecryption.java @@ -1,8 +1,6 @@ package io.opentdf.platform.sdk; -import javax.crypto.BadPaddingException; import javax.crypto.Cipher; -import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import java.security.*; import java.security.spec.InvalidKeySpecException; @@ -67,13 +65,14 @@ public byte[] decrypt(byte[] data) { throw new SDKException("error getting instance of cipher", e); } try { - cipher.init(Cipher.DECRYPT_MODE, this.privateKey); + cipher.init(Cipher.UNWRAP_MODE, this.privateKey); } catch (InvalidKeyException e) { throw new SDKException("error initializing cipher", e); } try { - return cipher.doFinal(data); - } catch (IllegalBlockSizeException | BadPaddingException e) { + Key key = cipher.unwrap(data, "AES", Cipher.SECRET_KEY); + return key.getEncoded(); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new SDKException("error performing decryption", e); } } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AsymEncryption.java b/sdk/src/main/java/io/opentdf/platform/sdk/AsymEncryption.java index 3a81b1f5..93989fca 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/AsymEncryption.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/AsymEncryption.java @@ -1,9 +1,9 @@ package io.opentdf.platform.sdk; -import javax.crypto.BadPaddingException; import javax.crypto.Cipher; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.SecretKeySpec; import java.io.ByteArrayInputStream; import java.security.*; @@ -99,13 +99,13 @@ public byte[] encrypt(byte[] data) { throw new SDKException("error getting instance of cipher during encryption", e); } try { - cipher.init(Cipher.ENCRYPT_MODE, this.publicKey); + cipher.init(Cipher.WRAP_MODE, this.publicKey); } catch (InvalidKeyException e) { throw new SDKException("error encrypting with private key", e); } try { - return cipher.doFinal(data); - } catch (IllegalBlockSizeException | BadPaddingException e) { + return cipher.wrap(new SecretKeySpec(data, "AES")); + } catch (IllegalBlockSizeException | InvalidKeyException e) { throw new SDKException("error performing encryption", e); } } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/ECKeyPair.java b/sdk/src/main/java/io/opentdf/platform/sdk/ECKeyPair.java index 92d778bd..ef906d06 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/ECKeyPair.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/ECKeyPair.java @@ -1,5 +1,8 @@ package io.opentdf.platform.sdk; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import javax.crypto.KeyAgreement; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -26,6 +29,7 @@ public class ECKeyPair { private static final int SHA256_BYTES = 32; private static final String EC_ALGORITHM = "EC"; + private static final Logger log = LoggerFactory.getLogger(ECKeyPair.class); private final ECCurve curve; @@ -106,8 +110,21 @@ public static byte[] computeECDHKey(ECPublicKey publicKey, ECPrivateKey privateK /** * Returns a HKDF key derived from the provided salt and secret * that is 32 bytes (256 bits) long. + * + * Delegates to a registered {@link HkdfProvider} when one is available on the + * classpath (e.g. {@code sdk-fips-bouncycastle}); otherwise falls back to the + * JDK-native HmacSHA256 implementation. */ public static byte[] calculateHKDF(byte[] salt, byte[] secret) { + HkdfProvider provider = HkdfResolver.get(); + if (provider != null) { + if (log.isDebugEnabled()) { + log.debug("Using resolved HKDF provider of type {}", provider.getClass().getName()); + } + return provider.computeHKDF(salt, secret); + } + + log.debug("using SDK HKDF implementation"); try { // RFC 5869: if salt is absent, substitute a zero-filled buffer of Hash output size. byte[] effectiveSalt = (salt == null || salt.length == 0) ? new byte[SHA256_BYTES] : salt; @@ -119,8 +136,14 @@ public static byte[] calculateHKDF(byte[] salt, byte[] secret) { hmac.init(new SecretKeySpec(prk, HMAC_SHA_256)); hmac.update((byte) 0x01); return hmac.doFinal(); - } catch (NoSuchAlgorithmException | InvalidKeyException e) { - throw new RuntimeException(e); + } catch (NoSuchAlgorithmException e) { + throw new SDKException("error computing HKDF", e) ; + } catch (Exception e) { + String className = e.getClass().getName(); + if (className.contains("bouncycastle") && className.endsWith("IllegalKeyException")) { + throw new SDKException("if running bouncycastle FIPS in approved_only mode include the sdk-fips-bouncycastle jar to use HKDF", e); + } + throw new SDKException("error computing HKDF", e); } } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/HkdfProvider.java b/sdk/src/main/java/io/opentdf/platform/sdk/HkdfProvider.java new file mode 100644 index 00000000..57581619 --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/HkdfProvider.java @@ -0,0 +1,18 @@ +package io.opentdf.platform.sdk; + +/** + * Service Provider Interface for HKDF (RFC 5869) key derivation. + * Implementations are discovered at runtime via {@link java.util.ServiceLoader}. + * When no implementation is on the classpath, {@link ECKeyPair#calculateHKDF} falls + * back to the JDK-native HmacSHA256 implementation. + * + * The FIPS-approved implementation is {@code io.opentdf.platform:sdk-fips-bouncycastle}, + * which uses the BouncyCastle FIPS KDF API directly. + */ +public interface HkdfProvider { + /** + * Derive a 32-byte key using HKDF-Extract+Expand with SHA-256 HMAC PRF + * and empty info, per RFC 5869. + */ + byte[] computeHKDF(byte[] salt, byte[] secret); +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/HkdfResolver.java b/sdk/src/main/java/io/opentdf/platform/sdk/HkdfResolver.java new file mode 100644 index 00000000..45c99fc4 --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/HkdfResolver.java @@ -0,0 +1,27 @@ +package io.opentdf.platform.sdk; + +import java.util.ServiceLoader; + +/** + * Locates a registered {@link HkdfProvider} via {@link ServiceLoader}. + * Returns {@code null} when no provider is registered, signalling + * the caller to use the JDK-native fallback. + */ +final class HkdfResolver { + + private HkdfResolver() {} + + private static final class Holder { + static final HkdfProvider PROVIDER = load(); + + private static HkdfProvider load() { + return ServiceLoader.load(HkdfProvider.class, HkdfResolver.class.getClassLoader()) + .findFirst() + .orElse(null); + } + } + + static HkdfProvider get() { + return Holder.PROVIDER; + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/ECKeyPairTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/ECKeyPairTest.java index be9e813b..537e2f8f 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/ECKeyPairTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/ECKeyPairTest.java @@ -2,13 +2,12 @@ import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; + +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; -import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.security.*; -import java.security.cert.CertificateException; -import java.security.spec.InvalidKeySpecException; import java.util.Arrays; import java.util.Base64; @@ -16,6 +15,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; public class ECKeyPairTest { @@ -84,8 +84,7 @@ void ecPublicKeyInPemformat() { } @Test - void extractPemPubKeyFromX509() throws CertificateException, IOException, NoSuchAlgorithmException, - InvalidKeySpecException, NoSuchProviderException, InvalidAlgorithmParameterException, InvalidKeyException { + void extractPemPubKeyFromX509() { String x509ECPubKey = "-----BEGIN CERTIFICATE-----\n" + "MIIBCzCBsgIJAK3Uxk7fP5oWMAoGCCqGSM49BAMCMA4xDDAKBgNVBAMMA2thczAe\n" + "Fw0yMzA0MjQxNzQ2MTVaFw0yNDA0MjMxNzQ2MTVaMA4xDDAKBgNVBAMMA2thczBZ\n" + @@ -178,4 +177,12 @@ void testECDSA() { assertEquals(verify, true); } } + + @Test + @Disabled("remove the additionalClassDependencies element in the FIPS profile to execute this test") + @EnabledIfSystemProperty(named = "org.bouncycastle.fips.approved_only", matches = "true") + void testInformativeException() { + var thrown = assertThrows(SDKException.class, () -> ECKeyPair.calculateHKDF(new byte[]{0}, new byte[]{1,2,3})); + assertThat(thrown).hasMessage("if running bouncycastle FIPS in approved_only mode include the sdk-fips-bouncycastle jar to use HKDF"); + } } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/FipsProviderVerificationTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/FipsProviderVerificationTest.java new file mode 100644 index 00000000..c86a6082 --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/FipsProviderVerificationTest.java @@ -0,0 +1,58 @@ +package io.opentdf.platform.sdk; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.security.Security; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the java.security.fips.test properties file was actually loaded when running + * under the fips Maven profile. Without this check, a misconfigured argLine would silently run + * all other tests against the default (non-FIPS) provider stack. + */ +@EnabledIfSystemProperty(named = "org.bouncycastle.fips.approved_only", matches = "true") +class FipsProviderVerificationTest { + + @Test + void bcFipsIsFirstProvider() { + var providers = Security.getProviders(); + assertThat(providers) + .as("No security providers registered") + .isNotNull() + .isNotEmpty(); + assertThat(providers[0].getName()) + .as("Expected BCFIPS as the first security provider but got: %s - the java.security.fips.test file was likely not loaded", + providers[0].getName()) + .isEqualTo("BCFIPS"); + } + + @Test + void bcJsseIsRegistered() { + assertThat(Security.getProvider("BCJSSE")) + .as("BCJSSE provider is not registered - the java.security.fips.test file was likely not loaded") + .isNotNull(); + } + + @Test + void sunJceIsNotRegistered() { + assertThat(Security.getProvider("SunJCE")) + .as("SunJCE provider is still registered - it should have been removed by java.security.fips.test") + .isNull(); + } + + @Test + void keyManagerFactoryAlgorithmIsPkix() { + assertThat(Security.getProperty("ssl.KeyManagerFactory.algorithm")) + .as("ssl.KeyManagerFactory.algorithm was not overridden to PKIX - the java.security.fips.test file was likely not loaded") + .isEqualTo("PKIX"); + } + + @Test + void providerResolves() { + assertThat(HkdfResolver.get()) + .as("the sdk-fips-bouncycastle library must be on the path so that the Hkdf provider resolves. this is configured in the surefire plugin and the sdk-fips-bouncycastle project must be packaged") + .isNotNull(); + } +} diff --git a/sdk/src/test/resources/empty-fips-truststore.bcfks b/sdk/src/test/resources/empty-fips-truststore.bcfks new file mode 100644 index 00000000..bbd77772 Binary files /dev/null and b/sdk/src/test/resources/empty-fips-truststore.bcfks differ diff --git a/sdk/src/test/resources/java.security.fips.test b/sdk/src/test/resources/java.security.fips.test index d14bb542..7b6d74fe 100644 --- a/sdk/src/test/resources/java.security.fips.test +++ b/sdk/src/test/resources/java.security.fips.test @@ -2,6 +2,7 @@ # support them. tell it to use PKIX instead which is supported by BC ssl.KeyManagerFactory.algorithm=PKIX ssl.TrustManagerFactory.algorithm=PKIX +keystore.type=FIPS # the SUN provider is required so that we can get the NativePRNGBlocking algorithm securerandom.strongAlgorithms=NativePRNGBlocking:SUN @@ -9,16 +10,3 @@ securerandom.strongAlgorithms=NativePRNGBlocking:SUN security.provider.1=org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider security.provider.2=org.bouncycastle.jsse.provider.BouncyCastleJsseProvider fips:BCFIPS security.provider.3=SUN - -# since this file is appended we need to make sure that we remove the other providers -security.provider.4= -security.provider.5= -security.provider.6= -security.provider.7= -security.provider.8= -security.provider.9= -security.provider.10= -security.provider.11= -security.provider.12= -security.provider.13= -security.provider.14= \ No newline at end of file