From 006ae586127c55f207bac0f0267a88d2400dc9d7 Mon Sep 17 00:00:00 2001 From: Colt Frederickson Date: Thu, 22 Jan 2026 12:05:42 -0700 Subject: [PATCH 1/3] Initial add of cached key decryptor --- .../kms/v1/CachedKeyDecryptor.java | 244 ++++++++++++++++++ .../kms/v1/DocumentDecryptor.java | 39 +++ .../kms/v1/TenantSecurityClient.java | 88 ++++++- .../kms/v1/CachedKeyDecryptorTest.java | 210 +++++++++++++++ 4 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java create mode 100644 src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/DocumentDecryptor.java create mode 100644 src/test/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptorTest.java diff --git a/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java new file mode 100644 index 0000000..8b261d0 --- /dev/null +++ b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java @@ -0,0 +1,244 @@ +package com.ironcorelabs.tenantsecurity.kms.v1; + +import java.io.Closeable; +import java.io.InputStream; +import java.io.OutputStream; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import com.ironcorelabs.tenantsecurity.kms.v1.exception.TscException; +import com.ironcorelabs.tenantsecurity.utils.CompletableFutures; + +/** + * Holds a cached DEK (Document Encryption Key) for repeated decrypt operations without making + * additional TSP unwrap calls. The DEK is securely zeroed when close() is called. + * + *

+ * This class is thread-safe and can be used concurrently for multiple decrypt operations. Once + * closed, all decrypt operations will fail. + * + *

+ * Expiration: This decryptor automatically expires after a short time period. Caching a DEK + * for long-term use is not supported as it would undermine the security benefits of key wrapping. + * The decryptor is intended for short-lived batch operations where multiple documents sharing the + * same EDEK need to be decrypted in quick succession. Use {@link #isExpired()} to check expiration + * status. + * + *

+ * Usage with loan pattern (recommended): + * + *

+ * client.withCachedDecryptor(edek, metadata, decryptor ->
+ *     decryptor.decrypt(encDoc1, metadata)
+ *         .thenCompose(doc1 -> decryptor.decrypt(encDoc2, metadata)))
+ * 
+ * + *

+ * Usage with try-with-resources: + * + *

+ * try (CachedKeyDecryptor decryptor = client.createCachedDecryptor(edek, metadata).get()) {
+ *   PlaintextDocument doc1 = decryptor.decrypt(encDoc1, metadata).get();
+ *   PlaintextDocument doc2 = decryptor.decrypt(encDoc2, metadata).get();
+ * } // DEK is automatically zeroed
+ * 
+ * + * @author IronCore Labs + */ +public final class CachedKeyDecryptor implements DocumentDecryptor, Closeable { + + // Maximum time the decryptor can be used before it expires + private static final Duration TIMEOUT = Duration.ofMinutes(1); + + // The cached DEK bytes - zeroed on close() + private final byte[] dek; + + // The EDEK that was used to derive the DEK - used for validation + private final String edek; + + // Executor for async field decryption operations + private final ExecutorService encryptionExecutor; + + // Flag to track if close() has been called + private final AtomicBoolean closed = new AtomicBoolean(false); + + // When this decryptor was created - used for timeout enforcement + private final Instant createdAt; + + /** + * Package-private constructor. Use TenantSecurityClient.createCachedDecryptor() to create + * instances. + * + * @param dek The unwrapped document encryption key bytes (will be copied) + * @param edek The encrypted document encryption key string + * @param encryptionExecutor Executor for async decryption operations + */ + CachedKeyDecryptor(byte[] dek, String edek, ExecutorService encryptionExecutor) { + if (dek == null || dek.length != 32) { + throw new IllegalArgumentException("DEK must be exactly 32 bytes"); + } + if (edek == null || edek.isEmpty()) { + throw new IllegalArgumentException("EDEK must not be null or empty"); + } + if (encryptionExecutor == null) { + throw new IllegalArgumentException("encryptionExecutor must not be null"); + } + // Copy DEK to prevent external modification + this.dek = Arrays.copyOf(dek, dek.length); + this.edek = edek; + this.encryptionExecutor = encryptionExecutor; + this.createdAt = Instant.now(); + } + + /** + * Get the EDEK associated with this cached decryptor. Useful for verifying which documents can be + * decrypted with this instance. + * + * @return The EDEK string + */ + public String getEdek() { + return edek; + } + + /** + * Check if this decryptor has been closed. + * + * @return true if close() has been called + */ + public boolean isClosed() { + return closed.get(); + } + + /** + * Check if this decryptor has expired due to timeout. + * + * @return true if the timeout has elapsed since creation + */ + public boolean isExpired() { + return Duration.between(createdAt, Instant.now()).compareTo(TIMEOUT) > 0; + } + + /** + * Decrypt the provided EncryptedDocument using the cached DEK. + * + *

+ * The document's EDEK must match the EDEK used to create this decryptor, otherwise an error is + * returned. + * + * @param encryptedDocument Document to decrypt + * @param metadata Metadata about the document being decrypted (used for audit/logging) + * @return CompletableFuture resolving to PlaintextDocument + */ + @Override + public CompletableFuture decrypt(EncryptedDocument encryptedDocument, + DocumentMetadata metadata) { + // Check if closed or expired + if (closed.get()) { + return CompletableFuture.failedFuture(new TscException( + TenantSecurityErrorCodes.DOCUMENT_DECRYPT_FAILED, "CachedKeyDecryptor has been closed")); + } + if (isExpired()) { + return CompletableFuture.failedFuture(new TscException( + TenantSecurityErrorCodes.DOCUMENT_DECRYPT_FAILED, "CachedKeyDecryptor has expired")); + } + + // Validate EDEK matches + if (!edek.equals(encryptedDocument.getEdek())) { + return CompletableFuture + .failedFuture(new TscException(TenantSecurityErrorCodes.DOCUMENT_DECRYPT_FAILED, + "EncryptedDocument EDEK does not match the cached EDEK. " + + "This decryptor can only decrypt documents with matching EDEKs.")); + } + + return decryptFields(encryptedDocument.getEncryptedFields(), encryptedDocument.getEdek()); + } + + /** + * Decrypt a stream using the cached DEK. + * + *

+ * The provided EDEK must match the EDEK used to create this decryptor, otherwise an error is + * returned. + * + * @param edek Encrypted document encryption key - must match this decryptor's EDEK + * @param input A stream representing the encrypted document + * @param output An output stream to write the decrypted document to + * @param metadata Metadata about the document being decrypted + * @return Future which will complete when input has been decrypted + */ + @Override + public CompletableFuture decryptStream(String edek, InputStream input, OutputStream output, + DocumentMetadata metadata) { + // Check if closed or expired + if (closed.get()) { + return CompletableFuture.failedFuture(new TscException( + TenantSecurityErrorCodes.DOCUMENT_DECRYPT_FAILED, "CachedKeyDecryptor has been closed")); + } + if (isExpired()) { + return CompletableFuture.failedFuture(new TscException( + TenantSecurityErrorCodes.DOCUMENT_DECRYPT_FAILED, "CachedKeyDecryptor has expired")); + } + + // Validate EDEK matches + if (!this.edek.equals(edek)) { + return CompletableFuture + .failedFuture(new TscException(TenantSecurityErrorCodes.DOCUMENT_DECRYPT_FAILED, + "Provided EDEK does not match the cached EDEK. " + + "This decryptor can only decrypt documents with matching EDEKs.")); + } + + return CompletableFuture.supplyAsync( + () -> CryptoUtils.decryptStreamInternal(dek, input, output).join(), encryptionExecutor); + } + + /** + * Decrypt all fields in the document using the cached DEK. Pattern follows + * TenantSecurityClient.decryptFields(). + */ + private CompletableFuture decryptFields(Map document, + String documentEdek) { + // Check closed/expired state again before starting decryption + if (closed.get()) { + return CompletableFuture.failedFuture(new TscException( + TenantSecurityErrorCodes.DOCUMENT_DECRYPT_FAILED, "CachedKeyDecryptor has been closed")); + } + if (isExpired()) { + return CompletableFuture.failedFuture(new TscException( + TenantSecurityErrorCodes.DOCUMENT_DECRYPT_FAILED, "CachedKeyDecryptor has expired")); + } + + // Parallel decrypt each field + Map> decryptOps = document.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, + entry -> CompletableFuture.supplyAsync( + () -> CryptoUtils.decryptDocument(entry.getValue(), dek).join(), + encryptionExecutor))); + + // Join all futures and build result + return CompletableFutures.tryCatchNonFatal(() -> { + Map decryptedBytes = decryptOps.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().join())); + return new PlaintextDocument(decryptedBytes, documentEdek); + }); + } + + /** + * Securely zero the DEK bytes and mark this decryptor as closed. After calling close(), all + * decrypt operations will fail. + * + *

+ * This method is idempotent - calling it multiple times has no additional effect. + */ + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + // Zero out the DEK bytes for security + Arrays.fill(dek, (byte) 0); + } + } +} diff --git a/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/DocumentDecryptor.java b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/DocumentDecryptor.java new file mode 100644 index 0000000..c77c34d --- /dev/null +++ b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/DocumentDecryptor.java @@ -0,0 +1,39 @@ +package com.ironcorelabs.tenantsecurity.kms.v1; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.CompletableFuture; + +/** + * Interface for document decryption capabilities. Implemented by both TenantSecurityClient (for + * standard decrypt operations that unwrap the EDEK each time) and CachedKeyDecryptor (for repeated + * decrypts using a cached DEK). + * + * @author IronCore Labs + */ +public interface DocumentDecryptor { + + /** + * Decrypt the provided EncryptedDocument and return the decrypted fields. + * + * @param encryptedDocument Document to decrypt which includes encrypted bytes as well as EDEK. + * @param metadata Metadata about the document being decrypted. + * @return CompletableFuture resolving to PlaintextDocument with decrypted field bytes. + */ + CompletableFuture decrypt(EncryptedDocument encryptedDocument, + DocumentMetadata metadata); + + /** + * Decrypt a stream using the provided EDEK. + * + * @param edek Encrypted document encryption key. + * @param input A stream representing the encrypted document. + * @param output An output stream to write the decrypted document to. Note that this output should + * not be used until after the future exits successfully because the GCM tag is not fully + * verified until that time. + * @param metadata Metadata about the document being decrypted. + * @return Future which will complete when input has been decrypted. + */ + CompletableFuture decryptStream(String edek, InputStream input, OutputStream output, + DocumentMetadata metadata); +} diff --git a/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/TenantSecurityClient.java b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/TenantSecurityClient.java index 0074447..aa5a14c 100644 --- a/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/TenantSecurityClient.java +++ b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/TenantSecurityClient.java @@ -14,6 +14,7 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; import com.ironcorelabs.tenantsecurity.kms.v1.exception.TenantSecurityException; @@ -27,7 +28,7 @@ * * @author IronCore Labs */ -public final class TenantSecurityClient implements Closeable { +public final class TenantSecurityClient implements Closeable, DocumentDecryptor { private final SecureRandom secureRandom; // Use fixed size thread pool for CPU bound operations (crypto ops). Defaults to @@ -434,6 +435,7 @@ public CompletableFuture encryptStream(InputStream input, Out * @param metadata Metadata about the document being encrypted. * @return Future which will complete when input has been decrypted. */ + @Override public CompletableFuture decryptStream(String edek, InputStream input, OutputStream output, DocumentMetadata metadata) { return this.encryptionService.unwrapKey(edek, metadata).thenApplyAsync( @@ -546,6 +548,7 @@ public CompletableFuture> encryptExistingBatch( * @param metadata Metadata about the document being decrypted. * @return PlaintextDocument which contains each documents decrypted field bytes. */ + @Override public CompletableFuture decrypt(EncryptedDocument encryptedDocument, DocumentMetadata metadata) { return this.encryptionService.unwrapKey(encryptedDocument.getEdek(), metadata).thenComposeAsync( @@ -553,6 +556,89 @@ public CompletableFuture decrypt(EncryptedDocument encryptedD decryptedDocumentAESKey, encryptedDocument.getEdek())); } + /** + * Create a CachedKeyDecryptor for repeated decrypt operations using the same DEK. This unwraps + * the EDEK once and caches the resulting DEK for subsequent decrypts. + * + *

+ * Use this when you need to decrypt multiple documents that share the same EDEK, to avoid + * repeated TSP unwrap calls. + * + *

+ * The returned decryptor implements AutoCloseable and should be used with try-with-resources to + * ensure the DEK is securely zeroed when done: + * + *

+   * try (CachedKeyDecryptor decryptor = client.createCachedDecryptor(edek, metadata).get()) {
+   *   PlaintextDocument doc1 = decryptor.decrypt(encDoc1, metadata).get();
+   *   PlaintextDocument doc2 = decryptor.decrypt(encDoc2, metadata).get();
+   * }
+   * 
+ * + * @param edek The encrypted document encryption key to unwrap + * @param metadata Metadata for the unwrap operation + * @return CompletableFuture resolving to a CachedKeyDecryptor + */ + public CompletableFuture createCachedDecryptor(String edek, + DocumentMetadata metadata) { + return this.encryptionService.unwrapKey(edek, metadata) + .thenApply(dekBytes -> new CachedKeyDecryptor(dekBytes, edek, this.encryptionExecutor)); + } + + /** + * Create a CachedKeyDecryptor from an existing EncryptedDocument. Convenience method that + * extracts the EDEK from the document. + * + * @param encryptedDocument The encrypted document whose EDEK should be unwrapped + * @param metadata Metadata for the unwrap operation + * @return CompletableFuture resolving to a CachedKeyDecryptor + */ + public CompletableFuture createCachedDecryptor( + EncryptedDocument encryptedDocument, DocumentMetadata metadata) { + return createCachedDecryptor(encryptedDocument.getEdek(), metadata); + } + + /** + * Execute an operation using a CachedKeyDecryptor with automatic lifecycle management. The + * decryptor is automatically closed (and DEK zeroed) when the operation completes, whether + * successfully or with an error. + * + *

+ * This is the recommended pattern for using cached decryptors with CompletableFuture composition: + * + *

+   * client.withCachedDecryptor(edek, metadata, decryptor ->
+   *     decryptor.decrypt(encDoc1, metadata)
+   *         .thenCompose(doc1 -> decryptor.decrypt(encDoc2, metadata)))
+   * 
+ * + * @param The type returned by the operation + * @param edek The encrypted document encryption key to unwrap + * @param metadata Metadata for the unwrap operation + * @param operation Function that takes the decryptor and returns a CompletableFuture + * @return CompletableFuture resolving to the operation's result + */ + public CompletableFuture withCachedDecryptor(String edek, DocumentMetadata metadata, + Function> operation) { + return createCachedDecryptor(edek, metadata).thenCompose( + decryptor -> operation.apply(decryptor).whenComplete((result, error) -> decryptor.close())); + } + + /** + * Execute an operation using a CachedKeyDecryptor with automatic lifecycle management. + * Convenience method that extracts the EDEK from the document. + * + * @param The type returned by the operation + * @param encryptedDocument The encrypted document whose EDEK should be unwrapped + * @param metadata Metadata for the unwrap operation + * @param operation Function that takes the decryptor and returns a CompletableFuture + * @return CompletableFuture resolving to the operation's result + */ + public CompletableFuture withCachedDecryptor(EncryptedDocument encryptedDocument, + DocumentMetadata metadata, Function> operation) { + return withCachedDecryptor(encryptedDocument.getEdek(), metadata, operation); + } + /** * Re-key a document's encrypted document key (EDEK) using a new KMS config. Decrypts the EDEK * then re-encrypts it using the specified tenant's current primary KMS config. The DEK is then diff --git a/src/test/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptorTest.java b/src/test/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptorTest.java new file mode 100644 index 0000000..33c24b4 --- /dev/null +++ b/src/test/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptorTest.java @@ -0,0 +1,210 @@ +package com.ironcorelabs.tenantsecurity.kms.v1; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Arrays; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; +import com.ironcorelabs.tenantsecurity.kms.v1.exception.TscException; + +@Test(groups = {"unit"}) +public class CachedKeyDecryptorTest { + + private ExecutorService executor; + private static final String TEST_EDEK = "test-edek-base64-string"; + private static final String DIFFERENT_EDEK = "different-edek-base64-string"; + private DocumentMetadata metadata = + new DocumentMetadata("tenantId", "requestingUserOrServiceId", "dataLabel"); + + @BeforeClass + public void setup() { + executor = Executors.newFixedThreadPool(2); + } + + @AfterClass + public void teardown() { + if (executor != null) { + executor.shutdown(); + } + } + + private byte[] createValidDek() { + byte[] dek = new byte[32]; + Arrays.fill(dek, (byte) 0x42); + return dek; + } + + // Constructor validation tests + + public void constructorRejectNullDek() { + try { + new CachedKeyDecryptor(null, TEST_EDEK, executor); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("DEK must be exactly 32 bytes")); + } + } + + public void constructorRejectWrongSizeDek() { + byte[] shortDek = new byte[16]; + try { + new CachedKeyDecryptor(shortDek, TEST_EDEK, executor); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("DEK must be exactly 32 bytes")); + } + } + + public void constructorRejectNullEdek() { + try { + new CachedKeyDecryptor(createValidDek(), null, executor); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("EDEK must not be null or empty")); + } + } + + public void constructorRejectEmptyEdek() { + try { + new CachedKeyDecryptor(createValidDek(), "", executor); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("EDEK must not be null or empty")); + } + } + + public void constructorRejectNullExecutor() { + try { + new CachedKeyDecryptor(createValidDek(), TEST_EDEK, null); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("encryptionExecutor must not be null")); + } + } + + // Getter tests + + public void getEdekReturnsCorrectValue() { + CachedKeyDecryptor decryptor = new CachedKeyDecryptor(createValidDek(), TEST_EDEK, executor); + assertEquals(decryptor.getEdek(), TEST_EDEK); + decryptor.close(); + } + + public void isClosedReturnsFalseInitially() { + CachedKeyDecryptor decryptor = new CachedKeyDecryptor(createValidDek(), TEST_EDEK, executor); + assertFalse(decryptor.isClosed()); + decryptor.close(); + } + + public void isClosedReturnsTrueAfterClose() { + CachedKeyDecryptor decryptor = new CachedKeyDecryptor(createValidDek(), TEST_EDEK, executor); + decryptor.close(); + assertTrue(decryptor.isClosed()); + } + + // Close tests + + public void closeIsIdempotent() { + CachedKeyDecryptor decryptor = new CachedKeyDecryptor(createValidDek(), TEST_EDEK, executor); + decryptor.close(); + assertTrue(decryptor.isClosed()); + // Should not throw + decryptor.close(); + decryptor.close(); + assertTrue(decryptor.isClosed()); + } + + // Decrypt validation tests + + public void decryptFailsWhenClosed() { + CachedKeyDecryptor decryptor = new CachedKeyDecryptor(createValidDek(), TEST_EDEK, executor); + decryptor.close(); + + EncryptedDocument encDoc = new EncryptedDocument(java.util.Collections.emptyMap(), TEST_EDEK); + + try { + decryptor.decrypt(encDoc, metadata).join(); + fail("Should have thrown CompletionException"); + } catch (CompletionException e) { + assertTrue(e.getCause() instanceof TscException); + assertTrue(e.getCause().getMessage().contains("CachedKeyDecryptor has been closed")); + } + } + + public void decryptFailsWhenEdekMismatch() { + CachedKeyDecryptor decryptor = new CachedKeyDecryptor(createValidDek(), TEST_EDEK, executor); + + EncryptedDocument encDoc = + new EncryptedDocument(java.util.Collections.emptyMap(), DIFFERENT_EDEK); + + try { + decryptor.decrypt(encDoc, metadata).join(); + fail("Should have thrown CompletionException"); + } catch (CompletionException e) { + assertTrue(e.getCause() instanceof TscException); + assertTrue(e.getCause().getMessage().contains("EDEK does not match")); + } finally { + decryptor.close(); + } + } + + // DecryptStream validation tests + + public void decryptStreamFailsWhenClosed() { + CachedKeyDecryptor decryptor = new CachedKeyDecryptor(createValidDek(), TEST_EDEK, executor); + decryptor.close(); + + ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + try { + decryptor.decryptStream(TEST_EDEK, input, output, metadata).join(); + fail("Should have thrown CompletionException"); + } catch (CompletionException e) { + assertTrue(e.getCause() instanceof TscException); + assertTrue(e.getCause().getMessage().contains("CachedKeyDecryptor has been closed")); + } + } + + public void decryptStreamFailsWhenEdekMismatch() { + CachedKeyDecryptor decryptor = new CachedKeyDecryptor(createValidDek(), TEST_EDEK, executor); + + ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + try { + decryptor.decryptStream(DIFFERENT_EDEK, input, output, metadata).join(); + fail("Should have thrown CompletionException"); + } catch (CompletionException e) { + assertTrue(e.getCause() instanceof TscException); + assertTrue(e.getCause().getMessage().contains("EDEK does not match")); + } finally { + decryptor.close(); + } + } + + // DEK copying test + + public void constructorCopiesDekToPreventExternalModification() { + byte[] originalDek = createValidDek(); + CachedKeyDecryptor decryptor = new CachedKeyDecryptor(originalDek, TEST_EDEK, executor); + + // Modify the original array + Arrays.fill(originalDek, (byte) 0x00); + + // The decryptor should still have the original values (0x42) + // We can't directly test this without reflection, but we verify the constructor + // doesn't throw when we create the decryptor, showing it made a copy + assertFalse(decryptor.isClosed()); + decryptor.close(); + } +} From b7d97f8c66382b40a708d9a967ed3bb425807c54 Mon Sep 17 00:00:00 2001 From: Colt Frederickson Date: Thu, 22 Jan 2026 12:15:18 -0700 Subject: [PATCH 2/3] Self review --- .../kms/v1/CachedKeyDecryptor.java | 22 +++++-------------- .../kms/v1/DocumentDecryptor.java | 2 -- .../kms/v1/CachedKeyDecryptorTest.java | 17 +++++++++----- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java index 8b261d0..22c8751 100644 --- a/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java +++ b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java @@ -30,25 +30,13 @@ * status. * *

- * Usage with loan pattern (recommended): - * - *

- * client.withCachedDecryptor(edek, metadata, decryptor ->
- *     decryptor.decrypt(encDoc1, metadata)
- *         .thenCompose(doc1 -> decryptor.decrypt(encDoc2, metadata)))
- * 
- * - *

- * Usage with try-with-resources: - * - *

- * try (CachedKeyDecryptor decryptor = client.createCachedDecryptor(edek, metadata).get()) {
- *   PlaintextDocument doc1 = decryptor.decrypt(encDoc1, metadata).get();
- *   PlaintextDocument doc2 = decryptor.decrypt(encDoc2, metadata).get();
- * } // DEK is automatically zeroed
- * 
+ * Instances are created via {@link TenantSecurityClient#createCachedDecryptor} or + * {@link TenantSecurityClient#withCachedDecryptor}. See those methods for usage examples. * * @author IronCore Labs + * @see TenantSecurityClient#createCachedDecryptor(String, DocumentMetadata) + * @see TenantSecurityClient#withCachedDecryptor(String, DocumentMetadata, + * java.util.function.Function) */ public final class CachedKeyDecryptor implements DocumentDecryptor, Closeable { diff --git a/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/DocumentDecryptor.java b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/DocumentDecryptor.java index c77c34d..8883a74 100644 --- a/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/DocumentDecryptor.java +++ b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/DocumentDecryptor.java @@ -8,8 +8,6 @@ * Interface for document decryption capabilities. Implemented by both TenantSecurityClient (for * standard decrypt operations that unwrap the EDEK each time) and CachedKeyDecryptor (for repeated * decrypts using a cached DEK). - * - * @author IronCore Labs */ public interface DocumentDecryptor { diff --git a/src/test/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptorTest.java b/src/test/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptorTest.java index 33c24b4..4cd6fdd 100644 --- a/src/test/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptorTest.java +++ b/src/test/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptorTest.java @@ -7,6 +7,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.lang.reflect.Field; import java.util.Arrays; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutorService; @@ -194,17 +195,23 @@ public void decryptStreamFailsWhenEdekMismatch() { // DEK copying test - public void constructorCopiesDekToPreventExternalModification() { + public void constructorCopiesDekToPreventExternalModification() throws Exception { byte[] originalDek = createValidDek(); CachedKeyDecryptor decryptor = new CachedKeyDecryptor(originalDek, TEST_EDEK, executor); // Modify the original array Arrays.fill(originalDek, (byte) 0x00); - // The decryptor should still have the original values (0x42) - // We can't directly test this without reflection, but we verify the constructor - // doesn't throw when we create the decryptor, showing it made a copy - assertFalse(decryptor.isClosed()); + // Use reflection to verify internal DEK still has original values + Field dekField = CachedKeyDecryptor.class.getDeclaredField("dek"); + dekField.setAccessible(true); + byte[] internalDek = (byte[]) dekField.get(decryptor); + + // Internal DEK should still be 0x42, not 0x00 + for (byte b : internalDek) { + assertEquals(b, (byte) 0x42, "Internal DEK should not be affected by external modification"); + } + decryptor.close(); } } From 2f1924c5c70c0bda69cb737c3faafeaa1baad46b Mon Sep 17 00:00:00 2001 From: Colt Frederickson Date: Thu, 22 Jan 2026 12:20:22 -0700 Subject: [PATCH 3/3] Self review --- .../ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java index 22c8751..fbd188c 100644 --- a/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java +++ b/src/main/java/com/ironcorelabs/tenantsecurity/kms/v1/CachedKeyDecryptor.java @@ -33,7 +33,6 @@ * Instances are created via {@link TenantSecurityClient#createCachedDecryptor} or * {@link TenantSecurityClient#withCachedDecryptor}. See those methods for usage examples. * - * @author IronCore Labs * @see TenantSecurityClient#createCachedDecryptor(String, DocumentMetadata) * @see TenantSecurityClient#withCachedDecryptor(String, DocumentMetadata, * java.util.function.Function)