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