feat(sdk,cmdline): settable segment and root integrity algorithms (DSPX-4736) - #400
dmihalcik-virtru wants to merge 1 commit into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (4)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe SDK adds root and segment integrity algorithm configuration. The ChangesIntegrity algorithm configuration
Priority: ⬇️ Low Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant EncryptCommand
participant IntegrityAlgorithmConverter
participant Config
participant TDFConfig
EncryptCommand->>IntegrityAlgorithmConverter: Convert option value
IntegrityAlgorithmConverter-->>EncryptCommand: Return IntegrityAlgorithm
EncryptCommand->>Config: Build integrity configuration
Config-->>EncryptCommand: Return TDFConfig option
EncryptCommand->>TDFConfig: Apply configuration before SDK creation
Suggested reviewers: Merge Risk: ⚪ Minimal · up to The new integrity-algorithm controls validate and apply supported values consistently, with no unresolved merge-blocking risk identified. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. A rabbit trims the algorithm's name Comment |
…PX-4736)
Adds explicit controls for the two integrity algorithms a ZTDF writer picks,
and makes an unsupported choice fail loudly instead of being silently accepted.
The two are not interchangeable:
- A segment hash is computed over ciphertext AES-GCM actually produced, so
"GMAC" there means reading back a real authentication tag. Both HS256 and
GMAC are valid.
- The root signature covers the aggregate of the segment hashes, which never
passes through AES-GCM. There is no tag to recover, so HS256 is the only
meaningful choice.
sdk:
- Config.withSegmentIntegrityAlgorithm(HS256|GMAC)
- Config.withRootIntegrityAlgorithm(HS256), which throws
IllegalArgumentException for anything else. The option exists so callers
can state the choice explicitly and a CLI can surface the refusal, not to
widen it.
cmdline:
- `encrypt --root-integrity-algorithm` and `--segment-integrity-algorithm`,
with a case-insensitive converter so the flags behave like the equivalents
in the other OpenTDF CLIs, and ${COMPLETION-CANDIDATES} in the description
so the accepted values appear in help.
- The root algorithm is validated before buildSDK(), so an unsupported value
is reported as a picocli usage error (exit 2) rather than after a platform
round trip.
- A test pins the literal flag names into the rendered encrypt help, because
the cross-SDK xtest feature detectors find them by grepping it.
This is the control surface for evaluating DSPX-4703. It does not itself change
how a manifest that already declares a GMAC root is verified on read.
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
chore: reduce verbosity of documentation
c51dab1 to
37d2f45
Compare
|
## Summary ### The bug 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 say nothing about its index, its neighbours, or how many segments exist — so segment-level integrity structurally 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 — the last segment hash. Manifest data compared against manifest data, with the payload key never used. ### Why this is not opt-in The algorithm was read from `rootSignature.alg` in the manifest, which is unauthenticated, and unknown values were *coerced* to HS256 rather than rejected. So an attacker **with no key** could take any 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. ### The fix * 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. * `segmentIntegrityAlgorithmFromManifest` allows both algorithms, because both are meaningful in that position, but still refuses an unrecognised name rather than defaulting it. A GMAC segment hash proves nothing on its own; what makes it trustworthy is that it is bracketed by two keyed checks — the HS256 root signature over the whole hash list, validated in `loadTDF` before any payload is read, and the real AES-GCM tag check under the payload key at decrypt time. The root has neither backstop, which is why the asymmetry between the two positions is structural rather than stylistic. * `createTDF` validates both configured algorithms before writing a byte, so the SDK cannot emit a file it would refuse to read, and cannot leave a partial TDF behind on a bad config. ### On the guard inside `rootIntegrity` `rootIntegrity` validates its own argument even though `createTDF` and the manifest resolver have both already checked. That is deliberate: the check is what makes the function safe to call, so it belongs with the function rather than only at today's call sites. It is genuinely redundant today, though, and the mutation results below say so plainly — with the resolver in place, a GMAC root cannot reach `rootIntegrity` through any public entry point. It is defence against a future edit, not against a current input. Because nothing else exercises it, this PR adds tests that call the guards directly; without those, a regression that reintroduced tag extraction inside `rootIntegrity` would leave the entire suite green. The two guards throw `IllegalArgumentException` rather than `SDK.TamperException`, matching how the config layer already reports bad caller-supplied values (`Config.withSegmentSize`). On a read they are unreachable, so if one ever does fire it means a bug in `TDF`, not a hostile file — it should escape loudly rather than arrive at callers wearing an exception type they routinely catch. ## Compatibility **No well-formed file is affected.** Every golden TDF in the cross-SDK corpus is `rootSignature.alg = "HS256"` with `segmentHashAlg = "GMAC"`. Nothing in the ecosystem emits a GMAC root: * **Go SDK** — uses position-specific types. `RootIntegrityAlg` admits only `RootHS256`; the GMAC constant is deprecated there and annotated as not a legal root algorithm. * **JS SDK** — `type RootIntegrityAlgorithm = 'HS256'`, so it is not expressible. * **Java SDK** — `Config` has never exposed a setter for the root algorithm, and `newTDFConfig` defaults it to HS256. The one way to have produced such a file is a caller who reached past the builder and assigned `TDFConfig.integrityAlgorithm = GMAC` on the public field. Those callers now get an `IllegalArgumentException` from `createTDF` instead of silently writing an unverifiable file. **There is no escape hatch, by design** — no flag, no compatibility mode, no target-mode exemption. A file this rejects is one no honest writer produces, and accepting it would restore the vulnerability for every reader. Anyone holding such a file must re-encrypt it. The legacy hex-encoded root path is still supported, and is covered by a test that confirms it did not become a way around the allowlist. ## Test plan ```bash mvn -q compiler:compile compiler:testCompile -pl sdk,cmdline mvn -q surefire:test -pl sdk # 261 tests, 0 failures, 8 skipped mvn -q surefire:test -pl cmdline # 13 tests, 0 failures ``` > Note: `mvn test` runs `generateSources`, which shells out to `buf` against the BSR and is rate-limited. Invoking the plugin goals directly, as above, skips that phase. Note also that `-pl sdk` on a *lifecycle phase* trips the `ReactorModuleConvergence` enforcer rule, since the parent is then outside the reactor; direct goal invocation avoids that too. `TDFRootSignatureTest`: **30 tests, 0 failures**, in two groups. **25 behavioural tests** drive the public API end to end — truncation, reordering, GMAC in several casings, an unknown root algorithm that must not be coerced, an unknown segment algorithm, a legacy hex-encoded GMAC root, a config-level GMAC root, plus controls that must keep passing for reasons unrelated to this change. **5 guard tests** call `rootIntegrity` / `segmentIntegrity` and their argument checks directly. These reference methods this PR introduces, so unlike the group above they cannot be run against `main`. ### Verified by reverting, and by mutation With this commit's `TDF.java` reverted to `main` and the test file otherwise untouched, the 25 behavioural tests **compile unmodified** and **9 fail** across 7 methods — the vulnerability is live and reproducible, and the tests are not merely asserting the shape of the new code: ``` gmacRootIsRejected gmacRootIsRejectedInAnyCasing[1..3] gmacDowngradeWithTruncatedSegmentsIsRejected gmacDowngradeWithReorderedSegmentsIsRejected unknownRootAlgorithmIsRejected unknownSegmentAlgorithmIsRejected # wrong type on main: SegmentSignatureMismatch, by coercion accident createTdfRefusesAGmacRootSetDirectlyOnTheConfig ``` The other 16 stay green, so the suite is discriminating rather than strict. Mutating one layer at a time locates which check is load-bearing: | variant | `TDFRootSignatureTest` | | --- | --- | | baseline | 30 pass | | resolver coerces unknown → HS256, `rootIntegrity` intact | **3 fail** | | `rootIntegrity` accepts GMAC again, resolver intact | **1 fail** (the new direct guard test) | | both reverted (`main`) | **9 of 25 fail** | The resolver is the layer that actually stops a hostile file. The `rootIntegrity` guard is caught only by the test written for it — which is the argument for having written it, and an honest statement of what that check is and is not doing. ## Related * Cross-SDK coverage and the attack corpus: `spec/DSPX-4703.md` in `opentdf/tests`, and opentdf/tests#594 * Write-side controls: #400 (DSPX-4736) — independent, no longer stacked; this PR targets `main` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Strengthened integrity verification for encrypted segments and TDF root signatures. * Rejected unknown, missing, unsupported, or invalid integrity algorithm declarations. * Prevented GMAC-based root-signature downgrades during TDF creation and verification. * Improved detection of modified or tampered TDF content, including legacy signature formats. * Added validation for configured integrity algorithms while preserving support for valid HS256 root signatures. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
|
these should be fixed values for now |



