From aede6c66d53e708034beea0046d7b492c89b859f Mon Sep 17 00:00:00 2001 From: sujan kota Date: Wed, 30 Sep 2026 16:13:04 -0400 Subject: [PATCH 1/3] fix(sdk): align TDF3 IV construction Signed-off-by: sujan kota --- .../java/io/opentdf/platform/sdk/TDF.java | 149 +++----- .../java/io/opentdf/platform/sdk/TDFTest.java | 339 ++++-------------- 2 files changed, 107 insertions(+), 381 deletions(-) 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 a16e56e9..c405c5f0 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -81,117 +81,60 @@ private static byte[] tdfECKeySaltCompute() { private static final Gson gson = new GsonBuilder().create(); - /** - * A self-imposed ceiling on the number of AES-GCM authenticated-encryption - * invocations under a single payload key. One invocation is spent on the - * metadata (IV 0), leaving 2^32 - 1 for payload segments. - *

- * This follows the deterministic IV construction of NIST SP 800-38D section - * 8.2.1. Because the payload key is freshly generated for each TDF and used by a - * single device, section 8.2.1 permits an empty fixed field, so the whole 96 bits - * are the invocation field and the constraint the standard actually imposes is - * 2^96. Section 8.3's limit of 2^32 does not bind here — it is scoped to - * RBG-based IVs and to deterministic IVs that are not 96 bits — but it is adopted - * anyway as a conservative ceiling. - *

- * It is not reachable in practice: at the smallest segment size - * {@link Config#withSegmentSize} permits ({@link Config#MIN_SEGMENT_SIZE}, 16 KiB) - * it would take 64 TiB of input. It is enforced so the invariant holds by - * construction rather than by assumption. - */ - static final long MAX_GCM_INVOCATIONS_PER_KEY = 1L << 32; - - /** - * A deterministic, unsigned 96-bit big-endian AES-GCM IV counter. - *

- * A TDF encrypts its metadata and its payload segments under keys that are - * identical when there is a single key split, so the two must never share an - * IV. IV 0 is reserved for the metadata and payload segments start at IV 1, - * incrementing once per segment. - *

- * The counter refuses to issue an IV once it reaches its limit, and no limit can - * exceed {@link #MAX_GCM_INVOCATIONS_PER_KEY}, so an IV can never be handed out - * twice and the counter can never reach a value that would collide with the - * metadata IV. - *

- * Precondition: this is safe only because the key is freshly generated - * for every TDF ({@code AesGcm.generateKey()} in {@code prepareManifest}). - * Reusing a key across two TDFs would repeat this IV sequence, which is - * catastrophic for AES-GCM — it leaks the XOR of the plaintexts and enables - * authentication-key recovery. Do not add a way to supply or reuse a payload - * key without also changing this construction. - */ static final class IvCounter { - /** The invocation reserved for the metadata. */ - static final long METADATA_INVOCATION = 0; - /** The first invocation available to payload segments. */ - static final long FIRST_PAYLOAD_INVOCATION = METADATA_INVOCATION + 1; - - /** Exclusive; the counter stops before issuing this invocation number. */ - private final long limit; - private long next; + static final int FIXED_FIELD_SIZE = 8; + static final long MAX_INVOCATION = 0xffff_ffffL; - /** - * The IV reserved for encrypting the TDF metadata. - * - * @return twelve zero bytes - */ - static byte[] metadataIv() { - return ivFor(METADATA_INVOCATION); - } + private final byte[] fixedField; + private long nextInvocation; + private boolean exhausted; - /** - * A payload IV counter whose first value is 1, leaving IV 0 for the metadata - * and the remainder of the per-key invocation budget for payload segments. - */ - static IvCounter forPayload() { - return new IvCounter(FIRST_PAYLOAD_INVOCATION, MAX_GCM_INVOCATIONS_PER_KEY); + IvCounter() { + this(generateFixedField(), 0); } - /** - * @param firstInvocation the first invocation number to issue, at least - * {@link #FIRST_PAYLOAD_INVOCATION} - * @param limit one past the last invocation number to issue - */ - IvCounter(long firstInvocation, long limit) { - if (firstInvocation < FIRST_PAYLOAD_INVOCATION) { - throw new IllegalArgumentException("invalid first invocation: " + firstInvocation - + "; invocation " + METADATA_INVOCATION + " is reserved for the metadata"); - } - if (limit < firstInvocation) { - throw new IllegalArgumentException( - "limit " + limit + " is below the first invocation " + firstInvocation); + IvCounter(byte[] fixedField, long initialInvocation) { + Objects.requireNonNull(fixedField, "fixed field"); + if (fixedField.length != FIXED_FIELD_SIZE) { + throw new IllegalArgumentException("invalid IV fixed field size: " + fixedField.length); } - if (limit > MAX_GCM_INVOCATIONS_PER_KEY) { - throw new IllegalArgumentException("limit " + limit + " exceeds the maximum of " - + MAX_GCM_INVOCATIONS_PER_KEY + " AES-GCM invocations for a single key"); + if (initialInvocation < 0 || initialInvocation > MAX_INVOCATION) { + throw new IllegalArgumentException("invalid IV invocation: " + initialInvocation); } - this.next = firstInvocation; - this.limit = limit; + this.fixedField = fixedField.clone(); + this.nextInvocation = initialInvocation; } - /** - * @return the next IV in the sequence, which has never been returned before - */ synchronized byte[] next() { - if (next >= limit) { - throw new SDKException("exceeded the maximum of " + MAX_GCM_INVOCATIONS_PER_KEY - + " AES-GCM invocations for a single key"); + if (exhausted) { + throw new SDKException("AES-GCM IV invocation field exhausted; output stream is incomplete"); } - return ivFor(next++); - } - /** - * Encodes an invocation number as an unsigned 96-bit big-endian IV. - */ - static byte[] ivFor(long invocation) { - byte[] iv = new byte[kGcmIvSize]; - for (int index = iv.length - 1; index >= 0 && invocation != 0; index--) { - iv[index] = (byte) invocation; - invocation >>>= Byte.SIZE; + long invocation = nextInvocation; + if (invocation == MAX_INVOCATION) { + exhausted = true; + } else { + nextInvocation++; } + + byte[] iv = new byte[kGcmIvSize]; + System.arraycopy(fixedField, 0, iv, 0, FIXED_FIELD_SIZE); + iv[8] = (byte) (invocation >>> 24); + iv[9] = (byte) (invocation >>> 16); + iv[10] = (byte) (invocation >>> 8); + iv[11] = (byte) invocation; return iv; } + + private static byte[] generateFixedField() { + byte[] fixedField = new byte[FIXED_FIELD_SIZE]; + try { + SecureRandom.getInstanceStrong().nextBytes(fixedField); + } catch (NoSuchAlgorithmException e) { + throw new SDKException("error generating IV fixed field", e); + } + return fixedField; + } } static class EncryptedMetadata { @@ -239,7 +182,8 @@ private PolicyObject createPolicyObject(List at private static final Base64.Encoder encoder = Base64.getEncoder(); - private void prepareManifest(Config.TDFConfig tdfConfig, Map> splits) { + private void prepareManifest(Config.TDFConfig tdfConfig, Map> splits, + byte[] metadataIv) { manifest.tdfVersion = tdfConfig.renderVersionInfoInManifest ? TDF_SPEC_VERSION : null; manifest.encryptionInformation.keyAccessType = kSplitKeyType; manifest.encryptionInformation.keyAccessObj = new ArrayList<>(); @@ -267,10 +211,6 @@ private void prepareManifest(Config.TDFConfig tdfConfig, Map(); boolean finished; @@ -691,7 +632,7 @@ TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFCo Manifest.Segment segmentInfo = new Manifest.Segment(); // encrypt - cipherData = tdfObject.aesGcm.encrypt(payloadIv.next(), AesGcm.GCM_TAG_LENGTH, + cipherData = tdfObject.aesGcm.encrypt(streamIv.next(), AesGcm.GCM_TAG_LENGTH, readBuf, 0, readThisLoop); payloadOutput.write(cipherData); 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 725ecbd2..824b1705 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java @@ -31,12 +31,9 @@ import java.util.Arrays; import java.util.Base64; import java.util.Collections; -import java.util.HashSet; import java.util.Map; import java.util.List; import java.util.Random; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.regex.Pattern; @@ -44,7 +41,6 @@ import static io.opentdf.platform.sdk.TDF.GLOBAL_KEY_SALT; import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -691,49 +687,58 @@ void testSimpleTDFWithAssertionWithHS256Failure() throws Exception { @Test public void testCreatingTDFWithMultipleSegments() throws Exception { - var random = new Random(); - + String metadata = "segment IV metadata"; Config.TDFConfig config = Config.newTDFConfig( Config.withAutoconfigure(false), - Config.withKasInformation(getRSAKASInfos()), - Config.withSegmentSize(Config.MIN_SEGMENT_SIZE)); + Config.withKasInformation(getSingleRSAKASInfo()), + Config.withSegmentSize(Config.MIN_SEGMENT_SIZE), + Config.withMetaData(metadata)); - // data should be large enough to have multiple complete and a partial segment var data = new byte[(int) (Config.MIN_SEGMENT_SIZE * 2.8)]; - random.nextBytes(data); - var plainTextInputStream = new ByteArrayInputStream(data); + new Random().nextBytes(data); var tdfOutputStream = new ByteArrayOutputStream(); var tdf = new TDF( new FakeServicesBuilder().setKas(kas) .setKeyAccessServerRegistryService(kasRegistryService).build()); - var tdfObject = tdf.createTDF(plainTextInputStream, tdfOutputStream, config); - + var tdfObject = tdf.createTDF(new ByteArrayInputStream(data), tdfOutputStream, config); + + Manifest.KeyAccess keyAccess = tdfObject.getManifest().encryptionInformation.keyAccessObj.get(0); + String decodedMetadata = new String(Base64.getDecoder().decode(keyAccess.encryptedMetadata), + StandardCharsets.UTF_8); + Map encryptedMetadata = new Gson().fromJson(decodedMetadata, + new TypeToken>() { + }.getType()); + byte[] metadataIv = Base64.getDecoder().decode(encryptedMetadata.get("iv")); + byte[] metadataCiphertext = Base64.getDecoder().decode(encryptedMetadata.get("ciphertext")); + assertThat(metadataIv).hasSize(AesGcm.GCM_NONCE_LENGTH); + assertThat(invocation(metadataIv)).isZero(); + assertThat(Arrays.copyOf(metadataCiphertext, AesGcm.GCM_NONCE_LENGTH)) + .containsExactly(metadataIv); + + byte[] fixedField = Arrays.copyOf(metadataIv, TDF.IvCounter.FIXED_FIELD_SIZE); var segments = tdfObject.getManifest().encryptionInformation.integrityInformation.segments; assertThat(segments) .withFailMessage("test needs more than one segment to be meaningful") .hasSizeGreaterThan(1); - // payload segments start at IV 1 (IV 0 is reserved for the metadata) and - // increment by one for every segment - var seenIvs = new ArrayList(); var encryptedReader = new TDFReader(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray())); - for (int segmentIndex = 0; segmentIndex < segments.size(); segmentIndex++) { - byte[] encryptedSegment = new byte[(int) segments.get(segmentIndex).encryptedSegmentSize]; + long expectedInvocation = 1; + for (Manifest.Segment segment : segments) { + byte[] encryptedSegment = new byte[(int) segment.encryptedSegmentSize]; assertThat(encryptedReader.readPayloadBytes(encryptedSegment)).isEqualTo(encryptedSegment.length); - byte[] iv = Arrays.copyOf(encryptedSegment, AesGcm.GCM_NONCE_LENGTH); - assertThat(iv).containsExactly(bigEndianIv(segmentIndex + 1)); - seenIvs.add(Base64.getEncoder().encodeToString(iv)); + byte[] payloadIv = Arrays.copyOf(encryptedSegment, AesGcm.GCM_NONCE_LENGTH); + assertThat(Arrays.copyOf(payloadIv, TDF.IvCounter.FIXED_FIELD_SIZE)) + .containsExactly(fixedField); + assertThat(invocation(payloadIv)).isEqualTo(expectedInvocation++); } - assertThat(seenIvs).doesNotHaveDuplicates(); var unwrappedData = new ByteArrayOutputStream(); var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), platformUrl); reader.readPayload(unwrappedData); - assertThat(unwrappedData.toByteArray()) .withFailMessage("extracted data does not match") .containsExactly(data); - + assertThat(reader.getMetadata()).isEqualTo(metadata); } /** @@ -919,193 +924,61 @@ private static ByteArrayOutputStream readZipEntry(ZipReader reader, String name) return data; } - /** - * The unsigned 96-bit big-endian encoding of {@code value}, for asserting on - * expected IVs. - */ - private static byte[] bigEndianIv(long value) { - byte[] iv = new byte[AesGcm.GCM_NONCE_LENGTH]; - for (int index = iv.length - 1; index >= 0 && value != 0; index--) { - iv[index] = (byte) value; - value >>>= 8; - } - return iv; - } - - @Test - public void testMetadataUsesIvZero() throws Exception { - Config.TDFConfig config = Config.newTDFConfig( - Config.withAutoconfigure(false), - Config.withKasInformation(getSingleRSAKASInfo()), - Config.withMetaData("here is some metadata")); - - var tdfOutputStream = new ByteArrayOutputStream(); - var tdf = new TDF( - new FakeServicesBuilder().setKas(kas) - .setKeyAccessServerRegistryService(kasRegistryService).build()); - var tdfObject = tdf.createTDF(new ByteArrayInputStream("some data".getBytes(StandardCharsets.UTF_8)), - tdfOutputStream, config); - - var keyAccessObjects = tdfObject.getManifest().encryptionInformation.keyAccessObj; - assertThat(keyAccessObjects).isNotEmpty(); - for (Manifest.KeyAccess keyAccess : keyAccessObjects) { - var encryptedMetadata = new Gson().fromJson( - new String(Base64.getDecoder().decode(keyAccess.encryptedMetadata), StandardCharsets.UTF_8), - JsonObject.class); - - assertThat(Base64.getDecoder().decode(encryptedMetadata.get("iv").getAsString())) - .withFailMessage("metadata IV is not zero") - .containsExactly(new byte[AesGcm.GCM_NONCE_LENGTH]); - // the ciphertext field carries the IV as a prefix as well - assertThat(Arrays.copyOf( - Base64.getDecoder().decode(encryptedMetadata.get("ciphertext").getAsString()), - AesGcm.GCM_NONCE_LENGTH)) - .containsExactly(new byte[AesGcm.GCM_NONCE_LENGTH]); - } - - var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), platformUrl); - assertThat(reader.getMetadata()).isEqualTo("here is some metadata"); - } - - @Test - public void testFirstPayloadSegmentUsesIvOne() throws Exception { - Config.TDFConfig config = Config.newTDFConfig( - Config.withAutoconfigure(false), - Config.withKasInformation(getSingleRSAKASInfo()), - Config.withMetaData("here is some metadata")); - - var tdfOutputStream = new ByteArrayOutputStream(); - var tdf = new TDF( - new FakeServicesBuilder().setKas(kas) - .setKeyAccessServerRegistryService(kasRegistryService).build()); - var tdfObject = tdf.createTDF(new ByteArrayInputStream("some data".getBytes(StandardCharsets.UTF_8)), - tdfOutputStream, config); - - var segments = tdfObject.getManifest().encryptionInformation.integrityInformation.segments; - var encryptedReader = new TDFReader(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray())); - byte[] firstSegment = new byte[(int) segments.get(0).encryptedSegmentSize]; - assertThat(encryptedReader.readPayloadBytes(firstSegment)).isEqualTo(firstSegment.length); - - assertThat(Arrays.copyOf(firstSegment, AesGcm.GCM_NONCE_LENGTH)) - .withFailMessage("first payload segment must use IV 1, leaving IV 0 for the metadata") - .containsExactly(bigEndianIv(1)); - } - - @Test - public void testPayloadIvCounterStartsAtOne() { - var counter = TDF.IvCounter.forPayload(); - - assertThat(TDF.IvCounter.metadataIv()).containsExactly(bigEndianIv(0)); - assertThat(counter.next()).containsExactly(bigEndianIv(1)); - assertThat(counter.next()).containsExactly(bigEndianIv(2)); - assertThat(counter.next()).containsExactly(bigEndianIv(3)); - } @Test - public void testPayloadIvCounterIncrementsWithCarry() { - // spelled out rather than built with bigEndianIv, so this doesn't just re-derive the - // encoding it is checking. one below a two-byte carry boundary: - var counter = new TDF.IvCounter(0xFFFF, 0x10002); + public void testIvCounterUsesBigEndianInvocationAndDefensiveCopy() { + byte[] fixedField = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7 }; + var counter = new TDF.IvCounter(fixedField, 0x0000ffffL); + Arrays.fill(fixedField, (byte) 0); assertThat(counter.next()).containsExactly( - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (byte) 0xff, (byte) 0xff); - assertThat(counter.next()).containsExactly( - 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0); + 0, 1, 2, 3, 4, 5, 6, 7, 0, 0, (byte) 0xff, (byte) 0xff); assertThat(counter.next()).containsExactly( - 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1); + 0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 0, 0); } @Test - public void testPayloadIvCounterCarriesAcrossTheFourByteBoundary() { - // an implementation that kept the counter in an int would break here - var counter = new TDF.IvCounter(0xFFFFFFFFL, TDF.MAX_GCM_INVOCATIONS_PER_KEY); - - assertThat(counter.next()).containsExactly( - 0, 0, 0, 0, 0, 0, 0, 0, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff); - // 2^32 is the limit, so the counter stops rather than issuing it - assertThrows(SDKException.class, counter::next); + public void testIvCounterValidatesInputs() { + assertThrows(IllegalArgumentException.class, () -> new TDF.IvCounter(new byte[7], 0)); + assertThrows(IllegalArgumentException.class, () -> new TDF.IvCounter(new byte[9], 0)); + assertThrows(IllegalArgumentException.class, () -> new TDF.IvCounter(new byte[8], -1)); + assertThrows(IllegalArgumentException.class, + () -> new TDF.IvCounter(new byte[8], TDF.IvCounter.MAX_INVOCATION + 1)); } @Test - public void testPayloadIvCounterStopsAtInvocationBudget() { - var counter = new TDF.IvCounter(1, 3); - - assertThat(counter.next()).containsExactly(bigEndianIv(1)); - assertThat(counter.next()).containsExactly(bigEndianIv(2)); + public void testIvCounterRejectsReuseAfterExhaustion() { + byte[] fixedField = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7 }; + var counter = new TDF.IvCounter(fixedField, TDF.IvCounter.MAX_INVOCATION); - var e = assertThrows(SDKException.class, counter::next); - assertThat(e).hasMessageContaining("AES-GCM invocations for a single key"); - // and it stays refused + 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("output stream is incomplete"); assertThrows(SDKException.class, counter::next); } @Test - public void testPayloadIvCounterRejectsALimitThatCouldCollideWithTheMetadataIv() { - // no caller can configure a counter that runs far enough to wrap back to IV 0 - assertThrows(IllegalArgumentException.class, - () -> new TDF.IvCounter(1, TDF.MAX_GCM_INVOCATIONS_PER_KEY + 1)); - assertThrows(IllegalArgumentException.class, () -> new TDF.IvCounter(1, Long.MAX_VALUE)); - assertThrows(IllegalArgumentException.class, () -> new TDF.IvCounter(-1, 10)); - assertThrows(IllegalArgumentException.class, () -> new TDF.IvCounter(10, 9)); - // and invocation 0 belongs to the metadata, so no payload counter can start there - assertThrows(IllegalArgumentException.class, () -> new TDF.IvCounter(0, 10)); - - assertDoesNotThrow(() -> new TDF.IvCounter(1, TDF.MAX_GCM_INVOCATIONS_PER_KEY)); - } - - @Test - public void testPayloadIvBudgetLeavesOneInvocationForMetadata() { - assertThat(TDF.MAX_GCM_INVOCATIONS_PER_KEY).isEqualTo(4294967296L); - - // the payload never issues the metadata IV - assertThat(TDF.IvCounter.forPayload().next()) - .isNotEqualTo(TDF.IvCounter.metadataIv()); - - // and the payload's budget is exactly one short of the per-key maximum, checked at the - // boundary rather than by reading the counter's internals - var counter = new TDF.IvCounter( - TDF.MAX_GCM_INVOCATIONS_PER_KEY - 2, TDF.MAX_GCM_INVOCATIONS_PER_KEY); - assertThat(counter.next()).containsExactly(bigEndianIv(TDF.MAX_GCM_INVOCATIONS_PER_KEY - 2)); - assertThat(counter.next()).containsExactly(bigEndianIv(TDF.MAX_GCM_INVOCATIONS_PER_KEY - 1)); - assertThrows(SDKException.class, counter::next); + public void testIvCounterDrawsNewFixedFieldPerStream() { + byte[] firstIv = new TDF.IvCounter().next(); + byte[] secondIv = new TDF.IvCounter().next(); + + assertThat(Arrays.copyOf(firstIv, TDF.IvCounter.FIXED_FIELD_SIZE)) + .isNotEqualTo(Arrays.copyOf(secondIv, TDF.IvCounter.FIXED_FIELD_SIZE)); + assertThat(invocation(firstIv)).isZero(); + assertThat(invocation(secondIv)).isZero(); } - @Test - public void testPayloadIvCounterHandsOutDistinctIvsAcrossThreads() throws Exception { - int threads = 8; - int perThread = 500; - var counter = new TDF.IvCounter(1, 1 + (long) threads * perThread); - - var pool = Executors.newFixedThreadPool(threads); - try { - var futures = new ArrayList>>(); - for (int t = 0; t < threads; t++) { - futures.add(pool.submit(() -> { - var mine = new ArrayList(); - for (int i = 0; i < perThread; i++) { - mine.add(Base64.getEncoder().encodeToString(counter.next())); - } - return mine; - })); - } - - var all = new ArrayList(); - for (var future : futures) { - all.addAll(future.get()); - } - assertThat(all).hasSize(threads * perThread); - assertThat(new HashSet<>(all)) - .withFailMessage("the counter handed out the same IV twice") - .hasSize(threads * perThread); - } finally { - pool.shutdownNow(); - } + private static long invocation(byte[] iv) { + return ((iv[8] & 0xffL) << 24) + | ((iv[9] & 0xffL) << 16) + | ((iv[10] & 0xffL) << 8) + | (iv[11] & 0xffL); } @Test public void testCreateTDFAcceptsInputSpanningManySegments() throws Exception { - // a partial trailing segment, so the expected count isn't confused by the empty segment - // createTDF's do/while emits when the input is an exact multiple of the segment size int fullSegments = 512; int expectedSegments = fullSegments + 1; var data = new byte[fullSegments * Config.MIN_SEGMENT_SIZE + 100]; @@ -1132,94 +1005,6 @@ public void testCreateTDFAcceptsInputSpanningManySegments() throws Exception { .containsExactly(data); } - /** - * With a single key split the metadata key and the payload key are the same key, so an IV - * shared between them would be catastrophic. This is the case the IV reservation exists for. - */ - @Test - public void testSingleSplitMetadataAndPayloadNeverShareAnIv() throws Exception { - int fullSegments = 4; - int expectedSegments = fullSegments + 1; // plus a partial trailing segment - var data = new byte[fullSegments * Config.MIN_SEGMENT_SIZE + 100]; - new Random(17).nextBytes(data); - - var tdf = new TDF(new FakeServicesBuilder().setKas(kas) - .setKeyAccessServerRegistryService(kasRegistryService).build()); - var tdfOutputStream = new ByteArrayOutputStream(); - var tdfObject = tdf.createTDF(new ByteArrayInputStream(data), tdfOutputStream, - Config.newTDFConfig( - Config.withAutoconfigure(false), - Config.withKasInformation(getSingleRSAKASInfo()), - Config.withSegmentSize(Config.MIN_SEGMENT_SIZE), - Config.withMetaData("here is some metadata"))); - - var keyAccessObjects = tdfObject.getManifest().encryptionInformation.keyAccessObj; - assertThat(keyAccessObjects) - .withFailMessage("this test is only meaningful with a single key split") - .hasSize(1); - - var seen = new HashSet(); - - var encryptedMetadata = new Gson().fromJson(new String( - Base64.getDecoder().decode(keyAccessObjects.get(0).encryptedMetadata), - StandardCharsets.UTF_8), JsonObject.class); - var metadataIv = Base64.getDecoder().decode(encryptedMetadata.get("iv").getAsString()); - assertThat(metadataIv).containsExactly(bigEndianIv(0)); - seen.add(Base64.getEncoder().encodeToString(metadataIv)); - - var encryptedReader = new TDFReader(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray())); - var manifestSegments = tdfObject.getManifest().encryptionInformation.integrityInformation.segments; - assertThat(manifestSegments).hasSize(expectedSegments); - for (int i = 0; i < manifestSegments.size(); i++) { - var segment = new byte[(int) manifestSegments.get(i).encryptedSegmentSize]; - assertThat(encryptedReader.readPayloadBytes(segment)).isEqualTo(segment.length); - - var iv = Arrays.copyOf(segment, AesGcm.GCM_NONCE_LENGTH); - assertThat(iv) - .withFailMessage("payload segment %s should use IV %s", i, i + 1) - .containsExactly(bigEndianIv(i + 1)); - assertThat(seen.add(Base64.getEncoder().encodeToString(iv))) - .withFailMessage("IV reused between the metadata and payload segment %s", i) - .isTrue(); - } - } - - /** - * The deterministic IV sequence is only safe because the payload key is fresh for every TDF. - * This pins both halves: the IVs repeat, and the ciphertext does not. - */ - @Test - public void testEachTdfUsesAFreshPayloadKey() throws Exception { - var data = "the same plaintext, encrypted twice".getBytes(StandardCharsets.UTF_8); - var tdf = new TDF(new FakeServicesBuilder().setKas(kas) - .setKeyAccessServerRegistryService(kasRegistryService).build()); - - var firstSegments = new ArrayList(); - for (int run = 0; run < 2; run++) { - // a fresh config per run: createTDF and loadTDF both mutate the config they are given - var tdfOutputStream = new ByteArrayOutputStream(); - var tdfObject = tdf.createTDF(new ByteArrayInputStream(data), tdfOutputStream, - Config.newTDFConfig( - Config.withAutoconfigure(false), - Config.withKasInformation(getSingleRSAKASInfo()))); - - var segments = tdfObject.getManifest().encryptionInformation.integrityInformation.segments; - var encryptedReader = new TDFReader( - new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray())); - var segment = new byte[(int) segments.get(0).encryptedSegmentSize]; - assertThat(encryptedReader.readPayloadBytes(segment)).isEqualTo(segment.length); - firstSegments.add(segment); - } - - assertThat(Arrays.copyOf(firstSegments.get(0), AesGcm.GCM_NONCE_LENGTH)) - .withFailMessage("the IV sequence is deterministic, so it should repeat") - .containsExactly(Arrays.copyOf(firstSegments.get(1), AesGcm.GCM_NONCE_LENGTH)); - - assertThat(firstSegments.get(0)) - .withFailMessage("identical ciphertext under a repeated IV means the payload key was reused") - .isNotEqualTo(firstSegments.get(1)); - } - @Test public void testCreateTDFWithMimeType() throws Exception { final String mimeType = "application/pdf"; From 974117c026cf6062d4916969a79045ea8eb23ce1 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 8 Oct 2026 16:22:51 -0400 Subject: [PATCH 2/3] fix(sdk): document IV construction, use non-blocking RNG, restore IV tests - Restore IvCounter javadoc for the NIST SP 800-38D 8.2.1 fixed field + invocation construction, the shared metadata/payload key with one split, and the fresh-payload-key precondition. - Draw the IV fixed field from a shared SecureRandom instead of getInstanceStrong(), which can block on /dev/random on some Linux hosts. - Make the exhaustion message self-contained and name the 2^32 limit. - Add tests for a fresh IV stream and payload key per TDF, multi-split metadata IVs, concurrent counter use, and carry into the top invocation byte; restore explanatory test comments. Signed-off-by: Dave Mihalcik --- .../java/io/opentdf/platform/sdk/TDF.java | 45 +++- .../java/io/opentdf/platform/sdk/TDFTest.java | 211 ++++++++++++++++-- 2 files changed, 226 insertions(+), 30 deletions(-) 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 c405c5f0..5234f8c0 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -81,10 +81,37 @@ private static byte[] tdfECKeySaltCompute() { private static final Gson gson = new GsonBuilder().create(); + /** + * An AES-GCM IV generator using the deterministic construction of NIST SP 800-38D + * section 8.2.1: a 64-bit fixed field followed by a 32-bit big-endian invocation field. + *

+ * The fixed field is drawn at random once per instance, and one instance is used per + * TDF. The invocation field starts at 0 and increments once per IV, so an instance + * issues at most 2^32 distinct IVs and then refuses rather than wrapping. At + * {@link Config#MIN_SEGMENT_SIZE} (16 KiB) that is roughly 64 TiB of payload. + *

+ * A single instance must cover every encryption under the payload key, metadata + * included. With a single key split the metadata key is the payload key, so + * {@code createTDF} spends invocation 0 on the metadata and payload segments continue + * from invocation 1. Drawing the metadata IV independently would risk IV reuse under + * that key. + *

+ * Precondition: the payload key is freshly generated for every TDF + * ({@code AesGcm.generateKey()} in {@code prepareManifest}). The random fixed field is + * defense in depth, not a substitute: were a key ever reused, IV uniqueness across TDFs + * would rest on 64 random bits alone. Reusing an AES-GCM IV under one key leaks the XOR + * of the plaintexts and enables authentication-key recovery. Do not add a way to supply + * or reuse a payload key without revisiting this construction. + */ static final class IvCounter { static final int FIXED_FIELD_SIZE = 8; + /** The largest value of the 32-bit invocation field, inclusive. */ static final long MAX_INVOCATION = 0xffff_ffffL; + // The fixed field is not secret, so a shared non-blocking SecureRandom suffices; + // getInstanceStrong() can block on /dev/random on some Linux hosts. + private static final SecureRandom RANDOM = new SecureRandom(); + private final byte[] fixedField; private long nextInvocation; private boolean exhausted; @@ -93,6 +120,10 @@ static final class IvCounter { this(generateFixedField(), 0); } + /** + * Visible for testing. Production code must use {@link #IvCounter()}, which draws a + * random fixed field. + */ IvCounter(byte[] fixedField, long initialInvocation) { Objects.requireNonNull(fixedField, "fixed field"); if (fixedField.length != FIXED_FIELD_SIZE) { @@ -107,10 +138,12 @@ static final class IvCounter { synchronized byte[] next() { if (exhausted) { - throw new SDKException("AES-GCM IV invocation field exhausted; output stream is incomplete"); + throw new SDKException("exceeded the maximum of 2^32 AES-GCM invocations under a single" + + " payload key; use a larger segment size"); } long invocation = nextInvocation; + // issue MAX_INVOCATION itself, then refuse; never wrap back to invocation 0 if (invocation == MAX_INVOCATION) { exhausted = true; } else { @@ -128,11 +161,7 @@ synchronized byte[] next() { private static byte[] generateFixedField() { byte[] fixedField = new byte[FIXED_FIELD_SIZE]; - try { - SecureRandom.getInstanceStrong().nextBytes(fixedField); - } catch (NoSuchAlgorithmException e) { - throw new SDKException("error generating IV fixed field", e); - } + RANDOM.nextBytes(fixedField); return fixedField; } } @@ -211,6 +240,8 @@ private void prepareManifest(Config.TDFConfig tdfConfig, Map encryptedMetadata = new Gson().fromJson(decodedMetadata, - new TypeToken>() { - }.getType()); - byte[] metadataIv = Base64.getDecoder().decode(encryptedMetadata.get("iv")); - byte[] metadataCiphertext = Base64.getDecoder().decode(encryptedMetadata.get("ciphertext")); - assertThat(metadataIv).hasSize(AesGcm.GCM_NONCE_LENGTH); + var keyAccessObjects = tdfObject.getManifest().encryptionInformation.keyAccessObj; + assertThat(keyAccessObjects) + .withFailMessage("this test is only meaningful with a single key split") + .hasSize(1); + byte[] metadataIv = metadataIv(keyAccessObjects.get(0)); assertThat(invocation(metadataIv)).isZero(); - assertThat(Arrays.copyOf(metadataCiphertext, AesGcm.GCM_NONCE_LENGTH)) - .containsExactly(metadataIv); byte[] fixedField = Arrays.copyOf(metadataIv, TDF.IvCounter.FIXED_FIELD_SIZE); var segments = tdfObject.getManifest().encryptionInformation.integrityInformation.segments; assertThat(segments) .withFailMessage("test needs more than one segment to be meaningful") .hasSizeGreaterThan(1); - - var encryptedReader = new TDFReader(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray())); - long expectedInvocation = 1; - for (Manifest.Segment segment : segments) { - byte[] encryptedSegment = new byte[(int) segment.encryptedSegmentSize]; - assertThat(encryptedReader.readPayloadBytes(encryptedSegment)).isEqualTo(encryptedSegment.length); - byte[] payloadIv = Arrays.copyOf(encryptedSegment, AesGcm.GCM_NONCE_LENGTH); - assertThat(Arrays.copyOf(payloadIv, TDF.IvCounter.FIXED_FIELD_SIZE)) - .containsExactly(fixedField); - assertThat(invocation(payloadIv)).isEqualTo(expectedInvocation++); - } + assertPayloadIvsFollowMetadata(tdfObject, tdfOutputStream.toByteArray(), fixedField); var unwrappedData = new ByteArrayOutputStream(); var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), platformUrl); @@ -937,6 +929,22 @@ public void testIvCounterUsesBigEndianInvocationAndDefensiveCopy() { 0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 0, 0); } + @Test + public void testIvCounterCarriesIntoTheTopInvocationByte() { + byte[] fixedField = new byte[] { (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff }; + var counter = new TDF.IvCounter(fixedField, 0x00ffffffL); + + assertThat(counter.next()).containsExactly( + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + 0, (byte) 0xff, (byte) 0xff, (byte) 0xff); + assertThat(counter.next()).containsExactly( + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + 1, 0, 0, 0); + } + @Test public void testIvCounterValidatesInputs() { assertThrows(IllegalArgumentException.class, () -> new TDF.IvCounter(new byte[7], 0)); @@ -949,16 +957,18 @@ public void testIvCounterValidatesInputs() { @Test public void testIvCounterRejectsReuseAfterExhaustion() { byte[] fixedField = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7 }; - var counter = new TDF.IvCounter(fixedField, TDF.IvCounter.MAX_INVOCATION); + var counter = new TDF.IvCounter(fixedField, TDF.IvCounter.MAX_INVOCATION - 1); + assertThat(invocation(counter.next())).isEqualTo(TDF.IvCounter.MAX_INVOCATION - 1); 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("output stream is incomplete"); + assertThat(exception).hasMessageContaining("2^32 AES-GCM invocations"); assertThrows(SDKException.class, counter::next); } + // probabilistic: two random 64-bit fixed fields collide with probability 2^-64 @Test public void testIvCounterDrawsNewFixedFieldPerStream() { byte[] firstIv = new TDF.IvCounter().next(); @@ -970,6 +980,157 @@ public void testIvCounterDrawsNewFixedFieldPerStream() { assertThat(invocation(secondIv)).isZero(); } + @Test + public void testIvCounterHandsOutDistinctIvsAcrossThreads() throws Exception { + int threads = 8; + int perThread = 500; + var counter = new TDF.IvCounter(new byte[TDF.IvCounter.FIXED_FIELD_SIZE], 0); + + var pool = Executors.newFixedThreadPool(threads); + try { + var futures = new ArrayList>>(); + for (int t = 0; t < threads; t++) { + futures.add(pool.submit(() -> { + var mine = new ArrayList(); + for (int i = 0; i < perThread; i++) { + mine.add(invocation(counter.next())); + } + return mine; + })); + } + + var all = new HashSet(); + for (var future : futures) { + all.addAll(future.get()); + } + assertThat(all) + .withFailMessage("the counter handed out the same IV twice") + .hasSize(threads * perThread); + assertThat(Collections.min(all)).isZero(); + assertThat(Collections.max(all)).isEqualTo(threads * perThread - 1L); + } finally { + pool.shutdownNow(); + } + } + + /** + * Each TDF must get its own IV stream and its own payload key. A counter shared across + * {@code createTDF} calls would start the second TDF's metadata past invocation 0. + */ + @Test + public void testEachTdfUsesAFreshIvStreamAndPayloadKey() throws Exception { + var data = "the same plaintext, encrypted twice".getBytes(StandardCharsets.UTF_8); + var tdf = new TDF(new FakeServicesBuilder().setKas(kas) + .setKeyAccessServerRegistryService(kasRegistryService).build()); + + var fixedFields = new ArrayList(); + var firstSegments = new ArrayList(); + for (int run = 0; run < 2; run++) { + // a fresh config per run: createTDF and loadTDF both mutate the config they are given + var tdfOutputStream = new ByteArrayOutputStream(); + var tdfObject = tdf.createTDF(new ByteArrayInputStream(data), tdfOutputStream, + Config.newTDFConfig( + Config.withAutoconfigure(false), + Config.withKasInformation(getSingleRSAKASInfo()), + Config.withMetaData("here is some metadata"))); + + byte[] metadataIv = metadataIv(tdfObject.getManifest().encryptionInformation.keyAccessObj.get(0)); + assertThat(invocation(metadataIv)).isZero(); + byte[] fixedField = Arrays.copyOf(metadataIv, TDF.IvCounter.FIXED_FIELD_SIZE); + fixedFields.add(fixedField); + firstSegments.add(assertPayloadIvsFollowMetadata(tdfObject, tdfOutputStream.toByteArray(), fixedField)); + } + + assertThat(fixedFields.get(0)) + .withFailMessage("two TDFs drew the same IV fixed field") + .isNotEqualTo(fixedFields.get(1)); + assertThat(Arrays.copyOfRange(firstSegments.get(0), AesGcm.GCM_NONCE_LENGTH, firstSegments.get(0).length)) + .withFailMessage("identical ciphertext for identical plaintext means the payload key was reused") + .isNotEqualTo(Arrays.copyOfRange(firstSegments.get(1), AesGcm.GCM_NONCE_LENGTH, + firstSegments.get(1).length)); + } + + /** + * With several splits each split encrypts the metadata under its own key, all with the + * stream's invocation 0. That IV repeats only under independent keys. + */ + @Test + public void testMultiSplitMetadataUsesInvocationZeroOfThePayloadStream() throws Exception { + String metadata = "multi-split metadata"; + var tdf = new TDF(new FakeServicesBuilder().setKas(kas) + .setKeyAccessServerRegistryService(kasRegistryService).build()); + var tdfOutputStream = new ByteArrayOutputStream(); + var tdfObject = tdf.createTDF(new ByteArrayInputStream(new byte[Config.MIN_SEGMENT_SIZE + 100]), + tdfOutputStream, + Config.newTDFConfig( + Config.withAutoconfigure(false), + // two RSA KAS entries (keypairs may hold only one RSA key), so two splits + Config.withKasInformation(getSingleRSAKASInfo()[0], getSingleRSAKASInfo()[0]), + Config.withSegmentSize(Config.MIN_SEGMENT_SIZE), + Config.withMetaData(metadata))); + + var keyAccessObjects = tdfObject.getManifest().encryptionInformation.keyAccessObj; + assertThat(keyAccessObjects) + .withFailMessage("this test is only meaningful with more than one key split") + .hasSize(2); + byte[] fixedField = null; + for (Manifest.KeyAccess keyAccess : keyAccessObjects) { + byte[] metadataIv = metadataIv(keyAccess); + assertThat(invocation(metadataIv)).isZero(); + if (fixedField == null) { + fixedField = Arrays.copyOf(metadataIv, TDF.IvCounter.FIXED_FIELD_SIZE); + } else { + assertThat(Arrays.copyOf(metadataIv, TDF.IvCounter.FIXED_FIELD_SIZE)).containsExactly(fixedField); + } + } + assertPayloadIvsFollowMetadata(tdfObject, tdfOutputStream.toByteArray(), fixedField); + + var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), platformUrl); + assertThat(reader.getMetadata()).isEqualTo(metadata); + } + + /** + * The metadata IV of a key access object, checked against the IV prefix of its ciphertext. + */ + private static byte[] metadataIv(Manifest.KeyAccess keyAccess) { + String decodedMetadata = new String(Base64.getDecoder().decode(keyAccess.encryptedMetadata), + StandardCharsets.UTF_8); + Map encryptedMetadata = new Gson().fromJson(decodedMetadata, + new TypeToken>() { + }.getType()); + byte[] metadataIv = Base64.getDecoder().decode(encryptedMetadata.get("iv")); + byte[] metadataCiphertext = Base64.getDecoder().decode(encryptedMetadata.get("ciphertext")); + assertThat(metadataIv).hasSize(AesGcm.GCM_NONCE_LENGTH); + assertThat(Arrays.copyOf(metadataCiphertext, AesGcm.GCM_NONCE_LENGTH)) + .containsExactly(metadataIv); + return metadataIv; + } + + /** + * Asserts every payload segment uses {@code fixedField} and invocations 1..n in order. + * + * @return the first encrypted segment + */ + private static byte[] assertPayloadIvsFollowMetadata(TDF.TDFObject tdfObject, byte[] tdfBytes, + byte[] fixedField) throws IOException { + var segments = tdfObject.getManifest().encryptionInformation.integrityInformation.segments; + var encryptedReader = new TDFReader(new SeekableInMemoryByteChannel(tdfBytes)); + byte[] firstSegment = null; + long expectedInvocation = 1; + for (Manifest.Segment segment : segments) { + byte[] encryptedSegment = new byte[(int) segment.encryptedSegmentSize]; + assertThat(encryptedReader.readPayloadBytes(encryptedSegment)).isEqualTo(encryptedSegment.length); + byte[] payloadIv = Arrays.copyOf(encryptedSegment, AesGcm.GCM_NONCE_LENGTH); + assertThat(Arrays.copyOf(payloadIv, TDF.IvCounter.FIXED_FIELD_SIZE)) + .containsExactly(fixedField); + assertThat(invocation(payloadIv)).isEqualTo(expectedInvocation++); + if (firstSegment == null) { + firstSegment = encryptedSegment; + } + } + return firstSegment; + } + private static long invocation(byte[] iv) { return ((iv[8] & 0xffL) << 24) | ((iv[9] & 0xffL) << 16) @@ -979,6 +1140,8 @@ private static long invocation(byte[] iv) { @Test public void testCreateTDFAcceptsInputSpanningManySegments() throws Exception { + // a partial trailing segment, so the expected count isn't confused by the empty segment + // createTDF's do/while emits when the input is an exact multiple of the segment size int fullSegments = 512; int expectedSegments = fullSegments + 1; var data = new byte[fullSegments * Config.MIN_SEGMENT_SIZE + 100]; From 213ff990b1a220ad4da55328ea9b98e4a3d52c9d Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 9 Oct 2026 14:01:01 -0400 Subject: [PATCH 3/3] feat(sdk): throw AesGcmExhaustedException when the per-key GCM invocation budget is spent (DSPX-4492) (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Stacked on #412 (TDF3 IV construction). Review and merge that first. Adds `SDK.AesGcmExhaustedException` (extends `SDKException`, nested alongside `TamperException`, `KasInfoMissing`, etc.). `TDF.IvCounter.next()` now throws it instead of a generic `SDKException` once a payload key's AES-GCM invocation budget is spent. ## Why NIST SP 800-38D §8 caps the probability of an IV collision under one key at 2^-32. #412 builds IVs as a random 64-bit fixed field followed by a 32-bit invocation counter, so at most 2^32 invocations can share a key: IV 0 for key-access metadata and 2^32 − 1 for payload segments. The same budget keeps random 96-bit IVs under the §8 limit, since their birthday bound is P ≈ k²/2^97 ≤ 2^-32 for k ≤ ~2^32.5. See the DSPX-4492 ADR (AES GCM i.v. Collision Risk). #412 already refuses before it would issue an IV past the counter limit. That check runs before each segment is encrypted, on the only payload write path (`createTDF`), which also handles streams of unknown length. This PR gives that refusal a dedicated, catchable type so callers can tell it apart from other SDK failures. The partially written TDF must be discarded. ## Crypto-risk callout No behavior or wire-format change relative to #412: same limit, same check point, same IVs. Only the exception type changes, and it still extends `SDKException`, so existing `catch (SDKException)` blocks behave the same. At the 16 KiB minimum segment size, hitting the limit takes 64 TiB of input. ## Test plan - `mvn -pl sdk -am test` (JDK 21): 284 tests, 0 failures, 8 skipped - `TDFTest`'s `testIvCounterRejectsReuseAfterExhaustion` now asserts `SDK.AesGcmExhaustedException` (and that it is an `SDKException`) once invocation 2^32 − 1 has been issued. No test encrypts 2^32 segments. Signed-off-by: Dave Mihalcik --- .../java/io/opentdf/platform/sdk/SDK.java | 20 +++++++++++++++++++ .../java/io/opentdf/platform/sdk/TDF.java | 9 +++++++-- .../java/io/opentdf/platform/sdk/TDFTest.java | 7 ++++--- 3 files changed, 31 insertions(+), 5 deletions(-) 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