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
- * 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");
}