Summary
Adds explicit controls for the two integrity algorithms a ZTDF writer picks, and makes an unsupported choice fail loudly instead of being silently accepted.
Rationale
The two algorithms are not interchangeable, and the API previously did not say so:
HS256,GMACHS256onlysdkConfig.withSegmentIntegrityAlgorithm(HS256|GMAC)Config.withRootIntegrityAlgorithm(HS256)— throwsIllegalArgumentExceptionfor anything else. The option exists so callers can state the choice explicitly and a CLI can surface the refusal, not to widen it. It is self-contained and does not reach intoTDF.cmdlineencrypt --root-integrity-algorithmand--segment-integrity-algorithm, with a case-insensitiveITypeConverterso the flags behave like the equivalents in the other OpenTDF CLIs, and${COMPLETION-CANDIDATES}so the accepted values render in help.buildSDK(), so an unsupported value is a picocli usage error (exit 2) rather than a failure after a platform round trip.encryptHelp_advertisesIntegrityAlgorithmFlagspins the literal flag names into the rendered encrypt help, because the cross-SDK xtest feature detectors find them by grepping it. Merging this flips theintegrity_algs/gmac_root_rejectedgates on and stops those cells silently skipping.Scope
This is the control surface for evaluating DSPX-4703, deliberately separated from the fix. It changes what a writer may choose; it does not change how a manifest that already declares a GMAC root is verified on read. That is the stacked follow-up.
Splitting it this way means the xtest exploit cases can run against this branch for a live "before" baseline, then against the fix branch to watch them go red → green.
Public API notes
Two new static factory methods on
Config. Nothing existing changes shape or default behaviour: the segment default staysGMAC, the root default staysHS256.Test plan
CommandTest: 26 tests, 0 failures — run with this commit asHEADand the DSPX-4703 fix commit entirely absent, to confirm the branch stands alone.Coverage added: GMAC rejected for root in three casings, HS256 accepted in two casings (and reaching the later missing-credentials error, proving it was not rejected), both algorithms accepted for segment, an unknown algorithm rejected, converter case-insensitivity, and the help-rendering assertion.
Summary by CodeRabbit
New Features
Bug Fixes
Cross-SDK coverage lives in opentdf/tests#594.