From bc303e3ebe3f8cf954254c538d0cbcb9ac68d010 Mon Sep 17 00:00:00 2001 From: Steven Jones Date: Thu, 20 Aug 2026 15:01:00 +0000 Subject: [PATCH 1/3] Translate CompletionException to GeneralSecurityException in AwsKmsAead AwsKmsAead.encrypt/decrypt only catch SdkClientException and KmsException, but the AWS SDK's internal synchronous credential resolution (AwsCredentialsAuthorizationStrategy -> CompletableFutureUtils.joinLikeSync) leaves a failed future's CompletionException wrapped, rather than unwrapping it, whenever its cause is not itself a RuntimeException -- which is exactly what happens when a credentials provider fails with a checked exception. That CompletionException then escapes encrypt/decrypt uncaught, breaking the Aead contract that only GeneralSecurityException is thrown. Fixes #5 --- .../tink/integration/awskms/AwsKmsAead.java | 11 +++ .../integration/awskms/AwsKmsAeadTest.java | 68 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/main/java/com/google/crypto/tink/integration/awskms/AwsKmsAead.java b/src/main/java/com/google/crypto/tink/integration/awskms/AwsKmsAead.java index da30dbb..fb200bc 100644 --- a/src/main/java/com/google/crypto/tink/integration/awskms/AwsKmsAead.java +++ b/src/main/java/com/google/crypto/tink/integration/awskms/AwsKmsAead.java @@ -22,6 +22,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletionException; import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.services.kms.KmsClient; @@ -67,6 +68,12 @@ public byte[] encrypt(final byte[] plaintext, final byte[] associatedData) return response.ciphertextBlob().asByteArray(); } catch (SdkClientException | KmsException e) { throw new GeneralSecurityException("encryption failed", e); + } catch (CompletionException e) { + // The SDK's internal synchronous credential resolution can throw this, still wrapping its + // cause, when that cause is not itself a RuntimeException (e.g. a credentials provider that + // fails with a checked exception). + throw new GeneralSecurityException( + "encryption failed", e.getCause() == null ? e : e.getCause()); } } @@ -95,6 +102,10 @@ public byte[] decrypt(final byte[] ciphertext, final byte[] associatedData) return result.plaintext().asByteArray(); } catch (SdkClientException | KmsException e) { throw new GeneralSecurityException("decryption failed", e); + } catch (CompletionException e) { + // See the comment in encrypt() above. + throw new GeneralSecurityException( + "decryption failed", e.getCause() == null ? e : e.getCause()); } } diff --git a/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java b/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java index 87d1fc7..9b5d5d8 100644 --- a/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java +++ b/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java @@ -24,11 +24,16 @@ import com.google.crypto.tink.aead.AeadConfig; import com.google.crypto.tink.subtle.Random; import java.security.GeneralSecurityException; +import java.util.concurrent.CompletionException; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; import software.amazon.awssdk.services.kms.KmsClient; +import software.amazon.awssdk.services.kms.model.DecryptRequest; +import software.amazon.awssdk.services.kms.model.DecryptResponse; +import software.amazon.awssdk.services.kms.model.EncryptRequest; +import software.amazon.awssdk.services.kms.model.EncryptResponse; /** Tests for AwsKmsAead. */ @RunWith(JUnit4.class) @@ -123,4 +128,67 @@ public void testDecryptWithInvalidKeyArn_success() throws Exception { Aead aeadWithInvalidArn = new AwsKmsAead(kms, invalidArn); assertThat(aeadWithInvalidArn.decrypt(ciphertext, aad)).isEqualTo(message); } + + @Test + public void testEncryptWithCompletionExceptionCause_translatedToGeneralSecurityException() + throws Exception { + GeneralSecurityException credentialFailure = + new GeneralSecurityException("credential refresh failed"); + KmsClient kms = new ThrowingKmsClient(new CompletionException(credentialFailure)); + Aead aead = new AwsKmsAead(kms, KEY_ARN); + + GeneralSecurityException thrown = + assertThrows( + GeneralSecurityException.class, + () -> aead.encrypt(Random.randBytes(20), Random.randBytes(20))); + + assertThat(thrown).hasCauseThat().isEqualTo(credentialFailure); + } + + @Test + public void testDecryptWithCompletionExceptionCause_translatedToGeneralSecurityException() + throws Exception { + GeneralSecurityException credentialFailure = + new GeneralSecurityException("credential refresh failed"); + KmsClient kms = new ThrowingKmsClient(new CompletionException(credentialFailure)); + Aead aead = new AwsKmsAead(kms, KEY_ARN); + + GeneralSecurityException thrown = + assertThrows( + GeneralSecurityException.class, + () -> aead.decrypt(Random.randBytes(20), Random.randBytes(20))); + + assertThat(thrown).hasCauseThat().isEqualTo(credentialFailure); + } + + /** + * A fake {@link KmsClient} whose {@code encrypt}/{@code decrypt} always throw a given {@link + * CompletionException}, simulating what the AWS SDK's internal synchronous credential resolution + * does when the configured credentials provider fails with a checked exception. + */ + private static final class ThrowingKmsClient implements KmsClient { + private final CompletionException exception; + + ThrowingKmsClient(CompletionException exception) { + this.exception = exception; + } + + @Override + public EncryptResponse encrypt(EncryptRequest request) { + throw exception; + } + + @Override + public DecryptResponse decrypt(DecryptRequest request) { + throw exception; + } + + @Override + public String serviceName() { + return "kms"; + } + + @Override + public void close() {} + } } From d895e110eeef67336ae1256d6513c58cd6bcd533 Mon Sep 17 00:00:00 2001 From: Steven Jones Date: Fri, 21 Aug 2026 04:38:40 +0000 Subject: [PATCH 2/3] Translate only the checked-cause CompletionException shape Only wrap a CompletionException in GeneralSecurityException when its cause is a checked exception -- the one specific shape AWS SDK v2s internal synchronous credential resolution can leave wrapped (AwsCredentialsAuthorizationStrategy -> CompletableFutureUtils.joinLikeSync only unwraps RuntimeException causes). A null, RuntimeException, or Error cause means this is not that shape, so the CompletionException itself is rethrown unchanged instead of being unwrapped or otherwise guessed at. Adds tests proving a checked cause is translated, and that CompletionException wrapping a RuntimeException, an Error, or nothing all propagate unchanged. --- .../tink/integration/awskms/AwsKmsAead.java | 18 ++-- .../integration/awskms/AwsKmsAeadTest.java | 86 +++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/google/crypto/tink/integration/awskms/AwsKmsAead.java b/src/main/java/com/google/crypto/tink/integration/awskms/AwsKmsAead.java index fb200bc..c02f2d8 100644 --- a/src/main/java/com/google/crypto/tink/integration/awskms/AwsKmsAead.java +++ b/src/main/java/com/google/crypto/tink/integration/awskms/AwsKmsAead.java @@ -71,9 +71,14 @@ public byte[] encrypt(final byte[] plaintext, final byte[] associatedData) } catch (CompletionException e) { // The SDK's internal synchronous credential resolution can throw this, still wrapping its // cause, when that cause is not itself a RuntimeException (e.g. a credentials provider that - // fails with a checked exception). - throw new GeneralSecurityException( - "encryption failed", e.getCause() == null ? e : e.getCause()); + // fails with a checked exception). Only that specific checked-cause shape is translated; a + // null, RuntimeException, or Error cause means this isn't that shape, so the + // CompletionException itself is rethrown unchanged rather than guessed at. + Throwable cause = e.getCause(); + if (cause == null || cause instanceof RuntimeException || cause instanceof Error) { + throw e; + } + throw new GeneralSecurityException("encryption failed", cause); } } @@ -104,8 +109,11 @@ public byte[] decrypt(final byte[] ciphertext, final byte[] associatedData) throw new GeneralSecurityException("decryption failed", e); } catch (CompletionException e) { // See the comment in encrypt() above. - throw new GeneralSecurityException( - "decryption failed", e.getCause() == null ? e : e.getCause()); + Throwable cause = e.getCause(); + if (cause == null || cause instanceof RuntimeException || cause instanceof Error) { + throw e; + } + throw new GeneralSecurityException("decryption failed", cause); } } diff --git a/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java b/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java index 9b5d5d8..ff31ee3 100644 --- a/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java +++ b/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java @@ -161,6 +161,92 @@ public void testDecryptWithCompletionExceptionCause_translatedToGeneralSecurityE assertThat(thrown).hasCauseThat().isEqualTo(credentialFailure); } + @Test + public void testEncryptWithCompletionExceptionRuntimeExceptionCause_propagatedUnchanged() + throws Exception { + CompletionException exception = new CompletionException(new IllegalStateException("bug")); + KmsClient kms = new ThrowingKmsClient(exception); + Aead aead = new AwsKmsAead(kms, KEY_ARN); + + CompletionException thrown = + assertThrows( + CompletionException.class, + () -> aead.encrypt(Random.randBytes(20), Random.randBytes(20))); + + assertThat(thrown).isSameInstanceAs(exception); + } + + @Test + public void testDecryptWithCompletionExceptionRuntimeExceptionCause_propagatedUnchanged() + throws Exception { + CompletionException exception = new CompletionException(new IllegalStateException("bug")); + KmsClient kms = new ThrowingKmsClient(exception); + Aead aead = new AwsKmsAead(kms, KEY_ARN); + + CompletionException thrown = + assertThrows( + CompletionException.class, + () -> aead.decrypt(Random.randBytes(20), Random.randBytes(20))); + + assertThat(thrown).isSameInstanceAs(exception); + } + + @Test + public void testEncryptWithCompletionExceptionErrorCause_propagatedUnchanged() throws Exception { + CompletionException exception = new CompletionException(new AssertionError("fatal")); + KmsClient kms = new ThrowingKmsClient(exception); + Aead aead = new AwsKmsAead(kms, KEY_ARN); + + CompletionException thrown = + assertThrows( + CompletionException.class, + () -> aead.encrypt(Random.randBytes(20), Random.randBytes(20))); + + assertThat(thrown).isSameInstanceAs(exception); + } + + @Test + public void testDecryptWithCompletionExceptionErrorCause_propagatedUnchanged() throws Exception { + CompletionException exception = new CompletionException(new AssertionError("fatal")); + KmsClient kms = new ThrowingKmsClient(exception); + Aead aead = new AwsKmsAead(kms, KEY_ARN); + + CompletionException thrown = + assertThrows( + CompletionException.class, + () -> aead.decrypt(Random.randBytes(20), Random.randBytes(20))); + + assertThat(thrown).isSameInstanceAs(exception); + } + + @Test + public void testEncryptWithCauselessCompletionException_propagatedUnchanged() throws Exception { + CompletionException exception = new CompletionException("no cause", null); + KmsClient kms = new ThrowingKmsClient(exception); + Aead aead = new AwsKmsAead(kms, KEY_ARN); + + CompletionException thrown = + assertThrows( + CompletionException.class, + () -> aead.encrypt(Random.randBytes(20), Random.randBytes(20))); + + assertThat(thrown).isSameInstanceAs(exception); + } + + @Test + public void testDecryptWithCauselessCompletionException_propagatedUnchanged() throws Exception { + CompletionException exception = new CompletionException("no cause", null); + KmsClient kms = new ThrowingKmsClient(exception); + Aead aead = new AwsKmsAead(kms, KEY_ARN); + + CompletionException thrown = + assertThrows( + CompletionException.class, + () -> aead.decrypt(Random.randBytes(20), Random.randBytes(20))); + + assertThat(thrown).isSameInstanceAs(exception); + } + /** * A fake {@link KmsClient} whose {@code encrypt}/{@code decrypt} always throw a given {@link * CompletionException}, simulating what the AWS SDK's internal synchronous credential resolution From a00feac3a379ddcabf2634fc5ec2a3af990516d1 Mon Sep 17 00:00:00 2001 From: Steven Jones Date: Fri, 21 Aug 2026 05:05:51 +0000 Subject: [PATCH 3/3] Add regression test proving an unrelated checked exception is also translated The existing checked-cause test only used GeneralSecurityException. Adds an IOException case to make explicit that any checked exception is translated, not just GeneralSecurityException specifically. --- .../tink/integration/awskms/AwsKmsAeadTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java b/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java index ff31ee3..2ebab64 100644 --- a/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java +++ b/src/test/java/com/google/crypto/tink/integration/awskms/AwsKmsAeadTest.java @@ -161,6 +161,22 @@ public void testDecryptWithCompletionExceptionCause_translatedToGeneralSecurityE assertThat(thrown).hasCauseThat().isEqualTo(credentialFailure); } + @Test + public void + testEncryptWithCompletionExceptionUnrelatedCheckedCause_translatedToGeneralSecurityException() + throws Exception { + java.io.IOException unrelatedFailure = new java.io.IOException("unrelated checked failure"); + KmsClient kms = new ThrowingKmsClient(new CompletionException(unrelatedFailure)); + Aead aead = new AwsKmsAead(kms, KEY_ARN); + + GeneralSecurityException thrown = + assertThrows( + GeneralSecurityException.class, + () -> aead.encrypt(Random.randBytes(20), Random.randBytes(20))); + + assertThat(thrown).hasCauseThat().isEqualTo(unrelatedFailure); + } + @Test public void testEncryptWithCompletionExceptionRuntimeExceptionCause_propagatedUnchanged() throws Exception {