Skip to content

Commit 39b87ee

Browse files
Fix S3 PartialContentInputStream to be compliant to InputStream specification (#2217)
* Make S3 PartialContentInputStream spec-compliant The PartialContentInputStream was a minimal implementation that worked well when used directly with the spring byte-range support. However, using the InputStream variant in any other place (like wrapping it for decryption), the minimal implementation breaks down, requiring workarounds when handling the encryption. Using an implementation that is fully compliant with the InputStream specifications makes implementation of a wrapping InputStream easier, as no special provisions need to be made for a read method that immediately reads the byte-range (instead of empty space before the start) * Simplify encryption for byte-range requests Because an actual compliant InputStream is now returned from the backing store, encryption can be handled simpler by just running the cipher for the whole range. Since encryption is not authenticated, and CTR mode is byte-addressable, this just works. The zero-bytes before/after the actual data are decrypted as garbage, but they will be cut out when streaming the response back to the user. * Make range-request not aligned to an AES block boundary Although the testcase works when aligned on a block boundary, the test is more comprehensive when it is not aligned * Add back block skipping for offsets Skipping decrypting blocks that do not have data and will be skipped anyways is wasted work. Reintroduce the technique to calculate IV for a certain block offset to save decryption work for larger offsets * Use long for file offsets Using an int limits the offset to about 2GB, which might not be sufficient for very large files. Using a long results in an offset of 9EB, which should be sufficient.
1 parent a6c42d2 commit 39b87ee

File tree

9 files changed

+551
-204
lines changed

9 files changed

+551
-204
lines changed

spring-content-encryption/src/main/java/internal/org/springframework/content/fragments/EncryptingContentStoreImpl.java

Lines changed: 8 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
import org.springframework.util.Assert;
1717

1818
import javax.crypto.CipherInputStream;
19-
import java.io.FilterInputStream;
2019
import java.io.IOException;
2120
import java.io.InputStream;
2221
import java.io.Serializable;
@@ -267,7 +266,7 @@ public Resource getResource(S entity, PropertyPath propertyPath) {
267266

268267
// remove cast and use conversion service
269268
unencryptedStream = encrypter.decrypt((byte[]) contentProperty.getCustomProperty(entity, this.encryptionKeyContentProperty), r.getInputStream(), 0, this.keyRing);
270-
r = new InputStreamResource(new SkipInputStream(unencryptedStream));
269+
r = new InputStreamResource(unencryptedStream);
271270
} catch (IOException e) {
272271
throw new StoreAccessException("error encrypting resource", e);
273272
}
@@ -282,7 +281,7 @@ public Resource getResource(S entity, PropertyPath propertyPath, org.springframe
282281
Assert.notNull(propertyPath, "propertyPath not set");
283282
Assert.notNull(storeDelegate, "store not set");
284283

285-
Resource r = storeDelegate.getResource(entity, propertyPath, rewriteParamsForCTR(params));
284+
Resource r = storeDelegate.getResource(entity, propertyPath, params);
286285

287286
if (r != null) {
288287
InputStream unencryptedStream = null;
@@ -309,7 +308,7 @@ public Resource getResource(S entity, PropertyPath propertyPath, GetResourcePara
309308
Assert.notNull(propertyPath, "propertyPath not set");
310309
Assert.notNull(delegate, "store not set");
311310

312-
Resource r = delegate.getResource(entity, propertyPath, rewriteParamsForCTR(params));
311+
Resource r = delegate.getResource(entity, propertyPath, params);
313312

314313
if (r != null) {
315314
InputStream unencryptedStream = null;
@@ -330,44 +329,26 @@ public Resource getResource(S entity, PropertyPath propertyPath, GetResourcePara
330329
return r;
331330
}
332331

333-
private GetResourceParams rewriteParamsForCTR(GetResourceParams params) {
334-
if (params.getRange() == null) {
335-
return params;
336-
}
337-
int begin = Integer.parseInt(StringUtils.substringBetween(params.getRange(), "bytes=", "-"));
338-
int blockBegin = begin - (begin % 16);
339-
return GetResourceParams.builder().range("bytes=" + blockBegin + "-" + StringUtils.substringAfter(params.getRange(), "-")).build();
340-
}
341-
342-
private org.springframework.content.commons.store.GetResourceParams rewriteParamsForCTR(org.springframework.content.commons.store.GetResourceParams params) {
343-
if (params.getRange() == null) {
344-
return params;
345-
}
346-
int begin = Integer.parseInt(StringUtils.substringBetween(params.getRange(), "bytes=", "-"));
347-
int blockBegin = begin - (begin % 16);
348-
return org.springframework.content.commons.store.GetResourceParams.builder().range("bytes=" + blockBegin + "-" + StringUtils.substringAfter(params.getRange(), "-")).build();
349-
}
350-
351-
private int getOffset(Resource r, GetResourceParams params) {
332+
private long getOffset(Resource r, GetResourceParams params) {
352333
int offset = 0;
353334

354335
if (r instanceof RangeableResource == false)
355336
return offset;
356337
if (params.getRange() == null)
357338
return offset;
358339

359-
return Integer.parseInt(StringUtils.substringBetween(params.getRange(), "bytes=", "-"));
340+
return Long.parseUnsignedLong(StringUtils.substringBetween(params.getRange(), "bytes=", "-"));
360341
}
361342

362-
private int getOffset(Resource r, org.springframework.content.commons.store.GetResourceParams params) {
363-
int offset = 0;
343+
private long getOffset(Resource r, org.springframework.content.commons.store.GetResourceParams params) {
344+
long offset = 0;
364345

365346
if (r instanceof RangeableResource == false)
366347
return offset;
367348
if (params.getRange() == null)
368349
return offset;
369350

370-
return Integer.parseInt(StringUtils.substringBetween(params.getRange(), "bytes=", "-"));
351+
return Long.parseUnsignedLong(StringUtils.substringBetween(params.getRange(), "bytes=", "-"));
371352
}
372353

373354
@Override
@@ -440,41 +421,6 @@ private void configure(Class<? extends Store> storeInterfaceClass) {
440421
}
441422
}
442423

443-
// CipherInputStream skip does not work. This wraps a cipherinputstream purely to override the skip with a
444-
// working version
445-
public class SkipInputStream extends FilterInputStream
446-
{
447-
private static final int MAX_SKIP_BUFFER_SIZE = 2048;
448-
449-
protected SkipInputStream (InputStream in)
450-
{
451-
super(in);
452-
}
453-
454-
public long skip(long n)
455-
throws IOException
456-
{
457-
long remaining = n;
458-
int nr;
459-
460-
if (n <= 0) {
461-
return 0;
462-
}
463-
464-
int size = (int)Math.min(MAX_SKIP_BUFFER_SIZE, remaining);
465-
byte[] skipBuffer = new byte[size];
466-
while (remaining > 0) {
467-
nr = in.read(skipBuffer, 0, (int)Math.min(size, remaining));
468-
if (nr < 0) {
469-
break;
470-
}
471-
remaining -= nr;
472-
}
473-
474-
return n - remaining;
475-
}
476-
}
477-
478424
public class EncryptingContentStoreConfigurationImpl implements EncryptingContentStoreConfiguration {
479425
private String encryptionKeyContentProperty;
480426
private String keyring;

spring-content-encryption/src/main/java/org/springframework/content/encryption/EnvelopeEncryptionService.java

Lines changed: 65 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package org.springframework.content.encryption;
22

3-
import org.springframework.beans.factory.InitializingBean;
3+
import java.math.BigInteger;
44
import org.springframework.data.util.Pair;
55
import org.springframework.vault.core.VaultOperations;
66
import org.springframework.vault.core.VaultTransitOperations;
@@ -11,7 +11,6 @@
1111
import java.io.FilterInputStream;
1212
import java.io.IOException;
1313
import java.io.InputStream;
14-
import java.math.BigInteger;
1514
import java.security.InvalidAlgorithmParameterException;
1615
import java.security.InvalidKeyException;
1716
import java.security.NoSuchAlgorithmException;
@@ -85,14 +84,14 @@ private SecretKey generateDataKey() {
8584
return KEY_GENERATOR.generateKey();
8685
}
8786

88-
private InputStream decryptInputStream(final SecretKeySpec secretKeySpec, byte[] nonce, int offset, InputStream is) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, IOException, InvalidAlgorithmParameterException {
87+
private InputStream decryptInputStream(final SecretKeySpec secretKeySpec, byte[] nonce, long offset, InputStream is) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, IOException, InvalidAlgorithmParameterException {
8988
Cipher cipher = Cipher.getInstance(transformation);
9089

9190
byte[] iv = new byte[128 / 8];
9291
System.arraycopy(nonce, 0, iv, 0, nonce.length);
9392

9493
int AES_BLOCK_SIZE = 16;
95-
int blockOffset = offset - (offset % AES_BLOCK_SIZE);
94+
long blockOffset = offset - (offset % AES_BLOCK_SIZE);
9695
final BigInteger ivBI = new BigInteger(1, iv);
9796
final BigInteger ivForOffsetBI = ivBI.add(BigInteger.valueOf(blockOffset / AES_BLOCK_SIZE));
9897
final byte[] ivForOffsetBA = ivForOffsetBI.toByteArray();
@@ -105,19 +104,17 @@ private InputStream decryptInputStream(final SecretKeySpec secretKeySpec, byte[]
105104
ivForOffset = new IvParameterSpec(ivForOffsetBASized);
106105
}
107106

108-
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivForOffset);
109-
110-
CipherInputStream cis = new CipherInputStream(is, cipher);
107+
// Skip the blocks that we are not going to decrypt.
108+
// We advanced the IV manually to compensate for these skipped blocks,
109+
// and the stream will be zero-prefixed to compensate on the other side as well.
110+
// This saves encryption processing for all blocks that would be discarded anyways
111+
is.skipNBytes(blockOffset);
111112

112-
InputStream inputStreamToReturn = cis;
113-
if (offset == 0) {
114-
inputStreamToReturn = new ZeroOffsetSkipInputStream(cis);
115-
} else if (offset > 0) {
116-
inputStreamToReturn = new OffsetSkipInputStream(cis, offset % AES_BLOCK_SIZE);
117-
}
113+
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivForOffset);
118114

119-
return inputStreamToReturn;
115+
return new OffsetInputStream(new SkippingInputStream(new CipherInputStream(is, cipher)), blockOffset);
120116
}
117+
121118
private SecretKeySpec decryptKey(byte[] encryptedKey, String keyName) {
122119
VaultTransitOperations transit = vaultOperations.opsForTransit();
123120
String decryptedBase64Key = transit.decrypt(keyName, new String(encryptedKey));
@@ -127,7 +124,7 @@ private SecretKeySpec decryptKey(byte[] encryptedKey, String keyName) {
127124
return key;
128125
}
129126

130-
public InputStream decrypt(byte[] ecryptedContext, InputStream is, int offset, String keyName) {
127+
public InputStream decrypt(byte[] ecryptedContext, InputStream is, long offset, String keyName) {
131128

132129
byte[] key = new byte[105];
133130
System.arraycopy(ecryptedContext, 0, key, 0, 105);
@@ -148,12 +145,12 @@ public void rotate(String keyName) {
148145
}
149146

150147
// CipherInputStream skip does not work. This wraps a cipherinputstream purely to override the skip with a
151-
// working version. Used when backend Store has not already primed the input stream.
152-
public class ZeroOffsetSkipInputStream extends FilterInputStream
148+
// working version.
149+
private static class SkippingInputStream extends FilterInputStream
153150
{
154151
private static final int MAX_SKIP_BUFFER_SIZE = 2048;
155152

156-
protected ZeroOffsetSkipInputStream(InputStream in)
153+
protected SkippingInputStream(InputStream in)
157154
{
158155
super(in);
159156
}
@@ -182,42 +179,64 @@ public long skip(long n)
182179
}
183180
}
184181

185-
// This wraps a cipherinputstream purely to override skip
186-
//
187-
// Used when a backend store has already satisfied a range request (this service will request a range to the nearest block).
188-
// Skips then skips bytes between the beginning of the block and the start actual range that the client requested.
189-
public class OffsetSkipInputStream extends FilterInputStream
190-
{
191-
private static final int MAX_SKIP_BUFFER_SIZE = 2048;
192-
private final int offset;
193-
194-
protected OffsetSkipInputStream(InputStream in, int offset)
195-
{
196-
super(in);
197-
this.offset = offset;
182+
/**
183+
* Adds a fixed amount of 0-bytes in front of the delegate {@link InputStream}
184+
* <p>
185+
*
186+
* */
187+
private static class OffsetInputStream extends InputStream {
188+
private InputStream delegate;
189+
private long offsetBytes;
190+
191+
public OffsetInputStream(InputStream delegate, long offsetBytes) {
192+
this.delegate = delegate;
193+
this.offsetBytes = offsetBytes;
198194
}
199195

200-
public long skip(long n)
201-
throws IOException
202-
{
203-
long remaining = offset;
204-
int nr;
196+
@Override
197+
public long skip(long n) throws IOException {
198+
if(n <= 0) {
199+
return 0;
200+
}
201+
if(n <= offsetBytes) {
202+
offsetBytes -= n;
203+
return n;
204+
}
205+
if(offsetBytes > 0) {
206+
n = n - offsetBytes; // Still skipping so many bytes from the offset
207+
try {
208+
return offsetBytes + delegate.skip(n);
209+
} finally {
210+
offsetBytes = 0; // Now the whole offset is consumed; skip to the delegate
211+
}
212+
}
205213

206-
if (n <= 0) {
214+
return delegate.skip(n);
215+
}
216+
217+
@Override
218+
public int read() throws IOException {
219+
if(offsetBytes > 0) {
220+
offsetBytes--;
207221
return 0;
208222
}
223+
return delegate.read();
224+
}
209225

210-
int size = (int)Math.min(MAX_SKIP_BUFFER_SIZE, remaining);
211-
byte[] skipBuffer = new byte[size];
212-
while (remaining > 0) {
213-
nr = in.read(skipBuffer, 0, (int)Math.min(size, remaining));
214-
if (nr < 0) {
215-
break;
216-
}
217-
remaining -= nr;
226+
@Override
227+
public int read(byte[] b, int off, int len) throws IOException {
228+
if(offsetBytes > 0) {
229+
return super.read(b, off, len);
218230
}
231+
return delegate.read(b, off, len);
232+
}
219233

220-
return n - remaining;
234+
@Override
235+
public int available() throws IOException {
236+
if(offsetBytes > 0) {
237+
return (int)Math.max(offsetBytes, Integer.MAX_VALUE);
238+
}
239+
return delegate.available();
221240
}
222241
}
223242
}

spring-content-encryption/src/test/java/org/springframework/content/encryption/LocalStack.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ private static class Singleton {
3232
public static S3Client getAmazonS3Client() throws URISyntaxException {
3333
return S3Client.builder()
3434
.endpointOverride(new URI(Singleton.INSTANCE.getEndpointConfiguration(LocalStackContainer.Service.S3).getServiceEndpoint()))
35+
.region(Region.US_EAST_1)
3536
.credentialsProvider(new CrossAwsCredentialsProvider(Singleton.INSTANCE.getDefaultCredentialsProvider()))
3637
.serviceConfiguration((bldr) -> bldr.pathStyleAccessEnabled(true).build())
3738
.build();

spring-content-encryption/src/test/java/org/springframework/content/encryption/s3/EncryptionIT.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,28 @@ public class EncryptionIT {
153153
MockMvcResponse r =
154154
given()
155155
.header("accept", "text/plain")
156-
.header("range", "bytes=16-27")
156+
.header("range", "bytes=14-27")
157157
.get("/files/" + f.getId() + "/content")
158158
.then()
159159
.statusCode(HttpStatus.SC_PARTIAL_CONTENT)
160160
.assertThat()
161161
.contentType(Matchers.startsWith("text/plain"))
162162
.and().extract().response();
163163

164-
assertThat(r.asString(), is("e encryption"));
164+
assertThat(r.asString(), is("ide encryption"));
165+
166+
r =
167+
given()
168+
.header("accept", "text/plain")
169+
.header("range", "bytes=19-27")
170+
.get("/files/" + f.getId() + "/content")
171+
.then()
172+
.statusCode(HttpStatus.SC_PARTIAL_CONTENT)
173+
.assertThat()
174+
.contentType(Matchers.startsWith("text/plain"))
175+
.and().extract().response();
176+
177+
assertThat(r.asString(), is("ncryption"));
165178
});
166179
Context("when the keyring is rotated", () -> {
167180
BeforeEach(() -> {

0 commit comments

Comments
 (0)