diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java index 5b5edc39..4cd68658 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java @@ -31,7 +31,6 @@ import com.nimbusds.jose.crypto.RSASSAVerifier; import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory; import com.nimbusds.jose.jwk.JWK; -import com.nimbusds.jose.util.X509CertUtils; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import io.opentdf.platform.sdk.SDK.AssertionException; @@ -52,7 +51,6 @@ import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.interfaces.RSAPublicKey; -import java.security.cert.X509Certificate; import java.text.ParseException; import java.util.AbstractList; import java.util.ArrayList; @@ -1008,6 +1006,11 @@ public void sign(final HashValues hashValues, final AssertionConfig.AssertionKey // Checks the binding signature of the assertion and // returns the hash and the signature. It returns an error if the verification // fails. + // + // assertionKey is the key the reader selected for this assertion: a configured + // key, or the payload key with HS256. It is the only key used, and only with its + // own algorithm. A jwk or x5c in the JWS header never supplies the verification + // key, and the header's alg must equal the key's algorithm. public Assertion.HashValues verify(AssertionConfig.AssertionKey assertionKey) throws ParseException, JOSEException, java.security.cert.CertificateException { if (binding == null) { @@ -1018,38 +1021,16 @@ public Assertion.HashValues verify(AssertionConfig.AssertionKey assertionKey) binding = null; // Clear the binding after use SignedJWT signedJWT = SignedJWT.parse(signatureString); - JWSHeader header = signedJWT.getHeader(); - JWSVerifier verifier = null; - - // Check for JWK in header - if (header.getJWK() != null) { - try { - verifier = createVerifier(header.getJWK()); - } catch (JOSEException e) { - throw new SDKException("Invalid JWK in JWT header", e); - } - } - - // Check for X.509 certificate chain in header - if (verifier == null && header.getX509CertChain() != null && !header.getX509CertChain().isEmpty()) { - try { - X509Certificate cert = X509CertUtils.parse(header.getX509CertChain().get(0).decode()); - if (cert.getPublicKey() instanceof RSAPublicKey) { - verifier = createVerifier((RSAPublicKey) cert.getPublicKey()); - } else { - throw new SDKException("Unsupported public key type in X.509 certificate"); - } - } catch (IllegalArgumentException e) { - throw new SDKException("Invalid Base64 in X.509 certificate in JWT header", e); - } + JWSVerifier verifier = createVerifier(assertionKey); + + // RSASSAVerifier and MACVerifier accept every RS* or HS* algorithm, so pin it here. + JWSAlgorithm keyAlg = JWSAlgorithm.parse(assertionKey.alg.name()); + JWSAlgorithm headerAlg = signedJWT.getHeader().getAlgorithm(); + if (!keyAlg.equals(headerAlg)) { + throw new AssertionException("binding alg " + headerAlg + + " does not match the verification key's alg " + keyAlg, this.id); } - - if (verifier == null) { - verifier = createVerifier(assertionKey); - } - - if (!signedJWT.verify(verifier)) { throw new SDKException("Unable to verify assertion signature"); } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AssertionConfiguredKeyTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionConfiguredKeyTest.java new file mode 100644 index 00000000..67577232 --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionConfiguredKeyTest.java @@ -0,0 +1,155 @@ +package io.opentdf.platform.sdk; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.SecureRandom; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * An assertion binding is verified only with the key the reader selected for it, and only with + * that key's algorithm. A jwk or x5c in the binding's JWS header is never used as the + * verification key. + */ +class AssertionConfiguredKeyTest { + + private static final String ID = "assertion1"; + + private static Manifest.Assertion newAssertion() { + var assertion = new Manifest.Assertion(); + assertion.id = ID; + assertion.type = AssertionConfig.Type.BaseAssertion.toString(); + assertion.scope = AssertionConfig.Scope.TrustedDataObj.toString(); + assertion.appliesToState = AssertionConfig.AppliesToState.Unencrypted.toString(); + assertion.statement = new AssertionConfig.Statement(); + assertion.statement.format = "string"; + assertion.statement.schema = "text"; + assertion.statement.value = "value"; + return assertion; + } + + private static Manifest.Assertion signedWith(AssertionConfig.AssertionKey signingKey) throws Exception { + var assertion = newAssertion(); + assertion.sign(new Manifest.Assertion.HashValues("hash", "sig"), signingKey); + return assertion; + } + + /** An assertion whose binding is a JWS with the given header alg, signed by {@code signer}. */ + private static Manifest.Assertion boundWith(JWSAlgorithm alg, com.nimbusds.jose.JWSSigner signer) + throws Exception { + var claims = new JWTClaimsSet.Builder() + .claim("assertionHash", "hash") + .claim("assertionSig", "sig") + .build(); + var jwt = new SignedJWT(new JWSHeader(alg), claims); + jwt.sign(signer); + var assertion = newAssertion(); + assertion.binding = new Manifest.Binding(); + assertion.binding.method = "jws"; + assertion.binding.signature = jwt.serialize(); + return assertion; + } + + private static AssertionConfig.AssertionKey rs256(Object key) { + return new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, key); + } + + private static AssertionConfig.AssertionKey hs256(byte[] key) { + return new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, key); + } + + private static byte[] secret(int length) { + var secret = new byte[length]; + new SecureRandom().nextBytes(secret); + return secret; + } + + private static JWK publicJwk(KeyPair keypair) throws Exception { + return JWK.parse(CryptoUtils.getPublicKeyJWK(keypair.getPublic())).toPublicJWK(); + } + + @Test + void embeddedJwkOfAnotherKeyDoesNotVerify() throws Exception { + var configured = CryptoUtils.generateRSAKeypair(); + var other = CryptoUtils.generateRSAKeypair(); + var assertion = signedWith(rs256(other.getPrivate()).withJwk(publicJwk(other))); + var verificationKey = rs256(configured.getPublic()); + + assertThatThrownBy(() -> assertion.verify(verificationKey)) + .isInstanceOf(SDKException.class); + } + + @Test + void embeddedX5cOfAnotherKeyDoesNotVerify() throws Exception { + var configured = CryptoUtils.generateRSAKeypair(); + var other = CryptoUtils.generateRSAKeypair(); + var cert = TestUtil.createTestCertificate(other.getPublic(), other.getPrivate()); + var x5c = List.of(com.nimbusds.jose.util.Base64.encode(cert.getEncoded())); + var assertion = signedWith(rs256(other.getPrivate()).withX5c(x5c)); + var verificationKey = rs256(configured.getPublic()); + + assertThatThrownBy(() -> assertion.verify(verificationKey)) + .isInstanceOf(SDKException.class); + } + + @Test + void embeddedJwkDoesNotReplaceThePayloadKey() throws Exception { + // With no key configured, the reader selects the payload key with HS256. + var signer = CryptoUtils.generateRSAKeypair(); + var assertion = signedWith(rs256(signer.getPrivate()).withJwk(publicJwk(signer))); + var verificationKey = hs256(secret(32)); + + assertThatThrownBy(() -> assertion.verify(verificationKey)) + .isInstanceOf(SDK.AssertionException.class) + .hasMessageContaining(ID); + } + + @Test + void embeddedJwkOfTheConfiguredKeyVerifies() throws Exception { + var signer = CryptoUtils.generateRSAKeypair(); + var assertion = signedWith(rs256(signer.getPrivate()).withJwk(publicJwk(signer))); + + var hashValues = assertion.verify(rs256(signer.getPublic())); + + assertThat(hashValues.getAssertionHash()).isEqualTo("hash"); + assertThat(hashValues.getSignature()).isEqualTo("sig"); + } + + @Test + void headerAlgMustEqualTheRsaKeyAlg() throws Exception { + var signer = CryptoUtils.generateRSAKeypair(); + var verificationKey = rs256(signer.getPublic()); + for (var alg : List.of(JWSAlgorithm.RS384, JWSAlgorithm.RS512, JWSAlgorithm.PS256)) { + var assertion = boundWith(alg, new RSASSASigner(signer.getPrivate())); + assertThatThrownBy(() -> assertion.verify(verificationKey)) + .as(alg.getName()) + .isInstanceOf(SDK.AssertionException.class); + } + var control = boundWith(JWSAlgorithm.RS256, new RSASSASigner(signer.getPrivate())); + assertThat(control.verify(rs256(signer.getPublic())).getAssertionHash()).isEqualTo("hash"); + } + + @Test + void headerAlgMustEqualTheHmacKeyAlg() throws Exception { + var key = secret(64); + var verificationKey = hs256(key); + for (var alg : List.of(JWSAlgorithm.HS384, JWSAlgorithm.HS512)) { + var assertion = boundWith(alg, new MACSigner(key)); + assertThatThrownBy(() -> assertion.verify(verificationKey)) + .as(alg.getName()) + .isInstanceOf(SDK.AssertionException.class); + } + var control = boundWith(JWSAlgorithm.HS256, new MACSigner(key)); + assertThat(control.verify(hs256(key)).getAssertionHash()).isEqualTo("hash"); + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java index cbc497de..1e12203a 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java @@ -3,7 +3,6 @@ import com.connectrpc.ResponseMessage; import com.connectrpc.UnaryBlockingCall; import com.google.gson.reflect.TypeToken; -import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.jwk.JWK; import com.google.gson.Gson; import com.google.gson.JsonObject; @@ -455,11 +454,37 @@ void testVerifyAssertionWithJwkHeaderInJWT() throws Exception { .setKeyAccessServerRegistryService(kasRegistryService).build()); tdf.createTDF(plainTextInputStream, tdfOutputStream, config); + assertEmbeddedKeyAddsNoTrust(tdf, tdfOutputStream.toByteArray(), keypair, plainText); + } + + /** + * A TDF whose assertion was signed by {@code signer} and carries the signer's public key in + * its JWS header. The embedded key grants nothing by itself: with no key configured the + * payload key with HS256 is selected and the RS256 binding fails, and an unrelated configured + * key fails too. Configuring the signer's key is what makes it verify. + */ + private void assertEmbeddedKeyAddsNoTrust(TDF tdf, byte[] tdfBytes, KeyPair signer, String plainText) + throws Exception { + var noKeyChannel = new SeekableInMemoryByteChannel(tdfBytes); + var noKeyConfig = Config.newTDFReaderConfig(); + assertThatThrownBy(() -> tdf.loadTDF(noKeyChannel, noKeyConfig, platformUrl)) + .isInstanceOf(SDK.AssertionException.class); + + var unrelated = new Config.AssertionVerificationKeys(); + unrelated.defaultKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, + CryptoUtils.generateRSAKeypair().getPublic()); + var unrelatedChannel = new SeekableInMemoryByteChannel(tdfBytes); + var unrelatedConfig = Config.newTDFReaderConfig(Config.withAssertionVerificationKeys(unrelated)); + assertThatThrownBy(() -> tdf.loadTDF(unrelatedChannel, unrelatedConfig, platformUrl)) + .isInstanceOf(SDKException.class); + + var configured = new Config.AssertionVerificationKeys(); + configured.defaultKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, + signer.getPublic()); var unwrappedData = new ByteArrayOutputStream(); - var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), - Config.newTDFReaderConfig(), platformUrl); + var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfBytes), + Config.newTDFReaderConfig(Config.withAssertionVerificationKeys(configured)), platformUrl); reader.readPayload(unwrappedData); - assertThat(unwrappedData.toString(StandardCharsets.UTF_8)) .withFailMessage("extracted data does not match") .isEqualTo(plainText); @@ -503,14 +528,7 @@ void testVerifyAssertionWithX5cHeaderInJWT() throws Exception { .setKeyAccessServerRegistryService(kasRegistryService).build()); tdf.createTDF(plainTextInputStream, tdfOutputStream, config); - var unwrappedData = new ByteArrayOutputStream(); - var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), - Config.newTDFReaderConfig(), platformUrl); - reader.readPayload(unwrappedData); - - assertThat(unwrappedData.toString(StandardCharsets.UTF_8)) - .withFailMessage("extracted data does not match") - .isEqualTo(plainText); + assertEmbeddedKeyAddsNoTrust(tdf, tdfOutputStream.toByteArray(), keypair, plainText); } @Test @@ -551,10 +569,10 @@ void testWithAssertionVerificationDisabled() throws Exception { var unwrappedData = new ByteArrayOutputStream(); var dataToUnwrap = new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()); var emptyConfig = Config.newTDFReaderConfig(); - var thrown = assertThrows(SDKException.class, () -> { + // no key configured, so the payload key with HS256 is selected, and the RS256 binding fails + assertThrows(SDK.AssertionException.class, () -> { tdf.loadTDF(dataToUnwrap, emptyConfig, platformUrl); }); - assertThat(thrown.getCause()).isInstanceOf(JOSEException.class); // try with assertion verification disabled and not passing the assertion // verification keys