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