Skip to content

feat(parquet/encryption): KMS support for reading PKMT1 key material - #1478

Open
EdwardShen125 wants to merge 2 commits into
apache:mainfrom
EdwardShen125:parquet-kms-read
Open

EdwardShen125 wants to merge 2 commits into
apache:mainfrom
EdwardShen125:parquet-kms-read

Conversation

@EdwardShen125

@EdwardShen125 EdwardShen125 commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Rationale for this change

#566 reported that files written by pyarrow's CryptoFactory cannot be read by arrow-go: the footer and column key metadata carry PKMT1 key material with a wrapped DEK, and arrow-go had no parser or unwrapping for it, so footer decryption failed without a hand-written KeyRetriever. As agreed in #566, the KMS support lives in the library as an optional component.

What changes are included in this PR?

A new optional parquet/encryption package:

  • KmsClient interface mirroring the parquet-cpp/pyarrow contract (WrapKey/UnwrapKey)
  • KeyMaterial/ParseKeyMetadata for PKMT1 key metadata (internal key storage)
  • KmsKeyRetriever implementing parquet.DecryptionKeyRetriever: in single wrapping mode the DEK is unwrapped by the KmsClient; in double wrapping mode the KEK is unwrapped through the client and the DEK is then decrypted locally with AES-GCM (nonce || ciphertext || tag, AAD = the base64 decoded KEK ID bytes), byte-compatible with parquet-cpp's EncryptKeyLocally
  • unwrapped KEKs are cached (10 minute expiry, 64 keys, LRU eviction; concurrent misses share one KMS call), so a double-wrapped file costs one KMS call per KEK instead of one per encrypted column plus the footer

It plugs into the existing parquet.WithKeyRetriever option; code that does not import the package is unaffected. The package doc and a godoc example (ExampleKmsKeyRetriever) show the wiring and state the optional nature explicitly.

The interop harness from #1439 gains a gen-kms mode: pyarrow's CryptoFactory writes double-wrapped (the default) and single-wrapped files using a test KMS client whose wrapping is plain base64, and Go reads both through the retriever — the footer key and a column key both take that path. No new python dependencies, so the existing cgo python CI job runs them unchanged.

The review nits from #1439 are addressed (flag rename, unused footer key metadata dropped, doc comment dedupe).

The write side (Go producing PKMT1 key material via a CryptoFactory) is a natural follow-up; this PR is read-only.

Are these changes tested?

Unit tests cover PKMT1 parsing (valid and error cases), single and double unwrap round-trips, and tamper detection. Against pyarrow 26.0.0 locally, with -race and PARQUET_TEST_REQUIRE_PYARROW=1, both interop modes pass; without pyarrow everything skips. The interop tests also count KMS unwrap calls: three key slots under two master keys resolve in two calls in double wrapping mode, three in single wrapping mode.

Are there any user-facing changes?

New optional public package parquet/encryption. No changes to existing APIs.

New optional parquet/encryption package with a KmsClient interface and
a KmsKeyRetriever that plugs into parquet.WithKeyRetriever, so files
written by the parquet-cpp/pyarrow CryptoFactory can be read: PKMT1
key metadata is parsed, and DEKs are resolved via the KMS client
(single wrapping) or by unwrapping the KEK through the KMS and
decrypting the locally wrapped DEK with AES-GCM (double wrapping,
AAD = decoded KEK ID bytes, matching parquet-cpp EncryptKeyLocally:
nonce || ciphertext || tag).

Interop tests against pyarrow CryptoFactory output cover both wrapping
modes; the test KMS wraps with plain base64 so no extra python
dependencies are needed and the existing cgo python CI job runs them
unchanged. Also addresses the review nits from apache#1439 (renamed the
requirePyarrow flag, dropped the unused footer key metadata, deduped a
doc comment).

