tink-crypto / tink-crypto/tink-java

StreamingAeadSeekableDecryptingChannel.read() never returns -1 — infinite CPU spin / hang on empty or truncated ciphertexts

Open
#79 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Java
Stars
305
Forks
42
PR merge metrics
No merged PRs in 30d

Description

tink-java: StreamingAeadSeekableDecryptingChannel.read() never returns -1 — infinite CPU spin / hang on empty or truncated ciphertexts

Repo: tink-crypto/tink-java (OT0)

Summary

StreamingAeadSeekableDecryptingChannel (returned by the public
StreamingAead.newSeekableDecryptingChannel API) violates the
SeekableByteChannel contract: read(ByteBuffer) never returns -1 at
end-of-stream. Callers using the universal idiom spin forever burning CPU, and
Channels.newInputStream(ch).readAllBytes() / transferTo never return.

Verified against HEAD (810d752), PoC output:

valid empty-plaintext ciphertext: 56 bytes
HANG: valid-empty (seg 64): read() returned 0 one-million times (11 ms of CPU spin), never -1
HANG: valid-empty (seg 64): Channels.newInputStream(...).readAllBytes() did not return within 3 s
HANG: truncated-56B (seg 64): read() returned 0 one-million times, never -1

Affects both AES-GCM-HKDF and legacy AES-CTR-HMAC (shared plumbing).

Root cause

read() returns 0 whenever read == 0 && !reachedEnd()
(StreamingAeadSeekableDecryptingChannel.java). reachedEnd() (line 282) requires
isCurrentSegmentDecrypted && currentSegmentNr == numberOfSegments-1 && plaintextSegment.remaining() == 0. When plaintextSize == 0 — which happens for a
valid empty-plaintext ciphertext, and for attacker-truncated ciphertexts whose
computed size fills complete segments — the fill loop
while (dst.remaining() > 0 && plaintextPosition < plaintextSize) never runs, no
segment is ever decrypted, so reachedEnd() is permanently false and read() loops
on 0 forever.

Note the impact split:

  • No attacker needed: encrypt an empty stream with the library itself, then read
    it through the seekable channel — the service thread hangs. A valid
    round-trip that hangs is a correctness bug on its own.
  • Attacker-controlled: decrypting an untrusted/truncated file via
    newSeekableDecryptingChannel hangs the thread (truncation scan of a 70-byte
    plaintext hangs at offsets 56/80/144 for segment size 64).

tink-go's reader handles the identical 56-byte ciphertext correctly (io.ReadAll
terminates — verified), so this is Java-specific. The existing test suite misses it
because StreamingTestUtil allocates plaintext.length-sized buffers and never
reads to EOF.

Fix direction

When the fill loop makes no progress and plaintextPosition >= plaintextSize (or
ciphertext is exhausted), decrypt the final (possibly empty) segment so
reachedEnd() becomes true and read() returns -1. Reproducer and a table-driven
regression test (empty, 0/1/±tag-boundary, truncated lengths) are ready to send.

Verified against HEAD (810d752). Reproducer harness output above was produced with the AES-GCM-HKDF keyset at segment size 64; AES-CTR-HMAC shares the plumbing and behaves identically.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in StreamingAeadSeekableDecryptingChannel.java, focusing on read(), reachedEnd(), and the fill loop; review StreamingTestUtil to understand why EOF is not covered. Add regression coverage for empty, boundary, and truncated ciphertexts across AES-GCM-HKDF and AES-CTR-HMAC, and verify reads return -1 rather than repeatedly returning 0.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
cryptography
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.