Skip to content

Commit b3bdec2

Browse files
authored
[feat] PIP-468: V5 end-to-end encryption API redesign (#25682)
1 parent dcb41fd commit b3bdec2

24 files changed

Lines changed: 1209 additions & 240 deletions
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.pulsar.client.api.v5;
20+
21+
import static org.testng.Assert.assertEquals;
22+
import static org.testng.Assert.assertNotNull;
23+
import static org.testng.Assert.assertNull;
24+
import static org.testng.Assert.assertTrue;
25+
import java.nio.file.Path;
26+
import java.time.Duration;
27+
import java.util.HashSet;
28+
import java.util.Set;
29+
import lombok.Cleanup;
30+
import org.apache.pulsar.client.api.v5.auth.ConsumerCryptoFailureAction;
31+
import org.apache.pulsar.client.api.v5.auth.PemFileKeyProvider;
32+
import org.apache.pulsar.client.api.v5.config.BatchingPolicy;
33+
import org.apache.pulsar.client.api.v5.config.ConsumerEncryptionPolicy;
34+
import org.apache.pulsar.client.api.v5.config.ProducerEncryptionPolicy;
35+
import org.apache.pulsar.client.api.v5.config.SubscriptionInitialPosition;
36+
import org.apache.pulsar.client.api.v5.schema.Schema;
37+
import org.testng.annotations.Test;
38+
39+
/**
40+
* End-to-end coverage for V5 message encryption: produce → broker → consume
41+
* round-trip, with payloads encrypted on the producer side and decrypted on the
42+
* consumer side. Reuses the test PEM keys under {@code certificate/} that the v4
43+
* tests already use.
44+
*
45+
* <p>Wiring under test:
46+
* <ul>
47+
* <li>{@link PemFileKeyProvider} loads PEM bytes from disk on each side.</li>
48+
* <li>{@link ProducerEncryptionPolicy} / {@link ConsumerEncryptionPolicy} carry
49+
* the providers + key names + failure actions through the V5 builders.</li>
50+
* <li>{@link org.apache.pulsar.client.impl.v5.CryptoKeyReaderAdapter} bridges
51+
* to the v4 {@code ConsumerImpl} / {@code ProducerImpl} crypto paths.</li>
52+
* </ul>
53+
*/
54+
public class V5EncryptionTest extends V5ClientBaseTest {
55+
56+
private static final String KEY_NAME = "client-rsa";
57+
private static final Path PUB_KEY =
58+
Path.of("./src/test/resources/certificate/public-key.client-rsa.pem");
59+
private static final Path PRIV_KEY =
60+
Path.of("./src/test/resources/certificate/private-key.client-rsa.pem");
61+
62+
private static PemFileKeyProvider producerKeys() {
63+
return PemFileKeyProvider.builder()
64+
.publicKey(KEY_NAME, PUB_KEY)
65+
.build();
66+
}
67+
68+
private static PemFileKeyProvider consumerKeys() {
69+
return PemFileKeyProvider.builder()
70+
.privateKey(KEY_NAME, PRIV_KEY)
71+
.build();
72+
}
73+
74+
private static ProducerEncryptionPolicy producerPolicy() {
75+
return ProducerEncryptionPolicy.builder()
76+
.publicKeyProvider(producerKeys())
77+
.keyName(KEY_NAME)
78+
.build();
79+
}
80+
81+
private static ConsumerEncryptionPolicy consumerPolicy() {
82+
return ConsumerEncryptionPolicy.builder()
83+
.privateKeyProvider(consumerKeys())
84+
.failureAction(ConsumerCryptoFailureAction.FAIL)
85+
.build();
86+
}
87+
88+
/** Single-segment round trip: producer encrypts, consumer decrypts, payload matches. */
89+
@Test
90+
public void testProducerConsumerRoundTrip() throws Exception {
91+
String topic = newScalableTopic(1);
92+
93+
@Cleanup
94+
Producer<String> producer = v5Client.newProducer(Schema.string())
95+
.topic(topic)
96+
.encryptionPolicy(producerPolicy())
97+
.create();
98+
@Cleanup
99+
QueueConsumer<String> consumer = v5Client.newQueueConsumer(Schema.string())
100+
.topic(topic)
101+
.subscriptionName("crypto-sub")
102+
.subscriptionInitialPosition(SubscriptionInitialPosition.EARLIEST)
103+
.encryptionPolicy(consumerPolicy())
104+
.subscribe();
105+
106+
producer.newMessage().value("hello-encrypted").send();
107+
108+
Message<String> msg = consumer.receive(Duration.ofSeconds(5));
109+
assertNotNull(msg, "consumer must receive the encrypted-then-decrypted message");
110+
assertEquals(msg.value(), "hello-encrypted");
111+
consumer.acknowledge(msg.id());
112+
}
113+
114+
/**
115+
* Multi-segment scalable topic: messages spread across segments by key, each
116+
* segment's per-segment v4 producer/consumer carries the same crypto config,
117+
* so every message decrypts correctly regardless of which segment it landed on.
118+
*/
119+
@Test
120+
public void testEncryptionAcrossMultipleSegments() throws Exception {
121+
String topic = newScalableTopic(3);
122+
123+
@Cleanup
124+
Producer<String> producer = v5Client.newProducer(Schema.string())
125+
.topic(topic)
126+
.encryptionPolicy(producerPolicy())
127+
.create();
128+
@Cleanup
129+
QueueConsumer<String> consumer = v5Client.newQueueConsumer(Schema.string())
130+
.topic(topic)
131+
.subscriptionName("crypto-multi-sub")
132+
.subscriptionInitialPosition(SubscriptionInitialPosition.EARLIEST)
133+
.encryptionPolicy(consumerPolicy())
134+
.subscribe();
135+
136+
int n = 30;
137+
Set<String> sent = new HashSet<>();
138+
for (int i = 0; i < n; i++) {
139+
String value = "msg-" + i;
140+
producer.newMessage().key("k-" + i).value(value).send();
141+
sent.add(value);
142+
}
143+
144+
Set<String> received = new HashSet<>();
145+
for (int i = 0; i < n; i++) {
146+
Message<String> msg = consumer.receive(Duration.ofSeconds(5));
147+
assertNotNull(msg, "expected message #" + (i + 1));
148+
received.add(msg.value());
149+
consumer.acknowledge(msg.id());
150+
}
151+
assertEquals(received, sent, "every encrypted message must decrypt to its original value");
152+
}
153+
154+
/**
155+
* Consumer with {@link ConsumerCryptoFailureAction#CONSUME} and no
156+
* {@link org.apache.pulsar.client.api.v5.auth.PrivateKeyProvider} configured
157+
* sees the still-encrypted payload, demonstrating the "I don't decrypt; just
158+
* give me the bytes" mode.
159+
*
160+
* <p>Batching disabled on the producer: v4 drops batched encrypted messages
161+
* even under CONSUME because it can't reframe a batch envelope it can't open.
162+
*/
163+
@Test
164+
public void testConsumerWithoutProviderAndConsumeAction() throws Exception {
165+
String topic = newScalableTopic(1);
166+
167+
@Cleanup
168+
Producer<String> producer = v5Client.newProducer(Schema.string())
169+
.topic(topic)
170+
.batchingPolicy(BatchingPolicy.ofDisabled())
171+
.encryptionPolicy(producerPolicy())
172+
.create();
173+
174+
@Cleanup
175+
QueueConsumer<byte[]> consumer = v5Client.newQueueConsumer(Schema.bytes())
176+
.topic(topic)
177+
.subscriptionName("crypto-consume-sub")
178+
.subscriptionInitialPosition(SubscriptionInitialPosition.EARLIEST)
179+
.encryptionPolicy(ConsumerEncryptionPolicy.builder()
180+
.failureAction(ConsumerCryptoFailureAction.CONSUME)
181+
.build())
182+
.subscribe();
183+
184+
producer.newMessage().value("plaintext-marker").send();
185+
186+
Message<byte[]> msg = consumer.receive(Duration.ofSeconds(5));
187+
assertNotNull(msg, "CONSUME must deliver the message even without a private key");
188+
// Payload is still encrypted — must not contain the plaintext marker.
189+
String body = new String(msg.value());
190+
assertTrue(!body.contains("plaintext-marker"),
191+
"payload should still be encrypted, got: " + body);
192+
consumer.acknowledge(msg.id());
193+
}
194+
195+
/**
196+
* Consumer with {@link ConsumerCryptoFailureAction#DISCARD} and no provider
197+
* silently drops undecryptable messages (cursor advances) — the application
198+
* never sees them.
199+
*/
200+
@Test
201+
public void testConsumerWithoutProviderAndDiscardAction() throws Exception {
202+
String topic = newScalableTopic(1);
203+
204+
@Cleanup
205+
Producer<String> producer = v5Client.newProducer(Schema.string())
206+
.topic(topic)
207+
.batchingPolicy(BatchingPolicy.ofDisabled())
208+
.encryptionPolicy(producerPolicy())
209+
.create();
210+
211+
@Cleanup
212+
QueueConsumer<String> consumer = v5Client.newQueueConsumer(Schema.string())
213+
.topic(topic)
214+
.subscriptionName("crypto-discard-sub")
215+
.subscriptionInitialPosition(SubscriptionInitialPosition.EARLIEST)
216+
.encryptionPolicy(ConsumerEncryptionPolicy.builder()
217+
.failureAction(ConsumerCryptoFailureAction.DISCARD)
218+
.build())
219+
.subscribe();
220+
221+
producer.newMessage().value("classified").send();
222+
223+
Message<String> msg = consumer.receive(Duration.ofMillis(500));
224+
assertNull(msg, "DISCARD must drop the undecryptable message before delivery");
225+
}
226+
}

pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/CheckpointConsumerBuilder.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
import java.util.Map;
2222
import java.util.concurrent.CompletableFuture;
23-
import org.apache.pulsar.client.api.v5.config.EncryptionPolicy;
23+
import org.apache.pulsar.client.api.v5.config.ConsumerEncryptionPolicy;
2424

2525
/**
2626
* Builder for configuring and creating a {@link CheckpointConsumer}.
@@ -111,9 +111,9 @@ public interface CheckpointConsumerBuilder<T> {
111111
*
112112
* @param policy the encryption policy to use
113113
* @return this builder instance for chaining
114-
* @see EncryptionPolicy#forConsumer
114+
* @see ConsumerEncryptionPolicy#builder()
115115
*/
116-
CheckpointConsumerBuilder<T> encryptionPolicy(EncryptionPolicy policy);
116+
CheckpointConsumerBuilder<T> encryptionPolicy(ConsumerEncryptionPolicy policy);
117117

118118
// --- Metadata ---
119119

pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/ProducerBuilder.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
import org.apache.pulsar.client.api.v5.config.BatchingPolicy;
2525
import org.apache.pulsar.client.api.v5.config.ChunkingPolicy;
2626
import org.apache.pulsar.client.api.v5.config.CompressionPolicy;
27-
import org.apache.pulsar.client.api.v5.config.EncryptionPolicy;
2827
import org.apache.pulsar.client.api.v5.config.ProducerAccessMode;
28+
import org.apache.pulsar.client.api.v5.config.ProducerEncryptionPolicy;
2929

3030
/**
3131
* Builder for configuring and creating a {@link Producer}.
@@ -133,11 +133,11 @@ public interface ProducerBuilder<T> {
133133
/**
134134
* Configure end-to-end message encryption.
135135
*
136-
* @param policy the encryption policy for producing encrypted messages
136+
* @param policy the producer-side encryption policy
137137
* @return this builder instance for chaining
138-
* @see EncryptionPolicy#forProducer(org.apache.pulsar.client.api.v5.auth.CryptoKeyReader, String...)
138+
* @see ProducerEncryptionPolicy#builder()
139139
*/
140-
ProducerBuilder<T> encryptionPolicy(EncryptionPolicy policy);
140+
ProducerBuilder<T> encryptionPolicy(ProducerEncryptionPolicy policy);
141141

142142
/**
143143
* Set the initial sequence ID for producer message deduplication. Subsequent messages

pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/QueueConsumerBuilder.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
import java.util.Map;
2323
import java.util.concurrent.CompletableFuture;
2424
import org.apache.pulsar.client.api.v5.config.BackoffPolicy;
25+
import org.apache.pulsar.client.api.v5.config.ConsumerEncryptionPolicy;
2526
import org.apache.pulsar.client.api.v5.config.DeadLetterPolicy;
26-
import org.apache.pulsar.client.api.v5.config.EncryptionPolicy;
2727
import org.apache.pulsar.client.api.v5.config.ProcessingTimeoutPolicy;
2828
import org.apache.pulsar.client.api.v5.config.SubscriptionInitialPosition;
2929

@@ -194,9 +194,9 @@ public interface QueueConsumerBuilder<T> {
194194
*
195195
* @param policy the encryption policy to use
196196
* @return this builder instance for chaining
197-
* @see EncryptionPolicy#forConsumer
197+
* @see ConsumerEncryptionPolicy#builder()
198198
*/
199-
QueueConsumerBuilder<T> encryptionPolicy(EncryptionPolicy policy);
199+
QueueConsumerBuilder<T> encryptionPolicy(ConsumerEncryptionPolicy policy);
200200

201201

202202
// --- Misc ---

pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/StreamConsumerBuilder.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import java.time.Instant;
2323
import java.util.Map;
2424
import java.util.concurrent.CompletableFuture;
25-
import org.apache.pulsar.client.api.v5.config.EncryptionPolicy;
25+
import org.apache.pulsar.client.api.v5.config.ConsumerEncryptionPolicy;
2626
import org.apache.pulsar.client.api.v5.config.SubscriptionInitialPosition;
2727

2828
/**
@@ -164,9 +164,9 @@ public interface StreamConsumerBuilder<T> {
164164
*
165165
* @param policy the encryption policy to use
166166
* @return this builder instance for chaining
167-
* @see EncryptionPolicy#forConsumer
167+
* @see ConsumerEncryptionPolicy#builder()
168168
*/
169-
StreamConsumerBuilder<T> encryptionPolicy(EncryptionPolicy policy);
169+
StreamConsumerBuilder<T> encryptionPolicy(ConsumerEncryptionPolicy policy);
170170

171171
// --- Metadata ---
172172

pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/CryptoKeyReader.java renamed to pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/ConsumerCryptoFailureAction.java

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,29 @@
1818
*/
1919
package org.apache.pulsar.client.api.v5.auth;
2020

21-
import java.util.Map;
22-
2321
/**
24-
* Interface for loading encryption and decryption keys for end-to-end message encryption.
22+
* Action a consumer takes when message decryption fails (e.g. the
23+
* {@link PrivateKeyProvider} cannot be reached, returns no key, or the
24+
* ciphertext is malformed).
2525
*/
26-
public interface CryptoKeyReader {
26+
public enum ConsumerCryptoFailureAction {
27+
28+
/**
29+
* Fail the {@code receive} call. The application sees the decryption error
30+
* and the message stays unacknowledged so it will be redelivered.
31+
*/
32+
FAIL,
2733

2834
/**
29-
* Get the public key for encrypting messages.
30-
*
31-
* @param keyName the name of the key
32-
* @param metadata additional metadata associated with the key
33-
* @return the encryption key info containing the public key data
35+
* Silently acknowledge and skip the message. Useful when the consumer
36+
* legitimately cannot read some encrypted streams (e.g. a side channel)
37+
* but should keep moving forward through the rest.
3438
*/
35-
EncryptionKeyInfo getPublicKey(String keyName, Map<String, String> metadata);
39+
DISCARD,
3640

3741
/**
38-
* Get the private key for decrypting messages.
39-
*
40-
* @param keyName the name of the key
41-
* @param metadata additional metadata associated with the key
42-
* @return the encryption key info containing the private key data
42+
* Deliver the message to the application as-is, with the still-encrypted
43+
* payload. The application can then handle decryption out-of-band.
4344
*/
44-
EncryptionKeyInfo getPrivateKey(String keyName, Map<String, String> metadata);
45+
CONSUME
4546
}

pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/EncryptionKeyInfo.java renamed to pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/CryptoKeyProvider.java

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,15 @@
1818
*/
1919
package org.apache.pulsar.client.api.v5.auth;
2020

21-
import java.util.Map;
22-
import java.util.Objects;
23-
2421
/**
25-
* Holds an encryption key and associated metadata.
22+
* Convenience interface for implementations that serve <em>both</em> public keys (for
23+
* producer-side encryption) and private keys (for consumer-side decryption) — for
24+
* example, a single PEM-file-backed key store used by both sides of an in-process
25+
* round trip.
2626
*
27-
* @param key the raw key bytes
28-
* @param metadata key-value metadata associated with the key
27+
* <p>Producer-only or consumer-only implementations should implement
28+
* {@link PublicKeyProvider} or {@link PrivateKeyProvider} directly instead — that
29+
* makes the role explicit and avoids stub methods that throw.
2930
*/
30-
public record EncryptionKeyInfo(byte[] key, Map<String, String> metadata) {
31-
public EncryptionKeyInfo {
32-
Objects.requireNonNull(key, "key must not be null");
33-
if (metadata == null) {
34-
metadata = Map.of();
35-
}
36-
}
31+
public interface CryptoKeyProvider extends PublicKeyProvider, PrivateKeyProvider {
3732
}

0 commit comments

Comments
 (0)