Part of apache#566

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this! I ran the unit tests and the interop tests locally against pyarrow 26.0.1 with PARQUET_TEST_REQUIRE_PYARROW=1, and both single and double wrapping read correctly, so the local unwrapping format matches. A few things before merging:

  1. Dead fallback in encryption_interop_pyarrow.py. CryptoFactory(kms_client_factory) has been the constructor since pyarrow added KMS support, and pyarrow has no register_kms_client_factory method (I checked 26.0.1), so the except TypeError branch could never work and the "pyarrow >= 26" comment is misleading. Please call the constructor directly.

  2. Example doc. "Without this package, files whose key metadata carries PKMT1 key material cannot be read" isn't quite true: a hand-written DecryptionKeyRetriever can read them. Maybe something like "This package handles the PKMT1 parsing and unwrapping that would otherwise need a custom retriever."

  3. Caching unwrapped KEKs. Within one file the reader already calls GetKey once per column, but across files every open calls the KMS once per encrypted column plus the footer. parquet-cpp caches unwrapped KEKs by KEK ID (with an expiry), and that cache is what makes double wrapping worth being the default: reading many files costs one KMS call per KEK rather than one per column per file. Without it, double wrapping makes as many KMS calls as single wrapping. Since a KmsKeyRetriever is meant to be shared across files, a small concurrency-safe cache keyed by (masterKeyID, keyEncryptionKeyID) would be good to have here.

Minor, non-blocking:

  • In doc.go, "carries no effect on code that does not import it" reads oddly; "has no effect" or just dropping the sentence would be fine.
  • GetKey returning "" means KMS errors surface as the footer panic or the "hidden column" error. The DecryptionKeyRetriever interface can't carry an error, so pointing to UnwrapKeyMetadata in the doc is reasonable.

The retriever now caches unwrapped KEKs, so a file with double wrapping
costs one KMS call per KEK instead of one per encrypted column plus the
footer, matching parquet-cpp. Entries live for 10 minutes (a rotated KEK
written under the same ID becomes visible after that) and the cache is
bounded at 64 keys with LRU eviction; concurrent misses for the same
KEK share a single KMS call.

Also addresses the review feedback on apache#1478:

- the interop script calls the CryptoFactory constructor directly;
  pyarrow has no register_kms_client_factory, so the fallback branch
  could never run
- the example no longer claims PKMT1 files cannot be read without this
  package; it says the package handles the parsing and unwrapping that
  would otherwise need a custom retriever
- the doc.go sentence about code that does not import the package is
  dropped, the preceding paragraph already says it

The interop test now counts KMS unwrap calls: the generated files have
three key slots under two master keys (pyarrow generates one KEK per
master key), so double wrapping mode resolves them in two calls instead
of three, single wrapping mode in three. On the previous commit the
double wrapping case makes three calls and the test fails.
@EdwardShen125

Copy link
Copy Markdown
Contributor Author

Pushed a fix. KEKs are cached per (masterKeyID, keyEncryptionKeyID) with a 10 minute expiry, LRU eviction, and one KMS call for concurrent misses. The interop script calls the CryptoFactory constructor directly now, and the example and doc.go wording are cleaned up.

pyarrow generates one KEK per master key, not one per file, so the interop test counts unwrap calls now: three key slots under two master keys resolve in two calls, three on the previous commit.

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, everything from my earlier review is addressed. I re-ran the unit tests with -race, and the interop tests against pyarrow 26.0.1 with PARQUET_TEST_REQUIRE_PYARROW=1; both wrapping modes pass. I also checked that the new unwrap count depends on the cache: with the previous commit's kms.go swapped back in, kms_double fails with expected 2, actual 3.

The cache looks right to me. Concurrent misses share one KMS call, the shared call re-checks the cache and stores the KEK before returning, and errors aren't cached. A crafted file that reuses a KEK ID can only get the cached KEK, and its own DEK then fails GCM authentication.

Two optional nits inline.

Comment thread parquet/encryption/kms.go
}

// put caches kek for key, replacing any previous entry.
func (c *kekCache) put(k kekCacheKey, kek []byte) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this stores the slice the KmsClient returned as-is. If a client reuses or zeroes its returned buffers (some do, to wipe key material), the cached KEK gets corrupted. bytes.Clone(kek) here is cheap insurance.

Comment thread parquet/encryption/kms.go
// NewKmsKeyRetriever returns a retriever that resolves keys through client.
// The retriever is safe for concurrent use and meant to be shared across
// files.
func NewKmsKeyRetriever(client KmsClient) *KmsKeyRetriever {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the cache lasts as long as the retriever, which is right because a retriever wraps a single KmsClient identity. parquet-cpp scopes its cache per access token instead. A sentence saying to share a retriever only among callers with the same KMS access would make that explicit. Making the expiry time and size limit configurable can wait for a follow-up if anyone needs it.

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.

2 participants