Skip to content

chore(tests): failing repros for the #1031 review findings (DSPX-4703) - #1039

Draft
pflynn-virtru wants to merge 2 commits into
mainfrom
DSPX-4703-repro-tests
Draft

pflynn-virtru wants to merge 2 commits into
mainfrom
DSPX-4703-repro-tests

Conversation

@pflynn-virtru

@pflynn-virtru pflynn-virtru commented Sep 14, 2026

Copy link
Copy Markdown
Member

What

Red-phase-only test branch for the defects found reviewing #1031. Nothing is fixed herecd lib && npm test is expected to fail, and that is the deliverable. Draft, because it is not mergeable until the fixes land.

Every new describe ships 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.

Test Defect
4.2.2 files with HS256 segments → round-trips segmentIntegrityVersion422 HMACs a lossy UTF-8 round-trip of the ciphertext; the reader HMACs raw bytes. Writes archives nothing can open. Newly reachable since #1030 made segmentIntegrityAlgorithm settable.
decrypt error propagation → several segments fail Only the first failed chunk is awaited; the rest reject into the void. The caller catches the IntegrityError and Node still tears the process down.
a CryptoService whose hmac returns a pooled view → 4.2.2 Writer hex-encodes sig.buffer, reader hex-encodes the view. A Node CryptoService returning a pooled Buffer writes files that fail their own read and leak heap into the manifest.
manifest validation (×3) Missing segments, missing rootSignature, non-base64 hash → bare TypeError / InvalidCharacterError, not TdfError. All land before asRootIntegrityAlgorithm can fail closed.
read path ordering → rejected without a rewrap A GMAC-rooted file costs a full KAS rewrap — policy decision, audit record, DEK release — before it is refused.
read path ordering → assertions before root signature Attacker-supplied JWT headers, which name their own verification key via jwk/x5c, are parsed while the manifest is still unauthenticated.
root algorithm rejection taxonomy (×2) HS384/HS512 are reported as tampering rather than unsupported. Contradicts root-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.
streamToBuffer rejects non-Uint8Array chunks (×3) Returns an empty or all-zero buffer reported as success. Response threw.
{type: "stream"} sources → decrypt accepts what fromSource does decrypt no longer takes a Blob source; fromSource still does.
{type: "stream"} sources → fromSource propagates the original error Karma only. seekable.ts still drains through Response, so OpenTDF.open()/read() launders an IntegrityError into TypeError: 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 control The legacy fixture emits base64(raw); a 4.2.2 reader compares base64(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 permutation Silently drops bytes on a payload that is not an exact multiple of the segment size (negative start reads from the end). Safe only because the fixture is exactly 4×1024.
npm run test:types alg: RootIntegrityAlgorithm | string collapses to string, so the invariant tdf.ts documents ("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 new npm run test:types, chained after test:with-server — otherwise a compile error would abort the build and hide all 16 runtime failures.
  • New specs share lib/tests/mocha/helpers/tdf-fixtures.ts rather than adding a third copy of the scaffolding root-signature.spec.ts and integrity-algorithms.spec.ts each carry. Folding those two in is left alone.
  • eslint.config.mjs names the extra tsconfig so the parser can place tests/types.

Not covered

Three review findings have no determinate failing test, and guessing at one would pin a requirement nobody has chosen:

  • Lowercase segmentHashAlg is now accepted by the reader. Whether to re-tighten or to correct the now-false comment at integrity-algorithms.spec.ts:156 is a decision, not a defect.
  • Round-trip tests assert on the live in-memory manifest. Latent fragility; passes today for incidental reasons.
  • Duplication cleanup (fourth copy of the concat helper, four spellings of the same algorithm guard, deprecated IntegrityAlgorithm still re-exported).

How to test

cd lib && npm test

What CI actually shows. npm test chains its stages with &&, so the mocha failure stops the run there:

npm run build && npm run test:with-server && npm run test:types
  test:with-server -> npm run test:mocha && npm run test:wtr && npm run test:browser && ...

CI therefore reports 424 passing / 16 failing from mocha and nothing further. The karma-only failure (fromSource masking, finding 11) and the three TS2578 type 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:

cd lib && npm run build
node dist/web/tests/server.js &
npx mocha 'dist/web/tests/mocha/**/*.spec.js'   # 424 passing, 16 failing
npm run test:browser                            # 423 passing, 17 failing
npm run test:types                              # 3x TS2578

The workflow itself cannot be adjusted from this branch: build-and-test.yaml calls reusable_build-and-test.yaml@main, so CI always runs main's copy.

🤖 Generated with Claude Code

…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>
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pflynn-virtru pflynn-virtru changed the title test(sdk): failing repros for the #1031 review findings (DSPX-4703) chore(tests): failing repros for the #1031 review findings (DSPX-4703) Sep 14, 2026
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>
Base automatically changed from DSPX-4703-reject-gmac-root to main September 17, 2026 14:29

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants