Skip to content

feat(sdk): plaintext byte-range decrypt in the SDK and CLI (DSPX-5116) - #1093

Draft
dmihalcik-virtru wants to merge 9 commits into
mainfrom
DSPX-5116
Draft

dmihalcik-virtru wants to merge 9 commits into
mainfrom
DSPX-5116

Conversation

@dmihalcik-virtru

@dmihalcik-virtru dmihalcik-virtru commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Part of DSPX-4886. Builds on #1092 (DSPX-4654, bounded prefetch) and #1089 (DSPX-5160, local file reads without openAsBlob), both now on main.

What

  • SDK: decrypt now takes a range: { offset, length? } option, with the same semantics as Blob.slice. It's available on ReadOptions and DecryptParamsBuilder.withRange().
    • The range is planned before the KAS rewrap, and only the segments that cover it are fetched. Prefetch is bounded by those segments, and the stream is cancelled once the range has been returned.
    • Each fetched segment is still checked against its root-signed hash. The root signature and any assertions are verified before any plaintext is returned.
  • Errors: adds a new PlaintextRangeError (a subclass of ConfigurationError, exported from @opentdf/sdk). It covers both a malformed range and an offset past the end.
  • CLI: adds decrypt <file-or-url> --offset N --length M.
  • Fix: sliceAndDecrypt found segments within a batch using %, which is only correct for batches starting at segment 0. It now subtracts the batch's first offset. This is in its own commit, with a regression test.
  • Spec: spec/DSPX-5116.md.

Risk: crypto / integrity

Byte positions in a range read have to be authenticated, because an attacker can edit the manifest and payload, though not the root-signed hash list.

  • Uniform layout: segment i starts at i·S. Every decrypted segment's real length must equal its recorded size. At least one non-last segment is always decrypted, which confirms S.
  • Any other layout: the SDK decrypts from segment 0 and drops the plaintext before the offset.

Tests reproduce the attacks: shrinking a segment, inflating it and padding the payload, and relabelling the layout. The residual assumption and the full threat model are in the spec's Security section. Please review that part closely.

How to test

  • cd lib && npm test. The new tests are decrypt-range.spec.ts, unit/plaintext-range.spec.ts and the sliceAndDecrypt cases in unit/tdf.spec.ts. They cover offsets above 2^32 against a sparse archive, a counting chunker that checks only the planned bytes are read, tamper cases, and comparison with a full decrypt.
  • cd cli && npm test (source.spec.ts: flag parsing, URL detection, range errors as CLI errors), plus the opentdf.bats range cases. The large-file reads are covered by fix(cli): read file input by position instead of openAsBlob (DSPX-5160) #1089's file-chunker.spec.ts.
  • Manual: opentdf decrypt <tdf-or-url> --offset 1000 --length 100.

@coderabbitai

coderabbitai Bot commented Oct 16, 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
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

Base automatically changed from DSPX-4654-streams-prefetch to main October 16, 2026 18:14
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
sliceAndDecrypt located each segment in the batch buffer with
`encryptedOffset % slice[0].encryptedOffset`, which is only correct
for batches that start at segment 0. Spans that start elsewhere, which
plaintext range reads produce, misplaced later segments and failed the
segment hash check. Subtract the batch's first offset instead.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Add a `range: { offset, length? }` decrypt option (ReadOptions,
DecryptParams / DecryptParamsBuilder.withRange, DecryptConfiguration).
The range is mapped onto covering segments with a binary search over
the segments' plaintext sizes; only those segments are fetched, each is
still checked against its root-signed hash, and the output is trimmed
to exactly the requested bytes. Prefetch is bounded by the range, and
the batch size is capped at the number of covering segments.

Offsets and lengths must be non-negative safe integers, so offsets above
2^32 work. An omitted length reads to the end, a span past the end is
cut short, and an offset at or past the end is a ConfigurationError.
Unsatisfiable ranges, and files whose segments lack plaintext sizes, are
refused before the KAS rewrap. The root signature is still verified
before any plaintext is emitted.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
`decrypt <file-or-url> --offset <N> --length <M>` decrypts only that
plaintext span, via the SDK's range option. Values must be plain
decimal safe integers; --length omitted reads to the end, --offset
omitted starts at 0, and an offset at or past the end is an error.
Bad flags fail before any I/O.

Inputs to decrypt and inspect now go through a source chosen by
location: http(s) URLs use the SDK's remote source (fromUrl, HTTP Range
requests), and local files use positioned reads on a file handle
instead of fs.openAsBlob, whose size wraps at 4 GiB on some Node
releases. Encrypt input is unchanged.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…ntics (DSPX-5116)

Range positions no longer come from summing the manifest's recorded
segment sizes, which the root signature does not cover: a keyless editor
could shrink or inflate an earlier segment (padding the payload to
match) and move an authentic segment to another offset.

planPlaintextRange replaces plaintextRangeChunks:
- Uniform layout (all segments but the last record size S and the
  cipher's ciphertext size for S; the last is in (0, S]): segment i
  starts at i * S. At least one non-last segment is always decrypted,
  adding segment n-2 when only the last covers the range, and every
  decrypted segment's authenticated length must equal its recorded size
  (IntegrityError otherwise), which confirms S.
- Any other layout: decrypt from segment 0 and drop output before the
  offset. The output stream now cancels the batch stream once the range
  is delivered, so this stops reading after the range.

Add PlaintextRangeError (extends ConfigurationError, exported from
@opentdf/sdk) for malformed and unsatisfiable ranges. Follow Blob.slice:
an offset equal to the plaintext size returns empty output with no
payload reads, an empty plaintext reads as empty at offset 0, and only
an offset past the end is an error.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…emantics (DSPX-5116)

A PlaintextRangeError from the SDK (such as an offset past the end of
the plaintext) is printed as a CLI error and exits with status 1. An
offset equal to the plaintext size now prints nothing and succeeds.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…slice semantics (DSPX-5116)

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…SPX-5116)

#1089 landed a positioned-read file chunker that also detects files
changing mid-read and closes its handle. Use it for decrypt ranges and
drop this branch's duplicate fromLocalFile/tdfSource.

Signed-off-by: Dave Mihalcik <david.mihalcik@virtru.com>
@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant