refactor(sdk): make the AEAD tag a property of the AEAD output - #403
Draft
dmihalcik-virtru wants to merge 1 commit into
Draft
refactor(sdk): make the AEAD tag a property of the AEAD output#403dmihalcik-virtru wants to merge 1 commit into
dmihalcik-virtru wants to merge 1 commit into
Conversation
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
dmihalcik-virtru
marked this pull request as draft
September 11, 2026 03:56
dmihalcik-virtru
force-pushed
the
DSPX-4703-aead-tag-type
branch
from
September 11, 2026 19:53
33884ba to
5ad082a
Compare
dmihalcik-virtru
force-pushed
the
DSPX-4703-aead-tag-type
branch
from
September 11, 2026 20:22
5ad082a to
9601792
Compare
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 <dmihalcik@virtru.com>
dmihalcik-virtru
force-pushed
the
DSPX-4703-aead-tag-type
branch
from
September 11, 2026 20:57
9601792 to
80c4e10
Compare
dmihalcik-virtru
added this pull request to stack #404
September 11, 2026 21:00
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #401. Base is
DSPX-4703-reject-gmac-root; review that one first.Why
#401 closes the GMAC-root hole at runtime — an allowlist over the manifest's declared root algorithm, plus a split of one signature routine into
segmentIntegrityandrootIntegrity. The split is the load-bearing part, but both halves still take abyte[], so nothing stops a future caller from handing the aggregate hash back to the segment routine and reintroducing the bug.TDF.aeadTag(byte[])was the concrete shape of that hazard: it recovered an AES-GCM tag by copying the trailing 16 bytes off an array, and its correctness rested on a precondition the signature could not express — that the argument was the complete output of one AES-GCM encryption. Applied to anything else it is not a MAC, it is a keyless copy of the input's own trailing bytes, forgeable by whoever supplied them.What
segmentIntegritynow takesAesGcm.Encrypted, the type the cipher actually produces.rootIntegritykeepsbyte[] aggregateHash. Passing an aggregate hash to the GMAC path no longer compiles.aeadTagand its duplicate ofGCM_TAG_LENGTHare gone. Tag extraction lives onAesGcm.Encrypted, which now holds one contiguousiv || ciphertext || tagbuffer behind a>= 28byte construction-time invariant. That invariant makesauthTag()total: no instance can be too short to have a tag, so the "payload too small" runtime branch has nowhere left to live.encryptIntois now the only code in the SDK that writes the layout, with explicitgetOutputSizeand 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 isn't hypothetical.Scope of the guarantee
Deliberately not overclaimed, in the javadoc as well as here.
Encryptedhas a public constructor taking arbitrary bytes, so it cannot prove its contents came out of a cipher — 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 from #401 stay; they cover the untrusted-manifest axis. Both are needed.Performance
Per segment: the write path loses a segment-sized memcpy and a segment-sized allocation; the read path loses a memcpy and an allocation.
decryptreads the buffer with offsets instead of reassembling it. At the default segment size the removed allocation is probably the bigger win.Wire format
Unchanged. HS256 hashes an identical byte range and GMAC slices an identical byte range.
Verified across builds rather than by self-comparison, since the payload key is random per TDF (so output is not byte-for-byte reproducible — I persisted the fake-KAS keypair instead and tested both directions):
All three for both
GMACandHS256segment algorithms.Verification
sdk-pqc-bcorsdk-fips-bc— the real test that the API change is additive, since both consumeAesGcm.Encrypted.mvn verify -P coverage(CI parity) BUILD SUCCESS.Reviewer notes
segmentIntegrityrejects an aggregate hash." It no longer compiles, which is the entire point.getIv()/getCiphertext()now return defensive copies. Zero callers in the repo; signatures unchanged; the only observable difference is that mutating the result no longer corrupts the instance.Encryptedis nowfinal, the two-arg constructor requires a 12-byte IV, and the single-arg minimum rose 12 → 28 bytes. These are the only theoretically-breaking items for out-of-tree code.Encrypted(byte[])has always split at offset 12 unconditionally, so a 16-byte-IV instance could never have round-tripped throughasBytes()— the type was implicitly 12-byte-IV-only and this makes it explicit. Happy to dropfinalif you'd prefer maximal caution.byte[]encrypt/decryptoverloads are@Deprecatedbut fully working. A test pins that the new and deprecatedencryptoverloads emit identical bytes — that's the migration guard.authTag()andbytesNoCopy()are package-private on purpose.authTag()must not become public API that invites constructing anEncryptedfrom junk just to read 16 bytes off the end.>= 28changes the failure for malformed KAS metadata fromSDKExceptionat decrypt toIllegalArgumentExceptionat construction. Both unchecked, andTDF.javaalready threw IAE for< 12, so this widens existing behavior. No real wrapped key is under 28 bytes.Follow-up, out of scope here
AesGcm.encrypt(byte[],int,int)callsSecureRandom.getInstanceStrong()on every invocation, which can block on Linux. Worth its own issue.