Skip to content

Commit 5ea5211

Browse files
committed
add kms key rotation for secure keys
1 parent 60e58d4 commit 5ea5211

4 files changed

Lines changed: 225 additions & 7 deletions

File tree

cdap-securestore-ext-cloudkms/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@
7777
<groupId>junit</groupId>
7878
<artifactId>junit</artifactId>
7979
</dependency>
80+
<dependency>
81+
<groupId>org.mockito</groupId>
82+
<artifactId>mockito-core</artifactId>
83+
</dependency>
8084
</dependencies>
8185

8286
<profiles>

cdap-securestore-ext-cloudkms/src/main/java/io/cdap/cdap/securestore/gcp/cloudkms/CloudKMSClient.java

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import com.google.api.services.cloudkms.v1.model.EncryptRequest;
3131
import com.google.api.services.cloudkms.v1.model.EncryptResponse;
3232
import com.google.api.services.cloudkms.v1.model.KeyRing;
33+
import com.google.common.annotations.VisibleForTesting;
3334
import com.google.common.io.CharStreams;
3435
import java.io.Closeable;
3536
import java.io.File;
@@ -40,7 +41,9 @@
4041
import java.net.HttpURLConnection;
4142
import java.net.URL;
4243
import java.nio.charset.StandardCharsets;
44+
import java.time.Instant;
4345
import java.util.HashSet;
46+
import java.util.List;
4447
import java.util.Map;
4548
import java.util.Set;
4649
import org.slf4j.Logger;
@@ -59,19 +62,23 @@ class CloudKMSClient implements Closeable {
5962
private static final String CLOUD_KMS = "cloudkms";
6063
private static final String PROJECT_ID = "project.id";
6164
private static final String SERVICE_ACCOUNT_FILE = "service.account.file";
65+
private static final String ENABLE_KEY_ROTATION = "key.rotation.enabled";
6266
private static final String METADATA_SERVER_API = "metadata.server.api";
6367
private static final String DEFAULT_METADATA_SERVER_API =
6468
"http://metadata.google.internal/computeMetadata"
6569
+ "/v1/project/project-id";
6670
private static final String KEYRING_ID = "keyring.id";
6771
private static final String DEFAULT_KEYRING_ID = "cdap";
72+
private static final String KEY_RING_FORMAT = "projects/%s/locations/%s/keyRings/%s";
73+
private static final String CRYPTO_KEY_FORMAT = KEY_RING_FORMAT + "/cryptoKeys/%s";
6874

6975

7076
private final CloudKMS cloudKMS;
7177
private final String projectId;
7278
private final String keyringId;
7379
// In-memory cache to hold created crypto keys, this is to avoid checking if a given crypto key exists.
7480
private final Set<String> knownCryptoKeys;
81+
private final Boolean enableKeyRotation;
7582

7683
/**
7784
* Constructs Cloud KMS client.
@@ -87,6 +94,8 @@ class CloudKMSClient implements Closeable {
8794
this.cloudKMS = createCloudKMS(serviceAccountFile);
8895
this.keyringId = properties.getOrDefault(KEYRING_ID, DEFAULT_KEYRING_ID);
8996
this.knownCryptoKeys = new HashSet<>();
97+
this.enableKeyRotation = Boolean.parseBoolean(
98+
properties.getOrDefault(ENABLE_KEY_ROTATION, "true"));
9099
}
91100

92101
/**
@@ -115,7 +124,8 @@ private String getSystemProjectId(String metadataServerApi) throws IOException {
115124
* @return an authorized CloudKMS client
116125
* @throws IOException if credentials can not be created in current environment
117126
*/
118-
private CloudKMS createCloudKMS(String serviceAccountFile) throws IOException {
127+
@VisibleForTesting
128+
CloudKMS createCloudKMS(String serviceAccountFile) throws IOException {
119129
HttpTransport transport = new NetHttpTransport();
120130
JsonFactory jsonFactory = new JacksonFactory();
121131
GoogleCredential credential;
@@ -163,6 +173,56 @@ void createKeyRingIfNotExists() throws IOException {
163173
}
164174
}
165175

176+
void enableRotationForExistingKeys() throws IOException {
177+
if (!this.enableKeyRotation) {
178+
return;
179+
}
180+
181+
String parentKeyRing = String.format(KEY_RING_FORMAT, projectId, LOCATION_ID, keyringId);
182+
try {
183+
List<CryptoKey> cryptoKeys = cloudKMS.projects().locations().keyRings().cryptoKeys()
184+
.list(parentKeyRing).execute().getCryptoKeys();
185+
if (cryptoKeys == null || cryptoKeys.isEmpty()) {
186+
LOG.info("No existing crypto keys found in keyring {}.", keyringId);
187+
return;
188+
}
189+
190+
for (CryptoKey cryptoKey : cryptoKeys) {
191+
String cryptoKeyName = cryptoKey.getName();
192+
String cryptoKeyId = cryptoKeyName.substring(
193+
cryptoKeyName.lastIndexOf('/') + 1);
194+
195+
if (cryptoKey.getRotationPeriod() == null || cryptoKey.getRotationPeriod().isEmpty()) {
196+
CryptoKey updateRequest = new CryptoKey();
197+
// Setting rotation period to 90 days for KMS keys.
198+
long rotationInSeconds = java.time.Duration.ofDays(90).getSeconds();
199+
updateRequest.setRotationPeriod(rotationInSeconds + "s");
200+
// Setting nextRotationTime to 1 day after the key is updated.
201+
Instant nextRotationTime = Instant.now().plus(java.time.Duration.ofDays(1));
202+
updateRequest.setNextRotationTime(nextRotationTime.toString());
203+
204+
String resourceName = String.format(CRYPTO_KEY_FORMAT, projectId, LOCATION_ID, keyringId, cryptoKeyId);
205+
try {
206+
cloudKMS.projects().locations().keyRings().cryptoKeys()
207+
.patch(resourceName, updateRequest)
208+
.setUpdateMask(
209+
"rotation_period,next_rotation_time")
210+
.execute();
211+
LOG.info("Successfully updated rotation period for crypto key {}.", cryptoKeyId);
212+
} catch (GoogleJsonResponseException e) {
213+
throw new IOException(
214+
String.format("Failed to update rotation period for crypto key %s due to %s",
215+
cryptoKeyId, e.getMessage()), e);
216+
}
217+
}
218+
}
219+
} catch (GoogleJsonResponseException e) {
220+
throw new IOException(
221+
String.format("Exception occurred while listing crypto keys in keyring %s: %s",
222+
keyringId, e.getMessage()), e);
223+
}
224+
}
225+
166226
/**
167227
* Creates a new crypto key on google cloud kms with the given id.
168228
*
@@ -175,13 +235,20 @@ void createCryptoKeyIfNotExists(String cryptoKeyId) throws IOException {
175235
return;
176236
}
177237

178-
String parent = String.format("projects/%s/locations/%s/keyRings/%s", projectId, LOCATION_ID,
179-
keyringId);
238+
String parent = String.format(KEY_RING_FORMAT, projectId, LOCATION_ID, keyringId);
180239

181240
CryptoKey cryptoKey = new CryptoKey();
182241
// This will allow the API access to the key for symmetric encryption and decryption.
183242
cryptoKey.setPurpose(ENCRYPT_DECRYPT);
184243

244+
if (this.enableKeyRotation) {
245+
long rotationInSeconds = java.time.Duration.ofDays(90).getSeconds();
246+
cryptoKey.setRotationPeriod(rotationInSeconds + "s");
247+
248+
Instant nextRotationTime = Instant.now().plus(java.time.Duration.ofDays(1));
249+
cryptoKey.setNextRotationTime(nextRotationTime.toString());
250+
}
251+
185252
try {
186253
cloudKMS.projects().locations().keyRings().cryptoKeys()
187254
.create(parent, cryptoKey)
@@ -210,8 +277,7 @@ void createCryptoKeyIfNotExists(String cryptoKeyId) throws IOException {
210277
* @throws IOException there's an error in encrypting secret
211278
*/
212279
byte[] encrypt(String cryptoKeyId, byte[] secret) throws IOException {
213-
String resourceName = String.format("projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s",
214-
projectId, LOCATION_ID,
280+
String resourceName = String.format(CRYPTO_KEY_FORMAT, projectId, LOCATION_ID,
215281
keyringId, cryptoKeyId);
216282
// secret must not be longer than 64KiB.
217283
EncryptRequest request = new EncryptRequest().encodePlaintext(secret);
@@ -236,8 +302,8 @@ byte[] encrypt(String cryptoKeyId, byte[] secret) throws IOException {
236302
* @throws IOException there's an error in decrypting secret
237303
*/
238304
byte[] decrypt(String cryptoKeyId, byte[] encryptedSecret) throws IOException {
239-
String resourceName = String.format("projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s",
240-
projectId, LOCATION_ID, keyringId, cryptoKeyId);
305+
String resourceName = String.format(CRYPTO_KEY_FORMAT, projectId, LOCATION_ID, keyringId,
306+
cryptoKeyId);
241307

242308
DecryptRequest request = new DecryptRequest().encodeCiphertext(encryptedSecret);
243309
DecryptResponse response = cloudKMS.projects().locations().keyRings().cryptoKeys()

cdap-securestore-ext-cloudkms/src/main/java/io/cdap/cdap/securestore/gcp/cloudkms/CloudSecretManager.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public void initialize(SecretManagerContext context) throws IOException {
5555
this.store = context.getSecretStore();
5656
this.client = new CloudKMSClient(context.getProperties());
5757
this.client.createKeyRingIfNotExists();
58+
this.client.enableRotationForExistingKeys();
5859
}
5960

6061
@Override
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* Copyright © 2025 Cask Data, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
17+
package io.cdap.cdap.securestore.gcp.cloudkms;
18+
19+
import com.google.api.services.cloudkms.v1.CloudKMS;
20+
import com.google.api.services.cloudkms.v1.model.CryptoKey;
21+
import com.google.api.services.cloudkms.v1.model.ListCryptoKeysResponse;
22+
import java.io.IOException;
23+
import java.time.Duration;
24+
import java.time.Instant;
25+
import java.util.Arrays;
26+
import java.util.HashMap;
27+
import java.util.Map;
28+
import org.junit.Before;
29+
import org.junit.Test;
30+
import org.junit.runner.RunWith;
31+
import org.mockito.ArgumentCaptor;
32+
import org.mockito.Captor;
33+
import org.mockito.Mock;
34+
import org.mockito.junit.MockitoJUnitRunner;
35+
36+
import static org.junit.Assert.assertEquals;
37+
import static org.junit.Assert.assertNotNull;
38+
import static org.junit.Assert.assertTrue;
39+
import static org.mockito.ArgumentMatchers.any;
40+
import static org.mockito.ArgumentMatchers.anyString;
41+
import static org.mockito.Mockito.times;
42+
import static org.mockito.Mockito.verify;
43+
import static org.mockito.Mockito.when;
44+
45+
@RunWith(MockitoJUnitRunner.class)
46+
public class CloudKMSClientTest {
47+
48+
private static final String TEST_PROJECT_ID = "test-project";
49+
private static final String TEST_KEYRING_ID = "test-instance";
50+
private static final String LOCATION_ID = "global";
51+
52+
@Mock
53+
private CloudKMS mockKms;
54+
@Mock
55+
private CloudKMS.Projects mockProjects;
56+
@Mock
57+
private CloudKMS.Projects.Locations mockLocations;
58+
@Mock
59+
private CloudKMS.Projects.Locations.KeyRings mockKeyRings;
60+
@Mock
61+
private CloudKMS.Projects.Locations.KeyRings.CryptoKeys mockCryptoKeys;
62+
@Mock
63+
private CloudKMS.Projects.Locations.KeyRings.CryptoKeys.Create mockCreate;
64+
@Mock
65+
private CloudKMS.Projects.Locations.KeyRings.CryptoKeys.Patch mockPatch;
66+
@Mock
67+
private CloudKMS.Projects.Locations.KeyRings.CryptoKeys.List mockList;
68+
69+
@Captor
70+
private ArgumentCaptor<CryptoKey> cryptoKeyCaptor;
71+
@Captor
72+
private ArgumentCaptor<String> resourceNameCaptor;
73+
@Captor
74+
private ArgumentCaptor<String> updateMaskCaptor;
75+
76+
private CloudKMSClient cloudKMSClient;
77+
78+
@Before
79+
public void setUp() throws Exception {
80+
when(mockKms.projects()).thenReturn(mockProjects);
81+
when(mockProjects.locations()).thenReturn(mockLocations);
82+
when(mockLocations.keyRings()).thenReturn(mockKeyRings);
83+
when(mockKeyRings.cryptoKeys()).thenReturn(mockCryptoKeys);
84+
when(mockCryptoKeys.create(anyString(), any(CryptoKey.class))).thenReturn(mockCreate);
85+
when(mockCreate.setCryptoKeyId(anyString())).thenReturn(mockCreate);
86+
when(mockCryptoKeys.patch(anyString(), any(CryptoKey.class))).thenReturn(mockPatch);
87+
when(mockPatch.setUpdateMask(anyString())).thenReturn(mockPatch);
88+
when(mockCryptoKeys.list(anyString())).thenReturn(mockList);
89+
90+
Map<String, String> properties = new HashMap<>();
91+
properties.put("project.id", TEST_PROJECT_ID);
92+
properties.put("keyring.id", TEST_KEYRING_ID);
93+
cloudKMSClient = new CloudKMSClient(properties) {
94+
@Override
95+
CloudKMS createCloudKMS(String serviceAccountFile) {
96+
return mockKms;
97+
}
98+
};
99+
}
100+
101+
@Test
102+
public void testCreateCryptoKeyIfNotExists_setsRotationPeriodDetails() throws IOException {
103+
String cryptoKeyId = "new-test-key";
104+
Instant testStartTime = Instant.now();
105+
cloudKMSClient.createCryptoKeyIfNotExists(cryptoKeyId);
106+
107+
verify(mockCryptoKeys).create(anyString(), cryptoKeyCaptor.capture());
108+
CryptoKey capturedKey = cryptoKeyCaptor.getValue();
109+
110+
long expectedSeconds = Duration.ofDays(90).getSeconds();
111+
assertEquals(expectedSeconds + "s", capturedKey.getRotationPeriod());
112+
113+
assertNotNull(capturedKey.getNextRotationTime());
114+
Instant nextRotation = Instant.parse(capturedKey.getNextRotationTime());
115+
assertTrue(nextRotation.isAfter(testStartTime));
116+
}
117+
118+
@Test
119+
public void testEnableRotationForExistingKeys_updatesKeyWithoutRotation() throws IOException {
120+
String keyToUpdateId = "key-without-rotation";
121+
String keyToUpdateFullName = String.format("projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s",
122+
TEST_PROJECT_ID, LOCATION_ID, TEST_KEYRING_ID, keyToUpdateId);
123+
124+
String keyToSkipId = "key-with-rotation";
125+
String keyToSkipFullName = String.format("projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s",
126+
TEST_PROJECT_ID, LOCATION_ID, TEST_KEYRING_ID, keyToSkipId);
127+
128+
CryptoKey keyToUpdate = new CryptoKey().setName(keyToUpdateFullName);
129+
CryptoKey keyToSkip = new CryptoKey().setName(keyToSkipFullName).setRotationPeriod("7776000s");
130+
131+
ListCryptoKeysResponse response = new ListCryptoKeysResponse().setCryptoKeys(
132+
Arrays.asList(keyToUpdate, keyToSkip));
133+
when(mockList.execute()).thenReturn(response);
134+
135+
cloudKMSClient.enableRotationForExistingKeys();
136+
137+
verify(mockPatch, times(1)).execute();
138+
verify(mockCryptoKeys).patch(resourceNameCaptor.capture(), cryptoKeyCaptor.capture());
139+
verify(mockPatch).setUpdateMask(updateMaskCaptor.capture());
140+
141+
assertEquals(keyToUpdateFullName, resourceNameCaptor.getValue());
142+
143+
long expectedSeconds = Duration.ofDays(90).getSeconds();
144+
assertEquals(expectedSeconds + "s", cryptoKeyCaptor.getValue().getRotationPeriod());
145+
assertEquals("rotation_period,next_rotation_time", updateMaskCaptor.getValue());
146+
}
147+
}

0 commit comments

Comments
 (0)