Skip to content

Commit

Permalink
Support multiple and pluggable content encryption keys and encryption…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
vierbergenlars authored Feb 6, 2025
1 parent 7e93b51 commit aebb709
Show file tree
Hide file tree
Showing 40 changed files with 2,407 additions and 648 deletions.
1 change: 1 addition & 0 deletions .github/workflows/prs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ jobs:
with:
repository: paulcwarren/spring-content-gettingstarted
path: spring-content-gettingstarted

- name: Validate against Getting Started Guides
run: |
pushd spring-content-gettingstarted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class ContentProperty {
private String originalFileNamePropertyPath;

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

BeanWrapper wrapper = getBeanWrapperForRead(entity);
try {
Expand All @@ -36,12 +36,16 @@ public Object getCustomProperty(Object entity, String propertyName) {
}

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

BeanWrapper wrapper = getBeanWrapperForWrite(entity);
wrapper.setPropertyValue(customContentPropertyPath, value);
}

public String getCustomPropertyPropertyPath(String propertyName) {
return contentPropertyPath + StringUtils.capitalize(propertyName);
}

public Object getContentId(Object entity) {
if (contentIdPropertyPath == null) {
return null;
Expand Down
10 changes: 5 additions & 5 deletions spring-content-encryption/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
<artifactId>spring-vault-core</artifactId>
<version>3.1.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>

<!-- Test Dependencies -->
<dependency>
Expand Down Expand Up @@ -56,11 +61,6 @@
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
Expand Down
67 changes: 52 additions & 15 deletions spring-content-encryption/src/main/asciidoc/enc.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ with a content-encryption key, and then encrypting the content-encryption key un

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

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

Expand All @@ -57,24 +57,17 @@ and then updating your application's configuration, as follows:
public static class Config {
@Bean
public EnvelopeEncryptionService encrypter(VaultOperations vaultOperations) { (1)
return new EnvelopeEncryptionService(vaultOperations);
}
@Bean
public EncryptingContentStoreConfigurer config() { (2)
public EncryptingContentStoreConfigurer<FileContentStore> config() { (1)
return new EncryptingContentStoreConfigurer<FileContentStore>() {
@Override
public void configure(EncryptingContentStoreConfiguration config) {
config.keyring("my-app-keyring").encryptionKeyContentProperty("key");
public void configure(EncryptingContentStoreConfiguration<FileContentStore> config) {
config.encryptionKeyContentProperty("key");
}
};
}
}
----
1. encrypter contributes the Envelope Encryption Service
2. The `config` configures the `FileContentStore` with the Hashicorp Vault keyring to use (for encrypting the
content encryption key) and the property to use to store the encryption key
1. The `config` configures the encryption for the `FileContentStore` with the property to use to store the encryption key
====

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

=== Content Encryption components

There are 3 separate components that work together to provide content encryption:

1. `ContentEncryptionEngine`: Performs encryption/decryption of the content itself with a content-encryption key
2. `DataEncryptionKeyWrapper`: Performs wrapping (encryption) and unwrapping (decryption) of the content-encryption key
3. `DataEncryptionKeyAccessor`: Stores and retrieves the encrypted content-encryption key

All components of the content encryption system can be configured or swapped out with custom implementations to suit your particular needs.

.Spring Content Encryption configuration
====
[source,java]
----
@EnableFilesystemStores
public static class Config {
@Bean
VaultDataEncryptionKeyWrapper vaultDataEncryptionKeyWrapper(VaultOperations vault) {
return new VaultDataEncryptionKeyWrapper(vault, "my-key");
}
@Bean
public EncryptingContentStoreConfigurer<FileContentStore> config(VaultDataEncryptionKeyWrapper keyWrapper) {
return new EncryptingContentStoreConfigurer<FileContentStore>() {
@Override
public void configure(EncryptingContentStoreConfiguration<FileContentStore> config) {
config.encryptionKeyContentProperty("key") (1)
.contentEncryptionMethod(ContentEncryptionMethod.AES_CTR_256) (2)
.dataEncryptionKeyWrappers(List.of(keyWrapper)) (3)
;
}
};
}
}
----
1. Configures the `FileContentStore` with the property to use to store the encryption key
2. Use AES-CTR with 256 bit keys for encrypting content
3. Use Hashicorp Vault to wrap (encrypt) the content-encryption key
====

== Byte-Range Support
Because the default implementation uses AES-CTR cipher even though a Store is encrypted it is still capable of
serving byte-ranges. However, type of storage depends on how exactly that happens.

Support for byte-range requests is dependent on the encryption algorithm that is used for content encryption.

The default implementation using AES-CTR is capable of serving byte-ranges.
However, it depends on the type of backing storage how that happens exactly.

With S3 storage byte-ranges will be forwarded onto S3 for fetching and therefore only the
byte range need be decrypted before serving. This is very efficient.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package internal.org.springframework.content.encryption.engine;

import java.io.InputStream;
import java.math.BigInteger;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.function.Function;
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.IvParameterSpec;
import lombok.SneakyThrows;
import org.springframework.content.encryption.engine.ContentEncryptionEngine;

/**
* Symmetric data encryption engine using AES-CTR encryption mode
*/
public class AesCtrEncryptionEngine implements ContentEncryptionEngine {
private final KeyGenerator keyGenerator;
private static final SecureRandom secureRandom = new SecureRandom();

private static final int AES_BLOCK_SIZE_BYTES = 16; // AES has a 128-bit block size
private static final int IV_SIZE_BYTES = AES_BLOCK_SIZE_BYTES; // IV is the same size as a block

@SneakyThrows({NoSuchAlgorithmException.class})
public AesCtrEncryptionEngine(int keySizeBits) {
keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(keySizeBits, secureRandom);
}

@Override
public EncryptionParameters createNewParameters() {
var secretKey = keyGenerator.generateKey();
byte[] iv = new byte[IV_SIZE_BYTES];
secureRandom.nextBytes(iv);
return new EncryptionParameters(
secretKey,
iv
);
}

@SneakyThrows
private Cipher initializeCipher(EncryptionParameters parameters, boolean forEncryption) {
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
cipher.init(
forEncryption?Cipher.ENCRYPT_MODE:Cipher.DECRYPT_MODE,
parameters.getSecretKey(),
new IvParameterSpec(parameters.getInitializationVector())
);

return cipher;
}

@Override
public InputStream encrypt(InputStream plainText, EncryptionParameters encryptionParameters) {
return new CipherInputStream(plainText, initializeCipher(encryptionParameters, true));
}

@Override
public InputStream decrypt(
Function<InputStreamRequestParameters, InputStream> cipherTextStreamRequest,
EncryptionParameters encryptionParameters,
InputStreamRequestParameters requestParameters
) {
var blockStartOffset = calculateBlockOffset(requestParameters.getStartByteOffset());

var adjustedIv = adjustIvForOffset(encryptionParameters.getInitializationVector(), blockStartOffset);

var adjustedParameters = new EncryptionParameters(
encryptionParameters.getSecretKey(),
adjustedIv
);

var byteStartOffset = blockStartOffset * AES_BLOCK_SIZE_BYTES;

var cipherTextStream = cipherTextStreamRequest.apply(requestParameters);

var cipher = initializeCipher(adjustedParameters, false);

return new ZeroPrefixedInputStream(
new EnsureSingleSkipInputStream(
new CipherInputStream(
new SkippingInputStream(
cipherTextStream,
byteStartOffset
),
cipher
)
),
byteStartOffset
);
}

private static long calculateBlockOffset(long offsetBytes) {
return (offsetBytes - (offsetBytes % AES_BLOCK_SIZE_BYTES)) / AES_BLOCK_SIZE_BYTES;
}

private byte[] adjustIvForOffset(byte[] iv, long offsetBlocks) {
// Optimization: no need to adjust the IV when we have no block offset
if(offsetBlocks == 0) {
return iv;
}

// AES-CTR works by having a separate IV for every block.
// This block IV is built from the initial IV and the block counter.
var initialIv = new BigInteger(1, iv);
byte[] bigintBytes = initialIv.add(BigInteger.valueOf(offsetBlocks))
.toByteArray();

// Because we're using BigInteger for math here,
// the resulting byte array may be longer (when overflowing the IV size, we should wrap around)
// or shorter (when our IV starts with a bunch of 0)
// It needs to be the proper length, and aligned properly
if(bigintBytes.length == AES_BLOCK_SIZE_BYTES) {
return bigintBytes;
} else if(bigintBytes.length > AES_BLOCK_SIZE_BYTES) {
// Byte array is longer, we need to cut a part of the front
return Arrays.copyOfRange(bigintBytes, bigintBytes.length-IV_SIZE_BYTES, bigintBytes.length);
} else {
// Byte array is shorter, we need to pad the front with 0 bytes
// Note that a bytes array is initialized to be all-zero by default
byte[] ivBytes = new byte[IV_SIZE_BYTES];
System.arraycopy(bigintBytes, 0, ivBytes, IV_SIZE_BYTES-bigintBytes.length, bigintBytes.length);
return ivBytes;
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package internal.org.springframework.content.encryption.engine;

import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;

/**
* Ensures that a single {@link #skip(long)} call skips exactly that amount of bytes.
* <p>
* This fixes an issue in the {@link javax.crypto.CipherInputStream} where skips can stop short of the requested skip amount
*/
class EnsureSingleSkipInputStream extends FilterInputStream {

public EnsureSingleSkipInputStream(InputStream in) {
super(in);
}

@Override
public long skip(long n) throws IOException {
long totalSkipped = 0;
while(totalSkipped < n) {
var skipAmount = super.skip(n-totalSkipped);
totalSkipped+=skipAmount;
if(skipAmount == 0) { // no bytes were skipped
// Read one byte to check for EOF
if(read() == -1) {
return totalSkipped;
}
totalSkipped++; // We skipped the byte we read above
}
}
return n;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package internal.org.springframework.content.encryption.engine;

import java.io.IOException;
import java.io.InputStream;

/**
* Skips a certain amount of bytes from the delegate {@link InputStream}
*/
class SkippingInputStream extends InputStream {
private final InputStream delegate;
private final long skipBytes;
private boolean hasSkipped;

public SkippingInputStream(InputStream delegate, long skipBytes) {
this.delegate = delegate;
this.skipBytes = skipBytes;
}

private void ensureSkipped() throws IOException {
if(!hasSkipped) {
delegate.skipNBytes(skipBytes);
hasSkipped = true;
}
}

@Override
public long skip(long n) throws IOException {
ensureSkipped();
return delegate.skip(n);
}

@Override
public int read() throws IOException {
ensureSkipped();
return delegate.read();
}

@Override
public int read(byte[] b, int off, int len) throws IOException {
ensureSkipped();
return delegate.read(b, off, len);
}

@Override
public void close() throws IOException {
delegate.close();
}
}
Loading

0 comments on commit aebb709

Please sign in to comment.