From 767654f0c4efd7496cf17e84bc6d2a867c09eb30 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 10 Sep 2026 23:37:54 -0400 Subject: [PATCH] refactor(sdk): make the AEAD tag a property of the AEAD output DSPX-4703 closed the GMAC-root hole at runtime: an allowlist over the manifest's declared root algorithm, plus a split of one signature routine into segmentIntegrity and rootIntegrity. The split is the load-bearing part, but both halves still took a byte[], so nothing stopped a future caller from handing the aggregate hash back to the segment routine and reintroducing the bug. This moves that invariant into the type system. segmentIntegrity now takes AesGcm.Encrypted, the type the cipher actually produces; rootIntegrity keeps byte[] aggregateHash. Passing an aggregate hash to the GMAC path no longer compiles. TDF.aeadTag(byte[]) is deleted, along with its duplicate of GCM_TAG_LENGTH. Tag extraction lives on AesGcm.Encrypted, which now holds one contiguous iv || ciphertext || tag buffer behind a >= 28 byte construction-time invariant. That invariant makes authTag() total: no instance can be too short to have a tag, so the "payload too small" runtime branch has nowhere left to live. The guarantee is narrower than "this came out of a cipher", and the javadoc says so. Encrypted has a public constructor taking arbitrary bytes; on the read path the bytes are attacker-supplied by definition and the tag check is what catches that. What the type rules out is the API misuse of treating a value that never passed through the AEAD as though it had. The runtime allowlists stay, since they cover the untrusted-manifest axis. encryptInto is now the only code in the SDK that writes the layout, with explicit getOutputSize and bytes-written assertions so a provider that sizes output differently fails loudly rather than writing a TDF this SDK cannot read back. BCFIPS is a supported provider, so that is not hypothetical. Per segment, the write path loses a segment-sized memcpy and a segment-sized allocation; the read path loses a memcpy and an allocation. decrypt now reads the buffer with offsets instead of reassembling it. The API change is additive: sdk-pqc-bc and sdk-fips-bc compile against it with zero edits. The raw-byte[] encrypt and decrypt overloads are deprecated rather than removed, and a test pins that the new and deprecated encrypt overloads emit identical bytes. The wire format does not move. HS256 hashes an identical byte range and GMAC slices an identical byte range. Verified across builds in both directions and for both segment algorithms: this build reads TDFs written before the change, the pre-change build reads TDFs written after it, and the byte sizes match. Refs: DSPX-4703. Signed-off-by: Dave Mihalcik --- .../java/io/opentdf/platform/sdk/AesGcm.java | 250 ++++++++++++++---- .../java/io/opentdf/platform/sdk/TDF.java | 88 +++--- .../io/opentdf/platform/sdk/AesGcmTest.java | 150 +++++++++++ .../platform/sdk/TDFRootSignatureTest.java | 17 +- 4 files changed, 404 insertions(+), 101 deletions(-) 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 bed1b57b..b10be66e 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/AesGcm.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/AesGcm.java @@ -6,12 +6,14 @@ import javax.crypto.KeyGenerator; import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; +import javax.crypto.ShortBufferException; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.util.Arrays; /** * The AesGcm class provides encryption and decryption methods using AES-GCM mode. @@ -52,39 +54,148 @@ public byte[] getKey() { return key.getEncoded(); } - public static class Encrypted { - private final byte[] iv; - private final byte[] ciphertext; + /** + * A complete AES-GCM message: a {@value #GCM_NONCE_LENGTH}-byte IV, the ciphertext, and + * the {@value #GCM_TAG_LENGTH}-byte tag the cipher computed over them, held as one + * contiguous {@code iv || ciphertext || tag} buffer. + *

