Skip to content

streamingaead: add NewDecryptingReaderAt for random-access decryption - #44

Open
iainmcgin wants to merge 3 commits into
tink-crypto:mainfrom
iainmcgin:streamingaead-readerat-noncebased
Open

streamingaead: add NewDecryptingReaderAt for random-access decryption#44
iainmcgin wants to merge 3 commits into
tink-crypto:mainfrom
iainmcgin:streamingaead-readerat-noncebased

Conversation

@iainmcgin

@iainmcgin iainmcgin commented Apr 1, 2026

Copy link
Copy Markdown

Summary

Adds streamingaead.NewDecryptingReaderAt, the Go counterpart to Java's StreamingAead.newSeekableDecryptingChannel. This enables random-access decryption of streaming AEAD ciphertexts — for example, reading an arbitrary byte range from a large encrypted blob in object storage without downloading and decrypting the whole stream.

API

package streamingaead

// SizedReaderAt is an io.ReaderAt that reports its size. *bytes.Reader and
// *io.SectionReader implement it.
type SizedReaderAt interface {
    io.ReaderAt
    Size() int64
}

func NewDecryptingReaderAt(h *keyset.Handle, ct SizedReaderAt, aad []byte) (*DecryptingReaderAt, error)

func (r *DecryptingReaderAt) ReadAt(p []byte, off int64) (int, error)
func (r *DecryptingReaderAt) Size() int64
func (r *DecryptingReaderAt) CiphertextRange(ptOff, ptLen int64) (off, n int64)

CiphertextRange returns the underlying ciphertext byte range that ReadAt will request for a given plaintext range, so callers backed by remote storage can fetch one contiguous range up front instead of one round-trip per segment.

Implementation notes

  • The segment index for a plaintext offset and the per-segment ciphertext bounds are computed using the same arithmetic as StreamingAeadSeekableDecryptingChannel in tink-java.
  • ReadAt is safe for concurrent use and executes in parallel: all per-stream state (derived key, nonce prefix, segment geometry) is immutable after construction, the most-recently-decrypted segment is cached behind an atomic.Pointer, and segment decryption allocates fresh working buffers per call.
  • To make the AES-CTR-HMAC segment decrypter safe for concurrent use, it now stores the HMAC key and hash constructor and creates a fresh hmac.New per segment instead of reusing a single hash.Hash. This also affects the existing NewDecryptingReader path; the per-segment cost is negligible relative to MAC computation over the segment.
  • For multi-key keysets the matching key is selected lazily on the first ReadAt, so reading at a large offset does not force a fetch of segment 0.

Testing

  • Correctness: full reads, boundary/spanning offsets, exponential (offset, len) grid, random-access fuzz, both AES-GCM-HKDF and AES-CTR-HMAC, multiple key templates and segment sizes, multi-key keysets.
  • Adversarial (TestReaderAtModifiedCiphertext, mirroring the existing TestAESCTRHMACModifiedCiphertext): truncation at every 8 bytes (each asserted to fail a full-plaintext read), appended garbage, every-byte bit flip, segment deletion, segment duplication, AAD modification — each verified at many offsets to assert no read ever returns corrupted plaintext.
  • SizedReaderAt reporting an incorrect size (TestDecryptingReaderAtWrongSize).
  • Concurrency: parallel ReadAt from multiple goroutines under -race, plus a RunParallel benchmark.

Truncation

Random access authenticates what it reads, so a read of a leading segment succeeds whether or not later segments are present, and Size() is derived from the ciphertext length rather than from anything authenticated. What must not happen is for a caller who reads the whole plaintext to accept a truncated stream, and that is what the last-segment nonce flag establishes.

A read that reaches the end of the plaintext therefore decrypts the final segment even when that segment carries no plaintext bytes, which is the case whenever the plaintext length is a multiple of the segment size. Without this, a stream cut to whole segments plus a tag would report a smaller Size() and return that many correct bytes with no error, while the sequential Reader rejects the same input. Reading the whole plaintext, or a single byte at Size()-1, is thus sufficient to establish that the size is genuine. An empty plaintext has no read that can reach its final segment, so that one is decrypted when the reader is constructed.

TestReaderAtModifiedCiphertext/truncate asserts this over every truncation length: for each, reading the whole plaintext must fail.

Commits

This PR is structured as three reviewable commits (noncebased core → subtle primitive wiring → keyset wrapper); each builds and passes tests independently.

@google-cla

google-cla Bot commented Apr 1, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@iainmcgin
iainmcgin force-pushed the streamingaead-readerat-noncebased branch from 7fc890c to 87ff222 Compare July 20, 2026 20:52
ReaderAt provides io.ReaderAt-style random access to the plaintext of a
nonce-based streaming AEAD ciphertext, given the total ciphertext size.
The segment containing a requested plaintext offset is computed
arithmetically, fetched via the underlying io.ReaderAt, decrypted, and
cached. A CiphertextRange helper exposes the underlying byte range that
will be touched for a given plaintext range, allowing callers backed by
remote storage to prefetch.

This mirrors the segment math used by Tink-Java's
StreamingAeadSeekableDecryptingChannel.
…ES-CTR-HMAC

Each primitive reads the stream header at offset 0 of the supplied
io.ReaderAt, derives the per-stream key, and constructs a
noncebased.ReaderAt over the segment ciphertext that follows the header.
The returned reader supports random access to plaintext bytes.
NewDecryptingReaderAt accepts a keyset handle, a SizedReaderAt over the
full ciphertext, and associated data, and returns a DecryptingReaderAt
that exposes io.ReaderAt, Size, and CiphertextRange over the plaintext.

For keysets with multiple keys the correct key is selected lazily on the
first ReadAt: each key whose header parameters match the stream header
is tried in keyset order until one authenticates the requested segment.

This is the Go counterpart to Tink-Java's
StreamingAead.newSeekableDecryptingChannel.
@iainmcgin
iainmcgin force-pushed the streamingaead-readerat-noncebased branch from 87ff222 to 67c5eea Compare July 20, 2026 21:16
@iainmcgin
iainmcgin marked this pull request as ready for review July 20, 2026 21:16
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