FiloSottile/age · error
offset out of range [0:%d]: %d
Error message
offset out of range [0:%d]: %d
What it means
DecryptReaderAt.ReadAt rejects offsets outside the valid plaintext range [0, size]. This mirrors io.ReaderAt conventions: off must be >= 0 and <= plaintext size (off == size is allowed and yields io.EOF for non-empty reads). The library returns a descriptive range error rather than panicking or wrapping negative indices.
Source
Thrown at internal/stream/stream.go:414
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)
}
if len(p) == 0 {
return 0, nil
}
var cacheUpdate *cachedChunk
chunk := make([]byte, encChunkSize)
for len(p) > 0 && off < r.size {
chunkIndex := off / ChunkSize
chunkOff := chunkIndex * encChunkSize
encSize := r.size + r.chunks*chacha20poly1305.Overhead
chunkSize := min(encSize-chunkOff, encChunkSize)
cached := r.cache.Load()
var plaintext []byte
if cached != nil && cached.off == chunkOff {
plaintext = cached.data
cacheUpdate = nil
} else {View on GitHub (pinned to b74dce4cdb)
Solutions
- Clamp or validate off against the plaintext size returned by stream.PlaintextSize(encryptedSize) before calling ReadAt.
- Use io.NewSectionReader(r, 0, plaintextSize) to get automatic bounds enforcement and correct EOF semantics.
- Check you are not passing ciphertext offsets; ReadAt operates in plaintext coordinates.
- Fix loops so they terminate at off == size (which yields io.EOF) rather than advancing past it.
Example fix
// before
buf := make([]byte, 100)
n, err := dr.ReadAt(buf, -1)
// after
if off < 0 || off > size {
return 0, fmt.Errorf("offset %d out of range", off)
}
n, err := dr.ReadAt(buf, off) Defensive patterns
Strategy: validation
Validate before calling
plainSize, err := stream.PlaintextSize(encryptedSize)
if err != nil { return err }
if off < 0 || off > plainSize {
return fmt.Errorf("offset %d out of [0:%d]", off, plainSize)
} Try / catch
n, err := dr.ReadAt(p, off)
if err != nil && !errors.Is(err, io.EOF) {
if strings.Contains(err.Error(), "offset out of range") {
return fmt.Errorf("caller bug: bad offset %d", off)
}
return err
} Prevention
- Wrap the reader in io.NewSectionReader(dr, 0, plainSize) for automatic bounds checks.
- Remember ReadAt uses plaintext offsets, not ciphertext offsets.
- Bound loops at off == size and rely on io.EOF for termination.
When it happens
Trigger: Calling ReadAt(p, off) with off < 0; or with off > r.size (beyond the plaintext length). Note off == r.size is accepted but immediately hits the EOF path once len(p) > 0.
Common situations: io.SectionReader or offset math gone wrong; passing the ciphertext offset instead of the plaintext offset; off-by-one loops with stale size values; concurrent readers sharing a stale size after a re-encryption.
Related errors
- failed to read final chunk: %w
- failed to read chunk at offset %d: %w
- invalid encrypted payload size: %d
- non-EOF error reading after end of encrypted file: %w
- failed to decrypt and authenticate final chunk: %w
AI-assisted analysis of FiloSottile/age@b74dce4cdb (2026-08-31).
Data as JSON: /api/errors/c522ed0b25f5767a.
Report an issue: GitHub.