+ * Every instance is at least {@link #MIN_LENGTH} bytes, so {@link #authTag()} is total: + * it always returns bytes that sit where an AEAD tag sits in a well-formed message, + * never a sixteen-byte slice of something too short to be one. That invariant is why + * {@code TDF.segmentIntegrity} takes this type rather than a {@code byte[]} — the GMAC + * branch cannot be reached with a value, such as the aggregate hash, that never went + * through the cipher. + *

+ * What this does not do: the public constructors accept arbitrary bytes, so an + * instance is not proof that its contents came out of a cipher. On the read path they + * are attacker-supplied by definition, and it is the tag check in + * {@link AesGcm#decrypt(Encrypted)} that catches a forgery. What the type rules out is + * the API misuse of treating a value that is not an AEAD output as though it were one. + */ + public static final class Encrypted { + /** The shortest well-formed AES-GCM message: an IV, an empty plaintext, and a tag. */ + static final int MIN_LENGTH = GCM_NONCE_LENGTH + GCM_TAG_LENGTH; - public byte[] getIv() { - return iv; - } + /** Exactly {@code iv || ciphertext || tag}; never shorter than {@link #MIN_LENGTH}. */ + private final byte[] buf; - public byte[] getCiphertext() { - return ciphertext; + /** + * Distinguishes the no-copy constructor from {@link #Encrypted(byte[])}, which has + * the same erasure, and marks the hand-off as an ownership transfer at each use. + */ + private enum Ownership { + TRANSFERRED } - public Encrypted(byte[] iv, byte[] ciphertext) { - this.iv = iv; - this.ciphertext = ciphertext; + private Encrypted(byte[] owned, Ownership transfer) { + this.buf = owned; } + /** + * @param ivAndCiphertext a whole AES-GCM message, {@code iv || ciphertext || tag}, + * which is copied + */ public Encrypted(byte[] ivAndCiphertext) { - if (ivAndCiphertext.length < GCM_NONCE_LENGTH) { - throw new IllegalArgumentException("too short for IV and ciphertext"); + this(requireWellFormed(ivAndCiphertext).clone(), Ownership.TRANSFERRED); + } + + /** + * @param iv the {@value #GCM_NONCE_LENGTH}-byte IV + * @param ciphertextAndTag the ciphertext with its trailing + * {@value #GCM_TAG_LENGTH}-byte tag + */ + public Encrypted(byte[] iv, byte[] ciphertextAndTag) { + this(join(iv, ciphertextAndTag), Ownership.TRANSFERRED); + } + + /** + * Takes ownership of {@code ivCiphertextAndTag} instead of copying it. The caller + * must not retain or mutate the array afterwards. + *

+ * For buffers this SDK allocated and will not touch again — a freshly read segment, + * or the output of {@link AesGcm#encryptInto}. Use {@link #Encrypted(byte[])} + * anywhere the array has another owner. + */ + static Encrypted wrapping(byte[] ivCiphertextAndTag) { + return new Encrypted(requireWellFormed(ivCiphertextAndTag), Ownership.TRANSFERRED); + } + + private static byte[] requireWellFormed(byte[] ivAndCiphertext) { + if (ivAndCiphertext.length < MIN_LENGTH) { + throw new IllegalArgumentException("too short to be an AES-GCM message: " + + ivAndCiphertext.length + " bytes, need at least " + MIN_LENGTH + " (" + + GCM_NONCE_LENGTH + "-byte IV + " + GCM_TAG_LENGTH + "-byte tag)"); + } + return ivAndCiphertext; + } + + private static byte[] join(byte[] iv, byte[] ciphertextAndTag) { + if (iv == null || iv.length != GCM_NONCE_LENGTH) { + throw new IllegalArgumentException("invalid IV size for an AES-GCM message: " + + (iv == null ? "null" : iv.length) + ", need " + GCM_NONCE_LENGTH); } - this.iv = new byte[GCM_NONCE_LENGTH]; - this.ciphertext = new byte[ivAndCiphertext.length - GCM_NONCE_LENGTH]; + if (ciphertextAndTag == null || ciphertextAndTag.length < GCM_TAG_LENGTH) { + throw new IllegalArgumentException("ciphertext is too short to carry a tag: " + + (ciphertextAndTag == null ? "null" : ciphertextAndTag.length) + + " bytes, need at least " + GCM_TAG_LENGTH); + } + byte[] joined = new byte[iv.length + ciphertextAndTag.length]; + System.arraycopy(iv, 0, joined, 0, iv.length); + System.arraycopy(ciphertextAndTag, 0, joined, iv.length, ciphertextAndTag.length); + return joined; + } - System.arraycopy(ivAndCiphertext, 0, iv, 0, iv.length); - System.arraycopy(ivAndCiphertext, GCM_NONCE_LENGTH, ciphertext, 0, ciphertext.length); + public byte[] getIv() { + return Arrays.copyOf(buf, GCM_NONCE_LENGTH); + } + + /** + * @return the ciphertext together with its trailing {@value #GCM_TAG_LENGTH}-byte tag + */ + public byte[] getCiphertext() { + return Arrays.copyOfRange(buf, GCM_NONCE_LENGTH, buf.length); } + /** + * @return a copy of the whole message, {@code iv || ciphertext || tag} + */ public byte[] asBytes() { - byte[] out = new byte[iv.length + ciphertext.length]; - System.arraycopy(iv, 0, out, 0, iv.length); - System.arraycopy(ciphertext, 0, out, iv.length, ciphertext.length); - return out; + return buf.clone(); + } + + /** + * The authentication tag AES-GCM produced over exactly the rest of this message. + *

+ * A genuine MAC only because the value is a whole AEAD output: the tag is keyed and + * covers the bytes it accompanies. The same sixteen-byte slice taken off something + * that never passed through the cipher — an aggregate hash, say — is not a MAC at + * all, but a copy of that input's own trailing bytes, keyless and forgeable by + * whoever supplied them. Requiring this type is what keeps the two apart; see + * {@code TDF.segmentIntegrity} and {@code TDF.rootIntegrity}. + * + * @return the trailing {@value #GCM_TAG_LENGTH} bytes, always present + */ + byte[] authTag() { + return Arrays.copyOfRange(buf, buf.length - GCM_TAG_LENGTH, buf.length); + } + + /** + * The backing {@code iv || ciphertext || tag} buffer, not copied. + *

+ * For in-package callers that only read it: the payload writer, and the HS256 branch + * of {@code TDF.segmentIntegrity}, which must authenticate the whole message. + * Mutating it corrupts this instance. Use {@link #asBytes()} anywhere else. + */ + byte[] bytesNoCopy() { + return buf; + } + + /** + * @return the length of the whole message, which is what a TDF records as its + * {@code encryptedSegmentSize} + */ + int size() { + return buf.length; } } @@ -125,33 +236,27 @@ public Encrypted encrypt(byte[] plaintext) { * @return the encrypted text */ public Encrypted encrypt(byte[] plaintext, int offset, int len) { - Cipher cipher; - try { - cipher = Cipher.getInstance(CIPHER_TRANSFORM); - } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { - throw new RuntimeException(e); - } byte[] nonce = new byte[GCM_NONCE_LENGTH]; try { SecureRandom.getInstanceStrong().nextBytes(nonce); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } - GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, nonce); - try { - cipher.init(Cipher.ENCRYPT_MODE, key, spec); - } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { - throw new RuntimeException(e); - } - - byte[] cipherText; - try { - cipherText = cipher.doFinal(plaintext, offset, len); - } catch (IllegalBlockSizeException | BadPaddingException e) { - throw new RuntimeException(e); - } + return encryptInto(nonce, plaintext, offset, len); + } - return new Encrypted(nonce, cipherText); + /** + *

encrypt.

+ * + * @param iv the IV vector, which must be {@value #GCM_NONCE_LENGTH} bytes and must never be + * reused under this key + * @param plaintext the plaintext byte array to encrypt + * @param offset where the input start + * @param len input length + * @return the whole AES-GCM message: the IV, the ciphertext and the tag + */ + public Encrypted encrypt(byte[] iv, byte[] plaintext, int offset, int len) { + return encryptInto(iv, plaintext, offset, len); } /** @@ -164,31 +269,57 @@ public Encrypted encrypt(byte[] plaintext, int offset, int len) { * @param offset where the input start * @param len input length * @return the encrypted text, prefixed with the IV + * @deprecated use {@link #encrypt(byte[], byte[], int, int)}, which returns the + * {@link Encrypted} the rest of the SDK works in terms of. The tag length was + * never variable — this overload only ever accepted + * {@value #GCM_TAG_LENGTH}. */ + @Deprecated 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 + // strict, because the read path assumes this length: Encrypted 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); } + return encryptInto(iv, plaintext, offset, len).asBytes(); + } + + /** + * Encrypts into one contiguous {@code iv || ciphertext || tag} buffer, the single place + * in the SDK that produces that layout. + *

+ * The cipher writes straight into the final buffer, so no segment-sized copy is made. + * The provider's output size is checked against the AES-GCM contract rather than + * assumed: a provider that disagrees fails loudly here instead of quietly writing a TDF + * this SDK could not read back. + */ + private Encrypted encryptInto(byte[] iv, 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)); + } try { Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORM); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv)); - GCMParameterSpec spec = new GCMParameterSpec(authTagLen * 8, iv); - cipher.init(Cipher.ENCRYPT_MODE, key, spec); + int outputSize = cipher.getOutputSize(len); + if (outputSize != len + GCM_TAG_LENGTH) { + throw new SDKException("unexpected AES-GCM output size: " + outputSize + + " for " + len + " bytes of plaintext, expected " + (len + GCM_TAG_LENGTH)); + } - byte[] cipherText = cipher.doFinal(plaintext, offset, len); - byte[] cipherTextWithNonce = new byte[iv.length + cipherText.length]; - System.arraycopy(iv, 0, cipherTextWithNonce, 0, iv.length); - System.arraycopy(cipherText, 0, cipherTextWithNonce, iv.length, cipherText.length); - return cipherTextWithNonce; + byte[] buf = new byte[GCM_NONCE_LENGTH + outputSize]; + System.arraycopy(iv, 0, buf, 0, GCM_NONCE_LENGTH); + + int written = cipher.doFinal(plaintext, offset, len, buf, GCM_NONCE_LENGTH); + if (written != outputSize) { + throw new SDKException("AES-GCM wrote " + written + " bytes, expected " + outputSize); + } + return Encrypted.wrapping(buf); } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException - | InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) { + | InvalidKeyException | BadPaddingException | IllegalBlockSizeException + | ShortBufferException e) { throw new SDKException("error gcm encrypt", e); } } @@ -201,10 +332,11 @@ public byte[] encrypt(byte[] iv, int authTagLen, byte[] plaintext, int offset, i */ public byte[] decrypt(Encrypted cipherTextWithNonce) { try { + byte[] buf = cipherTextWithNonce.bytesNoCopy(); Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORM); - GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, cipherTextWithNonce.iv); - cipher.init(Cipher.DECRYPT_MODE, key, spec); - return cipher.doFinal(cipherTextWithNonce.ciphertext); + cipher.init(Cipher.DECRYPT_MODE, key, + new GCMParameterSpec(GCM_TAG_LENGTH * 8, buf, 0, GCM_NONCE_LENGTH)); + return cipher.doFinal(buf, GCM_NONCE_LENGTH, buf.length - GCM_NONCE_LENGTH); } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException | InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) { throw new SDKException("error gcm decrypt", e); @@ -218,7 +350,11 @@ public byte[] decrypt(Encrypted cipherTextWithNonce) { * @param authTagLen the length of the auth tag * @param cipherData the cipherData byte array to decrypt * @return the decrypted data + * @deprecated use {@link #decrypt(Encrypted)}. Passing an IV, a tag length and a + * detached {@code byte[]} separately is the shape this SDK is moving away + * from: it cannot express that the three belong to one AES-GCM message. */ + @Deprecated public byte[] decrypt(byte[] iv, int authTagLen, byte[] cipherData) { try { Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORM); 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 f0268e97..b15c5c44 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -72,7 +72,6 @@ private static byte[] tdfECKeySaltCompute() { private static final String kKasProtocol = "kas"; private static final int kGcmIvSize = 12; private static final String kGCMCipherAlgorithm = "AES-256-GCM"; - private static final int kGMACPayloadLength = 16; private static final String kGmacIntegrityAlgorithm = "GMAC"; private static final String kHmacIntegrityAlgorithm = "HS256"; @@ -273,11 +272,11 @@ private void prepareManifest(Config.TDFConfig tdfConfig, Map - * Recovering a tag is not verifying one. These are bytes whoever supplied the input - * already holds, so comparing them against a manifest value is keyless and on its own - * proves nothing — an attacker can re-chunk a payload and write each chunk's own - * trailing sixteen bytes into its {@code segment.hash}. What makes a GMAC segment hash - * trustworthy is the keyed checks around it: {@code loadTDF} has already validated the - * whole list of segment hashes against the HS256 root signature, and {@code readPayload} - * follows the comparison with a real AES-GCM tag check under the payload key. - *

- * The root signature has neither backstop — it is the outermost check, so a "GMAC root" - * is a keyless comparison with nothing behind it. The asymmetry is therefore structural, - * not a property of the bytes, and it is why {@link #rootIntegrity} does not offer this - * algorithm. - */ - private static byte[] aeadTag(byte[] ciphertext) { - if (kGMACPayloadLength > ciphertext.length) { - throw new IllegalArgumentException("tried to calculate GMAC on too small a payload. payload is " - + ciphertext.length + " bytes while GMAC is " + kGMACPayloadLength + " bytes"); - } - - return Arrays.copyOfRange(ciphertext, ciphertext.length - kGMACPayloadLength, ciphertext.length); - } - /** * The integrity value recorded in a segment's {@code hash}. + *

+ * Takes an {@link AesGcm.Encrypted} rather than a {@code byte[]} deliberately. Under + * GMAC this returns the tag AES-GCM already produced over exactly these bytes, which is + * a genuine authenticator only because the value came out of the cipher; the same + * trailing sixteen bytes taken off anything else — an aggregate hash, say — are keyless + * and forgeable by whoever supplied them. Demanding the AEAD's own output type makes + * that mistake a compile error rather than a review comment. Compare + * {@link #rootIntegrity}, whose parameter is a plain {@code byte[]} and which therefore + * cannot reach this branch at all. * - * @param ciphertext the AES-GCM output for this segment, whole and unmodified - * @param key the payload key - * @param algorithm {@code GMAC} to reuse the segment's own AEAD tag, or - * {@code HS256} to HMAC the segment ciphertext + * @param segment the whole AES-GCM message for this segment: IV, ciphertext and tag + * @param key the payload key + * @param algorithm {@code GMAC} to reuse the segment's own AEAD tag, or {@code HS256} to + * HMAC the whole segment * @throws IllegalArgumentException if {@code algorithm} is null or unsupported */ - static byte[] segmentIntegrity(byte[] ciphertext, byte[] key, Config.IntegrityAlgorithm algorithm) { + static byte[] segmentIntegrity(AesGcm.Encrypted segment, byte[] key, Config.IntegrityAlgorithm algorithm) { requireSupportedSegmentIntegrityAlgorithm(algorithm); switch (algorithm) { case HS256: - return CryptoUtils.CalculateSHA256Hmac(key, ciphertext); + return CryptoUtils.CalculateSHA256Hmac(key, segment.bytesNoCopy()); case GMAC: - return aeadTag(ciphertext); + return segment.authTag(); default: throw new IllegalArgumentException("unsupported segment integrity algorithm: " + algorithm); } @@ -547,6 +544,12 @@ static byte[] segmentIntegrity(byte[] ciphertext, byte[] key, Config.IntegrityAl * hash, which is attacker-controlled manifest data. Accepting one would let anyone * truncate, reorder, duplicate or drop segments without holding a key, since nothing * else binds a segment to its index or to the segment count. + *

+ * The {@code byte[]} parameter is load-bearing, not incidental: it is what makes the + * GMAC branch of {@link #segmentIntegrity} unreachable from here, since that method + * takes an {@link AesGcm.Encrypted} and nothing wraps an aggregate hash in one. The + * runtime check below still matters — it covers the algorithm a caller or a manifest + * asks for — but the type is what rules out the mistake at the call site. * * @throws IllegalArgumentException if {@code algorithm} is anything but HS256 */ @@ -656,14 +659,13 @@ TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFCo } finished = nRead < 0; - byte[] cipherData; + AesGcm.Encrypted cipherData; byte[] segmentSig; Manifest.Segment segmentInfo = new Manifest.Segment(); // encrypt - cipherData = tdfObject.aesGcm.encrypt(payloadIv.next(), AesGcm.GCM_TAG_LENGTH, - readBuf, 0, readThisLoop); - payloadOutput.write(cipherData); + cipherData = tdfObject.aesGcm.encrypt(payloadIv.next(), readBuf, 0, readThisLoop); + payloadOutput.write(cipherData.bytesNoCopy()); segmentSig = segmentIntegrity(cipherData, tdfObject.payloadKey, tdfConfig.segmentIntegrityAlgorithm); if (tdfConfig.hexEncodeRootAndSegmentHashes) { @@ -673,7 +675,7 @@ TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFCo aggregateHash.write(segmentSig); segmentInfo.segmentSize = readThisLoop; - segmentInfo.encryptedSegmentSize = cipherData.length; + segmentInfo.encryptedSegmentSize = cipherData.size(); tdfObject.manifest.encryptionInformation.integrityInformation.segments.add(segmentInfo); } while (!finished); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AesGcmTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AesGcmTest.java index 721865a3..993995eb 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/AesGcmTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/AesGcmTest.java @@ -42,4 +42,154 @@ void encryptionWithEmptyKey() { assertThrows(IllegalArgumentException.class, () -> new AesGcm(key)); } + + // ------------------------------------------------- supplied-IV encryption + + private static final byte[] KEY = "ThisIsASecretKey".getBytes(); + + /** Fixed so the two encrypt overloads can be compared byte for byte. */ + private static byte[] iv() { + byte[] iv = new byte[AesGcm.GCM_NONCE_LENGTH]; + for (int index = 0; index < iv.length; index++) { + iv[index] = (byte) index; + } + return iv; + } + + @Test + void encryptionWithSuppliedIvRoundTrips() { + var aesGcm = new AesGcm(KEY); + byte[] plaintext = "Virtru, JavaSDK!".getBytes(); + + var encrypted = aesGcm.encrypt(iv(), plaintext, 0, plaintext.length); + + assertEquals(AesGcm.GCM_NONCE_LENGTH + plaintext.length + AesGcm.GCM_TAG_LENGTH, + encrypted.size()); + assertArrayEquals(iv(), encrypted.getIv()); + assertArrayEquals(plaintext, aesGcm.decrypt(encrypted)); + } + + /** + * Guards the migration: the {@link AesGcm.Encrypted}-returning overload must lay bytes out + * exactly the way the deprecated one did, or it would change the TDF wire format. + */ + @Test + @SuppressWarnings("deprecation") + void newAndDeprecatedEncryptOverloadsProduceIdenticalBytes() { + var aesGcm = new AesGcm(KEY); + byte[] plaintext = "Virtru, JavaSDK!".getBytes(); + + byte[] viaDeprecated = aesGcm.encrypt(iv(), AesGcm.GCM_TAG_LENGTH, plaintext, 0, plaintext.length); + byte[] viaEncrypted = aesGcm.encrypt(iv(), plaintext, 0, plaintext.length).asBytes(); + + assertArrayEquals(viaDeprecated, viaEncrypted); + } + + @Test + @SuppressWarnings("deprecation") + void deprecatedEncryptStillRejectsANonStandardTagLength() { + var aesGcm = new AesGcm(KEY); + byte[] plaintext = "Virtru, JavaSDK!".getBytes(); + + assertThrows(IllegalArgumentException.class, + () -> aesGcm.encrypt(iv(), 12, plaintext, 0, plaintext.length)); + } + + @Test + void encryptionRejectsAnIvOfTheWrongSize() { + var aesGcm = new AesGcm(KEY); + byte[] plaintext = "Virtru, JavaSDK!".getBytes(); + + assertThrows(IllegalArgumentException.class, + () -> aesGcm.encrypt(new byte[8], plaintext, 0, plaintext.length)); + } + + // ---------------------------------------------------- the Encrypted type + + /** + * The boundary that makes {@link AesGcm.Encrypted#authTag()} total: even an empty + * plaintext produces a full IV and tag, so there is no shorter well-formed message. + */ + @Test + void emptyPlaintextStillCarriesAnIvAndATag() { + var encrypted = new AesGcm(KEY).encrypt(iv(), new byte[0], 0, 0); + + assertEquals(AesGcm.Encrypted.MIN_LENGTH, encrypted.size()); + assertArrayEquals(new byte[0], new AesGcm(KEY).decrypt(encrypted)); + } + + @Test + void encryptedRejectsAMessageTooShortToHoldAnIvAndTag() { + assertThrows(IllegalArgumentException.class, + () -> new AesGcm.Encrypted(new byte[AesGcm.Encrypted.MIN_LENGTH - 1])); + assertThrows(IllegalArgumentException.class, + () -> AesGcm.Encrypted.wrapping(new byte[AesGcm.Encrypted.MIN_LENGTH - 1])); + + // exactly at the boundary is well-formed, whether or not it authenticates + assertEquals(AesGcm.Encrypted.MIN_LENGTH, + new AesGcm.Encrypted(new byte[AesGcm.Encrypted.MIN_LENGTH]).size()); + } + + @Test + void encryptedRejectsAMalformedIvOrCiphertext() { + assertThrows(IllegalArgumentException.class, + () -> new AesGcm.Encrypted(new byte[8], new byte[AesGcm.GCM_TAG_LENGTH])); + assertThrows(IllegalArgumentException.class, + () -> new AesGcm.Encrypted(new byte[AesGcm.GCM_NONCE_LENGTH], + new byte[AesGcm.GCM_TAG_LENGTH - 1])); + } + + @Test + void authTagIsTheTrailingSixteenBytesOfTheMessage() { + var plaintext = "Virtru, JavaSDK!".getBytes(); + var encrypted = new AesGcm(KEY).encrypt(iv(), plaintext, 0, plaintext.length); + + var bytes = encrypted.asBytes(); + var expected = new byte[AesGcm.GCM_TAG_LENGTH]; + System.arraycopy(bytes, bytes.length - expected.length, expected, 0, expected.length); + + assertArrayEquals(expected, encrypted.authTag()); + } + + /** + * The accessors hand out copies, so a caller cannot corrupt the message it was given. + */ + @Test + void accessorsDoNotAliasTheBackingBuffer() { + var plaintext = "Virtru, JavaSDK!".getBytes(); + var aesGcm = new AesGcm(KEY); + var encrypted = aesGcm.encrypt(iv(), plaintext, 0, plaintext.length); + var original = encrypted.asBytes(); + + encrypted.asBytes()[0] ^= 0xFF; + encrypted.getIv()[0] ^= 0xFF; + encrypted.getCiphertext()[0] ^= 0xFF; + encrypted.authTag()[0] ^= 0xFF; + + assertArrayEquals(original, encrypted.asBytes()); + assertArrayEquals(plaintext, aesGcm.decrypt(encrypted)); + } + + /** {@code Encrypted(byte[])} copies; {@code wrapping} deliberately does not. */ + @Test + void constructorCopiesWhileWrappingTakesOwnership() { + var plaintext = "Virtru, JavaSDK!".getBytes(); + var source = new AesGcm(KEY).encrypt(iv(), plaintext, 0, plaintext.length).asBytes(); + + var copied = new AesGcm.Encrypted(source); + var wrapped = AesGcm.Encrypted.wrapping(source); + + assertNotSame(source, copied.bytesNoCopy()); + assertSame(source, wrapped.bytesNoCopy()); + } + + @Test + void ivAndCiphertextTogetherReconstituteTheMessage() { + var plaintext = "Virtru, JavaSDK!".getBytes(); + var encrypted = new AesGcm(KEY).encrypt(iv(), plaintext, 0, plaintext.length); + + var rebuilt = new AesGcm.Encrypted(encrypted.getIv(), encrypted.getCiphertext()); + + assertArrayEquals(encrypted.asBytes(), rebuilt.asBytes()); + } } \ No newline at end of file diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java index b6a4c8de..f51f3d7a 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java @@ -287,6 +287,21 @@ void segmentBodyTamperIsCaughtUnderEitherSegmentAlgorithm(Config.IntegrityAlgori assertThatThrownBy(() -> decrypt(tampered)).isInstanceOf(SDKException.class); } + @Test + void segmentTooSmallToBeAnAeadMessageIsRejected() throws IOException { + // A segment shorter than an IV plus a tag cannot be AES-GCM output at all. It has + // always failed -- no sixteen-byte tail of it could match the recorded hash -- and it + // must keep failing as a signature mismatch rather than as an argument error escaping + // from the crypto layer. + var tampered = rewrite(createTdf(fourSegmentPlaintext()), + manifest -> segments(manifest).get(0).getAsJsonObject() + .addProperty("encryptedSegmentSize", AesGcm.GCM_NONCE_LENGTH + AesGcm.GCM_TAG_LENGTH - 1), + UnaryOperator.identity()); + + assertThatThrownBy(() -> decrypt(tampered)) + .isInstanceOf(SDK.SegmentSignatureMismatch.class); + } + @Test void defaultsAreHs256RootAndGmacSegments() throws IOException { var integrityInformation = integrityInformation( @@ -399,7 +414,7 @@ void rootIntegrityAcceptsHs256() { @Test void segmentIntegrityRefusesNullWhenCalledDirectly() { - assertThatThrownBy(() -> TDF.segmentIntegrity(new byte[64], new byte[32], null)) + assertThatThrownBy(() -> TDF.segmentIntegrity(new AesGcm.Encrypted(new byte[64]), new byte[32], null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("unsupported segment integrity algorithm"); }