Skip to content

Commit 66b25e2

Browse files
fix(sdk): reject GMAC root signatures on read (DSPX-4703)
A ZTDF's root signature is the only thing that authenticates the manifest's ordered list of segment hashes. AES-GCM tags bind a segment's own bytes and nothing about its index, its neighbours, or how many segments there are, so segment-level integrity cannot notice a truncated, reordered, or duplicated segment list. One signature routine served both jobs. For a segment, "GMAC" correctly means reading back the AES-GCM tag the cipher just computed over that segment's ciphertext. For the root it means nothing: the aggregate hash never passes through AES-GCM, so there is no tag to recover and the code returned a copy of the trailing bytes of its own input, i.e. the last segment hash. Manifest data compared against manifest data, with the payload key never used. The algorithm was read from `rootSignature.alg` in the manifest, which is not authenticated. An attacker with no key could therefore take an HS256-rooted TDF, rewrite the root to "GMAC" with a signature copied from the last segment hash, and then truncate, reorder, or duplicate segments with the file still verifying. Changes: - Split the routine into segmentIntegrity (HS256 or GMAC) and rootIntegrity (HS256 only), so tag extraction can no longer be pointed at a non-AEAD input. - rootIntegrityAlgorithmFromManifest resolves the root algorithm against an allowlist and throws SDK.RootSignatureValidationException for anything else, instead of coercing unknown values to HS256. - segmentIntegrityAlgorithmFromManifest stays permissive, since both algorithms are meaningful over ciphertext. - createTDF validates the configured root algorithm too, so the SDK will not write a file it would refuse to read. rootIntegrity validates its own argument in addition to its caller checking first. The redundancy is deliberate: the check is what makes the function safe, so it belongs with the function rather than only at today's call sites. Tests cover truncation, reordering, GMAC in several casings, an unknown algorithm that must not be coerced, and controls that must keep passing. Verified by mutation: restoring the old tag-extraction branch inside rootIntegrity turns exactly the 8 exploit tests red and leaves the other 21 green. Refs: DSPX-4703, and the write-side controls in DSPX-4736. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
1 parent 98c839e commit 66b25e2

2 files changed

Lines changed: 753 additions & 26 deletions

File tree

‎sdk/src/main/java/io/opentdf/platform/sdk/TDF.java‎

Lines changed: 168 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ public Manifest getManifest() {
411411
this.unencryptedMetadata = unencryptedMetadata;
412412
}
413413

