diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AesGcm.java b/sdk/src/main/java/io/opentdf/platform/sdk/AesGcm.java
index 77ec3655..bed1b57b 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/AesGcm.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/AesGcm.java
@@ -113,6 +113,12 @@ public Encrypted encrypt(byte[] plaintext) {
/**
*
encrypt.
*
+ * Generates a fresh random nonce from {@link SecureRandom} for every call. A
+ * random 96-bit nonce is only safe for a modest number of invocations under one key, so use
+ * this overload only with a key used once or a very small number of times. To encrypt many
+ * messages under a single key, use
+ * {@link #encrypt(byte[], int, byte[], int, int)} with a counter that never repeats.
+ *
* @param plaintext the plaintext byte array to encrypt
* @param offset where the input start
* @param len input length
@@ -151,14 +157,25 @@ public Encrypted encrypt(byte[] plaintext, int offset, int len) {
/**
* encrypt.
*
- * @param iv the IV vector
- * @param authTagLen the length of the auth tag
+ * @param iv the IV vector, which must be {@value #GCM_NONCE_LENGTH} bytes and must never be
+ * reused under this key
+ * @param authTagLen the length of the auth tag, which must be {@value #GCM_TAG_LENGTH}
* @param plaintext the plaintext byte array to encrypt
* @param offset where the input start
* @param len input length
- * @return the encrypted text
+ * @return the encrypted text, prefixed with the IV
*/
public byte[] encrypt(byte[] iv, int authTagLen, byte[] plaintext, int offset, int len) {
+ if (iv == null || iv.length != GCM_NONCE_LENGTH) {
+ throw new IllegalArgumentException(
+ "invalid IV size for gcm encryption: " + (iv == null ? "null" : iv.length));
+ }
+ // strict, because the read path assumes this length: Encrypted(byte[]) splits at
+ // GCM_NONCE_LENGTH and TDF validates segment sizes against GCM_TAG_LENGTH, so any other
+ // value would write a TDF this SDK cannot read
+ if (authTagLen != GCM_TAG_LENGTH) {
+ throw new IllegalArgumentException("invalid auth tag length for gcm encryption: " + authTagLen);
+ }
try {
Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORM);
@@ -170,10 +187,9 @@ public byte[] encrypt(byte[] iv, int authTagLen, byte[] plaintext, int offset, i
System.arraycopy(iv, 0, cipherTextWithNonce, 0, iv.length);
System.arraycopy(cipherText, 0, cipherTextWithNonce, iv.length, cipherText.length);
return cipherTextWithNonce;
- } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException e) {
- throw new RuntimeException("error gcm decrypt", e);
- } catch (InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) {
- throw new RuntimeException("error gcm decrypt", e);
+ } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException
+ | InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) {
+ throw new SDKException("error gcm encrypt", e);
}
}
@@ -189,10 +205,9 @@ public byte[] decrypt(Encrypted cipherTextWithNonce) {
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, cipherTextWithNonce.iv);
cipher.init(Cipher.DECRYPT_MODE, key, spec);
return cipher.doFinal(cipherTextWithNonce.ciphertext);
- } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException e) {
- throw new RuntimeException("error gcm decrypt", e);
- } catch (InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) {
- throw new RuntimeException("error gcm decrypt", e);
+ } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException
+ | InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) {
+ throw new SDKException("error gcm decrypt", e);
}
}
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 16c7a35a..5e903498 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
@@ -390,9 +390,19 @@ public SplitKeyException(String errorMessage) {
}
/**
- * {@link DataSizeNotSupported} is thrown when the user attempts to create
- * a TDF with a size larger than the maximum size (currently 64GiB).
+ * Legacy exception type retained for compatibility. Nothing throws it any more.
+ *
+ * TDF creation streams its input and writes zip64 offsets, so it no longer imposes a fixed
+ * input-size limit. The bounds that remain are practical rather than fixed: the manifest
+ * holds one record per segment and is assembled in memory, and a payload key is limited to
+ * 2^32 AES-GCM invocations.
+ *
+ * Because this extends {@link SDKException}, which is unchecked, an existing
+ * {@code catch (DataSizeNotSupported e)} still compiles and simply never runs.
+ *
+ * @deprecated nothing throws this any more; remove the catch block rather than replacing it.
*/
+ @Deprecated(since = "0.19.0", forRemoval = true)
public static class DataSizeNotSupported extends SDKException {
public DataSizeNotSupported(String errorMessage) {
super(errorMessage);
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 8827cb85..b54901b9 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -55,35 +55,14 @@ private static byte[] tdfECKeySaltCompute() {
*/
public static final String TDF_SPEC_VERSION = "4.3.0";
private static final String KEY_ACCESS_SCHEMA_VERSION = "1.0";
- private final long maximumSize;
-
private final SDK.Services services;
- /**
- * Constructs a new TDF instance using the default maximum input size defined by
- * MAX_TDF_INPUT_SIZE.
- *
- * This constructor is primarily used to initialize the TDF object with the
- * standard maximum
- * input size, which controls the maximum size of the input data that can be
- * processed.
- * For test purposes, an alternative constructor allows for setting a custom
- * maximum input size.
- */
TDF(SDK.Services services) {
- this(MAX_TDF_INPUT_SIZE, services);
- }
-
- // constructor for tests so that we can set a maximum size that's tractable for
- // tests
- TDF(long maximumInputSize, SDK.Services services) {
- this.maximumSize = maximumInputSize;
this.services = services;
}
private static final Logger logger = LoggerFactory.getLogger(TDF.class);
- private static final long MAX_TDF_INPUT_SIZE = 68719476736L;
private static final int GCM_KEY_SIZE = 32;
private static final String kSplitKeyType = "split";
private static final String kWrapped = "wrapped";
@@ -92,7 +71,6 @@ private static byte[] tdfECKeySaltCompute() {
private static final String kMlkemWrapped = "mlkem-wrapped";
private static final String kKasProtocol = "kas";
private static final int kGcmIvSize = 12;
- private static final int kAesBlockSize = 16;
private static final String kGCMCipherAlgorithm = "AES-256-GCM";
private static final int kGMACPayloadLength = 16;
private static final String kGmacIntegrityAlgorithm = "GMAC";
@@ -103,6 +81,119 @@ 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;
+
+ /**
+ * The IV reserved for encrypting the TDF metadata.
+ *
+ * @return twelve zero bytes
+ */
+ static byte[] metadataIv() {
+ return ivFor(METADATA_INVOCATION);
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * @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);
+ }
+ 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;
+ }
+
+ /**
+ * @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");
+ }
+ 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;
+ }
+ return iv;
+ }
+ }
+
static class EncryptedMetadata {
private String ciphertext;
private String iv;
@@ -176,12 +267,17 @@ private void prepareManifest(Config.TDFConfig tdfConfig, Map();
- long totalSize = 0;
boolean finished;
try (var payloadOutput = tdfWriter.payload()) {
do {
@@ -420,18 +516,14 @@ TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFCo
readThisLoop += nRead;
}
finished = nRead < 0;
- totalSize += readThisLoop;
-
- if (totalSize > maximumSize) {
- throw new SDK.DataSizeNotSupported("can't create tdf larger than 64gb");
- }
byte[] cipherData;
byte[] segmentSig;
Manifest.Segment segmentInfo = new Manifest.Segment();
// encrypt
- cipherData = tdfObject.aesGcm.encrypt(readBuf, 0, readThisLoop).asBytes();
+ cipherData = tdfObject.aesGcm.encrypt(payloadIv.next(), AesGcm.GCM_TAG_LENGTH,
+ readBuf, 0, readThisLoop);
payloadOutput.write(cipherData);
segmentSig = calculateSignature(cipherData, tdfObject.payloadKey, tdfConfig.segmentIntegrityAlgorithm);
@@ -693,7 +785,7 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig)
int segmentSize = manifest.encryptionInformation.integrityInformation.segmentSizeDefault;
int encryptedSegSize = manifest.encryptionInformation.integrityInformation.encryptedSegmentSizeDefault;
- if (segmentSize != encryptedSegSize - (kGcmIvSize + kAesBlockSize)) {
+ if (segmentSize != encryptedSegSize - (kGcmIvSize + AesGcm.GCM_TAG_LENGTH)) {
throw new IllegalStateException(
"segment size mismatch. encrypted segment size differs from plaintext segment size. the TDF is invalid");
}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDFWriter.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDFWriter.java
index 048822f6..7137c232 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDFWriter.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDFWriter.java
@@ -17,6 +17,13 @@ public TDFWriter(OutputStream destination) {
this.archiveWriter = new ZipWriter(destination);
}
+ /**
+ * Test seam. See {@link ZipWriter#ZipWriter(OutputStream, long)}.
+ */
+ TDFWriter(OutputStream destination, long maxNonZip64Value) {
+ this.archiveWriter = new ZipWriter(destination, maxNonZip64Value);
+ }
+
public void appendManifest(String manifest) throws IOException {
this.archiveWriter.data(TDF_MANIFEST_FILE_NAME, manifest.getBytes(StandardCharsets.UTF_8));
}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java b/sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java
index cf0b5772..576ba5de 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java
@@ -52,6 +52,15 @@ private int readInt() throws IOException {
return result.intValue();
}
+ /**
+ * Reads a 32-bit zip field as the unsigned value it is on the wire. {@link #readInt()}
+ * sign-extends, which silently turns offsets and sizes at or above 2 GiB into negative
+ * numbers.
+ */
+ private long readUnsignedInt() throws IOException {
+ return readInt() & 0xFFFFFFFFL;
+ }
+
final ByteBuffer shortBuf = ByteBuffer.allocate(Short.BYTES).order(ByteOrder.LITTLE_ENDIAN);
private short readShort() throws IOException {
@@ -63,6 +72,14 @@ private short readShort() throws IOException {
return shortBuf.getShort();
}
+ /**
+ * Reads a 16-bit zip field as the unsigned value it is on the wire. See
+ * {@link #readUnsignedInt()}.
+ */
+ private int readUnsignedShort() throws IOException {
+ return readShort() & 0xFFFF;
+ }
+
private static class CentralDirectoryRecord {
final long numEntries;
final long offsetToStart;
@@ -79,7 +96,10 @@ public CentralDirectoryRecord(long numEntries, long offsetToStart) {
private static final int CENTRAL_FILE_HEADER_SIGNATURE = 0x02014b50;
private static final int LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
- private static final int ZIP64_MAGICVAL = 0xFFFFFFFF;
+ /** Sentinel written into a 32-bit field whose real value lives in the zip64 extra field. */
+ private static final long ZIP64_MAGICVAL = 0xFFFFFFFFL;
+ /** The same sentinel for a 16-bit field, which is only two bytes wide. */
+ private static final int ZIP64_MAGIC_SHORT = 0xFFFF;
private static final int ZIP64_EXTID= 0x0001;
CentralDirectoryRecord readEndOfCentralDirectory() throws IOException {
@@ -105,12 +125,18 @@ CentralDirectoryRecord readEndOfCentralDirectory() throws IOException {
short centralDirectoryDiskNumber = readShort();
short numCDEntriesOnThisDisk = readShort();
- int totalNumEntries = readShort();
- int sizeOfCentralDirectory = readInt();
- long offsetToStartOfCentralDirectory = readInt();
- short commentLength = readShort();
-
- if (offsetToStartOfCentralDirectory != ZIP64_MAGICVAL) {
+ int totalNumEntries = readUnsignedShort();
+ long sizeOfCentralDirectory = readUnsignedInt();
+ long offsetToStartOfCentralDirectory = readUnsignedInt();
+ int commentLength = readUnsignedShort();
+
+ // any one of these fields may carry the sentinel that sends its real value to the zip64
+ // end of central directory record; an archive can need zip64 for its entry count alone
+ // while its central directory still starts below 4 GiB. the size is checked for the same
+ // reason even though nothing here reads it yet
+ if (totalNumEntries != ZIP64_MAGIC_SHORT
+ && sizeOfCentralDirectory != ZIP64_MAGICVAL
+ && offsetToStartOfCentralDirectory != ZIP64_MAGICVAL) {
return new CentralDirectoryRecord(totalNumEntries, offsetToStartOfCentralDirectory);
}
@@ -163,7 +189,24 @@ public String getName() {
return fileName;
}
- public InputStream getData() throws IOException {
+ /**
+ * Checks that this entry's local header offset points inside the archive, so a corrupt
+ * or truncated central directory fails here rather than at an arbitrary position.
+ */
+ private void checkOffsetToLocalHeader() throws IOException {
+ if (offsetToLocalHeader < 0 || offsetToLocalHeader >= zipChannel.size()) {
+ throw new InvalidZipException("local header offset out of range for entry ["
+ + fileName + "]: " + offsetToLocalHeader);
+ }
+ }
+
+ /**
+ * Reads this entry's local file header and returns the offset of the first byte of its
+ * data. Leaves the channel positioned within the header rather than at the returned
+ * offset, because the filename and extra field are skipped by arithmetic.
+ */
+ private long findStartOfData() throws IOException {
+ checkOffsetToLocalHeader();
zipChannel.position(offsetToLocalHeader);
Integer signature = readInteger();
if (signature == null || signature != LOCAL_FILE_HEADER_SIGNATURE) {
@@ -177,12 +220,16 @@ public InputStream getData() throws IOException {
+ Short.BYTES
+ Integer.BYTES);
- long compressedSize = readInt();
- long uncompressedSize = readInt();
- int filenameLength = readShort();
- int extrafieldLength = readShort();
+ long compressedSize = readUnsignedInt();
+ long uncompressedSize = readUnsignedInt();
+ int filenameLength = readUnsignedShort();
+ int extrafieldLength = readUnsignedShort();
+
+ return zipChannel.position() + filenameLength + extrafieldLength;
+ }
- final long startPosition = zipChannel.position() + filenameLength + extrafieldLength;
+ public InputStream getData() throws IOException {
+ final long startPosition = findStartOfData();
final long endPosition = startPosition + fileSize;
final ByteBuffer buf = ByteBuffer.allocate(1);
return new InputStream() {
@@ -193,6 +240,7 @@ public int read() throws IOException {
return -1;
}
setChannelPosition();
+ buf.clear();
while (buf.hasRemaining()) {
if (zipChannel.read(buf) <= 0) {
return -1;
@@ -242,15 +290,15 @@ public Entry readCentralDirectoryFileHeader() throws IOException {
short lastModFileTime = readShort();
short lastModFileDate = readShort();
int crc32 = readInt();
- long compressedSize = readInt();
- long uncompressedSize = readInt();
- int fileNameLength = readShort();
- int extraFieldLength = readShort();
- short fileCommentLength = readShort();
- int diskNumberStart = readShort();
+ long compressedSize = readUnsignedInt();
+ long uncompressedSize = readUnsignedInt();
+ int fileNameLength = readUnsignedShort();
+ int extraFieldLength = readUnsignedShort();
+ int fileCommentLength = readUnsignedShort();
+ int diskNumberStart = readUnsignedShort();
short internalFileAttributes = readShort();
int externalFileAttributes = readInt();
- long relativeOffsetOfLocalHeader = readInt();
+ long relativeOffsetOfLocalHeader = readUnsignedInt();
ByteBuffer fileName = ByteBuffer.allocate(fileNameLength);
while (fileName.hasRemaining()) {
@@ -262,20 +310,22 @@ public Entry readCentralDirectoryFileHeader() throws IOException {
// Parse the extra field
for (final long startPos = zipChannel.position(); zipChannel.position() < startPos + extraFieldLength; ) {
long fieldStart = zipChannel.position();
- int headerId = readShort();
- int dataSize = readShort();
+ int headerId = readUnsignedShort();
+ int dataSize = readUnsignedShort();
if (headerId == ZIP64_EXTID) {
- if (compressedSize == -1) {
- compressedSize = readLong();
- }
- if (uncompressedSize == -1) {
+ // APPNOTE 4.5.3 order: original size, compressed size, then local header offset
+ if (uncompressedSize == ZIP64_MAGICVAL) {
uncompressedSize = readLong();
}
- if (relativeOffsetOfLocalHeader == -1) {
+ if (compressedSize == ZIP64_MAGICVAL) {
+ compressedSize = readLong();
+ }
+ if (relativeOffsetOfLocalHeader == ZIP64_MAGICVAL) {
relativeOffsetOfLocalHeader = readLong();
}
- if (diskNumberStart == ZIP64_MAGICVAL) {
+ // a 2-byte field, so its sentinel is 0xFFFF rather than 0xFFFFFFFF
+ if (diskNumberStart == ZIP64_MAGIC_SHORT) {
diskNumberStart = readInt();
}
}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/ZipWriter.java b/sdk/src/main/java/io/opentdf/platform/sdk/ZipWriter.java
index 71aea34c..65357573 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/ZipWriter.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/ZipWriter.java
@@ -17,6 +17,19 @@ public class ZipWriter {
private static final int ZIP_VERSION = 0x2D;
private static final int ZIP_64_MAGIC_VAL = 0xFFFFFFFF;
+
+ /**
+ * The largest offset or size we will write into a 32-bit central directory field. Entries
+ * that don't fit are written as ZIP64 instead.
+ *
+ * This is {@link Integer#MAX_VALUE} rather than the {@code 0xFFFFFFFE} the format allows.
+ * The fields are unsigned on the wire, but readers that widen them with a signed read see
+ * anything at or above 2 GiB as negative. Switching to ZIP64 at 2 GiB costs
+ * {@value #ZIP_64_GLOBAL_EXTENDED_INFO_EXTRA_FIELD_SIZE} bytes per affected entry and keeps
+ * those readers working, including versions of this SDK that predate the unsigned reads in
+ * {@link ZipReader}.
+ */
+ static final long MAX_NON_ZIP64_VALUE = Integer.MAX_VALUE;
private static final long ZIP_64_END_OF_CD_RECORD_SIZE = 56;
private static final int ZIP_64_GLOBAL_EXTENDED_INFO_EXTRA_FIELD_SIZE = 28;
@@ -28,9 +41,44 @@ public class ZipWriter {
private static final int MONTH_SHIFT = 5;
private final CountingOutputStream out;
private final ArrayList fileInfos = new ArrayList<>();
+ private final long maxNonZip64Value;
public ZipWriter(OutputStream out) {
+ this(out, MAX_NON_ZIP64_VALUE);
+ }
+
+ /**
+ * Test seam. Lowering the threshold drives the real ZIP64 path in an archive small enough to
+ * write in a unit test.
+ *
+ * @param out the stream to write the archive to
+ * @param maxNonZip64Value the largest offset or size to write into a 32-bit field
+ */
+ ZipWriter(OutputStream out, long maxNonZip64Value) {
+ if (maxNonZip64Value < 0 || maxNonZip64Value > MAX_NON_ZIP64_VALUE) {
+ throw new IllegalArgumentException(
+ "zip64 threshold must be between 0 and " + MAX_NON_ZIP64_VALUE + ", got " + maxNonZip64Value);
+ }
this.out = new CountingOutputStream(out);
+ this.maxNonZip64Value = maxNonZip64Value;
+ }
+
+ private boolean needsZip64(long offset, long size) {
+ return offset > maxNonZip64Value || size > maxNonZip64Value;
+ }
+
+ /**
+ * Guards against silently truncating an entry that was not marked as ZIP64. Always checked
+ * against {@link #MAX_NON_ZIP64_VALUE} rather than the configured threshold: lowering the
+ * threshold only makes more entries ZIP64, so this can only fire on a genuine
+ * truncation.
+ */
+ static void checkFitsInCentralDirectory(String name, long offset, long size) {
+ if (offset > MAX_NON_ZIP64_VALUE || size > MAX_NON_ZIP64_VALUE) {
+ throw new SDKException("cannot write zip entry [" + name + "]: offset " + offset
+ + " and size " + size + " do not both fit in a 32-bit central directory field"
+ + " and the entry was not marked zip64");
+ }
}
public OutputStream stream(String name) throws IOException {
@@ -118,6 +166,10 @@ public long finish() throws IOException {
}
private static void writeCentralDirectoryHeader(FileInfo fileInfo, OutputStream out) throws IOException {
+ if (!fileInfo.isZip64) {
+ checkFitsInCentralDirectory(fileInfo.filename, fileInfo.offset, fileInfo.size);
+ }
+
CDFileHeader cdFileHeader = new CDFileHeader();
cdFileHeader.generalPurposeBitFlag = fileInfo.flag;
cdFileHeader.lastModifiedTime = fileInfo.fileTime;
@@ -179,7 +231,7 @@ private FileInfo writeByteArray(String name, byte[] data, CountingOutputStream o
fileInfo.filename = name;
fileInfo.fileTime = (short) fileTime;
fileInfo.fileDate = (short) fileDate;
- fileInfo.isZip64 = false;
+ fileInfo.isZip64 = needsZip64(startPosition, data.length);
return fileInfo;
}
@@ -371,8 +423,9 @@ void write(OutputStream out) throws IOException {
buffer.order(ByteOrder.LITTLE_ENDIAN);
buffer.putShort(signature);
buffer.putShort(size);
- buffer.putLong(compressedSize);
+ // APPNOTE 4.5.3 order: original size, compressed size, then local header offset
buffer.putLong(originalSize);
+ buffer.putLong(compressedSize);
buffer.putLong(localFileHeaderOffset);
out.write(buffer.array());
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 74ef151e..3fbb3eac 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
@@ -6,6 +6,7 @@
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.jwk.JWK;
import com.google.gson.Gson;
+import com.google.gson.JsonObject;
import io.opentdf.platform.policy.KeyAccessServer;
import io.opentdf.platform.policy.kasregistry.KeyAccessServerRegistryServiceClient;
import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersRequest;
@@ -20,23 +21,26 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
-import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
+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.atomic.AtomicInteger;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
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;
@@ -697,7 +701,26 @@ public void testCreatingTDFWithMultipleSegments() throws Exception {
var tdf = new TDF(
new FakeServicesBuilder().setKas(kas)
.setKeyAccessServerRegistryService(kasRegistryService).build());
- tdf.createTDF(plainTextInputStream, tdfOutputStream, config);
+ var tdfObject = tdf.createTDF(plainTextInputStream, tdfOutputStream, config);
+
+ 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();
+
var unwrappedData = new ByteArrayOutputStream();
var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), platformUrl);
reader.readPayload(unwrappedData);
@@ -708,52 +731,305 @@ public void testCreatingTDFWithMultipleSegments() throws Exception {
}
+ /**
+ * 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 testCreatingTooLargeTDF() {
- var random = new Random();
- var maxSize = random.nextInt(1024);
- var numReturned = new AtomicInteger(0);
-
- // return 1 more byte than the maximum size
- var is = new InputStream() {
- @Override
- public int read() {
- if (numReturned.get() > maxSize) {
- return -1;
- }
- numReturned.incrementAndGet();
- return 1;
- }
+ public void testMetadataUsesIvZero() throws Exception {
+ Config.TDFConfig config = Config.newTDFConfig(
+ Config.withAutoconfigure(false),
+ Config.withKasInformation(getSingleRSAKASInfo()),
+ Config.withMetaData("here is some metadata"));
- @Override
- public int read(byte[] b, int off, int len) {
- var numToReturn = Math.min(len, maxSize - numReturned.get() + 1);
- numReturned.addAndGet(numToReturn);
- return numToReturn;
- }
- };
+ 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);
+
+ 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);
+ assertThat(counter.next()).containsExactly(
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1);
+ }
+
+ @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);
+ }
- var os = new OutputStream() {
- @Override
- public void write(int b) {
+ @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);
+ }
+
+ @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);
+ }
+
+ @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;
+ }));
}
- @Override
- public void write(byte[] b, int off, int len) {
+ 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();
+ }
+ }
- var tdf = new TDF(maxSize, new FakeServicesBuilder().setKas(kas).build());
- var tdfConfig = Config.newTDFConfig(
- Config.withAutoconfigure(false),
- Config.withKasInformation(getRSAKASInfos()),
- Config.withSegmentSize(Config.MIN_SEGMENT_SIZE));
- assertThrows(SDK.DataSizeNotSupported.class,
- () -> tdf.createTDF(is, os, tdfConfig),
- "didn't throw an exception when we created TDF that was too large");
- assertThat(numReturned.get())
- .withFailMessage("test returned the wrong number of bytes")
- .isEqualTo(maxSize + 1);
+ @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);
+
+ 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 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);
+ }
+
+ /**
+ * 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
@@ -1079,6 +1355,16 @@ private static Config.KASInfo[] getECKASInfos() {
return getKASInfos(i -> i % 2 != 0);
}
+ /**
+ * Exactly one KAS, so the TDF gets a single key split and the payload key is the metadata
+ * key. Deterministic even though {@code keypairs} is randomly sized: index 0 always exists
+ * and is always RSA.
+ */
+ @Nonnull
+ private static Config.KASInfo[] getSingleRSAKASInfo() {
+ return getKASInfos(i -> i == 0);
+ }
+
private static boolean isHexChar(byte b) {
return (b >= 'a' && b <= 'f') || (b >= '0' && b <= '9');
}
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFWriterTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFWriterTest.java
index ad446bdb..2f5d22fc 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFWriterTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFWriterTest.java
@@ -1,5 +1,6 @@
package io.opentdf.platform.sdk;
+import org.apache.commons.compress.utils.SeekableInMemoryByteChannel;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
@@ -72,4 +73,32 @@ void simpleTDFCreate() throws IOException {
writer.finish();
fileOutStream.close();
}
+
+ /**
+ * The manifest is appended after the payload, so in a large TDF its local header offset
+ * doesn't fit in a 32-bit central directory field. Uses the lowered zip64 threshold to run
+ * that path against a small file.
+ */
+ @Test
+ void readsBackAManifestWrittenPastTheZip64Boundary() throws IOException {
+ var manifest = "{\"payload\":{\"url\":\"0.payload\"}}";
+ var payload = "a payload long enough to push the manifest past the threshold";
+
+ var out = new ByteArrayOutputStream();
+ var writer = new TDFWriter(out, 8);
+ try (var p = writer.payload()) {
+ new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8)).transferTo(p);
+ }
+ writer.appendManifest(manifest);
+ writer.finish();
+
+ try (var chan = new SeekableInMemoryByteChannel(out.toByteArray())) {
+ var reader = new TDFReader(chan);
+ assertEquals(manifest, reader.manifest());
+
+ var payloadBytes = new byte[payload.length()];
+ assertEquals(payload.length(), reader.readPayloadBytes(payloadBytes));
+ assertEquals(payload, new String(payloadBytes, StandardCharsets.UTF_8));
+ }
+ }
}
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java
index 087743db..fa2014bd 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java
@@ -4,6 +4,7 @@
import org.apache.commons.compress.archivers.zip.Zip64Mode;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
+import org.apache.commons.compress.archivers.zip.ZipFile;
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel;
import org.junit.jupiter.api.Test;
@@ -11,6 +12,8 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
@@ -91,6 +94,31 @@ public void testReadingAFileWrittenUsingCommons() throws IOException {
}
}
+ @Test
+ public void testSingleByteReadAdvancesThroughEntry() throws IOException {
+ byte[] expected = "contents with distinct bytes".getBytes(StandardCharsets.UTF_8);
+ SeekableInMemoryByteChannel outputChannel = new SeekableInMemoryByteChannel();
+ ZipArchiveOutputStream zip = new ZipArchiveOutputStream(outputChannel);
+ ZipArchiveEntry zipEntry = new ZipArchiveEntry("entry");
+ zipEntry.setMethod(0);
+ zip.putArchiveEntry(zipEntry);
+ zip.write(expected);
+ zip.closeArchiveEntry();
+ zip.close();
+
+ var reader = new ZipReader(new SeekableInMemoryByteChannel(outputChannel.array()));
+ var entry = reader.getEntries().get(0);
+ var actual = new ByteArrayOutputStream();
+ try (var data = entry.getData()) {
+ int next;
+ while ((next = data.read()) != -1) {
+ actual.write(next);
+ }
+ }
+
+ assertThat(actual.toByteArray()).isEqualTo(expected);
+ }
+
@Test
public void testReadingAndWritingRandomFiles() throws IOException {
Random r = new Random();
@@ -149,4 +177,100 @@ public void testReadingAndWritingRandomFiles() throws IOException {
assertThat(reader.getEntries().size()).isEqualTo(namesToData.size());
}
-}
\ No newline at end of file
+
+ private static final String PAYLOAD = "a payload long enough to push the manifest along";
+ private static final String MANIFEST = "{\"payload\":{\"protocol\":\"zip\"}}";
+
+ /** Sizes of the three trailing records, which are fixed when the archive has no comment. */
+ private static final int EOCD_SIZE = 22;
+ private static final int ZIP64_EOCD_LOCATOR_SIZE = 20;
+ private static final int ZIP64_EOCD_SIZE = 56;
+
+ private static final int EOCD_SIGNATURE = 0x06054b50;
+ private static final int ZIP64_EOCD_SIGNATURE = 0x06064b50;
+
+ /**
+ * An archive doesn't have to use the central directory offset sentinel to be zip64: one with
+ * more than 65,535 entries needs zip64 for its entry count alone, while its central directory
+ * still starts below 4 GiB. A reader that looks only at the offset takes the non-zip64 path,
+ * believes there are 65,535 entries, and walks off the end of the central directory.
+ */
+ @Test
+ public void testReadingAZip64ArchiveThatOnlyFlagsItsEntryCount() throws IOException {
+ assertReadsEveryEntry(keepOnlyTheEntryCountSentinel(zip64Archive()));
+ }
+
+ /**
+ * A zip64 archive small enough to check here. The lowered writer threshold marks the later
+ * entries zip64, so the writer emits a zip64 end of central directory record and fills every
+ * end of central directory field with its sentinel.
+ */
+ private static byte[] zip64Archive() throws IOException {
+ var out = new ByteArrayOutputStream();
+ var writer = new ZipWriter(out, 8);
+ writer.data("small.txt", "tiny".getBytes(StandardCharsets.UTF_8));
+ try (var entry = writer.stream("0.payload")) {
+ new ByteArrayInputStream(PAYLOAD.getBytes(StandardCharsets.UTF_8)).transferTo(entry);
+ }
+ writer.data("0.manifest.json", MANIFEST.getBytes(StandardCharsets.UTF_8));
+ writer.finish();
+ return out.toByteArray();
+ }
+
+ /**
+ * Rewrites the trailing end of central directory record so the entry count is the only field
+ * left holding a sentinel, taking the true size and offset out of the zip64 record that
+ * already carries them. The result is still a valid zip64 archive; it just no longer
+ * announces itself through the offset, which is the field the reader used to follow.
+ */
+ private static byte[] keepOnlyTheEntryCountSentinel(byte[] archive) {
+ var buf = ByteBuffer.wrap(archive).order(ByteOrder.LITTLE_ENDIAN);
+
+ int zip64Eocd = archive.length - (EOCD_SIZE + ZIP64_EOCD_LOCATOR_SIZE + ZIP64_EOCD_SIZE);
+ assertThat(buf.getInt(zip64Eocd)).isEqualTo(ZIP64_EOCD_SIGNATURE);
+ long centralDirectorySize = buf.getLong(zip64Eocd + 40);
+ long centralDirectoryOffset = buf.getLong(zip64Eocd + 48);
+
+ int eocd = archive.length - EOCD_SIZE;
+ assertThat(buf.getInt(eocd)).isEqualTo(EOCD_SIGNATURE);
+ buf.putInt(eocd + 12, (int) centralDirectorySize);
+ buf.putInt(eocd + 16, (int) centralDirectoryOffset);
+
+ return archive;
+ }
+
+ private static void assertReadsEveryEntry(byte[] archive) throws IOException {
+ try (var channel = new SeekableInMemoryByteChannel(archive)) {
+ var reader = new ZipReader(channel);
+ assertThat(reader.getEntries()).hasSize(3);
+ assertThat(readEntry(reader, "small.txt")).isEqualTo("tiny");
+ assertThat(readEntry(reader, "0.payload")).isEqualTo(PAYLOAD);
+ assertThat(readEntry(reader, "0.manifest.json")).isEqualTo(MANIFEST);
+ }
+
+ // and an independent implementation, so this shows the patched archive is well formed
+ // rather than just something our own reader happens to tolerate
+ try (var channel = new SeekableInMemoryByteChannel(archive)) {
+ var zip = new ZipFile.Builder().setSeekableByteChannel(channel).get();
+ assertThat(readEntry(zip, "small.txt")).isEqualTo("tiny");
+ assertThat(readEntry(zip, "0.payload")).isEqualTo(PAYLOAD);
+ assertThat(readEntry(zip, "0.manifest.json")).isEqualTo(MANIFEST);
+ }
+ }
+
+ private static String readEntry(ZipReader reader, String name) throws IOException {
+ var entry = reader.getEntries().stream()
+ .filter(e -> e.getName().equals(name))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no entry named " + name));
+ var data = new ByteArrayOutputStream();
+ entry.getData().transferTo(data);
+ return data.toString(StandardCharsets.UTF_8);
+ }
+
+ private static String readEntry(ZipFile zip, String name) throws IOException {
+ var data = new ByteArrayOutputStream();
+ zip.getInputStream(zip.getEntry(name)).transferTo(data);
+ return data.toString(StandardCharsets.UTF_8);
+ }
+}
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/ZipWriterTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/ZipWriterTest.java
index 27ed1603..d1d287b2 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/ZipWriterTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/ZipWriterTest.java
@@ -1,7 +1,9 @@
package io.opentdf.platform.sdk;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipExtraField;
import org.apache.commons.compress.archivers.zip.ZipFile;
+import org.apache.commons.compress.archivers.zip.ZipShort;
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@@ -20,6 +22,8 @@
import java.util.Random;
import java.util.zip.CRC32;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
public class ZipWriterTest {
@@ -70,11 +74,93 @@ public void createsNonZip64Archive() throws IOException {
var entry2 = z.getEntry("file2.txt");
assertThat(entry1).isNotNull();
assertThat(getDataStream(z, entry2).toString(StandardCharsets.UTF_8)).isEqualTo("Here are some more things to look at");
+
+ assertThat(containsZip64EndOfCentralDirectory(out.toByteArray()))
+ .withFailMessage("expected a small byte-array-only archive to stay non-zip64")
+ .isFalse();
+ }
+
+ /**
+ * The manifest is written after the payload, so in a large TDF its local header offset is
+ * past the 32-bit central directory field. Uses the lowered threshold so the real zip64 path
+ * runs against an archive small enough to check here.
+ */
+ @Test
+ public void writesReadableZip64EntriesForOffsetsPastTheThreshold() throws IOException {
+ var manifest = "{\"payload\":{\"protocol\":\"zip\"}}";
+ var payload = "a payload long enough to push the manifest past the threshold";
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ var writer = new ZipWriter(out, 8);
+ writer.data("small.txt", "tiny".getBytes(StandardCharsets.UTF_8));
+ try (var entry = writer.stream("0.payload")) {
+ new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8)).transferTo(entry);
+ }
+ writer.data("0.manifest.json", manifest.getBytes(StandardCharsets.UTF_8));
+ writer.finish();
+
+ var archive = out.toByteArray();
+ assertThat(containsZip64EndOfCentralDirectory(archive))
+ .withFailMessage("expected the lowered threshold to produce a zip64 archive")
+ .isTrue();
+
+ // our own reader
+ try (var chan = new SeekableInMemoryByteChannel(archive)) {
+ var reader = new ZipReader(chan);
+ assertThat(reader.getEntries().size()).isEqualTo(3);
+ assertThat(readEntry(reader, "small.txt")).isEqualTo("tiny");
+ assertThat(readEntry(reader, "0.payload")).isEqualTo(payload);
+ assertThat(readEntry(reader, "0.manifest.json")).isEqualTo(manifest);
+ }
+
+ // and an independent implementation, so we aren't just agreeing with ourselves
+ try (var chan = new SeekableInMemoryByteChannel(archive)) {
+ ZipFile z = new ZipFile.Builder().setSeekableByteChannel(chan).get();
+ assertThat(getDataStream(z, z.getEntry("small.txt")).toString(StandardCharsets.UTF_8))
+ .isEqualTo("tiny");
+ assertThat(getDataStream(z, z.getEntry("0.payload")).toString(StandardCharsets.UTF_8))
+ .isEqualTo(payload);
+ assertThat(getDataStream(z, z.getEntry("0.manifest.json")).toString(StandardCharsets.UTF_8))
+ .isEqualTo(manifest);
+
+ // the manifest sits past the threshold, so it has to carry a zip64 extra field
+ // rather than a truncated 32-bit offset. without this the test would still pass
+ // against a writer that never marks byte array entries as zip64
+ assertThat(zip64ExtraField(z, "0.manifest.json"))
+ .withFailMessage("the entry past the threshold was not written as zip64")
+ .isNotNull();
+ // and an entry below the threshold is left alone
+ assertThat(zip64ExtraField(z, "small.txt"))
+ .withFailMessage("an entry below the threshold should not be zip64")
+ .isNull();
+ }
+ }
+
+ @Test
+ public void refusesToTruncateAnOffsetIntoThirtyTwoBits() {
+ assertThatThrownBy(() -> ZipWriter.checkFitsInCentralDirectory("0.manifest.json", 1L << 31, 10))
+ .isInstanceOf(SDKException.class)
+ .hasMessageContaining("0.manifest.json");
+
+ assertThatThrownBy(() -> ZipWriter.checkFitsInCentralDirectory("big.bin", 0, 1L << 32))
+ .isInstanceOf(SDKException.class);
+
+ assertThatCode(() -> ZipWriter.checkFitsInCentralDirectory(
+ "boundary.bin", Integer.MAX_VALUE, Integer.MAX_VALUE))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ public void rejectsAnOutOfRangeZip64Threshold() {
+ var out = new ByteArrayOutputStream();
+ assertThatThrownBy(() -> new ZipWriter(out, -1)).isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> new ZipWriter(out, 1L << 32)).isInstanceOf(IllegalArgumentException.class);
}
@Test
@Disabled("this takes a long time and shouldn't run on build machines")
public void testWritingLargeFile() throws IOException {
+ var trailingEntry = "{\"written\":\"after the big payload\"}";
var random = new Random();
// create a file between 7 and 8 GB
long fileSize = 7 * (1L << 30) + (long)Math.floor(random.nextDouble() * (1L << 30));
@@ -103,10 +189,20 @@ public void testWritingLargeFile() throws IOException {
try (var entry = writer.stream("a big one")) {
in.transferTo(entry);
}
+ // a byte array entry after the big stream, the way a TDF appends its manifest.
+ // its local header offset is past 32 bits, so it has to be written as zip64
+ writer.data("0.manifest.json", trailingEntry.getBytes(StandardCharsets.UTF_8));
writer.finish();
}
}
+ try (var chan = FileChannel.open(zipFile.toPath(), StandardOpenOption.READ)) {
+ var reader = new ZipReader(chan);
+ assertThat(readEntry(reader, "0.manifest.json"))
+ .withFailMessage("couldn't read back an entry written past the 32-bit offset limit")
+ .isEqualTo(trailingEntry);
+ }
+
var unzippedData = File.createTempFile("big-file-unzipped", "");
unzippedData.deleteOnExit();
try (var unzippedStream = new FileOutputStream(unzippedData)) {
@@ -122,44 +218,33 @@ public void testWritingLargeFile() throws IOException {
.isEqualTo(testFile.length());
- var buf = new byte[2048];
- var unzippedCRC = new CRC32();
- try (var inputStream = new FileInputStream(unzippedData)) {
- var read = inputStream.read(buf);
- unzippedCRC.update(buf, 0, read);
- }
+ var unzippedCRC = crcOfWholeFile(unzippedData);
unzippedData.delete();
- var testFileCRC = new CRC32();
- try (var inputStream = new FileInputStream(testFile)) {
- var read = inputStream.read(buf);
- testFileCRC.update(buf, 0, read);
- }
- testFile.delete();
+ var testFileCRC = crcOfWholeFile(testFile);
- assertThat(unzippedCRC.getValue())
+ assertThat(unzippedCRC)
.withFailMessage("the extracted file's CRC differs from the CRC of the test data")
- .isEqualTo(testFileCRC.getValue());
+ .isEqualTo(testFileCRC);
var ourUnzippedData = File.createTempFile("big-file-we-unzipped", "");
ourUnzippedData.deleteOnExit();
try (var unzippedStream = new FileOutputStream(ourUnzippedData)) {
try (var chan = FileChannel.open(zipFile.toPath(), StandardOpenOption.READ)) {
ZipReader reader = new ZipReader(chan);
- assertThat(reader.getEntries().size()).isEqualTo(1);
- reader.getEntries().get(0).getData().transferTo(unzippedStream);
+ assertThat(reader.getEntries().size()).isEqualTo(2);
+ var bigEntry = reader.getEntries().stream()
+ .filter(e -> e.getName().equals("a big one"))
+ .findFirst()
+ .orElseThrow();
+ bigEntry.getData().transferTo(unzippedStream);
}
}
+ testFile.delete();
- var ourTestFileCRC = new CRC32();
- try (var inputStream = new FileInputStream(ourUnzippedData)) {
- var read = inputStream.read(buf);
- ourTestFileCRC.update(buf, 0, read);
- }
-
- assertThat(ourTestFileCRC.getValue())
+ assertThat(crcOfWholeFile(ourUnzippedData))
.withFailMessage("the file we extracted differs from the CRC of the test data")
- .isEqualTo(testFileCRC.getValue());
+ .isEqualTo(testFileCRC);
}
@Nonnull
@@ -168,4 +253,44 @@ private static ByteArrayOutputStream getDataStream(ZipFile z, ZipArchiveEntry en
z.getInputStream(entry).transferTo(entry1Data);
return entry1Data;
}
+
+ /** commons-compress keeps {@code Zip64ExtendedInformationExtraField.HEADER_ID} package-private. */
+ private static final ZipShort ZIP64_HEADER_ID = new ZipShort(0x0001);
+
+ private static ZipExtraField zip64ExtraField(ZipFile z, String name) {
+ return z.getEntry(name).getExtraField(ZIP64_HEADER_ID);
+ }
+
+ private static String readEntry(ZipReader reader, String name) throws IOException {
+ var entry = reader.getEntries().stream()
+ .filter(e -> e.getName().equals(name))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no entry named " + name));
+ var data = new ByteArrayOutputStream();
+ entry.getData().transferTo(data);
+ return data.toString(StandardCharsets.UTF_8);
+ }
+
+ /** Looks for the zip64 end of central directory signature, 0x06064b50 little-endian. */
+ private static boolean containsZip64EndOfCentralDirectory(byte[] archive) {
+ for (int i = 0; i + 4 <= archive.length; i++) {
+ if (archive[i] == 0x50 && archive[i + 1] == 0x4b
+ && archive[i + 2] == 0x06 && archive[i + 3] == 0x06) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static long crcOfWholeFile(File file) throws IOException {
+ var crc = new CRC32();
+ var buf = new byte[1 << 16];
+ try (var inputStream = new FileInputStream(file)) {
+ int read;
+ while ((read = inputStream.read(buf)) > 0) {
+ crc.update(buf, 0, read);
+ }
+ }
+ return crc.getValue();
+ }
}