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 a16e56e9..fddead94 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -82,116 +82,93 @@ 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. + * 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. *

- * 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. + * 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. *

- * 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. + * 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: 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. + * 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 { - /** 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; + static final int FIXED_FIELD_SIZE = 8; + /** The largest value of the 32-bit invocation field, inclusive. */ + static final long MAX_INVOCATION = 0xffff_ffffL; - /** Exclusive; the counter stops before issuing this invocation number. */ - private final long limit; - private long next; + // 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(); - /** - * 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 + * Visible for testing. Production code must use {@link #IvCounter()}, which draws a + * random fixed field. */ - 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"); + 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 < firstInvocation) { - throw new IllegalArgumentException( - "limit " + limit + " is below the first invocation " + firstInvocation); + if (initialInvocation < 0 || initialInvocation > MAX_INVOCATION) { + throw new IllegalArgumentException("invalid IV invocation: " + initialInvocation); } - 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"); - } - 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 + * @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 (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 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"); } - 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; + // issue MAX_INVOCATION itself, then refuse; never wrap back to invocation 0 + 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]; + RANDOM.nextBytes(fixedField); + return fixedField; + } } static class EncryptedMetadata { @@ -239,7 +216,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 +245,8 @@ private void prepareManifest(Config.TDFConfig tdfConfig, Map(); boolean finished; @@ -691,7 +670,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..aa5b38e6 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java @@ -44,7 +44,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; @@ -689,51 +688,49 @@ void testSimpleTDFWithAssertionWithHS256Failure() throws Exception { } } + /** + * Single split, so the metadata key is the payload key: the metadata must take invocation 0 + * and payload segments 1..n under the same fixed field, so no IV repeats under that key. + */ @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); + + 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(); + 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]; - 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)); - } - assertThat(seenIvs).doesNotHaveDuplicates(); + assertPayloadIvsFollowMetadata(tdfObject, tdfOutputStream.toByteArray(), fixedField); 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,305 +916,257 @@ 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); + 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( - 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); + (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 testPayloadIvCounterStopsAtInvocationBudget() { - var counter = new TDF.IvCounter(1, 3); - - assertThat(counter.next()).containsExactly(bigEndianIv(1)); - assertThat(counter.next()).containsExactly(bigEndianIv(2)); - - var e = assertThrows(SDKException.class, counter::next); - assertThat(e).hasMessageContaining("AES-GCM invocations for a single key"); - // and it stays refused - 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 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)); + 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 - 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); + 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 @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 { + public void testIvCounterHandsOutDistinctIvsAcrossThreads() throws Exception { int threads = 8; int perThread = 500; - var counter = new TDF.IvCounter(1, 1 + (long) threads * perThread); + var counter = new TDF.IvCounter(new byte[TDF.IvCounter.FIXED_FIELD_SIZE], 0); var pool = Executors.newFixedThreadPool(threads); try { - var futures = new ArrayList>>(); + var futures = new ArrayList>>(); for (int t = 0; t < threads; t++) { futures.add(pool.submit(() -> { - var mine = new ArrayList(); + var mine = new ArrayList(); for (int i = 0; i < perThread; i++) { - mine.add(Base64.getEncoder().encodeToString(counter.next())); + mine.add(invocation(counter.next())); } return mine; })); } - var all = new ArrayList(); + var all = new HashSet(); for (var future : futures) { all.addAll(future.get()); } - assertThat(all).hasSize(threads * perThread); - assertThat(new HashSet<>(all)) + 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 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]; - new Random(31).nextBytes(data); - + 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 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))); - assertThat(tdfObject.getManifest().encryptionInformation.integrityInformation.segments) - .hasSize(expectedSegments); + 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)); + } - var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), - Config.newTDFReaderConfig(), platformUrl); - var decrypted = new ByteArrayOutputStream(); - reader.readPayload(decrypted); - assertThat(decrypted.toByteArray()) - .withFailMessage("a multi-segment TDF did not round trip") - .containsExactly(data); + 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 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. + * 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 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); - + 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(data), tdfOutputStream, + var tdfObject = tdf.createTDF(new ByteArrayInputStream(new byte[Config.MIN_SEGMENT_SIZE + 100]), + tdfOutputStream, Config.newTDFConfig( Config.withAutoconfigure(false), - Config.withKasInformation(getSingleRSAKASInfo()), + // 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("here is some metadata"))); + Config.withMetaData(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(); + .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 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. + * 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) + | ((iv[10] & 0xffL) << 8) + | (iv[11] & 0xffL); + } + @Test - public void testEachTdfUsesAFreshPayloadKey() throws Exception { - var data = "the same plaintext, encrypted twice".getBytes(StandardCharsets.UTF_8); + 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]; + new Random(31).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))); - 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(tdfObject.getManifest().encryptionInformation.integrityInformation.segments) + .hasSize(expectedSegments); - assertThat(firstSegments.get(0)) - .withFailMessage("identical ciphertext under a repeated IV means the payload key was reused") - .isNotEqualTo(firstSegments.get(1)); + var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), + Config.newTDFReaderConfig(), platformUrl); + var decrypted = new ByteArrayOutputStream(); + reader.readPayload(decrypted); + assertThat(decrypted.toByteArray()) + .withFailMessage("a multi-segment TDF did not round trip") + .containsExactly(data); } @Test