You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Add pluggable page-level AEAD encryption support - #866
add an optional file encryption header carrying provider, profile, key, wrapped data key, and file identity metadata
introduce a provider-neutral AEAD SPI and registry without embedding a concrete cryptographic implementation
encrypt compressed page bodies for non-aligned, aligned, and table-model write paths, and decrypt them on the corresponding read paths
preserve existing unencrypted TsFile behavior and propagate encryption metadata through append, recovery, lazy loading, and sketch tooling
add compatibility, round-trip, tamper-detection, append, and recovery tests
Motivation
TsFile currently has legacy encryption interfaces but no self-describing file-level context for pluggable page-level authenticated encryption. This change establishes the format and I/O integration points while leaving algorithm and key-management implementations to external providers.
Compatibility
Existing unencrypted TsFiles keep their current layout and remain readable.
Encrypted files require a registered provider matching the identifiers stored in the encryption header.
Page metadata remains readable while compressed page bodies are protected with AEAD.
The encrypted-file format and public API are still under discussion and are not intended as a compatibility commitment in this draft.
❌ Patch coverage is 82.50319% with 274 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.91%. Comparing base (d33e640) to head (ecb0b6b). ⚠️ Report is 9 commits behind head on develop.
The reason will be displayed to describe this comment to others. Learn more.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
This PR introduces pluggable page-level AEAD encryption for TsFile by adding a self-describing file encryption header, a provider-neutral encryption SPI/registry, and integrating page body encrypt/decrypt across write and read paths while preserving unencrypted TsFile compatibility.
Changes:
Added FileEncryptionHeader (written after the TsFile version byte) and propagated encryption context through writers/readers, append, and recovery flows.
Introduced provider-neutral AEAD SPI (IEncryptProvider, EncryptionProviderRegistry) and page-associated-data binding (PageCryptoContext) with per-chunk ordinals and per-page indices.
Added/updated tests to validate round-trips, tamper detection, ordinal continuity across append/recovery, and reader behavior.
Encrypting every page body leaves TsFileLastReader.readAlignedLastPoint incompatible with encrypted aligned BLOB/OBJECT columns: that path slices the stored body and passes it directly to IUnCompressor/ValuePageReader without decryption (TsFileLastReader.java:179-216). It will feed ciphertext to the decoder (or decompressor), so last-point reads fail for these supported types. Route that path through the AEAD-aware page deserialization while tracking the selected page index and chunk ordinal.
Provider IDs are trimmed when registering and unregistering, but lookup uses the untrimmed persisted value. A provider whose declared ID contains surrounding whitespace is therefore registered successfully under the trimmed key yet can never be resolved from a parameter/header carrying that declared ID. Apply the same normalization during lookup (or reject non-canonical IDs consistently).
Preserve encryption context for materialized chunks
The same EncryptParameter instance is attached to every Chunk returned by readMemChunk() and to its lazy page readers. Destroying it here means an already materialized in-memory chunk becomes unusable as soon as the sequence reader is closed; subsequent page loading fails with parameter_destroyed. Give returned chunks an independently owned context or otherwise coordinate the context lifetime instead of invalidating it with the file handle.
The reason will be displayed to describe this comment to others. Learn more.
Thanks for working on this. The overall direction looks good: a self-describing file header, a provider SPI, and per-page AEAD with the AAD bound to fileCryptoId / chunk ordinal / page index. The fail-closed checks on chunk copy/merge, the immutable file context and the ordinal validation are nice, and the lifecycle tests cover a lot of edge paths.
Validation: I ran the 7 test classes listed in the description plus the two new lifecycle test classes (72 tests), and the full java/tsfile suite (1038 tests, 0 failures, 10 skipped), on b58e5c37. The only change since then, fc88fb87, adds a pageWriter null check in reset() and doesn't affect the comments below. I reproduced each issue with a small probe test; the repro steps are in the inline comments.
Summary of the inline comments:
Blocking: page-level raw copy (writePageHeaderAndDataIntoBuff(ByteBuffer, PageHeader)) is not fail-closed for page AEAD and silently writes undecryptable files. This is the IoTDB fast-compaction path.
The AAD binds pages to chunk ordinals, but nothing binds chunks to their series: swapping whole chunks between s1 and s2 goes undetected.
TsFileReader now strongly retains every QueryDataSet, which changes the contract for existing users of unencrypted files.
With the reference-counted Chunk / ChunkReader ownership, cleanup only happens if every caller closes every chunk.
Recovery and unsealed reads: headerless file + AEAD parameter, a torn header surfacing as an unchecked exception, and the getEncryptParam() fallback.
Parameter ownership, SPI hardening (reachability of the legacy encrypt/decrypt, per-file key generation, nonce guidance), and a few nits.
The AAD binds the copied ciphertext to the source file's fileCryptoId, chunk ordinal and page index, so it can never authenticate inside the new chunk.
Repro:
Write file A with page AEAD.
Read a raw page body with readMemChunk + ChunkReader.readCompressedPageData.
Feed it to new ChunkWriterImpl(schema, targetParam).writePageHeaderAndDataIntoBuff(raw, pageHeader) for file B, then call writeToFileWriter and endFile(). All of these succeed.
Query B: it fails with AEADBadTagException: Tag mismatch.
This is the path IoTDB's fast compaction uses (AbstractCompactionWriter#flushNonAlignedPageToChunkWriter / #flushAlignedPageToChunkWriter, InstantPageLoader). Compaction deletes the source files afterwards, so this ends in data loss.
Suggestion: at minimum, reject raw page copy when the target writer has a chunk ordinal (fail closed, like writeChunk). Better: offer a re-encrypting copy path that decrypts with the source context and re-encrypts the still-compressed plaintext with the target chunk ordinal and page index. No decompress/decode is needed, so fast compaction stays cheap for encrypted files.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in d391241. The scalar, time and value raw-page copy entry points now reject page-AEAD targets before changing the page buffer; aligned copy delegates to those guarded paths. Regression tests exercise the rejection. A compressed-plaintext re-encryption API is not introduced in this patch; the database integration must use a safe rewrite path instead of copying authenticated ciphertext across contexts.
The reason will be displayed to describe this comment to others. Learn more.
The AAD binds a page to (fileCryptoId, chunkOrdinal, pageIndex, sizes), but nothing binds a chunk to its series.
measurementId, dataType, encoding and compressionType (and the device) are not in the associated data. The ordinal lives in the unauthenticated chunk header, so it moves together with the chunk.
Repro: write d1.s1 = 11 and d1.s2 = 22 (PLAIN/UNCOMPRESSED, one point each, so both chunks are 79 bytes). Swap the two chunks' whole byte ranges (header + pages) in the file. Querying d1.s1 returns 22, with no authentication error.
Truncating trailing pages or chunks, or editing the plaintext statistics so pages get skipped, also goes undetected. testSwappingEqualSizedPagesBetweenChunksFailsAuthentication only covers swapping pages between chunks.
Suggestion: put the fields that are fixed when a page is encrypted (dataType, encoding, compressionType, time/value column mask) into the AAD. measurementId and the device need more thought, because names can be remapped at flush time (measurementNameRemapper) and chunk writers don't know the device. Either way, please document that page AEAD does not provide chunk-level integrity (deletion, truncation, reordering).
The reason will be displayed to describe this comment to others. Learn more.
Addressed the whole-chunk swap repro in 2f2a452 with a separate position proof in encrypted Chunk Headers. Immediately before writing the header, the provider authenticates a domain/version, fileCryptoId, the actual absolute chunk-marker offset, and chunkOrdinal. Sequential and positional readers verify the proof before using the ordinal or page body. The proof is 28 opaque bytes; the concrete TimechoDB provider uses a fresh 12-byte IV plus a 16-byte GCM tag over empty plaintext. Page AAD and page-buffer encryption timing remain unchanged. ChunkPositionAuthenticationTest covers equal-sized whole-chunk swaps, both read paths, query, empty chunks and recovery. Scope clarification: this protects relocation of an existing chunk, not general metadata integrity. Measurement/device mapping, codec fields, statistics and indexes remain unauthenticated; pruning based on altered statistics, deletion/truncation and index remapping are not claimed to be detected by this design.
The reason will be displayed to describe this comment to others. Learn more.
This silently changes the contract of TsFileReader#query for existing users, including users of unencrypted files.
Before this PR, QueryDataSet had no close() and wasn't AutoCloseable. The only (and correct) way to use it was to drop the reference and let GC reclaim it. Now every returned dataset is held here until it is fully iterated or explicitly closed. Existing code that stops early (reads only the first rows, breaks, or throws while iterating) now piles up datasets for the reader's lifetime, each pinning its current chunk buffer. Nothing warns at compile time.
Repro on an unencrypted file: 1000 × (query(...), next() once, drop the reference) leaves queryDataSets.size() == 1000.
The tracking is only needed to release page-AEAD references, but it applies to every file. Suggestion: only track datasets when fileReader.hasFileEncryptionHeader() is true. That keeps the unencrypted path fully backward compatible, and requiring close() for the new encrypted feature is a reasonable new contract. If the intent is to require close() for all files, please document it as a breaking change on query() and in the release notes.
Minor: this HashSet isn't thread-safe if one TsFileReader is shared by threads issuing queries concurrently.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in d391241: only datasets for files with a page-AEAD encryption header are retained for cleanup. Plaintext queries are not added to the tracking set, preserving their existing lifetime behavior. The tracking set is synchronized, including iteration during reader close. Added a plaintext early-abandonment regression test.
The reason will be displayed to describe this comment to others. Learn more.
With reference counting, provider cleanup only happens if every caller closes every Chunk, and existing callers don't.
Every Chunk built with a page-AEAD parameter now retain()s it (copying the plaintext key if the parameter carries one) and must be closed. Existing callers of readMemChunk don't close chunks. For example, IoTDB's ChunkCache caches the Chunk returned by readMemChunk and doesn't close it on eviction. With the cache disabled, it re-wraps the loaded chunk in constructChunk and never closes the original. In practice the count rarely reaches zero, so "the provider is closed after the last reference" doesn't hold. Meanwhile every downstream call site has to learn a new close contract.
Suggestion: decouple the provider context's lifetime from object lifetimes. For example, keep a bounded process-wide cache of IEncrypt keyed by (providerId, profileId, keyId, keyVersion, wrappedDataKey) that closes entries on eviction, and let Chunk hold only the immutable file encryption descriptor. Then Chunk doesn't need to be closeable, and unencrypted paths stay untouched.
The reason will be displayed to describe this comment to others. Learn more.
We are keeping explicit ownership for the new page-AEAD feature rather than adding a process-wide crypto cache to TsFile. Chunk retains the shared file-level provider context, and ChunkReader owns/releases the supplied chunk; retained chunks do not independently unwrap a key on each read. The plaintext path does not acquire crypto references. The consuming TimechoDB branch has been adapted: ChunkCache closes entries on eviction, closes temporary loaded chunks after creating retained query views, and handles eviction/retain races; query readers release their owned views. This downstream adaptation is required before enabling page AEAD. We do not claim that arbitrary unmodified downstream callers will release encrypted resources automatically.
The reason will be displayed to describe this comment to others. Learn more.
Closing a ChunkReader now destroys the encryption reference of the caller's Chunk.
Before this PR, close() here was a no-op. Now it closes the supplied chunk.
Repro: Chunk c = reader.readMemChunk(meta), read it through new ChunkReader(c), then close(). Afterwards c.getEncryptParam().isDestroyed() is true. Re-wrapping the chunk's data with its parameter, new Chunk(c.getHeader(), data, c.getEncryptParam()) (the ChunkCache#constructChunk pattern), throws Encryption parameters have been destroyed. That worked before this PR and still works for unencrypted chunks. It's a behavior change worth documenting, or avoiding with the approach suggested on Chunk.
Also, ChunkReader and AbstractAlignedChunkReader now own resources but aren't AutoCloseable, so try-with-resources can't be used.
The reason will be displayed to describe this comment to others. Learn more.
The ownership transfer for page-AEAD chunks is intentional and documented on ChunkReader: closing it releases the supplied chunk's reference. A cache must pass an independently retained Chunk view, not its own cache entry. The TimechoDB integration now follows that rule and closes cache-owned references on eviction. Plaintext chunks do not have this crypto lifetime requirement. In d391241, IChunkReader also extends AutoCloseable, so scalar and aligned readers can be used with try-with-resources.
The reason will be displayed to describe this comment to others. Learn more.
nit: this public overload passes a null chunk parameter, so in a page-AEAD file it always fails the sharesPageAeadFileContext check, and the ordinal only matters for page AEAD. The 5-arg LazyLoadPageData(..., int pageIndex) constructor has the same problem: it fixes chunkOrdinal to -1. Neither can be used successfully with page AEAD; consider removing them.
The reason will be displayed to describe this comment to others. Learn more.
Removed both unusable PR-added overloads in d391241. The original legacy overloads remain, and the full AEAD-aware overloads require the necessary chunk context/ordinal.
The reason will be displayed to describe this comment to others. Learn more.
nit: every reader open, including for unencrypted files, now does an extra 1-byte positional read at offset 7. For remote or object-storage TsFileInputs that's an extra round trip. It could be merged with the version-byte read in loadFileVersion.
The reason will be displayed to describe this comment to others. Learn more.
Acknowledged as an I/O optimization opportunity. This patch still performs a separate marker read when checking the optional encryption header; it does not yet combine that read with the version read. The metadata-offset constructors retain their existing no-checked-exception signature and load the optional encryption header lazily. I am not marking the extra-read nit as fixed.
The reason will be displayed to describe this comment to others. Learn more.
question: this adds an fsync for every new encrypted file. Is it needed, given that unflushed data is covered by the caller's WAL? The magic/version of a plaintext file isn't forced either.
The reason will be displayed to describe this comment to others. Learn more.
Removed the per-encrypted-file force() in d391241. The header is flushed for normal visibility, but TsFile does not impose an additional fsync policy. The database integration is responsible for ordering file durability against its WAL; the TimechoDB flush path now forces encrypted TsFile data before memtable release/WAL completion callbacks.
The reason will be displayed to describe this comment to others. Learn more.
nit: SERVICES_LOADED is set before iterating. If one provider throws (a duplicate id with a different class, a ServiceConfigurationError, ...), the remaining providers are never registered and loading is never retried. Consider registering the rest and reporting the failures instead.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in d391241. Service discovery is synchronized through registration; the loaded flag is published only after successful completion. A per-provider failure does not stop registration of subsequent providers, and failed discovery remains retryable. Added regression tests for continued registration and retry after failure.
The reason will be displayed to describe this comment to others. Learn more.
Acknowledged. chunkOrdinal is not yet included in ChunkHeader.toString() in this update, so this debug-output nit remains open. Authentication proof bytes are intentionally not printed.
The reason will be displayed to describe this comment to others. Learn more.
Please address the two inline findings before merging: page authentication failures can be silently converted into incomplete table-query results, and concurrent first use can fail while service-provider registration is still in progress. Both issues were reproduced locally on b58e5c3; the affected code paths are unchanged in fc88fb8.
The reason will be displayed to describe this comment to others. Learn more.
[P1] Propagate page authentication failures to the query caller
This AEAD overload wraps authentication failures in IOException, which the table-query stack treats as a recoverable read failure: DeviceOrderedTsBlockReader.hasNext() catches it during reader construction, logs it, and skips the device; SingleDeviceTsBlockReader.hasNext() similarly returns false for an IOException during iteration. Consequently, a tampered encrypted file can produce an apparently successful empty or partial query result.
I reproduced this by writing a three-row encrypted table, verifying that it returned all three rows, and flipping one byte in the time-page body. The log contained AEADBadTagException: Tag mismatch, but ResultSet.next() returned false without throwing, and the query returned zero rows. Please preserve and propagate authentication failures through both table-reader paths instead of treating them as end-of-data, and add a regression test at the public table-query API.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in d391241. Both DeviceOrderedTsBlockReader and SingleDeviceTsBlockReader propagate encryption failures (including causes wrapped by IOException) instead of treating them as a skipped device/end-of-data. Provider runtime failures are normalized at the page-AEAD boundary without losing the cause. Public table-query regression tests cover tampered time-page construction and a later value-page failure: both must throw rather than return an apparently successful empty/partial result.
The reason will be displayed to describe this comment to others. Learn more.
[P2] Wait for service discovery to finish before publishing the loaded state
SERVICES_LOADED becomes true before the providers have been registered. If a second thread creates an encryption context while the first thread is still constructing a service-loaded provider, it skips loadServices() and looks up an incomplete PROVIDERS map, incorrectly throwing provider_not_found for a valid installed provider.
I reproduced this deterministically by pausing a provider constructor with a latch and creating a second context concurrently: the second call failed with "Encryption provider is not registered", while the first completed successfully after registration resumed. Please make concurrent callers wait for initialization to complete and publish the loaded state only after registration succeeds.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in d391241. Concurrent callers wait for service discovery/registration to complete, and SERVICES_LOADED is set only after successful registration. EncryptionProviderRegistryTest.testConcurrentFirstUseWaitsForServiceRegistration uses a latched provider constructor to reproduce the race deterministically and verifies both callers succeed.
The reason will be displayed to describe this comment to others. Learn more.
mind the import
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Motivation
TsFile currently has legacy encryption interfaces but no self-describing file-level context for pluggable page-level authenticated encryption. This change establishes the format and I/O integration points while leaving algorithm and key-management implementations to external providers.
Compatibility
Validation
./mvnw spotless:check -P with-java -pl java/tsfile -am./mvnw test -P with-java -pl java/tsfile -am -Dtest='PageCryptoContextTest,FileEncryptionHeaderTest,TDEPageAeadTsFileTest,UnClosedTsFileReaderTest,ForceAppendTsFileWriterTest,RestorableTsFileIOWriterTest,TimePageWriterTest' -Dsurefire.failIfNoSpecifiedTests=falseDraft discussion points