Skip to content

Commit cdeda0d

Browse files
EncryptionRequestHandler supports encryption requests distribution. (#115)
1 parent 4e1819f commit cdeda0d

11 files changed

+568
-170
lines changed

encryption/build.gradle

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,22 @@ sourceSets {
3333
}
3434

3535
dependencies {
36-
implementation 'org.apache.solr:solr-core:9.6.0'
37-
implementation 'org.apache.lucene:lucene-core:9.10.0'
36+
implementation 'org.apache.solr:solr-core:9.8.0'
37+
implementation 'org.apache.lucene:lucene-core:9.11.1'
3838
implementation 'com.google.code.findbugs:jsr305:3.0.2'
3939

40-
// Optional, used by the KmsKeySupplier.
41-
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
40+
// Optional, used by the KmsKeySupplier example.
41+
implementation 'com.github.ben-manes.caffeine:caffeine:3.2.0'
4242
implementation 'io.opentracing:opentracing-util:0.33.0'
4343

4444
// Optional, commons-io and commons-codec are only required by the
4545
// tool class CharStreamEncrypter, which is not used for the index
4646
// encryption.
47-
implementation 'commons-io:commons-io:2.11.0'
48-
implementation 'commons-codec:commons-codec:1.16.0'
47+
implementation 'commons-io:commons-io:2.18.0'
48+
implementation 'commons-codec:commons-codec:1.18.0'
4949

50-
testImplementation 'org.apache.solr:solr-test-framework:9.6.0'
51-
testImplementation 'org.apache.lucene:lucene-test-framework:9.10.0'
50+
testImplementation 'org.apache.solr:solr-test-framework:9.8.0'
51+
testImplementation 'org.apache.lucene:lucene-test-framework:9.11.1'
5252
}
5353

5454
test {

encryption/src/main/java/org/apache/solr/encryption/EncryptionRequestHandler.java

Lines changed: 231 additions & 37 deletions
Large diffs are not rendered by default.

encryption/src/main/java/org/apache/solr/encryption/crypto/CharStreamEncrypter.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
* buffers allocated. The encryption transformation is AES/CTR/NoPadding.
4040
* A secure random IV is generated for each encryption and appended as the first
4141
* appended chars.
42+
* <p>
43+
* This encryption tool is intended to encrypt write-once and then read-only strings,
44+
* it should not be used to encrypt updatable content as CTR is not designed for that.
4245
*/
4346
public class CharStreamEncrypter {
4447

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.apache.solr.encryption.kms;
22

33
import org.apache.solr.common.SolrException;
4+
import org.apache.solr.common.params.ModifiableSolrParams;
45
import org.apache.solr.encryption.EncryptionRequestHandler;
56
import org.apache.solr.request.SolrQueryRequest;
67
import org.apache.solr.response.SolrQueryResponse;
@@ -14,38 +15,45 @@
1415
*/
1516
public class KmsEncryptionRequestHandler extends EncryptionRequestHandler {
1617

17-
/**
18-
* Tenant Id request parameter - required.
19-
*/
20-
public static final String PARAM_TENANT_ID = "tenantId";
21-
/**
22-
* Data Key Blob request parameter - required.
23-
*/
24-
public static final String PARAM_ENCRYPTION_KEY_BLOB = "encryptionKeyBlob";
18+
/**
19+
* Tenant Id request parameter - required.
20+
*/
21+
public static final String PARAM_TENANT_ID = "tenantId";
22+
/**
23+
* Data Key Blob request parameter - required.
24+
*/
25+
public static final String PARAM_ENCRYPTION_KEY_BLOB = "encryptionKeyBlob";
2526

26-
/**
27-
* Builds the KMS key cookie based on key id and key blob parameters of the request.
28-
* If a required parameter is missing, this method throws a {@link SolrException} with
29-
* {@link SolrException.ErrorCode#BAD_REQUEST} and sets the response status to failure.
30-
*/
31-
@Override
32-
protected Map<String, String> buildKeyCookie(String keyId,
33-
SolrQueryRequest req,
34-
SolrQueryResponse rsp) {
35-
String tenantId = getRequiredRequestParam(req, PARAM_TENANT_ID, rsp);
36-
String encryptionKeyBlob = getRequiredRequestParam(req, PARAM_ENCRYPTION_KEY_BLOB, rsp);
37-
return Map.of(
38-
PARAM_TENANT_ID, tenantId,
39-
PARAM_ENCRYPTION_KEY_BLOB, encryptionKeyBlob
40-
);
41-
}
27+
/**
28+
* Builds the KMS key cookie based on key id and key blob parameters of the request.
29+
* If a required parameter is missing, this method throws a {@link SolrException} with
30+
* {@link SolrException.ErrorCode#BAD_REQUEST} and sets the response status to failure.
31+
*/
32+
@Override
33+
protected Map<String, String> buildKeyCookie(String keyId,
34+
SolrQueryRequest req,
35+
SolrQueryResponse rsp) {
36+
String tenantId = getRequiredRequestParam(req, PARAM_TENANT_ID, rsp);
37+
String encryptionKeyBlob = getRequiredRequestParam(req, PARAM_ENCRYPTION_KEY_BLOB, rsp);
38+
return Map.of(
39+
PARAM_TENANT_ID, tenantId,
40+
PARAM_ENCRYPTION_KEY_BLOB, encryptionKeyBlob
41+
);
42+
}
4243

43-
private String getRequiredRequestParam(SolrQueryRequest req, String param, SolrQueryResponse rsp) {
44-
String arg = req.getParams().get(param);
45-
if (arg == null || arg.isEmpty()) {
46-
rsp.add(STATUS, STATUS_FAILURE);
47-
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Required parameter " + param + " must be present and not empty.");
48-
}
49-
return arg;
44+
private String getRequiredRequestParam(SolrQueryRequest req, String param, SolrQueryResponse rsp) {
45+
String arg = req.getParams().get(param);
46+
if (arg == null || arg.isEmpty()) {
47+
rsp.add(STATUS, STATUS_FAILURE);
48+
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Required parameter " + param + " must be present and not empty.");
5049
}
50+
return arg;
51+
}
52+
53+
@Override
54+
protected ModifiableSolrParams createDistributedRequestParams(SolrQueryRequest req, SolrQueryResponse rsp, String keyId) {
55+
return super.createDistributedRequestParams(req, rsp, keyId)
56+
.set(PARAM_TENANT_ID, getRequiredRequestParam(req, PARAM_TENANT_ID, rsp))
57+
.set(PARAM_ENCRYPTION_KEY_BLOB, getRequiredRequestParam(req, PARAM_ENCRYPTION_KEY_BLOB, rsp));
58+
}
5159
}

encryption/src/test/java/org/apache/solr/encryption/EncryptionDirectoryTest.java

Lines changed: 67 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@
3030
import org.apache.solr.encryption.crypto.AesCtrEncrypterFactory;
3131

3232
import java.io.IOException;
33+
import java.util.ArrayList;
34+
import java.util.Arrays;
3335
import java.util.HashMap;
36+
import java.util.List;
3437
import java.util.Map;
3538
import java.util.UUID;
3639

@@ -44,16 +47,11 @@
4447

4548
/**
4649
* Tests {@link EncryptionDirectory}.
47-
* <p>
48-
* This test class ignores the DirectoryFactory defined in solrconfig.xml to use
49-
* {@link EncryptionDirectoryFactory}.
5050
*/
5151
public class EncryptionDirectoryTest extends SolrCloudTestCase {
5252

5353
private static final String COLLECTION_PREFIX = EncryptionDirectoryTest.class.getSimpleName() + "-collection-";
5454

55-
private static MockEncryptionDirectory mockDir;
56-
5755
private String collectionName;
5856
private CloudSolrClient solrClient;
5957
private EncryptionTestUtil testUtil;
@@ -80,14 +78,13 @@ public void setUp() throws Exception {
8078
solrClient = cluster.getSolrClient();
8179
CollectionAdminRequest.createCollection(collectionName, 2, 2).process(solrClient);
8280
cluster.waitForActiveCollection(collectionName, 2, 4);
83-
testUtil = new EncryptionTestUtil(solrClient, collectionName);
81+
testUtil = new EncryptionTestUtil(solrClient, collectionName)
82+
.setShouldDistributeRequests(false);
8483
}
8584

8685
@Override
8786
public void tearDown() throws Exception {
88-
if (mockDir != null) {
89-
mockDir.clearMockValues();
90-
}
87+
MockFactory.clearMockValues();
9188
CollectionAdminRequest.deleteCollection(collectionName).process(solrClient);
9289
super.tearDown();
9390
}
@@ -106,7 +103,7 @@ public void testEncryptionFromNoKeysToOneKey() throws Exception {
106103
*/
107104
private void indexAndEncryptOneSegment() throws Exception {
108105
// Start with no key ids defined in the latest commit metadata.
109-
mockDir.clearMockValues();
106+
MockFactory.clearMockValues();
110107
// Create 2 index segments without encryption.
111108
testUtil.indexDocsAndCommit("weather broadcast");
112109
testUtil.indexDocsAndCommit("sunny weather");
@@ -120,23 +117,23 @@ private void indexAndEncryptOneSegment() throws Exception {
120117

121118
// Set the encryption key id in the commit user data,
122119
// and run an optimized commit to rewrite the index, now encrypted.
123-
mockDir.setKeysInCommitUserData(KEY_ID_1);
120+
MockFactory.setKeysInCommitUserData(KEY_ID_1);
124121
optimizeCommit();
125122

126123
// Verify that without key id, we cannot decrypt the index anymore.
127-
mockDir.forceClearText = true;
124+
MockFactory.forceClearText = true;
128125
testUtil.assertCannotReloadCores();
129126
// Verify that with a wrong key id, we cannot decrypt the index.
130-
mockDir.forceClearText = false;
131-
mockDir.forceKeySecret = KEY_SECRET_2;
127+
MockFactory.forceClearText = false;
128+
MockFactory.forceKeySecret = KEY_SECRET_2;
132129
testUtil.assertCannotReloadCores();
133130
// Verify that with the right key id, we can decrypt the index and search it.
134-
mockDir.forceKeySecret = null;
135-
mockDir.expectedKeySecret = KEY_SECRET_1;
131+
MockFactory.forceKeySecret = null;
132+
MockFactory.expectedKeySecrets = List.of(KEY_SECRET_1);
136133
testUtil.reloadCores();
137134
testUtil.assertQueryReturns("weather", 2);
138135
testUtil.assertQueryReturns("sunny", 1);
139-
mockDir.clearMockValues();
136+
MockFactory.clearMockValues();
140137
}
141138

142139
/**
@@ -156,24 +153,24 @@ private void indexAndEncryptTwoSegments() throws Exception {
156153
indexAndEncryptOneSegment();
157154

158155
// Create 1 new segment with the same encryption key id.
159-
mockDir.setKeysInCommitUserData(KEY_ID_1);
156+
MockFactory.setKeysInCommitUserData(KEY_ID_1);
160157
testUtil.indexDocsAndCommit("foggy weather");
161158
testUtil.indexDocsAndCommit("boo");
162159

163160
// Verify that without key id, we cannot decrypt the index.
164-
mockDir.forceClearText = true;
161+
MockFactory.forceClearText = true;
165162
testUtil.assertCannotReloadCores();
166163
// Verify that with a wrong key id, we cannot decrypt the index.
167-
mockDir.forceClearText = false;
168-
mockDir.forceKeySecret = KEY_SECRET_2;
164+
MockFactory.forceClearText = false;
165+
MockFactory.forceKeySecret = KEY_SECRET_2;
169166
testUtil.assertCannotReloadCores();
170167
// Verify that with the right key id, we can decrypt the index and search it.
171-
mockDir.forceKeySecret = null;
172-
mockDir.expectedKeySecret = KEY_SECRET_1;
168+
MockFactory.forceKeySecret = null;
169+
MockFactory.expectedKeySecrets = List.of(KEY_SECRET_1);
173170
testUtil.reloadCores();
174171
testUtil.assertQueryReturns("weather", 3);
175172
testUtil.assertQueryReturns("sunny", 1);
176-
mockDir.clearMockValues();
173+
MockFactory.clearMockValues();
177174
}
178175

179176
/**
@@ -186,19 +183,19 @@ public void testReEncryptionFromOneKeyToAnotherKey() throws Exception {
186183

187184
// Set the new encryption key id in the commit user data,
188185
// and run an optimized commit to rewrite the index, now encrypted with the new key.
189-
mockDir.setKeysInCommitUserData(KEY_ID_1, KEY_ID_2);
186+
MockFactory.setKeysInCommitUserData(KEY_ID_1, KEY_ID_2);
190187
optimizeCommit();
191188

192189
// Verify that without key id, we cannot decrypt the index.
193-
mockDir.forceClearText = true;
190+
MockFactory.forceClearText = true;
194191
testUtil.assertCannotReloadCores();
195192
// Verify that with a wrong key id, we cannot decrypt the index.
196-
mockDir.forceClearText = false;
197-
mockDir.forceKeySecret = KEY_SECRET_1;
193+
MockFactory.forceClearText = false;
194+
MockFactory.forceKeySecret = KEY_SECRET_1;
198195
testUtil.assertCannotReloadCores();
199196
// Verify that with the right key id, we can decrypt the index and search it.
200-
mockDir.forceKeySecret = null;
201-
mockDir.expectedKeySecret = KEY_SECRET_2;
197+
MockFactory.forceKeySecret = null;
198+
MockFactory.expectedKeySecrets = List.of(KEY_SECRET_1, KEY_SECRET_2);
202199
testUtil.reloadCores();
203200
testUtil.assertQueryReturns("weather", 3);
204201
testUtil.assertQueryReturns("sunny", 1);
@@ -214,11 +211,11 @@ public void testDecryptionFromOneKeyToNoKeys() throws Exception {
214211

215212
// Remove the active key parameter from the commit user data,
216213
// and run an optimized commit to rewrite the index, now cleartext with no keys.
217-
mockDir.setKeysInCommitUserData(KEY_ID_1, null);
214+
MockFactory.setKeysInCommitUserData(KEY_ID_1, null);
218215
optimizeCommit();
219216

220217
// Verify that without key id, we can reload the index because it is not encrypted.
221-
mockDir.forceClearText = true;
218+
MockFactory.forceClearText = true;
222219
testUtil.reloadCores();
223220
testUtil.assertQueryReturns("weather", 3);
224221
testUtil.assertQueryReturns("sunny", 1);
@@ -232,28 +229,49 @@ public void testDecryptionFromOneKeyToNoKeys() throws Exception {
232229
* {@link EncryptionRequestHandler}, but this test is designed to work independently.
233230
*/
234231
private void optimizeCommit() {
235-
testUtil.forAllReplicas(replica -> {
232+
testUtil.forAllReplicas(false, replica -> {
236233
UpdateRequest request = new UpdateRequest();
237234
request.setAction(UpdateRequest.ACTION.OPTIMIZE, true, true, 1);
238235
testUtil.requestCore(request, replica);
239236
});
240237
}
241238

242239
public static class MockFactory implements EncryptionDirectoryFactory.InnerFactory {
240+
241+
static final List<MockEncryptionDirectory> mockDirs = new ArrayList<>();
242+
243+
static boolean forceClearText;
244+
static byte[] forceKeySecret;
245+
static List<byte[]> expectedKeySecrets;
246+
247+
static void clearMockValues() {
248+
forceClearText = false;
249+
forceKeySecret = null;
250+
expectedKeySecrets = null;
251+
for (MockEncryptionDirectory mockDir : mockDirs) {
252+
mockDir.clearMockValues();
253+
}
254+
}
255+
256+
static void setKeysInCommitUserData(String... keyIds) throws IOException {
257+
for (MockEncryptionDirectory mockDir : mockDirs) {
258+
mockDir.setKeysInCommitUserData(keyIds);
259+
}
260+
}
261+
243262
@Override
244263
public EncryptionDirectory create(Directory delegate,
245264
AesCtrEncrypterFactory encrypterFactory,
246265
KeySupplier keySupplier) throws IOException {
247-
return mockDir = new MockEncryptionDirectory(delegate, encrypterFactory, keySupplier);
266+
MockEncryptionDirectory mockDir = new MockEncryptionDirectory(delegate, encrypterFactory, keySupplier);
267+
mockDirs.add(mockDir);
268+
return mockDir;
248269
}
249270
}
250271

251272
private static class MockEncryptionDirectory extends EncryptionDirectory {
252273

253274
final KeySupplier keySupplier;
254-
boolean forceClearText;
255-
byte[] forceKeySecret;
256-
byte[] expectedKeySecret;
257275

258276
MockEncryptionDirectory(Directory delegate, AesCtrEncrypterFactory encrypterFactory, KeySupplier keySupplier)
259277
throws IOException {
@@ -263,9 +281,6 @@ private static class MockEncryptionDirectory extends EncryptionDirectory {
263281

264282
void clearMockValues() {
265283
commitUserData = new CommitUserData(commitUserData.segmentFileName, Map.of());
266-
forceClearText = false;
267-
forceKeySecret = null;
268-
expectedKeySecret = null;
269284
}
270285

271286
/**
@@ -285,7 +300,7 @@ void setKeysInCommitUserData(String... keyIds) throws IOException {
285300

286301
@Override
287302
public IndexInput openInput(String fileName, IOContext context) throws IOException {
288-
return forceClearText ? in.openInput(fileName, context) : super.openInput(fileName, context);
303+
return MockFactory.forceClearText ? in.openInput(fileName, context) : super.openInput(fileName, context);
289304
}
290305

291306
@Override
@@ -298,12 +313,19 @@ protected CommitUserData readLatestCommitUserData() {
298313

299314
@Override
300315
protected byte[] getKeySecret(String keyRef) throws IOException {
301-
if (forceKeySecret != null) {
302-
return forceKeySecret;
316+
if (MockFactory.forceKeySecret != null) {
317+
return MockFactory.forceKeySecret;
303318
}
304319
byte[] keySecret = super.getKeySecret(keyRef);
305-
if (expectedKeySecret != null) {
306-
assertArrayEquals(expectedKeySecret, keySecret);
320+
if (MockFactory.expectedKeySecrets != null) {
321+
boolean keySecretMatches = false;
322+
for (byte[] expectedKeySecret : MockFactory.expectedKeySecrets) {
323+
if (Arrays.equals(expectedKeySecret, keySecret)) {
324+
keySecretMatches = true;
325+
break;
326+
}
327+
}
328+
assertTrue(keySecretMatches);
307329
}
308330
return keySecret;
309331
}

0 commit comments

Comments
 (0)