414-
public void readPayload(OutputStream outputStream) throws SDK.SegmentSignatureMismatch, IOException {
414+
public void readPayload(OutputStream outputStream) throws SDK.TamperException, IOException {
415415

416416
MessageDigest digest = null;
417417
try {
@@ -438,13 +438,10 @@ public void readPayload(OutputStream outputStream) throws SDK.SegmentSignatureMi
438438
var isLegacyTdf = manifest.tdfVersion == null || manifest.tdfVersion.isEmpty();
439439

440440
if (manifest.payload.isEncrypted) {
441-
String segHashAlg = manifest.encryptionInformation.integrityInformation.segmentHashAlg;
442-
Config.IntegrityAlgorithm sigAlg = Config.IntegrityAlgorithm.HS256;
443-
if (segHashAlg.compareToIgnoreCase(kGmacIntegrityAlgorithm) == 0) {
444-
sigAlg = Config.IntegrityAlgorithm.GMAC;
445-
}
441+
var sigAlg = segmentIntegrityAlgorithmFromManifest(
442+
manifest.encryptionInformation.integrityInformation.segmentHashAlg);
446443

447-
var payloadSig = calculateSignature(readBuf, payloadKey, sigAlg);
444+
var payloadSig = segmentIntegrity(readBuf, payloadKey, sigAlg);
448445
if (isLegacyTdf) {
449446
payloadSig = Hex.encodeHexString(payloadSig).getBytes(StandardCharsets.UTF_8);
450447
}
@@ -470,22 +467,164 @@ public void readPayload(OutputStream outputStream) throws SDK.SegmentSignatureMi
470467
public PolicyObject readPolicyObject() {
471468
return tdfReader.readPolicyObject();
472469
}
473-
}
474470

475-
private static byte[] calculateSignature(byte[] data, byte[] secret, Config.IntegrityAlgorithm algorithm) {
476-
if (algorithm == Config.IntegrityAlgorithm.HS256) {
477-
return CryptoUtils.CalculateSHA256Hmac(secret, data);
471+
/**
472+
* Resolves {@code segmentHashAlg} as read from the manifest. Both algorithms are
473+
* allowed: a GMAC segment hash proves nothing by itself, but unlike the root it is
474+
* bracketed by keyed checks that do (see {@link TDF#aeadTag}). An unrecognized name
475+
* is still refused rather than defaulted. Contrast
476+
* {@link TDF#rootIntegrityAlgorithmFromManifest}, where only HS256 is meaningful.
477+
*/
478+
private static Config.IntegrityAlgorithm segmentIntegrityAlgorithmFromManifest(String declared) {
479+
if (declared != null) {
480+
String name = declared.trim();
481+
if (kGmacIntegrityAlgorithm.equalsIgnoreCase(name)) {
482+
return Config.IntegrityAlgorithm.GMAC;
483+
}
484+
if (kHmacIntegrityAlgorithm.equalsIgnoreCase(name)) {
485+
return Config.IntegrityAlgorithm.HS256;
486+
}
487+
}
488+
// Not a SegmentSignatureMismatch: no signature was compared. Still a
489+
// TamperException, because segmentHashAlg is not covered by the root
490+
// signature and so is something an attacker can freely rewrite.
491+
throw new SDK.TamperException("unsupported segment integrity algorithm: " + declared);
478492
}
493+
}
479494

480-
if (kGMACPayloadLength > data.length) {
495+
/**
496+
* Recovers the trailing AES-GCM authentication tag from a segment's ciphertext.
497+
* <p>
498+
* Recovering a tag is not verifying one. These are bytes whoever supplied the input
499+
* already holds, so comparing them against a manifest value is keyless and on its own
500+
* proves nothing — an attacker can re-chunk a payload and write each chunk's own
501+
* trailing sixteen bytes into its {@code segment.hash}. What makes a GMAC segment hash
502+
* trustworthy is the keyed checks around it: {@code loadTDF} has already validated the
503+
* whole list of segment hashes against the HS256 root signature, and {@code readPayload}
504+
* follows the comparison with a real AES-GCM tag check under the payload key.
505+
* <p>
506+
* The root signature has neither backstop — it is the outermost check, so a "GMAC root"
507+
* is a keyless comparison with nothing behind it. The asymmetry is therefore structural,
508+
* not a property of the bytes, and it is why {@link #rootIntegrity} does not offer this
509+
* algorithm.
510+
*/
511+
private static byte[] aeadTag(byte[] ciphertext) {
512+
if (kGMACPayloadLength > ciphertext.length) {
481513
throw new IllegalArgumentException("tried to calculate GMAC on too small a payload. payload is "
482-
+ data.length + "bytes while GMAC is " + kGMACPayloadLength + " bytes");
514+
+ ciphertext.length + " bytes while GMAC is " + kGMACPayloadLength + " bytes");
515+
}
516+
517+
return Arrays.copyOfRange(ciphertext, ciphertext.length - kGMACPayloadLength, ciphertext.length);
518+
}
519+
520+
/**
521+
* The integrity value recorded in a segment's {@code hash}.
522+
*
523+
* @param ciphertext the AES-GCM output for this segment, whole and unmodified
524+
* @param key the payload key
525+
* @param algorithm {@code GMAC} to reuse the segment's own AEAD tag, or
526+
* {@code HS256} to HMAC the segment ciphertext
527+
* @throws IllegalArgumentException if {@code algorithm} is null or unsupported
528+
*/
529+
static byte[] segmentIntegrity(byte[] ciphertext, byte[] key, Config.IntegrityAlgorithm algorithm) {
530+
requireSupportedSegmentIntegrityAlgorithm(algorithm);
531+
switch (algorithm) {
532+
case HS256:
533+
return CryptoUtils.CalculateSHA256Hmac(key, ciphertext);
534+
case GMAC:
535+
return aeadTag(ciphertext);
536+
default:
537+
throw new IllegalArgumentException("unsupported segment integrity algorithm: " + algorithm);
538+
}
539+
}
540+
541+
/**
542+
* The integrity value recorded in {@code rootSignature.sig}, over the concatenated
543+
* segment hashes.
544+
* <p>
545+
* HS256 only. The aggregate hash never passes through the AEAD, so there is no tag
546+
* to recover from it; a "GMAC" root signature is just a copy of the last segment
547+
* hash, which is attacker-controlled manifest data. Accepting one would let anyone
548+
* truncate, reorder, duplicate or drop segments without holding a key, since nothing
549+
* else binds a segment to its index or to the segment count.
550+
*
551+
* @throws IllegalArgumentException if {@code algorithm} is anything but HS256
552+
*/
553+
static byte[] rootIntegrity(byte[] aggregateHash, byte[] key, Config.IntegrityAlgorithm algorithm) {
554+
requireSupportedRootIntegrityAlgorithm(algorithm);
555+
return CryptoUtils.CalculateSHA256Hmac(key, aggregateHash);
556+
}
557+
558+
/**
559+
* The segment counterpart to {@link #requireSupportedRootIntegrityAlgorithm}. Both
560+
* algorithms are legal in this position, so this exists to reject {@code null} and any
561+
* future enum value in {@code createTDF} rather than partway through the payload:
562+
* TDFConfig's fields are public, so a field left unset arrives here as {@code null} and
563+
* would otherwise surface as a {@link NullPointerException} at the switch below, after
564+
* segments had already been written to the output stream.
565+
*
566+
* @throws IllegalArgumentException if {@code algorithm} cannot hash a segment
567+
*/
568+
static void requireSupportedSegmentIntegrityAlgorithm(Config.IntegrityAlgorithm algorithm) {
569+
if (algorithm != Config.IntegrityAlgorithm.HS256 && algorithm != Config.IntegrityAlgorithm.GMAC) {
570+
throw new IllegalArgumentException("unsupported segment integrity algorithm: " + algorithm);
571+
}
572+
}
573+
574+
/**
575+
* The write-path gate, and a second checkpoint inside {@link #rootIntegrity}.
576+
* <p>
577+
* An {@link IllegalArgumentException} rather than a {@link SDK.TamperException}, on
578+
* purpose. Reading, this is unreachable: {@link #rootIntegrityAlgorithmFromManifest}
579+
* has already narrowed the manifest's declaration to HS256 or thrown
580+
* {@link SDK.RootSignatureValidationException} trying. So if it ever does fire on a
581+
* read, the cause is a bug in this class rather than a hostile file, and it should
582+
* escape {@code loadTDF} uncaught instead of being reported to callers as tamper —
583+
* fail loud, and do not let a defect hide inside an exception type that callers
584+
* routinely handle.
585+
*
586+
* @throws IllegalArgumentException if {@code algorithm} cannot authenticate a root
587+
* signature
588+
*/
589+
static void requireSupportedRootIntegrityAlgorithm(Config.IntegrityAlgorithm algorithm) {
590+
if (algorithm != Config.IntegrityAlgorithm.HS256) {
591+
throw new IllegalArgumentException("unsupported root integrity algorithm: " + algorithm
592+
+ "; the root signature must be " + kHmacIntegrityAlgorithm);
483593
}
594+
}
484595

485-
return Arrays.copyOfRange(data, data.length - kGMACPayloadLength, data.length);
596+
/**
597+
* Resolves {@code rootSignature.alg} as read from the (unauthenticated) manifest.
598+
* <p>
599+
* An allowlist, deliberately: anything other than HS256 — GMAC, an unknown name, an
600+
* empty string — is refused rather than being defaulted to HS256. Defaulting would
601+
* validate a downgraded manifest against an algorithm it does not declare.
602+
*/
603+
private static Config.IntegrityAlgorithm rootIntegrityAlgorithmFromManifest(String declared) {
604+
if (declared != null && kHmacIntegrityAlgorithm.equalsIgnoreCase(declared.trim())) {
605+
return Config.IntegrityAlgorithm.HS256;
606+
}
607+
throw new SDK.RootSignatureValidationException("unsupported root integrity algorithm: " + declared
608+
+ "; the root signature must be " + kHmacIntegrityAlgorithm);
486609
}
487610

611+
/**
612+
* @throws IllegalArgumentException if {@code tdfConfig} selects an integrity algorithm
613+
* that cannot be written. Unchecked and not an
614+
* {@link SDKException}, matching how the config layer
615+
* already reports out-of-range values (see
616+
* {@link Config#withSegmentSize}): this is a caller
617+
* mistake to fix in code, not a condition to handle
618+
* alongside I/O and tamper failures.
619+
*/
488620
TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFConfig tdfConfig) throws SDKException, IOException {
621+
// Checked before anything is written so an unusable algorithm cannot produce a
622+
// partial TDF. There are no setters for these -- the config defaults to an HS256
623+
// root and GMAC segments -- but TDFConfig's fields are public, so re-check what
624+
// was actually set.
625+
requireSupportedRootIntegrityAlgorithm(tdfConfig.integrityAlgorithm);
626+
requireSupportedSegmentIntegrityAlgorithm(tdfConfig.segmentIntegrityAlgorithm);
627+
489628
Planner planner = new Planner(tdfConfig, services, Autoconfigure::createGranter);
490629
Map<String, List<KASInfo>> splits = planner.getSplits();
491630

@@ -526,7 +665,7 @@ TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFCo
526665
readBuf, 0, readThisLoop);
527666
payloadOutput.write(cipherData);
528667

529-
segmentSig = calculateSignature(cipherData, tdfObject.payloadKey, tdfConfig.segmentIntegrityAlgorithm);
668+
segmentSig = segmentIntegrity(cipherData, tdfObject.payloadKey, tdfConfig.segmentIntegrityAlgorithm);
530669
if (tdfConfig.hexEncodeRootAndSegmentHashes) {
531670
segmentSig = Hex.encodeHexString(segmentSig).getBytes(StandardCharsets.UTF_8);
532671
}
@@ -542,18 +681,17 @@ TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFCo
542681

543682
Manifest.RootSignature rootSignature = new Manifest.RootSignature();
544683

545-
byte[] rootSig = calculateSignature(aggregateHash.toByteArray(), tdfObject.payloadKey,
684+
byte[] rootSig = rootIntegrity(aggregateHash.toByteArray(), tdfObject.payloadKey,
546685
tdfConfig.integrityAlgorithm);
547686
byte[] encodedRootSig = tdfConfig.hexEncodeRootAndSegmentHashes
548687
? Hex.encodeHexString(rootSig).getBytes(StandardCharsets.UTF_8)
549688
: rootSig;
550689
rootSignature.signature = Base64.getEncoder().encodeToString(encodedRootSig);
551690

552-
String alg = kGmacIntegrityAlgorithm;
553-
if (tdfConfig.integrityAlgorithm == Config.IntegrityAlgorithm.HS256) {
554-
alg = kHmacIntegrityAlgorithm;
555-
}
556-
rootSignature.algorithm = alg;
691+
// Unconditional. createTDF refuses any other root algorithm before a byte is
692+
// written and rootIntegrity would refuse it again; selecting on tdfConfig here
693+
// would leave a path that emits alg="GMAC" should either check ever be relaxed.
694+
rootSignature.algorithm = kHmacIntegrityAlgorithm;
557695

558696
tdfObject.manifest.encryptionInformation.integrityInformation.rootSignature = rootSignature;
559697
tdfObject.manifest.encryptionInformation.integrityInformation.segmentSizeDefault = tdfConfig.defaultSegmentSize;
@@ -757,17 +895,21 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig)
757895
String rootSigValue;
758896
boolean isLegacyTdf = manifest.tdfVersion == null || manifest.tdfVersion.isEmpty();
759897
if (manifest.payload.isEncrypted) {
760-
Config.IntegrityAlgorithm sigAlg = Config.IntegrityAlgorithm.HS256;
761-
if (rootAlgorithm.compareToIgnoreCase(kGmacIntegrityAlgorithm) == 0) {
762-
sigAlg = Config.IntegrityAlgorithm.GMAC;
763-
}
898+
var sigAlg = rootIntegrityAlgorithmFromManifest(rootAlgorithm);
764899

765-
var sig = calculateSignature(aggregateHash.toByteArray(), payloadKey, sigAlg);
900+
var sig = rootIntegrity(aggregateHash.toByteArray(), payloadKey, sigAlg);
766901
if (isLegacyTdf) {
767902
sig = Hex.encodeHexString(sig).getBytes();
768903
}
769904
rootSigValue = Base64.getEncoder().encodeToString(sig);
770905
} else {
906+
// KNOWN GAP, untouched by this change and tracked separately: this branch is a
907+
// bare SHA-256, so it authenticates nothing. `payload.isEncrypted` is itself
908+
// unauthenticated manifest data, so flipping it to false selects a keyless
909+
// verification path that anyone can satisfy -- and readPayload then emits the
910+
// segment bytes without decrypting them. Same downgrade shape as a GMAC root.
911+
// Left alone here because closing it means deciding whether unencrypted TDFs
912+
// are supported at all, which is a wider question than this fix.
771913
MessageDigest digest;
772914
try {
773915
digest = MessageDigest.getInstance("SHA-256");

0 commit comments

Comments
 (0)