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