diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java index b471cabb..f69b6896 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -420,6 +420,26 @@ public DataSizeNotSupported(String errorMessage) { } } + /** + * {@link AesGcmExhaustedException} is thrown during TDF creation when encrypting another + * payload segment would exceed the number of AES-GCM invocations allowed under a single + * data encryption key. + *
+ * IVs are a random 64-bit fixed field followed by a 32-bit invocation counter (NIST SP + * 800-38D section 8.2.1), so at most 2^32 invocations can use one key without repeating an + * IV: one for the key access metadata and 2^32 - 1 for payload segments. The SDK throws this + * before encrypting the segment that would cross that limit. (The same budget keeps random + * 96-bit IVs, whose collision probability is roughly k^2 / 2^97, under the section 8 limit + * of 2^-32.) The TDF being written is + * incomplete and must be discarded; split the input across multiple TDFs or use a larger + * segment size. + */ + public static class AesGcmExhaustedException extends SDKException { + public AesGcmExhaustedException(String errorMessage) { + super(errorMessage); + } + } + /** * {@link KasInfoMissing} is thrown during TDF creation when no KAS information is present. */ diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java index 5234f8c0..fddead94 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -136,10 +136,15 @@ static final class IvCounter { this.nextInvocation = initialInvocation; } + /** + * @return the next IV in the sequence, which has never been returned before + * @throws SDK.AesGcmExhaustedException if the per-key invocation budget is spent; this + * is checked before the IV is issued, so no segment is encrypted past the limit + */ synchronized byte[] next() { if (exhausted) { - throw new SDKException("exceeded the maximum of 2^32 AES-GCM invocations under a single" - + " payload key; use a larger segment size"); + throw new SDK.AesGcmExhaustedException("exceeded the maximum of 2^32 AES-GCM invocations" + + " under a single payload key; the TDF is incomplete. Use a larger segment size"); } long invocation = nextInvocation; diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java index 757c2d5e..aa5b38e6 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java @@ -963,9 +963,10 @@ public void testIvCounterRejectsReuseAfterExhaustion() { assertThat(counter.next()).containsExactly( 0, 1, 2, 3, 4, 5, 6, 7, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff); - SDKException exception = assertThrows(SDKException.class, counter::next); - assertThat(exception).hasMessageContaining("2^32 AES-GCM invocations"); - assertThrows(SDKException.class, counter::next); + var exception = assertThrows(SDK.AesGcmExhaustedException.class, counter::next); + assertThat(exception).isInstanceOf(SDKException.class) + .hasMessageContaining("2^32 AES-GCM invocations"); + assertThrows(SDK.AesGcmExhaustedException.class, counter::next); } // probabilistic: two random 64-bit fixed fields collide with probability 2^-64