Skip to content

Commit aebb709

Browse files
Support multiple and pluggable content encryption keys and encryption algorithms (#2240)
* Refactor content encryption into pluggable components * Extend possible configuration for EncryptingContentStore Allow configuration of all pluggable components and provide default values when nothing is configured * Update documentation to reference swappable components * Add encryption of DEKs with vault * Add test for AesCtrEncryptionEngine * Add tests for StoredDataEncryptionKey data conversions * Add integration tests with a DataEncryptionKeyWrapper that does not decrypt * Move implementation of addKey/removeKey methods to DataEncryptionKeyAccessor interface * Add integration test using custom DataEncryptionKeyAccessor We need to ensure that the accessor is able to read the content property from the entity before it is removed/after it is created. This is necessary to have custom key accessors work, so they can store the encryption key somewhere other than the entity itself, for example based on the content id * Move VaultTransitDataEncryptionKeyWrapper to public API This DataEncryptionKeyWrapper object needs to be instanciated by users to use vault encryption, so it should not be in the internal package * Temp: Use updated gettingstarted repo * reset gettingstarted branch - gettingstarted PR now merged
1 parent 7e93b51 commit aebb709

File tree

40 files changed

+2407
-648
lines changed

40 files changed

+2407
-648
lines changed

.github/workflows/prs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ jobs:
6262
with:
6363
repository: paulcwarren/spring-content-gettingstarted
6464
path: spring-content-gettingstarted
65+
6566
- name: Validate against Getting Started Guides
6667
run: |
6768
pushd spring-content-gettingstarted

spring-content-commons/src/main/java/org/springframework/content/commons/mappingcontext/ContentProperty.java

+6-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public class ContentProperty {
2525
private String originalFileNamePropertyPath;
2626

2727
public Object getCustomProperty(Object entity, String propertyName) {
28-
String customContentPropertyPath = contentPropertyPath + StringUtils.capitalize(propertyName);
28+
String customContentPropertyPath = getCustomPropertyPropertyPath(propertyName);
2929

3030
BeanWrapper wrapper = getBeanWrapperForRead(entity);
3131
try {
@@ -36,12 +36,16 @@ public Object getCustomProperty(Object entity, String propertyName) {
3636
}
3737

3838
public void setCustomProperty(Object entity, String propertyName, Object value) {
39-
String customContentPropertyPath = contentPropertyPath + StringUtils.capitalize(propertyName);
39+
String customContentPropertyPath = getCustomPropertyPropertyPath(propertyName);
4040

4141
BeanWrapper wrapper = getBeanWrapperForWrite(entity);
4242
wrapper.setPropertyValue(customContentPropertyPath, value);
4343
}
4444

45+
public String getCustomPropertyPropertyPath(String propertyName) {
46+
return contentPropertyPath + StringUtils.capitalize(propertyName);
47+
}
48+
4549
public Object getContentId(Object entity) {
4650
if (contentIdPropertyPath == null) {
4751
return null;

spring-content-encryption/pom.xml

+5-5
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
<artifactId>spring-vault-core</artifactId>
2828
<version>3.1.2</version>
2929
</dependency>
30+
<dependency>
31+
<groupId>org.projectlombok</groupId>
32+
<artifactId>lombok</artifactId>
33+
<scope>provided</scope>
34+
</dependency>
3035

3136
<!-- Test Dependencies -->
3237
<dependency>
@@ -56,11 +61,6 @@
5661
<version>${mockito.version}</version>
5762
<scope>test</scope>
5863
</dependency>
59-
<dependency>
60-
<groupId>org.projectlombok</groupId>
61-
<artifactId>lombok</artifactId>
62-
<scope>test</scope>
63-
</dependency>
6464
<dependency>
6565
<groupId>org.springframework.boot</groupId>
6666
<artifactId>spring-boot-starter-data-jpa</artifactId>

spring-content-encryption/src/main/asciidoc/enc.adoc

+52-15
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ with a content-encryption key, and then encrypting the content-encryption key un
3030

3131
Every time
3232
content is stored it is first encrypted using the AES-CTR cipher and a randomly generated key. That
33-
content-encryption key is then encrypted using Hashicorp's vault and that key is then storied on the domain
34-
object. Any user with authorization to decrypt the encryption key can retrieve the content.
33+
content-encryption key is then (optionally) encrypted using a different key-encryption method and the encrypted content-encryption key
34+
is then storied on the domain object. Any user with authorization to decrypt the content-encryption key can retrieve the content.
3535

3636
Spring Content Encryption can be added to your application by adding the dependency:
3737

@@ -57,24 +57,17 @@ and then updating your application's configuration, as follows:
5757
public static class Config {
5858
5959
@Bean
60-
public EnvelopeEncryptionService encrypter(VaultOperations vaultOperations) { (1)
61-
return new EnvelopeEncryptionService(vaultOperations);
62-
}
63-
64-
@Bean
65-
public EncryptingContentStoreConfigurer config() { (2)
60+
public EncryptingContentStoreConfigurer<FileContentStore> config() { (1)
6661
return new EncryptingContentStoreConfigurer<FileContentStore>() {
6762
@Override
68-
public void configure(EncryptingContentStoreConfiguration config) {
69-
config.keyring("my-app-keyring").encryptionKeyContentProperty("key");
63+
public void configure(EncryptingContentStoreConfiguration<FileContentStore> config) {
64+
config.encryptionKeyContentProperty("key");
7065
}
7166
};
7267
}
7368
}
7469
----
75-
1. encrypter contributes the Envelope Encryption Service
76-
2. The `config` configures the `FileContentStore` with the Hashicorp Vault keyring to use (for encrypting the
77-
content encryption key) and the property to use to store the encryption key
70+
1. The `config` configures the encryption for the `FileContentStore` with the property to use to store the encryption key
7871
====
7972

8073
Domain objects content properties are then updated with a custom attribute to store the encrypted content-encryption key.
@@ -115,9 +108,53 @@ public interface FileContentStore extends ContentStore<File, UUID>, EncryptingCo
115108
----
116109
====
117110

111+
=== Content Encryption components
112+
113+
There are 3 separate components that work together to provide content encryption:
114+
115+
1. `ContentEncryptionEngine`: Performs encryption/decryption of the content itself with a content-encryption key
116+
2. `DataEncryptionKeyWrapper`: Performs wrapping (encryption) and unwrapping (decryption) of the content-encryption key
117+
3. `DataEncryptionKeyAccessor`: Stores and retrieves the encrypted content-encryption key
118+
119+
All components of the content encryption system can be configured or swapped out with custom implementations to suit your particular needs.
120+
121+
.Spring Content Encryption configuration
122+
====
123+
[source,java]
124+
----
125+
@EnableFilesystemStores
126+
public static class Config {
127+
128+
@Bean
129+
VaultDataEncryptionKeyWrapper vaultDataEncryptionKeyWrapper(VaultOperations vault) {
130+
return new VaultDataEncryptionKeyWrapper(vault, "my-key");
131+
}
132+
133+
@Bean
134+
public EncryptingContentStoreConfigurer<FileContentStore> config(VaultDataEncryptionKeyWrapper keyWrapper) {
135+
return new EncryptingContentStoreConfigurer<FileContentStore>() {
136+
@Override
137+
public void configure(EncryptingContentStoreConfiguration<FileContentStore> config) {
138+
config.encryptionKeyContentProperty("key") (1)
139+
.contentEncryptionMethod(ContentEncryptionMethod.AES_CTR_256) (2)
140+
.dataEncryptionKeyWrappers(List.of(keyWrapper)) (3)
141+
;
142+
}
143+
};
144+
}
145+
}
146+
----
147+
1. Configures the `FileContentStore` with the property to use to store the encryption key
148+
2. Use AES-CTR with 256 bit keys for encrypting content
149+
3. Use Hashicorp Vault to wrap (encrypt) the content-encryption key
150+
====
151+
118152
== Byte-Range Support
119-
Because the default implementation uses AES-CTR cipher even though a Store is encrypted it is still capable of
120-
serving byte-ranges. However, type of storage depends on how exactly that happens.
153+
154+
Support for byte-range requests is dependent on the encryption algorithm that is used for content encryption.
155+
156+
The default implementation using AES-CTR is capable of serving byte-ranges.
157+
However, it depends on the type of backing storage how that happens exactly.
121158

122159
With S3 storage byte-ranges will be forwarded onto S3 for fetching and therefore only the
123160
byte range need be decrypted before serving. This is very efficient.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package internal.org.springframework.content.encryption.engine;
2+
3+
import java.io.InputStream;
4+
import java.math.BigInteger;
5+
import java.security.NoSuchAlgorithmException;
6+
import java.security.SecureRandom;
7+
import java.util.Arrays;
8+
import java.util.function.Function;
9+
import javax.crypto.Cipher;
10+
import javax.crypto.CipherInputStream;
11+
import javax.crypto.KeyGenerator;
12+
import javax.crypto.spec.IvParameterSpec;
13+
import lombok.SneakyThrows;
14+
import org.springframework.content.encryption.engine.ContentEncryptionEngine;
15+
16+
/**
17+
* Symmetric data encryption engine using AES-CTR encryption mode
18+
*/
19+
public class AesCtrEncryptionEngine implements ContentEncryptionEngine {
20+
private final KeyGenerator keyGenerator;
21+
private static final SecureRandom secureRandom = new SecureRandom();
22+
23+
private static final int AES_BLOCK_SIZE_BYTES = 16; // AES has a 128-bit block size
24+
private static final int IV_SIZE_BYTES = AES_BLOCK_SIZE_BYTES; // IV is the same size as a block
25+
26+
@SneakyThrows({NoSuchAlgorithmException.class})
27+
public AesCtrEncryptionEngine(int keySizeBits) {
28+
keyGenerator = KeyGenerator.getInstance("AES");
29+
keyGenerator.init(keySizeBits, secureRandom);
30+
}
31+
32+
@Override
33+
public EncryptionParameters createNewParameters() {
34+
var secretKey = keyGenerator.generateKey();
35+
byte[] iv = new byte[IV_SIZE_BYTES];
36+
secureRandom.nextBytes(iv);
37+
return new EncryptionParameters(
38+
secretKey,
39+
iv
40+
);
41+
}
42+
43+
@SneakyThrows
44+
private Cipher initializeCipher(EncryptionParameters parameters, boolean forEncryption) {
45+
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
46+
cipher.init(
47+
forEncryption?Cipher.ENCRYPT_MODE:Cipher.DECRYPT_MODE,
48+
parameters.getSecretKey(),
49+
new IvParameterSpec(parameters.getInitializationVector())
50+
);
51+
52+
return cipher;
53+
}
54+
55+
@Override
56+
public InputStream encrypt(InputStream plainText, EncryptionParameters encryptionParameters) {
57+
return new CipherInputStream(plainText, initializeCipher(encryptionParameters, true));
58+
}
59+
60+
@Override
61+
public InputStream decrypt(
62+
Function<InputStreamRequestParameters, InputStream> cipherTextStreamRequest,
63+
EncryptionParameters encryptionParameters,
64+
InputStreamRequestParameters requestParameters
65+
) {
66+
var blockStartOffset = calculateBlockOffset(requestParameters.getStartByteOffset());
67+
68+
var adjustedIv = adjustIvForOffset(encryptionParameters.getInitializationVector(), blockStartOffset);
69+
70+
var adjustedParameters = new EncryptionParameters(
71+
encryptionParameters.getSecretKey(),
72+
adjustedIv
73+
);
74+
75+
var byteStartOffset = blockStartOffset * AES_BLOCK_SIZE_BYTES;
76+
77+
var cipherTextStream = cipherTextStreamRequest.apply(requestParameters);
78+
79+
var cipher = initializeCipher(adjustedParameters, false);
80+
81+
return new ZeroPrefixedInputStream(
82+
new EnsureSingleSkipInputStream(
83+
new CipherInputStream(
84+
new SkippingInputStream(
85+
cipherTextStream,
86+
byteStartOffset
87+
),
88+
cipher
89+
)
90+
),
91+
byteStartOffset
92+
);
93+
}
94+
95+
private static long calculateBlockOffset(long offsetBytes) {
96+
return (offsetBytes - (offsetBytes % AES_BLOCK_SIZE_BYTES)) / AES_BLOCK_SIZE_BYTES;
97+
}
98+
99+
private byte[] adjustIvForOffset(byte[] iv, long offsetBlocks) {
100+
// Optimization: no need to adjust the IV when we have no block offset
101+
if(offsetBlocks == 0) {
102+
return iv;
103+
}
104+
105+
// AES-CTR works by having a separate IV for every block.
106+
// This block IV is built from the initial IV and the block counter.
107+
var initialIv = new BigInteger(1, iv);
108+
byte[] bigintBytes = initialIv.add(BigInteger.valueOf(offsetBlocks))
109+
.toByteArray();
110+
111+
// Because we're using BigInteger for math here,
112+
// the resulting byte array may be longer (when overflowing the IV size, we should wrap around)
113+
// or shorter (when our IV starts with a bunch of 0)
114+
// It needs to be the proper length, and aligned properly
115+
if(bigintBytes.length == AES_BLOCK_SIZE_BYTES) {
116+
return bigintBytes;
117+
} else if(bigintBytes.length > AES_BLOCK_SIZE_BYTES) {
118+
// Byte array is longer, we need to cut a part of the front
119+
return Arrays.copyOfRange(bigintBytes, bigintBytes.length-IV_SIZE_BYTES, bigintBytes.length);
120+
} else {
121+
// Byte array is shorter, we need to pad the front with 0 bytes
122+
// Note that a bytes array is initialized to be all-zero by default
123+
byte[] ivBytes = new byte[IV_SIZE_BYTES];
124+
System.arraycopy(bigintBytes, 0, ivBytes, IV_SIZE_BYTES-bigintBytes.length, bigintBytes.length);
125+
return ivBytes;
126+
}
127+
}
128+
129+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package internal.org.springframework.content.encryption.engine;
2+
3+
import java.io.FilterInputStream;
4+
import java.io.IOException;
5+
import java.io.InputStream;
6+
7+
/**
8+
* Ensures that a single {@link #skip(long)} call skips exactly that amount of bytes.
9+
* <p>
10+
* This fixes an issue in the {@link javax.crypto.CipherInputStream} where skips can stop short of the requested skip amount
11+
*/
12+
class EnsureSingleSkipInputStream extends FilterInputStream {
13+
14+
public EnsureSingleSkipInputStream(InputStream in) {
15+
super(in);
16+
}
17+
18+
@Override
19+
public long skip(long n) throws IOException {
20+
long totalSkipped = 0;
21+
while(totalSkipped < n) {
22+
var skipAmount = super.skip(n-totalSkipped);
23+
totalSkipped+=skipAmount;
24+
if(skipAmount == 0) { // no bytes were skipped
25+
// Read one byte to check for EOF
26+
if(read() == -1) {
27+
return totalSkipped;
28+
}
29+
totalSkipped++; // We skipped the byte we read above
30+
}
31+
}
32+
return n;
33+
}
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package internal.org.springframework.content.encryption.engine;
2+
3+
import java.io.IOException;
4+
import java.io.InputStream;
5+
6+
/**
7+
* Skips a certain amount of bytes from the delegate {@link InputStream}
8+
*/
9+
class SkippingInputStream extends InputStream {
10+
private final InputStream delegate;
11+
private final long skipBytes;
12+
private boolean hasSkipped;
13+
14+
public SkippingInputStream(InputStream delegate, long skipBytes) {
15+
this.delegate = delegate;
16+
this.skipBytes = skipBytes;
17+
}
18+
19+
private void ensureSkipped() throws IOException {
20+
if(!hasSkipped) {
21+
delegate.skipNBytes(skipBytes);
22+
hasSkipped = true;
23+
}
24+
}
25+
26+
@Override
27+
public long skip(long n) throws IOException {
28+
ensureSkipped();
29+
return delegate.skip(n);
30+
}
31+
32+
@Override
33+
public int read() throws IOException {
34+
ensureSkipped();
35+
return delegate.read();
36+
}
37+
38+
@Override
39+
public int read(byte[] b, int off, int len) throws IOException {
40+
ensureSkipped();
41+
return delegate.read(b, off, len);
42+
}
43+
44+
@Override
45+
public void close() throws IOException {
46+
delegate.close();
47+
}
48+
}

0 commit comments

Comments
 (0)