FiloSottile/age · error

failed to read final chunk: %w

Error message

failed to read final chunk: %w

What it means

NewDecryptReaderAt validates an encrypted payload by reading and authenticating the final chunk via ReadAt. This error wraps a failure to read those ciphertext bytes from the io.ReaderAt: the read returned an error, or fewer bytes than expected (io.ErrUnexpectedEOF from readFullAt). The size was accepted by EncryptedChunkCount, but the source cannot actually supply the final chunk's bytes.

Source

Thrown at internal/stream/stream.go:396

}

func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReaderAt, error) {
	aead, err := chacha20poly1305.New(key)
	if err != nil {
		return nil, err
	}

	// Check that size is valid by decrypting the final chunk.
	chunks, err := EncryptedChunkCount(size)
	if err != nil {
		return nil, err
	}
	finalChunkIndex := chunks - 1
	finalChunkOff := finalChunkIndex * encChunkSize
	finalChunkSize := size - finalChunkOff
	finalChunk := make([]byte, finalChunkSize)
	if err := readFullAt(src, finalChunk, finalChunkOff); err != nil {
		return nil, fmt.Errorf("failed to read final chunk: %w", err)
	}
	nonce := nonceForChunk(finalChunkIndex)
	setLastChunkFlag(nonce)
	plaintext, err := aead.Open(finalChunk[:0], nonce[:], finalChunk, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to decrypt and authenticate final chunk: %w", err)
	}
	cache := &cachedChunk{off: finalChunkOff, data: plaintext}

	plaintextSize := size - chunks*chacha20poly1305.Overhead
	r := &DecryptReaderAt{a: aead, src: src, size: plaintextSize, chunks: chunks}
	r.cache.Store(cache)
	return r, nil
}

func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
	if off < 0 || off > r.size {
		return 0, fmt.Errorf("offset out of range [0:%d]: %d", r.size, off)

View on GitHub (pinned to b74dce4cdb)

Solutions

  1. Verify src actually contains at least size bytes: bytes.NewReader(data) with len(data) >= size, or the file's real size matches the passed size.
  2. Ensure the size argument and the src refer to the same ciphertext (same file, not a stale stat).
  3. If you implemented io.ReaderAt, confirm it returns io.EOF when reads go past the end and (n, err) per the interface contract.
  4. Re-obtain the ciphertext if it was truncated during transfer; NewDecryptReaderAt deliberately reads the tail to detect this early.

Example fix

// before
r, err := stream.NewDecryptReaderAt(key, f, declaredSize) // declaredSize > actual file size
// after
info, _ := f.Stat()
r, err := stream.NewDecryptReaderAt(key, f, info.Size())
Defensive patterns

Strategy: validation

Validate before calling

info, err := os.Stat(path)
if err != nil { return err }
if info.Size() != declaredSize {
    return fmt.Errorf("size mismatch: declared %d, actual %d", declaredSize, info.Size())
}

Try / catch

r, err := stream.NewDecryptReaderAt(key, src, size)
if err != nil {
    if errors.Is(err, io.ErrUnexpectedEOF) {
        return fmt.Errorf("ciphertext shorter than declared size %d", size)
    }
    return err
}

Prevention

When it happens

Trigger: NewDecryptReaderAt(key, src, size) where src.ReadAt at offset (chunks-1)*65552 fails or short-reads: the ReaderAt is shorter than size; the implementation returns a spurious error; or the implementation violates the io.ReaderAt contract.

Common situations: Passing a size larger than the actual data backing the ReaderAt (e.g. stat size from a different file, or size includes metadata); mmap-backed or bytes.Reader sources that were truncated; a custom ReaderAt that returns nil error on short read.

Related errors


AI-assisted analysis of FiloSottile/age@b74dce4cdb (2026-08-31). Data as JSON: /api/errors/875a771df128d662. Report an issue: GitHub.