chore(tests): failing repros for the #1031 review findings (DSPX-4703) - #1039
Draft
pflynn-virtru wants to merge 2 commits into
Draft
pflynn-virtru wants to merge 2 commits into
pflynn-virtru wants to merge 2 commits into
Conversation
…t (DSPX-4703) A segment's `GMAC` hash is the AES-GCM tag the AEAD just computed over that segment's ciphertext, so reading the trailing 16 bytes back out is legitimate. The root signature covers the aggregate hash — a concatenation of segment hashes that AES-GCM never processed — so applying the same trailing-bytes extraction there returns a keyless copy of the last segment hash rather than anything authenticated. Because `rootSignature.alg` is read from the unauthenticated manifest and unknown values were coerced to HS256, an attacker with no key could rewrite `alg` to `GMAC`, recompute the "signature" from the segment hashes they were handing over anyway, and then truncate, reorder, or duplicate segments undetected. AEAD tags bind no ordering, index, or count, so the ordered segment list is exactly what only the root signature protects. Split the one overloaded `getSignature` into four functions that say what they authenticate — `segmentIntegrity`/`rootIntegrity` and their 4.2.2 hex-then-base64 counterparts — so the aggregate hash no longer has a code path that can read a tag out of it. `segmentIntegrity` accepts HS256 and GMAC; `rootIntegrity` accepts only HS256. On read, `asRootIntegrityAlgorithm` fails closed: anything other than HS256, in any casing, raises an `IntegrityError` rather than being coerced. `asSegmentIntegrityAlgorithm` stays permissive about casing, since existing writers emit both spellings. Manifest types widen `rootSignature.alg` and `segmentHashAlg` to plain strings to reflect that they are attacker-controlled until validated. Also stop `streamToBuffer` from laundering the error it now raises. It drained the plaintext with `new Response(stream).arrayBuffer()`, and Chrome replaces *any* error raised while it pulls a Response body with a bare `TypeError: Failed to fetch`. So in the browser — and only there — a tampered payload arrived at the caller indistinguishable from a dropped connection, which is precisely the distinction this commit exists to make: the first is an attack and the second is a retry. Draining with a reader instead preserves the `IntegrityError`. `toString` went through `Response.text()` and had the same problem; it now buffers first. Verified against the previous commit as a baseline: with this commit's tests run on the unfixed code, 14 pass and 9 fail — every case under `controls` stays green while all 8 `exploit: GMAC root downgrade` cases and the lowercase-`segmentHashAlg` case fail. The `streamToBuffer` fix is pinned the same way: reverting it alone turns 409 karma passes into 6 failures — the two payload-tampering cases plus the four error-propagation cases in `tests/mocha/unit/decorated-readable-stream.spec.ts` — while Node stays green, since the masking only ever reproduced in Chrome. With both fixes the whole suite is green: 409 passing / 6 pending under mocha, 409 under karma, and 253 under web-test-runner. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
|
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 |
Red phase only. Every test here reproduces a defect found reviewing #1031; none of them are fixed, so `cd lib && npm test` fails on this branch by design. Each new describe carries controls that pass, so a failure is attributable to the defect and not to the harness. Runtime failures (16 under mocha, 17 under karma): - 4.2.2 + HS256 segments write an archive nothing can read. The legacy writer HMACs a lossy UTF-8 round-trip of the ciphertext; the reader HMACs the raw bytes. Newly reachable since #1030 made the algorithm settable. - A tampered file that fails in more than one segment leaves an unhandled rejection even when the caller catches the error, because only the first failed chunk is ever awaited. - A CryptoService whose hmac returns a pooled view (the obvious Node implementation) writes 4.2.2 files that fail their own read: the writer hex-encodes sig.buffer, the reader hex-encodes the view. - Malformed manifests surface as bare TypeError / InvalidCharacterError rather than TdfError, on the same read path this PR hardens. - A GMAC-rooted file still costs a full KAS rewrap before it is refused. - Assertions -- including attacker-supplied JWT headers that name their own verification key -- are interpreted before the root signature is checked. - Unknown-but-not-forgeable root algorithms are reported as tampering rather than as unsupported. (This contradicts an assertion in root-signature.spec.ts, which is the point: both cannot hold.) - streamToBuffer returns an empty or all-zero buffer, reported as success, for any chunk that is not a Uint8Array; and decrypt no longer accepts the stream sources fromSource still does. - seekable.ts still drains {type:'stream'} through Response, so the modern OpenTDF read path launders an IntegrityError into "Failed to fetch". Red in the karma run only, which is where the masking happens. - Two controls added to root-signature.spec.ts show the file's own fixtures are inert: the forged 4.2.2 GMAC signature is not one any 4.2.2 reader would compare against, and reverseSegments silently drops bytes on a payload that is not an exact multiple of the segment size. Type-level failure: - tests/types holds @ts-expect-error assertions that the manifest's alg fields carry the invariant tdf.ts documents. `alg: RootIntegrityAlgorithm | string` collapses to string, so they do not. Checked by a new `npm run test:types`, run after the suite so it cannot mask the runtime failures, and excluded from the build's tsconfig for the same reason. New specs share tests/mocha/helpers/tdf-fixtures.ts rather than adding a third copy of the scaffolding; folding the existing two specs into it is left alone here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Paul Flynn <pflynn-virtru@users.noreply.github.com>
pflynn-virtru
force-pushed
the
DSPX-4703-repro-tests
branch
from
September 14, 2026 21:34
56a15c6 to
33b90c4
Compare
This branch has not been deployed
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.
What
Red-phase-only test branch for the defects found reviewing #1031. Nothing is fixed here —
cd lib && npm testis expected to fail, and that is the deliverable. Draft, because it is not mergeable until the fixes land.Every new
describeships controls that pass, so each failure is attributable to the defect rather than to the harness.Why
Each finding gets a test that fails now and passes once the defect is fixed, so the fixes can be reviewed against evidence instead of argument. Three findings from the review are deliberately not here — see the bottom.
Failures
16 under mocha, 17 under karma, plus 1 type-level.
4.2.2 files with HS256 segments→ round-tripssegmentIntegrityVersion422HMACs a lossy UTF-8 round-trip of the ciphertext; the reader HMACs raw bytes. Writes archives nothing can open. Newly reachable since #1030 madesegmentIntegrityAlgorithmsettable.decrypt error propagation→ several segments failIntegrityErrorand Node still tears the process down.a CryptoService whose hmac returns a pooled view→ 4.2.2sig.buffer, reader hex-encodes the view. A NodeCryptoServicereturning a pooledBufferwrites files that fail their own read and leak heap into the manifest.manifest validation(×3)segments, missingrootSignature, non-base64 hash → bareTypeError/InvalidCharacterError, notTdfError. All land beforeasRootIntegrityAlgorithmcan fail closed.read path ordering→ rejected without a rewrapread path ordering→ assertions before root signaturejwk/x5c, are parsed while the manifest is still unauthenticated.root algorithm rejection taxonomy(×2)HS384/HS512are reported as tampering rather than unsupported. Contradictsroot-signature.spec.ts→ "rejects an unknown root algorithm rather than defaulting to HS256", which is the point: both cannot hold, and this branch says that assertion is the wrong one.streamToBufferrejects non-Uint8Arraychunks (×3)Responsethrew.{type: "stream"} sources→ decrypt accepts what fromSource doesdecryptno longer takes aBlobsource;fromSourcestill does.{type: "stream"} sources→ fromSource propagates the original errorseekable.tsstill drains throughResponse, soOpenTDF.open()/read()launders anIntegrityErrorintoTypeError: Failed to fetch— the exact masking #1031 removed from the legacy surface. Passes under mocha; Node has nothing to mask.root-signature.spec.ts→ forged GMAC signature controlbase64(raw); a 4.2.2 reader comparesbase64(hex(...))— 24 chars vs 44. The "rejects a GMAC root downgrade" test passes on unfixed code, so it pins nothing.root-signature.spec.ts→ reverseSegments is a permutationstartreads from the end). Safe only because the fixture is exactly 4×1024.npm run test:typesalg: RootIntegrityAlgorithm | stringcollapses tostring, so the invarianttdf.tsdocuments ("cannot even be typed as GMAC") does not hold on the one field an attacker controls.Infrastructure notes
lib/tests/types/holds deliberate type-level failures. It is excluded from the build's tsconfig and checked by a newnpm run test:types, chained aftertest:with-server— otherwise a compile error would abort the build and hide all 16 runtime failures.lib/tests/mocha/helpers/tdf-fixtures.tsrather than adding a third copy of the scaffoldingroot-signature.spec.tsandintegrity-algorithms.spec.tseach carry. Folding those two in is left alone.eslint.config.mjsnames the extra tsconfig so the parser can placetests/types.Not covered
Three review findings have no determinate failing test, and guessing at one would pin a requirement nobody has chosen:
segmentHashAlgis now accepted by the reader. Whether to re-tighten or to correct the now-false comment atintegrity-algorithms.spec.ts:156is a decision, not a defect.IntegrityAlgorithmstill re-exported).How to test
What CI actually shows.
npm testchains its stages with&&, so the mocha failure stops the run there:CI therefore reports 424 passing / 16 failing from mocha and nothing further. The karma-only failure (
fromSourcemasking, finding 11) and the threeTS2578type errors (finding 6) are real and reproduce locally, but are unreachable in CI until the mocha failures are fixed — at which point they surface on their own, one stage at a time.To see them now, run the stages individually:
The workflow itself cannot be adjusted from this branch:
build-and-test.yamlcallsreusable_build-and-test.yaml@main, so CI always runs main's copy.🤖 Generated with Claude Code