Skip to content

Commit 46b1181

Browse files
authored
Merge pull request #9 from OSGP/feature/FDP-1737-signing-library-in-gxf-java-utilities
FDP-1737: enable signing and verifying in ProducerRecord signature header
2 parents 988ea17 + 2d357c4 commit 46b1181

File tree

9 files changed

+410
-30
lines changed

9 files changed

+410
-30
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,28 @@ new DefaultKafkaConsumerFactory(
2626
)
2727
```
2828

29+
## kafka-message-signing
30+
Library for signing Kafka messages and for verification of signed Kafka messages.
31+
32+
Two variations are supported:
33+
- The signature is set on the message, via `SignableMessageWrapper`'s `signature` field;
34+
- The signature is set as a `signature` header on the Kafka `ProducerRecord`.
35+
36+
The `MessageSigner` class is used for both signing and verifying a signature.
37+
38+
To sign a message, use `MessageSigner`'s `sign()` method: choose between `SignableMessageWrapper` or `ProducerRecord`.
39+
40+
To verify a signature, use `MessageSigner`'s `verify()` method: choose between `SignableMessageWrapper` or `ProducerRecord`.
41+
42+
The `MessageSigner` class can be created with the following configuration options:
43+
- signingEnabled
44+
- stripAvroHeader
45+
- signatureAlgorithm
46+
- signatureProvider
47+
- signatureKeyAlgorithm
48+
- signatureKeySize
49+
- signingKey: from `java.security.PrivateKey` object, from a byte array or from a pem file
50+
- verificationKey: `from java.security.PrivateKey` object, from a byte array or from a pem file
2951

3052
## oauth-token-client
3153
Library that easily configures the [msal4j](https://github.com/AzureAD/microsoft-authentication-library-for-java) oauth token provider.

kafka-message-signing/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,17 @@
44

55
dependencies {
66
implementation("org.springframework:spring-context")
7+
implementation("org.springframework.kafka:spring-kafka")
8+
implementation("org.springframework.boot:spring-boot-autoconfigure")
9+
10+
api(libs.avro)
711

812
testImplementation("org.junit.jupiter:junit-jupiter-api")
913
testImplementation("org.junit.jupiter:junit-jupiter-engine")
1014
testImplementation("org.assertj:assertj-core")
15+
testImplementation("org.springframework:spring-test")
16+
testImplementation("org.springframework.boot:spring-boot-test")
17+
testImplementation("org.springframework.boot:spring-boot-starter")
1118
}
1219

1320
tasks.test {

kafka-message-signing/src/main/java/com/alliander/osgp/kafka/message/signing/MessageSigner.java

Lines changed: 139 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
import java.util.Objects;
2626
import java.util.Optional;
2727
import java.util.regex.Pattern;
28+
import org.apache.avro.message.BinaryMessageEncoder;
29+
import org.apache.avro.specific.SpecificRecordBase;
30+
import org.apache.kafka.clients.producer.ProducerRecord;
31+
import org.apache.kafka.common.header.Header;
2832

2933
public class MessageSigner {
3034
public static final String DEFAULT_SIGNATURE_ALGORITHM = "SHA256withRSA";
@@ -35,27 +39,35 @@ public class MessageSigner {
3539
// Two magic bytes (0xC3, 0x01) followed by an 8-byte fingerprint
3640
public static final int AVRO_HEADER_LENGTH = 10;
3741

42+
public static final String RECORD_HEADER_KEY_SIGNATURE = "signature";
43+
3844
private final boolean signingEnabled;
3945

40-
private boolean stripHeaders;
46+
private boolean stripAvroHeader;
4147

4248
private String signatureAlgorithm;
4349
private String signatureProvider;
4450
private String signatureKeyAlgorithm;
4551
private int signatureKeySize;
4652

47-
private Signature signingSignature;
48-
private Signature verificationSignature;
53+
private final Signature signingSignature;
54+
private final Signature verificationSignature;
4955

5056
private PrivateKey signingKey;
5157
private PublicKey verificationKey;
5258

5359
private MessageSigner(final Builder builder) {
60+
this.signingSignature =
61+
signatureInstance(
62+
builder.signatureAlgorithm, builder.signatureProvider, builder.signingKey);
63+
this.verificationSignature =
64+
signatureInstance(
65+
builder.signatureAlgorithm, builder.signatureProvider, builder.verificationKey);
5466
this.signingEnabled = builder.signingEnabled;
5567
if (!this.signingEnabled) {
5668
return;
5769
}
58-
this.stripHeaders = builder.stripHeaders;
70+
this.stripAvroHeader = builder.stripAvroHeader;
5971
this.signatureAlgorithm = builder.signatureAlgorithm;
6072
this.signatureKeyAlgorithm = builder.signatureKeyAlgorithm;
6173
this.signatureKeySize = builder.signatureKeySize;
@@ -65,12 +77,6 @@ private MessageSigner(final Builder builder) {
6577
}
6678
this.signingKey = builder.signingKey;
6779
this.verificationKey = builder.verificationKey;
68-
this.signingSignature =
69-
signatureInstance(
70-
builder.signatureAlgorithm, builder.signatureProvider, builder.signingKey);
71-
this.verificationSignature =
72-
signatureInstance(
73-
builder.signatureAlgorithm, builder.signatureProvider, builder.verificationKey);
7480
if (builder.signatureProvider != null) {
7581
this.signatureProvider = builder.signatureProvider;
7682
} else if (this.signingSignature != null) {
@@ -104,6 +110,23 @@ public void sign(final SignableMessageWrapper<?> message) {
104110
}
105111
}
106112

113+
/**
114+
* Signs the provided {@code producerRecord} in the header, overwriting an existing signature, if a non-null value is
115+
* already set.
116+
*
117+
* @param producerRecord the record to be signed
118+
* @throws IllegalStateException if this message signer has a public key for signature
119+
* verification, but does not have the private key needed for signing messages.
120+
* @throws UncheckedIOException if determining the bytes for the message throws an IOException.
121+
* @throws UncheckedSecurityException if the signing process throws a SignatureException.
122+
*/
123+
public void sign(final ProducerRecord<String, ? extends SpecificRecordBase> producerRecord) {
124+
if (this.signingEnabled) {
125+
final byte[] signature = this.signature(producerRecord);
126+
producerRecord.headers().add(RECORD_HEADER_KEY_SIGNATURE, signature);
127+
}
128+
}
129+
107130
/**
108131
* Determines the signature for the given {@code message}.
109132
*
@@ -127,8 +150,8 @@ public byte[] signature(final SignableMessageWrapper<?> message) {
127150
message.setSignature(null);
128151
synchronized (this.signingSignature) {
129152
final byte[] messageBytes;
130-
if (this.stripHeaders) {
131-
messageBytes = this.stripHeaders(this.toByteBuffer(message));
153+
if (this.stripAvroHeader) {
154+
messageBytes = this.stripAvroHeader(this.toByteBuffer(message));
132155
} else {
133156
messageBytes = this.toByteBuffer(message).array();
134157
}
@@ -142,6 +165,47 @@ public byte[] signature(final SignableMessageWrapper<?> message) {
142165
}
143166
}
144167

168+
/**
169+
* Determines the signature for the given {@code producerRecord}.
170+
*
171+
* <p>The value for the signature in the record will be set to {@code null} to properly determine
172+
* the signature, but is restored to its original value before this method returns.
173+
*
174+
* @param producerRecord the record to be signed
175+
* @return the signature for the record
176+
* @throws IllegalStateException if this message signer has a public key for signature
177+
* verification, but does not have the private key needed for signing messages.
178+
* @throws UncheckedIOException if determining the bytes throws an IOException.
179+
* @throws UncheckedSecurityException if the signing process throws a SignatureException.
180+
*/
181+
public byte[] signature(final ProducerRecord<String, ? extends SpecificRecordBase> producerRecord) {
182+
if (!this.canSignMessages()) {
183+
throw new IllegalStateException(
184+
"This MessageSigner is not configured for signing, it can only be used for verification");
185+
}
186+
final Header oldSignatureHeader = producerRecord.headers().lastHeader(RECORD_HEADER_KEY_SIGNATURE);
187+
try {
188+
producerRecord.headers().remove(RECORD_HEADER_KEY_SIGNATURE);
189+
synchronized (this.signingSignature) {
190+
final byte[] messageBytes;
191+
final SpecificRecordBase specificRecordBase = producerRecord.value();
192+
if (this.stripAvroHeader) {
193+
messageBytes = this.stripAvroHeader(this.toByteBuffer(specificRecordBase));
194+
} else {
195+
messageBytes = this.toByteBuffer(specificRecordBase).array();
196+
}
197+
this.signingSignature.update(messageBytes);
198+
return this.signingSignature.sign();
199+
}
200+
} catch (final SignatureException e) {
201+
throw new UncheckedSecurityException("Unable to sign message", e);
202+
} finally {
203+
if(oldSignatureHeader != null) {
204+
producerRecord.headers().add(RECORD_HEADER_KEY_SIGNATURE, oldSignatureHeader.value());
205+
}
206+
}
207+
}
208+
145209
public boolean canVerifyMessageSignatures() {
146210
return this.signingEnabled && this.verificationSignature != null;
147211
}
@@ -175,14 +239,7 @@ public boolean verify(final SignableMessageWrapper<?> message) {
175239
try {
176240
message.setSignature(null);
177241
synchronized (this.verificationSignature) {
178-
final byte[] messageBytes;
179-
if (this.stripHeaders) {
180-
messageBytes = this.stripHeaders(this.toByteBuffer(message));
181-
} else {
182-
messageBytes = this.toByteBuffer(message).array();
183-
}
184-
this.verificationSignature.update(messageBytes);
185-
return this.verificationSignature.verify(signatureBytes);
242+
return this.verifySignatureBytes(signatureBytes, this.toByteBuffer(message));
186243
}
187244
} catch (final SignatureException e) {
188245
throw new UncheckedSecurityException("Unable to verify message signature", e);
@@ -192,13 +249,63 @@ public boolean verify(final SignableMessageWrapper<?> message) {
192249
}
193250
}
194251

252+
/**
253+
* Verifies the signature of the provided {@code producerRecord}.
254+
*
255+
* @param producerRecord the record to be verified
256+
* @return {@code true} if the signature of the given {@code producerRecord} was verified; {@code false}
257+
* if not.
258+
* @throws IllegalStateException if this message signer has a private key needed for signing
259+
* messages, but does not have the public key for signature verification.
260+
* @throws UncheckedIOException if determining the bytes throws an IOException.
261+
* @throws UncheckedSecurityException if the signature verification process throws a
262+
* SignatureException.
263+
*/
264+
public boolean verify(final ProducerRecord<String, ? extends SpecificRecordBase> producerRecord) {
265+
if (!this.canVerifyMessageSignatures()) {
266+
throw new IllegalStateException(
267+
"This MessageSigner is not configured for verification, it can only be used for signing");
268+
}
269+
270+
final Header header = producerRecord.headers().lastHeader(RECORD_HEADER_KEY_SIGNATURE);
271+
if(header == null) {
272+
throw new IllegalStateException(
273+
"This ProducerRecord does not contain a signature header");
274+
}
275+
final byte[] signatureBytes = header.value();
276+
if (signatureBytes == null || signatureBytes.length == 0) {
277+
return false;
278+
}
279+
280+
try {
281+
producerRecord.headers().remove(RECORD_HEADER_KEY_SIGNATURE);
282+
synchronized (this.verificationSignature) {
283+
final SpecificRecordBase specificRecordBase = producerRecord.value();
284+
return this.verifySignatureBytes(signatureBytes, this.toByteBuffer(specificRecordBase));
285+
}
286+
} catch (final SignatureException e) {
287+
throw new UncheckedSecurityException("Unable to verify message signature", e);
288+
}
289+
}
290+
291+
private boolean verifySignatureBytes(final byte[] signatureBytes, final ByteBuffer messageByteBuffer) throws SignatureException {
292+
final byte[] messageBytes;
293+
if (this.stripAvroHeader) {
294+
messageBytes = this.stripAvroHeader(messageByteBuffer);
295+
} else {
296+
messageBytes = messageByteBuffer.array();
297+
}
298+
this.verificationSignature.update(messageBytes);
299+
return this.verificationSignature.verify(signatureBytes);
300+
}
301+
195302
private boolean hasAvroHeader(final byte[] bytes) {
196303
return bytes.length >= AVRO_HEADER_LENGTH
197304
&& (bytes[0] & 0xFF) == 0xC3
198305
&& (bytes[1] & 0xFF) == 0x01;
199306
}
200307

201-
private byte[] stripHeaders(final ByteBuffer byteBuffer) {
308+
private byte[] stripAvroHeader(final ByteBuffer byteBuffer) {
202309
final byte[] bytes = new byte[byteBuffer.remaining()];
203310
byteBuffer.get(bytes);
204311
if (this.hasAvroHeader(bytes)) {
@@ -215,6 +322,14 @@ private ByteBuffer toByteBuffer(final SignableMessageWrapper<?> message) {
215322
}
216323
}
217324

325+
private ByteBuffer toByteBuffer(final SpecificRecordBase message) {
326+
try {
327+
return new BinaryMessageEncoder<>(message.getSpecificData(), message.getSchema()).encode(message);
328+
} catch (final IOException e) {
329+
throw new UncheckedIOException("Unable to determine ByteBuffer for Message", e);
330+
}
331+
}
332+
218333
public boolean isSigningEnabled() {
219334
return this.signingEnabled;
220335
}
@@ -338,7 +453,7 @@ public static final class Builder {
338453

339454
private boolean signingEnabled;
340455

341-
private boolean stripHeaders;
456+
private boolean stripAvroHeader;
342457

343458
private String signatureAlgorithm = DEFAULT_SIGNATURE_ALGORITHM;
344459
private String signatureProvider = DEFAULT_SIGNATURE_PROVIDER;
@@ -353,8 +468,8 @@ public Builder signingEnabled(final boolean signingEnabled) {
353468
return this;
354469
}
355470

356-
public Builder stripHeaders(final boolean stripHeaders) {
357-
this.stripHeaders = stripHeaders;
471+
public Builder stripAvroHeader(final boolean stripAvroHeader) {
472+
this.stripAvroHeader = stripAvroHeader;
358473
return this;
359474
}
360475

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.alliander.osgp.kafka.message.signing;
2+
3+
import java.io.IOException;
4+
import java.io.UncheckedIOException;
5+
import java.nio.charset.StandardCharsets;
6+
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.context.annotation.Bean;
8+
import org.springframework.context.annotation.Configuration;
9+
import org.springframework.core.io.Resource;
10+
11+
@Configuration
12+
public class MessageSigningAutoConfiguration {
13+
14+
@Value("${message-signing.enabled}")
15+
private boolean signingEnabled;
16+
17+
@Value("${message-signing.strip-headers}")
18+
private boolean stripHeaders;
19+
20+
@Value("${message-signing.signature.algorithm:SHA256withRSA}")
21+
private String signatureAlgorithm;
22+
23+
@Value("${message-signing.signature.provider:SunRsaSign}")
24+
private String signatureProvider;
25+
26+
@Value("${message-signing.signature.key.algorithm:RSA}")
27+
private String signatureKeyAlgorithm;
28+
29+
@Value("${message-signing.signature.key.size:2048}")
30+
private int signatureKeySize;
31+
32+
@Value("${message-signing.signature.key.private:#{null}}")
33+
private Resource signingKeyResource;
34+
35+
@Value("${message-signing.signature.key.public:#{null}}")
36+
private Resource verificationKeyResource;
37+
38+
@Bean
39+
public MessageSigner messageSigner() {
40+
if(this.signingEnabled) {
41+
return MessageSigner.newBuilder()
42+
.signingEnabled(this.signingEnabled)
43+
.stripAvroHeader(this.stripHeaders)
44+
.signatureAlgorithm(this.signatureAlgorithm)
45+
.signatureProvider(this.signatureProvider)
46+
.signatureKeyAlgorithm(this.signatureKeyAlgorithm)
47+
.signatureKeySize(this.signatureKeySize)
48+
.signingKey(this.readKeyFromPemResource(this.signingKeyResource))
49+
.verificationKey(this.readKeyFromPemResource(this.verificationKeyResource))
50+
.build();
51+
} else {
52+
return MessageSigner.newBuilder()
53+
.signingEnabled(false)
54+
.build();
55+
}
56+
}
57+
58+
private String readKeyFromPemResource(final Resource keyResource) {
59+
if (keyResource == null) {
60+
return null;
61+
}
62+
try {
63+
return keyResource.getContentAsString(StandardCharsets.ISO_8859_1);
64+
} catch (final IOException e) {
65+
throw new UncheckedIOException("Unable to read " + keyResource.getFilename() + " as ISO-LATIN-1 PEM text", e);
66+
}
67+
}
68+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
com.alliander.osgp.kafka.message.signing.MessageSigningAutoConfiguration

0 commit comments

Comments
 (0)