diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/LicenseAuthenticator.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/LicenseAuthenticator.java new file mode 100644 index 000000000..5013e8bb6 --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/LicenseAuthenticator.java @@ -0,0 +1,110 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.responsive.kafka.internal.license.exception.LicenseAuthenticationException; +import dev.responsive.kafka.internal.license.model.LicenseDocument; +import dev.responsive.kafka.internal.license.model.LicenseDocumentV1; +import dev.responsive.kafka.internal.license.model.LicenseInfo; +import dev.responsive.kafka.internal.license.model.SigningKeys; +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Objects; + +public class LicenseAuthenticator { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final SigningKeys signingKeys; + + public LicenseAuthenticator(final SigningKeys signingKeys) { + this.signingKeys = Objects.requireNonNull(signingKeys); + } + + public LicenseInfo authenticate(final LicenseDocument license) { + if (license instanceof LicenseDocumentV1) { + return authenticateLicenseV1((LicenseDocumentV1) license); + } else { + throw new IllegalArgumentException( + "unrecognized license doc type: " + license.getClass().getName()); + } + } + + private LicenseInfo authenticateLicenseV1(final LicenseDocumentV1 license) { + final byte[] infoBytes = verifyLicenseV1Signature(license); + try { + return MAPPER.readValue(infoBytes, LicenseInfo.class); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + + private byte[] verifyLicenseV1Signature(final LicenseDocumentV1 license) { + if (!license.algo().equals("RSASSA_PSS_SHA_256")) { + throw new IllegalArgumentException("unrecognized license algo: " + license.algo()); + } + final PublicKey publicKey = loadPublicKey(signingKeys.lookupKey(license.key())); + final Signature signature; + try { + signature = Signature.getInstance("RSASSA-PSS"); + signature.setParameter( + new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1) + ); + signature.initVerify(publicKey); + final byte[] info = license.decodeInfo(); + signature.update(info); + if (!signature.verify(license.decodeSignature())) { + throw new LicenseAuthenticationException("license info did not match signature"); + } + return info; + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } + } + + private PublicKey loadPublicKey(final SigningKeys.SigningKey signingKey) { + final File file; + try { + file = new File(this.getClass().getClassLoader().getResource(signingKey.path()).toURI()); + } catch (final URISyntaxException e) { + throw new RuntimeException(e); + } + final byte[] publicKeyBytes = PublicKeyPemFileParser.parsePemFile(file); + final KeyFactory keyFactory; + try { + keyFactory = KeyFactory.getInstance("RSA"); + } catch (final NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + final X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes); + try { + return keyFactory.generatePublic(keySpec); + } catch (final InvalidKeySpecException e) { + throw new RuntimeException(e); + } + } +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/LicenseChecker.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/LicenseChecker.java new file mode 100644 index 000000000..4913519c8 --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/LicenseChecker.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license; + +import dev.responsive.kafka.internal.license.exception.LicenseUseViolationException; +import dev.responsive.kafka.internal.license.model.LicenseInfo; +import dev.responsive.kafka.internal.license.model.TimedTrialV1; +import java.time.Instant; + +public class LicenseChecker { + public void checkLicense(final LicenseInfo licenseInfo) { + if (licenseInfo instanceof TimedTrialV1) { + verifyTimedTrialV1((TimedTrialV1) licenseInfo); + } else { + throw new IllegalArgumentException( + "unsupported license type: " + licenseInfo.getClass().getName()); + } + } + + private void verifyTimedTrialV1(final TimedTrialV1 timedTrial) { + final Instant expiresAt = Instant.ofEpochSecond(timedTrial.expiresAt()); + if (Instant.now().isAfter(expiresAt)) { + throw new LicenseUseViolationException("license expired at: " + expiresAt); + } + } +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/PublicKeyPemFileParser.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/PublicKeyPemFileParser.java new file mode 100644 index 000000000..1f45ffdf1 --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/PublicKeyPemFileParser.java @@ -0,0 +1,56 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Base64; +import java.util.List; + +public class PublicKeyPemFileParser { + private static final String HEADER_PREFIX = "-----"; + private static final String BEGIN_PUBLIC_KEY = "BEGIN PUBLIC KEY"; + private static final String END_PUBLIC_KEY = "END PUBLIC KEY"; + private static final String BEGIN_PUBLIC_KEY_HEADER + = HEADER_PREFIX + BEGIN_PUBLIC_KEY + HEADER_PREFIX; + private static final String END_PUBLIC_KEY_HEADER + = HEADER_PREFIX + END_PUBLIC_KEY + HEADER_PREFIX; + + public static byte[] parsePemFile(final File file) { + final List lines; + try { + lines = Files.readAllLines(file.toPath()); + } catch (IOException e) { + throw new RuntimeException(e); + } + final StringBuilder keyB64Builder = new StringBuilder(); + boolean foundBegin = false; + for (final String l : lines) { + if (l.equals(BEGIN_PUBLIC_KEY_HEADER)) { + foundBegin = true; + } else if (foundBegin) { + if (l.equals(END_PUBLIC_KEY_HEADER)) { + final String keyB64 = keyB64Builder.toString(); + return Base64.getDecoder().decode(keyB64); + } + keyB64Builder.append(l); + } + } + throw new IllegalArgumentException("invalid public key pem"); + } +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/exception/LicenseAuthenticationException.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/exception/LicenseAuthenticationException.java new file mode 100644 index 000000000..d4773d3a7 --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/exception/LicenseAuthenticationException.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license.exception; + +public class LicenseAuthenticationException extends LicenseException { + private static final long serialVersionUID = 0L; + + public LicenseAuthenticationException(final String message) { + super(message); + } +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/exception/LicenseException.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/exception/LicenseException.java new file mode 100644 index 000000000..219b718db --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/exception/LicenseException.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license.exception; + +public class LicenseException extends RuntimeException { + private static final long serialVersionUID = 0L; + + public LicenseException(final String message) { + super(message); + } +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/exception/LicenseUseViolationException.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/exception/LicenseUseViolationException.java new file mode 100644 index 000000000..b48ae43f3 --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/exception/LicenseUseViolationException.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license.exception; + +public class LicenseUseViolationException extends LicenseException { + private static final long serialVersionUID = 0L; + + public LicenseUseViolationException(final String message) { + super(message); + } +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseDocument.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseDocument.java new file mode 100644 index 000000000..8913e2d21 --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseDocument.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "version", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = LicenseDocumentV1.class, name = "1") +}) +public abstract class LicenseDocument { + private final String version; + + @JsonCreator + public LicenseDocument(@JsonProperty("version") final String version) { + this.version = version; + } +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseDocumentV1.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseDocumentV1.java new file mode 100644 index 000000000..ea3b9598c --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseDocumentV1.java @@ -0,0 +1,69 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Base64; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class LicenseDocumentV1 extends LicenseDocument { + private final String info; + private final String signature; + private final String key; + private final String algo; + + @JsonCreator + public LicenseDocumentV1( + @JsonProperty("version") final String version, + @JsonProperty("info") final String info, + @JsonProperty("signature") final String signature, + @JsonProperty("key") final String key, + @JsonProperty("algo") final String algo + ) { + super(version); + this.info = info; + this.signature = signature; + this.key = key; + this.algo = algo; + } + + public String info() { + return info; + } + + public String key() { + return key; + } + + public String signature() { + return signature; + } + + public String algo() { + return algo; + } + + public byte[] decodeInfo() { + return Base64.getDecoder().decode(info); + } + + public byte[] decodeSignature() { + return Base64.getDecoder().decode(signature); + } +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseInfo.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseInfo.java new file mode 100644 index 000000000..3f59e6bc6 --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseInfo.java @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "type", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TimedTrialV1.class, name = "timed_trial_v1") +}) +public abstract class LicenseInfo { + private final String type; + + LicenseInfo(@JsonProperty("type") final String type) { + this.type = type; + } +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseType.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseType.java new file mode 100644 index 000000000..337c4a16d --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/LicenseType.java @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum LicenseType { + @JsonProperty("timed_trial") + TIMED_TRIAL +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/SigningKeys.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/SigningKeys.java new file mode 100644 index 000000000..1c302e39f --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/SigningKeys.java @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Objects; + +public class SigningKeys { + private final List keys; + + @JsonCreator + public SigningKeys(@JsonProperty("keys") final List keys) { + this.keys = Objects.requireNonNull(keys); + } + + public SigningKey lookupKey(final String keyId) { + return keys.stream() + .filter(k -> k.keyId.equals(keyId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Key not found: " + keyId)); + } + + public enum KeyType { + RSA_4096 + } + + public static class SigningKey { + private final KeyType type; + private final String keyId; + private final String path; + + @JsonCreator + public SigningKey( + @JsonProperty("type") final KeyType type, + @JsonProperty("keyId") final String keyId, + @JsonProperty("path") final String path + ) { + this.type = type; + this.keyId = keyId; + this.path = path; + } + + public KeyType type() { + return type; + } + + public String path() { + return path; + } + } +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/TimedTrialV1.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/TimedTrialV1.java new file mode 100644 index 000000000..70178c900 --- /dev/null +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/license/model/TimedTrialV1.java @@ -0,0 +1,44 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; + +public class TimedTrialV1 extends LicenseInfo { + private final String email; + private final long issuedAt; + private final long expiresAt; + + @JsonCreator + public TimedTrialV1( + @JsonProperty("type") final String type, + @JsonProperty("email") final String email, + @JsonProperty("issuedAt") final long issuedAt, + @JsonProperty("expiresAt") final long expiresAt + ) { + super(type); + this.email = Objects.requireNonNull(email); + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public long expiresAt() { + return expiresAt; + } +} diff --git a/kafka-client/src/main/java/dev/responsive/kafka/internal/utils/Utils.java b/kafka-client/src/main/java/dev/responsive/kafka/internal/utils/Utils.java index 5b77bce13..37e8ec12e 100644 --- a/kafka-client/src/main/java/dev/responsive/kafka/internal/utils/Utils.java +++ b/kafka-client/src/main/java/dev/responsive/kafka/internal/utils/Utils.java @@ -121,5 +121,4 @@ public static String extractThreadIdFromThreadName(final String threadName) { LOG.warn("Unable to parse the stream thread id, falling back to thread name {}", threadName); return threadName; } - } diff --git a/kafka-client/src/test/java/dev/responsive/kafka/api/ResponsiveKafkaStreamsTest.java b/kafka-client/src/test/java/dev/responsive/kafka/api/ResponsiveKafkaStreamsTest.java index de67fb40d..cfb5aeaf6 100644 --- a/kafka-client/src/test/java/dev/responsive/kafka/api/ResponsiveKafkaStreamsTest.java +++ b/kafka-client/src/test/java/dev/responsive/kafka/api/ResponsiveKafkaStreamsTest.java @@ -125,7 +125,7 @@ public void setUp() { properties.put(DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.LongSerde.class.getName()); properties.put(RESPONSIVE_ORG_CONFIG, "responsive"); - properties.put(RESPONSIVE_ENV_CONFIG, "test"); + properties.put(RESPONSIVE_ENV_CONFIG, "license-test"); } @SuppressWarnings("resource") diff --git a/kafka-client/src/test/java/dev/responsive/kafka/internal/license/LicenseAuthenticatorTest.java b/kafka-client/src/test/java/dev/responsive/kafka/internal/license/LicenseAuthenticatorTest.java new file mode 100644 index 000000000..27592107a --- /dev/null +++ b/kafka-client/src/test/java/dev/responsive/kafka/internal/license/LicenseAuthenticatorTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.responsive.kafka.internal.license.exception.LicenseAuthenticationException; +import dev.responsive.kafka.internal.license.model.LicenseDocument; +import dev.responsive.kafka.internal.license.model.SigningKeys; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class LicenseAuthenticatorTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final LicenseAuthenticator verifier = new LicenseAuthenticator(loadSigningKeys()); + + @Test + public void shouldVerifyLicense() { + // given: + final LicenseDocument license = loadLicense("license-test.json"); + + // when/then (no throw): + verifier.authenticate(license); + } + + @Test + public void shouldThrowForFailedSignatureVerification() { + // given: + final LicenseDocument license = loadLicense("license-test-invalid-signature.json"); + + // when/then: + assertThrows( + LicenseAuthenticationException.class, + () -> verifier.authenticate(license) + ); + } + + private static LicenseDocument loadLicense(final String file) { + return loadResource(file, LicenseDocument.class); + } + + private static SigningKeys loadSigningKeys() { + return loadResource("signing-keys.json", SigningKeys.class); + } + + private static T loadResource(final String path, final Class clazz) { + final String fullPath = "license-test/license-verifier/" + path; + try { + return MAPPER.readValue( + LicenseAuthenticatorTest.class.getClassLoader().getResource(fullPath), + clazz + ); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/kafka-client/src/test/java/dev/responsive/kafka/internal/license/LicenseCheckerTest.java b/kafka-client/src/test/java/dev/responsive/kafka/internal/license/LicenseCheckerTest.java new file mode 100644 index 000000000..26d283574 --- /dev/null +++ b/kafka-client/src/test/java/dev/responsive/kafka/internal/license/LicenseCheckerTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.responsive.kafka.internal.license.exception.LicenseUseViolationException; +import dev.responsive.kafka.internal.license.model.LicenseInfo; +import dev.responsive.kafka.internal.license.model.TimedTrialV1; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class LicenseCheckerTest { + private final LicenseChecker checker = new LicenseChecker(); + + @Test + public void shouldThrowOnExpiredTrialV1License() { + // given: + final LicenseInfo info = new TimedTrialV1( + "timed_trial_v1", + "foo@bar.com", + 0, + Instant.now().minus(Duration.ofHours(1)).getEpochSecond() + ); + + // when/then: + assertThrows(LicenseUseViolationException.class, () -> checker.checkLicense(info)); + } + + @Test + public void shouldAcceptValidTrialV1License() { + // given: + final LicenseInfo info = new TimedTrialV1( + "timed_trial_v1", + "foo@bar.com", + 0, + Instant.now().plus(Duration.ofHours(1)).getEpochSecond() + ); + + // when/then (no throw): + checker.checkLicense(info); + } +} \ No newline at end of file diff --git a/kafka-client/src/test/java/dev/responsive/kafka/internal/license/PublicKeyPemFileParserTest.java b/kafka-client/src/test/java/dev/responsive/kafka/internal/license/PublicKeyPemFileParserTest.java new file mode 100644 index 000000000..750f3050f --- /dev/null +++ b/kafka-client/src/test/java/dev/responsive/kafka/internal/license/PublicKeyPemFileParserTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2024 Responsive Computing, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.responsive.kafka.internal.license; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.File; +import java.net.URISyntaxException; +import org.junit.jupiter.api.Test; + +class PublicKeyPemFileParserTest { + + @Test + public void shouldParseValidPemFile() { + // given: + final File file = getPemFile("valid.pem"); + + // when: + final byte[] key = PublicKeyPemFileParser.parsePemFile(file); + + // then: + assertThat(new String(key), is("foobarbaz")); + } + + @Test + public void shouldParsePemFileWithRealKey() { + // given: + final File file = getPemFile("valid-real.pem"); + + // when: + final byte[] key = PublicKeyPemFileParser.parsePemFile(file); + + // then: + assertThat(key.length, is(550)); + } + + @Test + public void shouldParseValidPemFileWithComment() { + // given: + final File file = getPemFile("valid-with-comment.pem"); + + // when: + final byte[] key = PublicKeyPemFileParser.parsePemFile(file); + + // then: + assertThat(new String(key), is("foobarbaz")); + } + + @Test + public void shouldFailToParseInvalidPemFileWithMissingFooter() { + // given: + final File file = getPemFile("invalid-missing-footer.pem"); + + // when/then: + assertThrows(IllegalArgumentException.class, () -> PublicKeyPemFileParser.parsePemFile(file)); + } + + @Test + public void shouldFailToParseInvalidPemFileWithMissingHeader() { + // given: + final File file = getPemFile("invalid-missing-header.pem"); + + // when/then: + assertThrows(IllegalArgumentException.class, () -> PublicKeyPemFileParser.parsePemFile(file)); + } + + private File getPemFile(final String filename) { + final String path = "license-test/public-key-pem-file-parser/" + filename; + try { + return new File( + PublicKeyPemFileParserTest.class.getClassLoader().getResource(path).toURI() + ); + } catch (final URISyntaxException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/kafka-client/src/test/resources/license-test/license-verifier/keys/test.pem b/kafka-client/src/test/resources/license-test/license-verifier/keys/test.pem new file mode 100644 index 000000000..1f5b4d8d3 --- /dev/null +++ b/kafka-client/src/test/resources/license-test/license-verifier/keys/test.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAzsXcm9qZxyeoH0MN+Pvf +2AnxXhPHB3lnJgKC/e9/j8eLHQPI4LoLcvSwB+D97XrMtgjtiMt+64Ba3YDPQzIQ +pAkYPFttW6+K8bZYmql8xMbAJLF/zeFa/gN3Pbwlc2ZGE32u/uq/0cJPtwoy22HW +kp2xITDXROCeXbRhV8aL3THCfKtCtWaS8Ms5vpEQ/qqtJ/oBTQrR9r6PwzhbZ5vT +MU8j5hCO5A2ih6shtT3Sc1yZwVM8CahoLl1XWkrT4P2VbbE120d8haSCDr+uYUNg +KxRyO56rcxfbKaoeR09+PtKrVgjzEe7v718X6GZcpcX9cXDgm1viZ4L426OZFhuq +qNtr0NYUt3FPrfj27S95RYDVe29Iovt++jIvqeb+Ffw+W1zkW/4efAJuByUse8Va +Sx/XH+QwuMc6XNNm/HcSiXq8pFuXEmMl5fXlkwBq6acEBh3GIG1MMBZzEt8j/YTB +pibYurTtPmO8k5ZC65hBeuCJLJ1dPX3BiNIUtH6EJWx7qOUP4bp9vl2aAxIhh/N9 +W29DFxXO7HDjKXK+OAeRySXg+qqQBScThUZWwUafZMRVbarQc5/E3iZKzQY9p21u +7/ZWxBCWLWi7XxYbiqlupKhG/+sSZuMHuxnP2iM0c3Yge0iPPYTU2Rpscd/92Jnb +NGz+ApGGrznw/bYk8CorwgMCAwEAAQ== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/kafka-client/src/test/resources/license-test/license-verifier/license-test-invalid-signature.json b/kafka-client/src/test/resources/license-test/license-verifier/license-test-invalid-signature.json new file mode 100644 index 000000000..ca4d8d7af --- /dev/null +++ b/kafka-client/src/test/resources/license-test/license-verifier/license-test-invalid-signature.json @@ -0,0 +1,8 @@ +{ + "info": "eyJlbWFpbCI6InJvaGFuQHJlc3BvbnNpdmUuZGV2IiwidHlwZSI6InRpbWVkX3RyaWFsX3YxIiwiaXNzdWVkQXQiOjE3MzE2NTU5MjQsImV4cGlyZXNBdCI6MjM2MjM3NTkyNH0=", + "signature": "A9qhaMdHLAga8v6fT7U7B2e61TOciEnL99ZLD/j9PtUeuzncQDXZkxornXPXwR2Pb5pQc+mlHUhOdX0vPfHtnpSNynhjCSF7CfoIJ+mgG69CBW4iyPvzURMHysTmiJcvCAUykr2rAMd76guefAUFC4HhVWVr9GJikbFkemEJAZI9tjTYNRBEEp5vg6pwsS/dg332okqb8Fpx+y02e0oKa+VKGLOFKHOxdOfewjmHLvXRXaQkoDhl3YtKk8sFfS7GrbGynAIsQ/rI2n8pTzuaEf6hQU5YSduaqFGtZtLY+AB/HgcBD0Fx9isR+N0VhmeZuS+sFUUodbK8BRrDuhQl5YqizfgrVaqr1Gi3bUQTKU+Ex4NNFOQ5NkDetiFSFJnmi50Imgh4ViirTxFR+4+PvkmKF9p173Hsmg/dWta8n1kJhMztTG5n6hAbL2R/4PX8KbtNtSwzFKll9Ytk3f0FFHYonIwPBSSF5Je/2MlK/C+M9ann+dYpA2kBDPiUNm0q853ZiP/hVWZ3kcRB65rQ0eUoZE+7ybhci+JOqUIpQxh/iLJn0rQel1pXLa6lgBeVKQlJNe3bN0t+ZEYQX7A5ZBZet7OWnNBzpOFRTg2ILRCCgB2/GK+zL/GfsETtVXWpggUgI94reAf0hhN6VnN1BGK60EtKfxFGykh8HztzF5E=", + "key": "test", + "algo": "RSASSA_PSS_SHA_256", + "messageType": "RAW", + "version": "1" +} \ No newline at end of file diff --git a/kafka-client/src/test/resources/license-test/license-verifier/license-test.json b/kafka-client/src/test/resources/license-test/license-verifier/license-test.json new file mode 100644 index 000000000..c01529dc6 --- /dev/null +++ b/kafka-client/src/test/resources/license-test/license-verifier/license-test.json @@ -0,0 +1,8 @@ +{ + "info": "eyJlbWFpbCI6InJvaGFuQHJlc3BvbnNpdmUuZGV2IiwidHlwZSI6InRpbWVkX3RyaWFsX3YxIiwiaXNzdWVkQXQiOjE3MzE2NTU5MjQsImV4cGlyZXNBdCI6MjM2MjM3NTkyNH0=", + "signature": "UqDSI9w1T35WzCRY2hdriCLlDPh3bqu24J6apLQvucXEf+VhH6Zk7Of4G8hA5w6kQUYIpoJei5UWI2FwOVdVX5kyJwdOOgp/j6I0mm5Jidv5KR6UFTd2d1n/9eg9ChFw7YspeigFL8xfns6IjipGR9mAdnRv76LEDCSsNOFUZ1YjnhM/Rxsa4c1N4K/+ZEpMYauf3A64EyJJEdUbc7y3kPlOgu8l5Dyd47M9SkVeEtTHcHR0PUODwcwRyLO9JvxwVL/65WA0fWiEkk2o0ikeKVUt4lMg9oVd32nyTNeUnG1Ak44Yt6dMlBE7BkebXLNVSNCK8AG5eE7Kkc6kZDO0KooKNe/VcD/5fLLga95gS7HiuBu2A3PB/ifovuLUKFXRP/74847kbQefVKV/HchTl2Y8Fb4/uHniF91Vapjd7sg6xc3DYrmtgv1cfIk5FSU0ag3LQEKwmfwRFL4iRVDPdtdk+CnRYbH+Ksy03VW/3YIJPWAbF4734DQncmJr3DTY2/9b5ZTk+VA2qlvQroxBi1iR0ye4hPejki5gpQBNJVEtMWFojaGpaJl+IFTFhY+cqXFxVJFKgXdz/bKUwXrzx0+u1CvPDtm3ZsPQmhVczArGNgf+eWMOfEdAr7G3+4qEeYRP9xtQ7mOduTOzven83OC8P/xHzObtfGHibm76SyU=", + "key": "test", + "algo": "RSASSA_PSS_SHA_256", + "messageType": "RAW", + "version": "1" +} diff --git a/kafka-client/src/test/resources/license-test/license-verifier/signing-keys.json b/kafka-client/src/test/resources/license-test/license-verifier/signing-keys.json new file mode 100644 index 000000000..1a016b667 --- /dev/null +++ b/kafka-client/src/test/resources/license-test/license-verifier/signing-keys.json @@ -0,0 +1,9 @@ +{ + "keys": [ + { + "type": "RSA_4096", + "keyId": "test", + "path": "license-test/license-verifier/keys/test.pem" + } + ] +} \ No newline at end of file diff --git a/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/invalid-missing-footer.pem b/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/invalid-missing-footer.pem new file mode 100644 index 000000000..3eb952247 --- /dev/null +++ b/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/invalid-missing-footer.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +Zm9v +YmFy +YmF6 \ No newline at end of file diff --git a/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/invalid-missing-header.pem b/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/invalid-missing-header.pem new file mode 100644 index 000000000..6db873d2d --- /dev/null +++ b/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/invalid-missing-header.pem @@ -0,0 +1,4 @@ +Zm9v +YmFy +YmF6 +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/valid-real.pem b/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/valid-real.pem new file mode 100644 index 000000000..1f5b4d8d3 --- /dev/null +++ b/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/valid-real.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAzsXcm9qZxyeoH0MN+Pvf +2AnxXhPHB3lnJgKC/e9/j8eLHQPI4LoLcvSwB+D97XrMtgjtiMt+64Ba3YDPQzIQ +pAkYPFttW6+K8bZYmql8xMbAJLF/zeFa/gN3Pbwlc2ZGE32u/uq/0cJPtwoy22HW +kp2xITDXROCeXbRhV8aL3THCfKtCtWaS8Ms5vpEQ/qqtJ/oBTQrR9r6PwzhbZ5vT +MU8j5hCO5A2ih6shtT3Sc1yZwVM8CahoLl1XWkrT4P2VbbE120d8haSCDr+uYUNg +KxRyO56rcxfbKaoeR09+PtKrVgjzEe7v718X6GZcpcX9cXDgm1viZ4L426OZFhuq +qNtr0NYUt3FPrfj27S95RYDVe29Iovt++jIvqeb+Ffw+W1zkW/4efAJuByUse8Va +Sx/XH+QwuMc6XNNm/HcSiXq8pFuXEmMl5fXlkwBq6acEBh3GIG1MMBZzEt8j/YTB +pibYurTtPmO8k5ZC65hBeuCJLJ1dPX3BiNIUtH6EJWx7qOUP4bp9vl2aAxIhh/N9 +W29DFxXO7HDjKXK+OAeRySXg+qqQBScThUZWwUafZMRVbarQc5/E3iZKzQY9p21u +7/ZWxBCWLWi7XxYbiqlupKhG/+sSZuMHuxnP2iM0c3Yge0iPPYTU2Rpscd/92Jnb +NGz+ApGGrznw/bYk8CorwgMCAwEAAQ== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/valid-with-comment.pem b/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/valid-with-comment.pem new file mode 100644 index 000000000..f105eeac9 --- /dev/null +++ b/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/valid-with-comment.pem @@ -0,0 +1,6 @@ +# this is a comment +-----BEGIN PUBLIC KEY----- +Zm9v +YmFy +YmF6 +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/valid.pem b/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/valid.pem new file mode 100644 index 000000000..bed2d8663 --- /dev/null +++ b/kafka-client/src/test/resources/license-test/public-key-pem-file-parser/valid.pem @@ -0,0 +1,5 @@ +-----BEGIN PUBLIC KEY----- +Zm9v +YmFy +YmF6 +-----END PUBLIC KEY----- \ No newline at end of file