This is an automated email from the ASF dual-hosted git repository.
wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new 4f195d0b feat(encryption): add AES GCM cipher and AES GCM Stream
(AGS1) files (#1030)
4f195d0b is described below
commit 4f195d0be65dde346802b3a6555bd46eaf344fa2
Author: Xinli Shang <[email protected]>
AuthorDate: Wed Oct 21 01:38:27 2026 -0700
feat(encryption): add AES GCM cipher and AES GCM Stream (AGS1) files (#1030)
---
ci/scripts/generate_public_headers.py | 56 ++--
cmake_modules/IcebergBuildUtils.cmake | 3 +-
src/iceberg/CMakeLists.txt | 4 +
src/iceberg/cpp20_compatibility_internal.h | 4 +
src/iceberg/encryption/CMakeLists.txt | 10 +-
src/iceberg/encryption/aes_gcm.cc | 177 ++++++++++++
src/iceberg/encryption/aes_gcm_internal.h | 71 +++++
src/iceberg/encryption/aes_gcm_stream.cc | 369 ++++++++++++++++++++++++
src/iceberg/encryption/aes_gcm_stream.h | 99 +++++++
src/iceberg/encryption/standard_key_metadata.h | 4 +
src/iceberg/test/CMakeLists.txt | 8 +
src/iceberg/test/aes_gcm_test.cc | 376 +++++++++++++++++++++++++
12 files changed, 1162 insertions(+), 19 deletions(-)
diff --git a/ci/scripts/generate_public_headers.py
b/ci/scripts/generate_public_headers.py
index 56f196d8..d4b80be8 100755
--- a/ci/scripts/generate_public_headers.py
+++ b/ci/scripts/generate_public_headers.py
@@ -19,20 +19,21 @@
"""Regenerate src/iceberg/cpp20_compatibility_internal.h.
-The generated header includes every public header, i.e. every header that
-iceberg_install_all_headers() installs: *.h files in a directory whose
-CMakeLists.txt calls it, except those with "internal" in their name. This
script
-applies the same rule, so the list never has to be maintained by hand. The
-generate-public-headers pre-commit hook runs it and fails when the checked-in
-header is out of date.
-
-Headers of optional components are wrapped in a macro that
-cpp20_compatibility_test defines only when that component is built.
+The generated header covers all possible public headers installed by
+iceberg_install_all_headers(): *.h and *.hpp files matching its PATTERN
arguments,
+except those with "internal" in their name. The generate-public-headers
pre-commit
+hook runs this script and fails when the checked-in header is out of date.
+
+Headers of optional components are grouped by directory or an explicit
+HEADER_GROUP in the installation call. cpp20_compatibility_test defines each
+group's macro only when the corresponding component is built.
"""
from __future__ import annotations
+import fnmatch
import re
+import shlex
import sys
from pathlib import Path
@@ -51,7 +52,7 @@ OPTIONAL_COMPONENTS = [
("iceberg/parquet", "ICEBERG_PUBLIC_HEADERS_BUNDLE"),
]
-INSTALL_CALL = re.compile(r"iceberg_install_all_headers\(\s*([^\s)]+)")
+INSTALL_CALL = re.compile(r"iceberg_install_all_headers\((.*?)\)", re.DOTALL)
LICENSE = """\
/*
@@ -75,7 +76,9 @@ LICENSE = """\
"""
-def component_of(install_path: str) -> str | None:
+def component_of(install_path: str, header_group: str | None = None) -> str |
None:
+ if header_group:
+ return header_group
for prefix, macro in OPTIONAL_COMPONENTS:
if install_path == prefix or install_path.startswith(prefix + "/"):
return macro
@@ -85,13 +88,29 @@ def component_of(install_path: str) -> str | None:
def collect_public_headers() -> dict[str | None, list[str]]:
headers: dict[str | None, list[str]] = {}
for cmake_file in sorted(SRC.rglob("CMakeLists.txt")):
- for install_path in INSTALL_CALL.findall(cmake_file.read_text()):
+ for call in INSTALL_CALL.findall(cmake_file.read_text()):
+ tokens = shlex.split(call, comments=True)
+ if not tokens:
+ continue
+ install_path = tokens[0]
+ try:
+ pattern_start = tokens.index("PATTERN") + 1
+ except ValueError:
+ pattern_start = len(tokens)
+ try:
+ group_start = tokens.index("HEADER_GROUP")
+ header_group = tokens[group_start + 1]
+ except ValueError:
+ group_start = len(tokens)
+ header_group = None
+ patterns = tokens[pattern_start:group_start] or ["*.h", "*.hpp"]
+ group = component_of(install_path, header_group)
for header in cmake_file.parent.iterdir():
- if header.suffix != ".h" or "internal" in header.name:
+ if not any(fnmatch.fnmatch(header.name, pattern) for pattern
in patterns):
continue
- headers.setdefault(component_of(install_path), []).append(
- f"{install_path}/{header.name}"
- )
+ if header.suffix not in {".h", ".hpp"} or "internal" in
header.name:
+ continue
+ headers.setdefault(group,
[]).append(f"{install_path}/{header.name}")
return headers
@@ -107,7 +126,10 @@ def render(headers: dict[str | None, list[str]]) -> str:
"",
]
lines += [f'#include "{h}"' for h in sorted(headers.get(None, []))]
- for macro in sorted({m for _, m in OPTIONAL_COMPONENTS}):
+ optional_macros = {m for _, m in OPTIONAL_COMPONENTS} | {
+ group for group in headers if group is not None
+ }
+ for macro in sorted(optional_macros):
if macro not in headers:
continue
lines += ["", f"#ifdef {macro}"]
diff --git a/cmake_modules/IcebergBuildUtils.cmake
b/cmake_modules/IcebergBuildUtils.cmake
index 035c312d..fa1accd2 100644
--- a/cmake_modules/IcebergBuildUtils.cmake
+++ b/cmake_modules/IcebergBuildUtils.cmake
@@ -275,7 +275,8 @@ endfunction()
function(iceberg_install_all_headers PATH)
set(options)
- set(one_value_args)
+ # HEADER_GROUP labels optional headers for
ci/scripts/generate_public_headers.py.
+ set(one_value_args HEADER_GROUP)
set(multi_value_args PATTERN)
cmake_parse_arguments(ARG
"${options}"
diff --git a/src/iceberg/CMakeLists.txt b/src/iceberg/CMakeLists.txt
index 091acd3e..c751cd7a 100644
--- a/src/iceberg/CMakeLists.txt
+++ b/src/iceberg/CMakeLists.txt
@@ -326,6 +326,10 @@ if(ICEBERG_BUILD_BUNDLE)
parquet/parquet_writer.cc
parquet/row_number_reader.cc)
+ if(ICEBERG_ENCRYPTION)
+ list(APPEND ICEBERG_BUNDLE_SOURCES encryption/aes_gcm.cc
encryption/aes_gcm_stream.cc)
+ endif()
+
# Libraries to link with exported libiceberg_bundle.{so,a}.
set(ICEBERG_BUNDLE_STATIC_BUILD_INTERFACE_LIBS)
set(ICEBERG_BUNDLE_SHARED_BUILD_INTERFACE_LIBS)
diff --git a/src/iceberg/cpp20_compatibility_internal.h
b/src/iceberg/cpp20_compatibility_internal.h
index 359e5ddf..bd9a9475 100644
--- a/src/iceberg/cpp20_compatibility_internal.h
+++ b/src/iceberg/cpp20_compatibility_internal.h
@@ -216,6 +216,10 @@
# include "iceberg/parquet/parquet_writer.h"
#endif // ICEBERG_PUBLIC_HEADERS_BUNDLE
+#ifdef ICEBERG_PUBLIC_HEADERS_ENCRYPTION
+# include "iceberg/encryption/aes_gcm_stream.h"
+#endif // ICEBERG_PUBLIC_HEADERS_ENCRYPTION
+
#ifdef ICEBERG_PUBLIC_HEADERS_HIVE
# include "iceberg/catalog/hive/hive_catalog.h"
# include "iceberg/catalog/hive/hive_catalog_properties.h"
diff --git a/src/iceberg/encryption/CMakeLists.txt
b/src/iceberg/encryption/CMakeLists.txt
index c493ee25..73072dbc 100644
--- a/src/iceberg/encryption/CMakeLists.txt
+++ b/src/iceberg/encryption/CMakeLists.txt
@@ -15,4 +15,12 @@
# specific language governing permissions and limitations
# under the License.
-iceberg_install_all_headers(iceberg/encryption)
+iceberg_install_all_headers(iceberg/encryption PATTERN encrypted_key.h
+ standard_key_metadata.h)
+if(ICEBERG_BUILD_BUNDLE AND ICEBERG_ENCRYPTION)
+ iceberg_install_all_headers(iceberg/encryption
+ PATTERN
+ aes_gcm_stream.h
+ HEADER_GROUP
+ ICEBERG_PUBLIC_HEADERS_ENCRYPTION)
+endif()
diff --git a/src/iceberg/encryption/aes_gcm.cc
b/src/iceberg/encryption/aes_gcm.cc
new file mode 100644
index 00000000..eefb7654
--- /dev/null
+++ b/src/iceberg/encryption/aes_gcm.cc
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <limits>
+
+#include <openssl/evp.h>
+#include <openssl/rand.h>
+
+#include "iceberg/encryption/aes_gcm_internal.h"
+#include "iceberg/util/macros.h"
+
+namespace iceberg::encryption {
+
+namespace {
+
+const EVP_CIPHER* CipherForKey(size_t key_length) {
+ switch (key_length) {
+ case 16:
+ return EVP_aes_128_gcm();
+ case 24:
+ return EVP_aes_192_gcm();
+ case 32:
+ return EVP_aes_256_gcm();
+ default:
+ return nullptr;
+ }
+}
+
+EVP_CIPHER_CTX* Ctx(void* ctx) { return static_cast<EVP_CIPHER_CTX*>(ctx); }
+
+} // namespace
+
+Status SecureRandomBytes(std::span<uint8_t> out) {
+ if (out.size() > static_cast<size_t>(std::numeric_limits<int>::max()) ||
+ RAND_bytes(out.data(), static_cast<int>(out.size())) != 1) {
+ return IOError("Failed to generate secure random bytes");
+ }
+ return {};
+}
+
+Result<std::unique_ptr<AesGcmCipher>> AesGcmCipher::Make(std::span<const
uint8_t> key) {
+ if (CipherForKey(key.size()) == nullptr) {
+ return InvalidArgument("Invalid key length: {} (must be 16, 24, or 32
bytes)",
+ key.size());
+ }
+ EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
+ if (ctx == nullptr) {
+ return IOError("Failed to create cipher context");
+ }
+ return std::unique_ptr<AesGcmCipher>(
+ new AesGcmCipher(std::vector<uint8_t>(key.begin(), key.end()), ctx));
+}
+
+AesGcmCipher::AesGcmCipher(std::vector<uint8_t> key, void* ctx)
+ : key_(std::move(key)), ctx_(ctx) {}
+
+AesGcmCipher::~AesGcmCipher() { EVP_CIPHER_CTX_free(Ctx(ctx_)); }
+
+Status AesGcmCipher::Encrypt(std::span<const uint8_t> plaintext,
+ std::span<const uint8_t> aad, std::span<uint8_t>
out) {
+ if (out.size() != plaintext.size() + kNonceLength + kGcmTagLength) {
+ return InvalidArgument("Invalid ciphertext buffer size: {}", out.size());
+ }
+ if (plaintext.size() > static_cast<size_t>(std::numeric_limits<int>::max()))
{
+ return InvalidArgument("Plaintext too large: {}", plaintext.size());
+ }
+
+ auto nonce = out.first(kNonceLength);
+ auto ciphertext = out.subspan(kNonceLength, plaintext.size());
+ auto tag = out.last(kGcmTagLength);
+ ICEBERG_RETURN_UNEXPECTED(SecureRandomBytes(nonce));
+
+ auto* ctx = Ctx(ctx_);
+ int len = 0;
+ if (EVP_EncryptInit_ex(ctx, CipherForKey(key_.size()), /*impl=*/nullptr,
+ /*key=*/nullptr, /*iv=*/nullptr) != 1 ||
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, kNonceLength,
/*ptr=*/nullptr) !=
+ 1 ||
+ EVP_EncryptInit_ex(ctx, /*cipher=*/nullptr, /*impl=*/nullptr,
key_.data(),
+ nonce.data()) != 1) {
+ return IOError("Failed to initialize AES GCM encryption");
+ }
+ if (!aad.empty() && EVP_EncryptUpdate(ctx, /*out=*/nullptr, &len, aad.data(),
+ static_cast<int>(aad.size())) != 1) {
+ return IOError("Failed to set AES GCM AAD");
+ }
+ if (!plaintext.empty() &&
+ EVP_EncryptUpdate(ctx, ciphertext.data(), &len, plaintext.data(),
+ static_cast<int>(plaintext.size())) != 1) {
+ return IOError("Failed to encrypt");
+ }
+ if (EVP_EncryptFinal_ex(ctx, ciphertext.data() + plaintext.size(), &len) !=
1 ||
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, kGcmTagLength,
tag.data()) != 1) {
+ return IOError("Failed to finalize AES GCM encryption");
+ }
+ return {};
+}
+
+Status AesGcmCipher::Decrypt(std::span<const uint8_t> ciphertext,
+ std::span<const uint8_t> aad, std::span<uint8_t>
out) {
+ if (ciphertext.size() < static_cast<size_t>(kNonceLength + kGcmTagLength)) {
+ return Invalid(
+ "Cannot decrypt cipher text of length {}: shorter than nonce and tag,
text may "
+ "not be encrypted with AES GCM",
+ ciphertext.size());
+ }
+ size_t plaintext_length = ciphertext.size() - kNonceLength - kGcmTagLength;
+ if (out.size() != plaintext_length) {
+ return InvalidArgument("Invalid plaintext buffer size: {}", out.size());
+ }
+
+ auto nonce = ciphertext.first(kNonceLength);
+ auto data = ciphertext.subspan(kNonceLength, plaintext_length);
+ auto tag = ciphertext.last(kGcmTagLength);
+
+ auto* ctx = Ctx(ctx_);
+ int len = 0;
+ if (EVP_DecryptInit_ex(ctx, CipherForKey(key_.size()), /*impl=*/nullptr,
+ /*key=*/nullptr, /*iv=*/nullptr) != 1 ||
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, kNonceLength,
/*ptr=*/nullptr) !=
+ 1 ||
+ EVP_DecryptInit_ex(ctx, /*cipher=*/nullptr, /*impl=*/nullptr,
key_.data(),
+ nonce.data()) != 1) {
+ return IOError("Failed to initialize AES GCM decryption");
+ }
+ if (!aad.empty() && EVP_DecryptUpdate(ctx, /*out=*/nullptr, &len, aad.data(),
+ static_cast<int>(aad.size())) != 1) {
+ return IOError("Failed to set AES GCM AAD");
+ }
+ if (!data.empty() && EVP_DecryptUpdate(ctx, out.data(), &len, data.data(),
+ static_cast<int>(data.size())) != 1) {
+ return IOError("Failed to decrypt");
+ }
+ if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, kGcmTagLength,
+ const_cast<uint8_t*>(tag.data())) != 1 ||
+ EVP_DecryptFinal_ex(ctx, out.data() + plaintext_length, &len) != 1) {
+ return Invalid(
+ "GCM tag check failed. Possible reasons: wrong decryption key; or "
+ "corrupt/tampered data. AES GCM doesn't differentiate between these
two.");
+ }
+ return {};
+}
+
+Result<std::vector<uint8_t>> AesGcmCipher::Encrypt(std::span<const uint8_t>
plaintext,
+ std::span<const uint8_t>
aad) {
+ std::vector<uint8_t> out(plaintext.size() + kNonceLength + kGcmTagLength);
+ ICEBERG_RETURN_UNEXPECTED(Encrypt(plaintext, aad, out));
+ return out;
+}
+
+Result<std::vector<uint8_t>> AesGcmCipher::Decrypt(std::span<const uint8_t>
ciphertext,
+ std::span<const uint8_t>
aad) {
+ if (ciphertext.size() < static_cast<size_t>(kNonceLength + kGcmTagLength)) {
+ return Invalid("Cannot decrypt cipher text of length {}",
ciphertext.size());
+ }
+ std::vector<uint8_t> out(ciphertext.size() - kNonceLength - kGcmTagLength);
+ ICEBERG_RETURN_UNEXPECTED(Decrypt(ciphertext, aad, out));
+ return out;
+}
+
+} // namespace iceberg::encryption
diff --git a/src/iceberg/encryption/aes_gcm_internal.h
b/src/iceberg/encryption/aes_gcm_internal.h
new file mode 100644
index 00000000..e09a7802
--- /dev/null
+++ b/src/iceberg/encryption/aes_gcm_internal.h
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#pragma once
+
+/// \file iceberg/encryption/aes_gcm_internal.h
+/// AES GCM cipher compatible with Java's
org.apache.iceberg.encryption.Ciphers.
+
+#include <cstdint>
+#include <memory>
+#include <span>
+#include <vector>
+
+#include "iceberg/result.h"
+
+namespace iceberg::encryption {
+
+inline constexpr int32_t kNonceLength = 12;
+inline constexpr int32_t kGcmTagLength = 16;
+
+/// \brief Fill `out` with cryptographically secure random bytes.
+Status SecureRandomBytes(std::span<uint8_t> out);
+
+/// \brief AES GCM with a random 12-byte nonce.
+///
+/// Ciphertext layout: nonce (12) | encrypted data | tag (16).
+class AesGcmCipher {
+ public:
+ /// \brief Create a cipher for a 16, 24 or 32 byte key.
+ static Result<std::unique_ptr<AesGcmCipher>> Make(std::span<const uint8_t>
key);
+
+ ~AesGcmCipher();
+
+ /// \brief Encrypt `plaintext` into `out`, which must hold plaintext.size()
+ 28 bytes.
+ Status Encrypt(std::span<const uint8_t> plaintext, std::span<const uint8_t>
aad,
+ std::span<uint8_t> out);
+
+ /// \brief Decrypt and verify `ciphertext` into `out`, which must hold
+ /// ciphertext.size() - 28 bytes.
+ Status Decrypt(std::span<const uint8_t> ciphertext, std::span<const uint8_t>
aad,
+ std::span<uint8_t> out);
+
+ Result<std::vector<uint8_t>> Encrypt(std::span<const uint8_t> plaintext,
+ std::span<const uint8_t> aad);
+ Result<std::vector<uint8_t>> Decrypt(std::span<const uint8_t> ciphertext,
+ std::span<const uint8_t> aad);
+
+ private:
+ AesGcmCipher(std::vector<uint8_t> key, void* ctx);
+
+ std::vector<uint8_t> key_;
+ void* ctx_; // EVP_CIPHER_CTX
+};
+
+} // namespace iceberg::encryption
diff --git a/src/iceberg/encryption/aes_gcm_stream.cc
b/src/iceberg/encryption/aes_gcm_stream.cc
new file mode 100644
index 00000000..7fb545db
--- /dev/null
+++ b/src/iceberg/encryption/aes_gcm_stream.cc
@@ -0,0 +1,369 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "iceberg/encryption/aes_gcm_stream.h"
+
+#include <algorithm>
+#include <array>
+#include <cstring>
+#include <limits>
+#include <utility>
+
+#include "iceberg/encryption/aes_gcm_internal.h"
+#include "iceberg/util/macros.h"
+
+namespace iceberg {
+
+namespace {
+
+using encryption::AesGcmCipher;
+
+constexpr int64_t kPlainBlock = AesGcmStream::kPlainBlockSize;
+constexpr int64_t kCipherBlock = AesGcmStream::kCipherBlockSize;
+constexpr int64_t kBlockOverhead =
+ AesGcmStream::kNonceLength + AesGcmStream::kGcmTagLength;
+
+/// \brief Block AAD: file AAD prefix followed by the block index as a 4-byte
LE int.
+std::vector<uint8_t> BlockAad(const std::vector<uint8_t>& aad_prefix,
+ int32_t block_index) {
+ std::vector<uint8_t> aad(aad_prefix);
+ for (int i = 0; i < 4; ++i) {
+ aad.push_back(
+ static_cast<uint8_t>((static_cast<uint32_t>(block_index) >> (8 * i)) &
0xFF));
+ }
+ return aad;
+}
+
+std::array<std::byte, AesGcmStream::kHeaderLength> HeaderBytes() {
+ std::array<std::byte, AesGcmStream::kHeaderLength> header{};
+ std::memcpy(header.data(), AesGcmStream::kMagic.data(),
AesGcmStream::kMagic.size());
+ for (int i = 0; i < 4; ++i) {
+ header[4 + i] = static_cast<std::byte>(
+ (static_cast<uint32_t>(AesGcmStream::kPlainBlockSize) >> (8 * i)) &
0xFF);
+ }
+ return header;
+}
+
+class AesGcmInputStream : public SeekableInputStream {
+ public:
+ /// \brief Construct over an encrypted length already accepted by
+ /// AesGcmStream::PlaintextLength, which bounds the block count.
+ AesGcmInputStream(std::unique_ptr<SeekableInputStream> source,
+ std::unique_ptr<AesGcmCipher> cipher, std::vector<uint8_t>
aad_prefix,
+ int64_t encrypted_length, int64_t plain_size)
+ : source_(std::move(source)),
+ cipher_(std::move(cipher)),
+ aad_prefix_(std::move(aad_prefix)),
+ cipher_block_(kCipherBlock),
+ plain_block_(kPlainBlock),
+ plain_size_(plain_size) {
+ int64_t stream_length = encrypted_length - AesGcmStream::kHeaderLength;
+ int64_t num_full_blocks = stream_length / kCipherBlock;
+ int64_t last_bytes = stream_length - num_full_blocks * kCipherBlock;
+ bool full_blocks_only = last_bytes == 0;
+ num_blocks_ = full_blocks_only ? num_full_blocks : num_full_blocks + 1;
+ last_cipher_block_size_ = full_blocks_only ? kCipherBlock : last_bytes;
+ }
+
+ Status ValidateHeader() {
+ std::array<std::byte, AesGcmStream::kHeaderLength> header{};
+ ICEBERG_RETURN_UNEXPECTED(source_->ReadFully(0, header));
+ if (std::memcmp(header.data(), AesGcmStream::kMagic.data(),
+ AesGcmStream::kMagic.size()) != 0) {
+ return Invalid("Invalid GCM stream: magic does not match AGS1");
+ }
+ uint32_t plain_block_size = 0;
+ for (int i = 0; i < 4; ++i) {
+ plain_block_size |= static_cast<uint32_t>(header[4 + i]) << (8 * i);
+ }
+ if (plain_block_size !=
static_cast<uint32_t>(AesGcmStream::kPlainBlockSize)) {
+ return Invalid("Invalid GCM stream: block size {} != {}",
plain_block_size,
+ AesGcmStream::kPlainBlockSize);
+ }
+ return {};
+ }
+
+ /// \brief Authenticate the sole block of an empty stream.
+ ///
+ /// An empty AGS1 file still carries one empty block with a nonce and a tag,
so a
+ /// reader that stops at plain_size_ == 0 would accept a wrong key, a wrong
AAD
+ /// prefix, a corrupt tag or a file truncated behind its trusted length. Java
+ /// authenticates the block on the first read; an empty stream is never read
from,
+ /// so it is authenticated when the stream is opened.
+ Status ValidateEmptyStream() {
+ if (plain_size_ != 0) {
+ return {};
+ }
+ return DecryptBlock(0);
+ }
+
+ Result<int64_t> Position() const override { return position_; }
+
+ Status Seek(int64_t position) override {
+ if (position < 0 || position > plain_size_) {
+ return IOError("Invalid position {} for stream of length {}", position,
+ plain_size_);
+ }
+ position_ = position;
+ return {};
+ }
+
+ Result<int64_t> Read(std::span<std::byte> out) override {
+ int64_t total = 0;
+ while (std::cmp_less(total, out.size()) && position_ < plain_size_) {
+ int64_t block_index = position_ / kPlainBlock;
+ ICEBERG_RETURN_UNEXPECTED(DecryptBlock(block_index));
+ int64_t offset = position_ % kPlainBlock;
+ int64_t n = std::min<int64_t>(current_block_size_ - offset,
+ static_cast<int64_t>(out.size()) - total);
+ std::memcpy(out.data() + total, plain_block_.data() + offset, n);
+ total += n;
+ position_ += n;
+ }
+ return total;
+ }
+
+ Status ReadFully(int64_t position, std::span<std::byte> out) override {
+ // The position is bounded before the length is added to it, so that a
position
+ // near int64 max does not overflow the range check.
+ if (position < 0 || position > plain_size_ ||
+ std::cmp_greater(out.size(), plain_size_ - position)) {
+ return IOError("Cannot read {} bytes at {} from stream of length {}",
out.size(),
+ position, plain_size_);
+ }
+ ICEBERG_RETURN_UNEXPECTED(Seek(position));
+ ICEBERG_ASSIGN_OR_RAISE(auto n, Read(out));
+ if (std::cmp_not_equal(n, out.size())) {
+ return IOError("Short read from GCM stream");
+ }
+ return {};
+ }
+
+ Status Close() override { return source_->Close(); }
+
+ private:
+ Status DecryptBlock(int64_t block_index) {
+ if (block_index == current_block_index_) {
+ return {};
+ }
+ // num_blocks_ is bounded by int32 max at construction, so the cast below
is safe.
+ int64_t size =
+ block_index == num_blocks_ - 1 ? last_cipher_block_size_ :
kCipherBlock;
+ auto cipher_block = std::span(cipher_block_).first(size);
+ ICEBERG_RETURN_UNEXPECTED(
+ source_->ReadFully(AesGcmStream::kHeaderLength + block_index *
kCipherBlock,
+ std::as_writable_bytes(cipher_block)));
+ auto aad = BlockAad(aad_prefix_, static_cast<int32_t>(block_index));
+ int64_t plain_size = size - kBlockOverhead;
+ // Decryption writes plaintext before the tag is checked, so the cached
block is
+ // invalid until the whole block authenticates.
+ current_block_index_ = -1;
+ current_block_size_ = 0;
+ ICEBERG_RETURN_UNEXPECTED(
+ cipher_->Decrypt(cipher_block, aad,
std::span(plain_block_).first(plain_size)));
+ current_block_size_ = plain_size;
+ current_block_index_ = block_index;
+ return {};
+ }
+
+ std::unique_ptr<SeekableInputStream> source_;
+ std::unique_ptr<AesGcmCipher> cipher_;
+ std::vector<uint8_t> aad_prefix_;
+ std::vector<uint8_t> cipher_block_;
+ std::vector<uint8_t> plain_block_;
+ int64_t num_blocks_ = 0;
+ int64_t last_cipher_block_size_ = 0;
+ int64_t plain_size_ = 0;
+ int64_t position_ = 0;
+ int64_t current_block_index_ = -1;
+ int64_t current_block_size_ = 0;
+};
+
+class AesGcmOutputStream : public PositionOutputStream {
+ public:
+ AesGcmOutputStream(std::unique_ptr<PositionOutputStream> target,
+ std::unique_ptr<AesGcmCipher> cipher,
+ std::vector<uint8_t> aad_prefix)
+ : target_(std::move(target)),
+ cipher_(std::move(cipher)),
+ aad_prefix_(std::move(aad_prefix)),
+ plain_block_(kPlainBlock),
+ cipher_block_(kCipherBlock) {}
+
+ Result<int64_t> Position() const override {
+ // Closing flushes the last block, which resets the in-block position, so
the
+ // plaintext length is captured before that happens.
+ if (closed_) {
+ return final_position_;
+ }
+ return block_index_ * kPlainBlock + position_in_block_;
+ }
+
+ Result<int64_t> StoredLength() const override { return
target_->StoredLength(); }
+
+ Status Write(std::span<const std::byte> data) override {
+ if (closed_) {
+ return IOError("Writing to closed stream");
+ }
+ ICEBERG_RETURN_UNEXPECTED(WriteHeaderIfNeeded());
+ while (!data.empty()) {
+ int64_t n = std::min<int64_t>(kPlainBlock - position_in_block_,
data.size());
+ std::memcpy(plain_block_.data() + position_in_block_, data.data(), n);
+ position_in_block_ += n;
+ data = data.subspan(n);
+ if (position_in_block_ == kPlainBlock) {
+ ICEBERG_RETURN_UNEXPECTED(EncryptAndWriteBlock());
+ }
+ }
+ return {};
+ }
+
+ Status Flush() override { return target_->Flush(); }
+
+ Status Close() override {
+ if (closed_) {
+ return {};
+ }
+ ICEBERG_RETURN_UNEXPECTED(WriteHeaderIfNeeded());
+ ICEBERG_ASSIGN_OR_RAISE(final_position_, Position());
+ // Like Java, an empty stream gets one empty block; otherwise only a
non-empty
+ // partial block is written.
+ if (position_in_block_ > 0 || block_index_ == 0) {
+ ICEBERG_RETURN_UNEXPECTED(EncryptAndWriteBlock());
+ }
+ closed_ = true;
+ return target_->Close();
+ }
+
+ private:
+ Status WriteHeaderIfNeeded() {
+ if (!header_written_) {
+ auto header = HeaderBytes();
+ ICEBERG_RETURN_UNEXPECTED(target_->Write(header));
+ header_written_ = true;
+ }
+ return {};
+ }
+
+ Status EncryptAndWriteBlock() {
+ if (last_block_written_) {
+ return Invalid("Cannot encrypt block: a partial block has already been
written");
+ }
+ if (block_index_ == std::numeric_limits<int32_t>::max()) {
+ return IOError("Cannot write block: exceeded Integer.MAX_VALUE blocks");
+ }
+ if (position_in_block_ != kPlainBlock) {
+ last_block_written_ = true;
+ }
+ auto aad = BlockAad(aad_prefix_, static_cast<int32_t>(block_index_));
+ auto out = std::span(cipher_block_).first(position_in_block_ +
kBlockOverhead);
+ ICEBERG_RETURN_UNEXPECTED(
+ cipher_->Encrypt(std::span(plain_block_).first(position_in_block_),
aad, out));
+ ICEBERG_RETURN_UNEXPECTED(target_->Write(std::as_bytes(out)));
+ position_in_block_ = 0;
+ ++block_index_;
+ return {};
+ }
+
+ std::unique_ptr<PositionOutputStream> target_;
+ std::unique_ptr<AesGcmCipher> cipher_;
+ std::vector<uint8_t> aad_prefix_;
+ std::vector<uint8_t> plain_block_;
+ std::vector<uint8_t> cipher_block_;
+ int64_t position_in_block_ = 0;
+ int64_t block_index_ = 0;
+ int64_t final_position_ = 0;
+ bool header_written_ = false;
+ bool last_block_written_ = false;
+ bool closed_ = false;
+};
+
+} // namespace
+
+Result<int64_t> AesGcmStream::PlaintextLength(int64_t encrypted_length) {
+ if (encrypted_length < kMinStreamLength) {
+ return Invalid(
+ "Invalid encrypted stream: {} is shorter than the minimum possible
stream "
+ "length {}",
+ encrypted_length, kMinStreamLength);
+ }
+ int64_t stream_length = encrypted_length - kHeaderLength;
+ int64_t num_full_blocks = stream_length / kCipherBlockSize;
+ int64_t last_bytes = stream_length - num_full_blocks * kCipherBlockSize;
+ int64_t num_blocks = last_bytes == 0 ? num_full_blocks : num_full_blocks + 1;
+ // Block AAD carries the block index as an int32, so Java bounds the block
count
+ // with Math.toIntExact. Reject here rather than when the block is decrypted.
+ if (num_blocks > std::numeric_limits<int32_t>::max()) {
+ return Invalid("Invalid encrypted stream: {} bytes exceeds the maximum of
{} blocks",
+ encrypted_length, std::numeric_limits<int32_t>::max());
+ }
+ if (last_bytes != 0 && last_bytes < kNonceLength + kGcmTagLength) {
+ return Invalid("Invalid GCM stream: truncated last block");
+ }
+ int64_t last_plain = last_bytes == 0 ? 0 : last_bytes - kNonceLength -
kGcmTagLength;
+ return num_full_blocks * kPlainBlockSize + last_plain;
+}
+
+AesGcmInputFile::AesGcmInputFile(std::unique_ptr<InputFile> source,
+ std::vector<uint8_t> key,
+ std::vector<uint8_t> aad_prefix,
+ int64_t encrypted_length)
+ : source_(std::move(source)),
+ key_(std::move(key)),
+ aad_prefix_(std::move(aad_prefix)),
+ encrypted_length_(encrypted_length) {}
+
+Result<int64_t> AesGcmInputFile::Size() const {
+ return AesGcmStream::PlaintextLength(encrypted_length_);
+}
+
+Result<std::unique_ptr<SeekableInputStream>> AesGcmInputFile::Open() {
+ ICEBERG_ASSIGN_OR_RAISE(auto plain_size,
+ AesGcmStream::PlaintextLength(encrypted_length_));
+ ICEBERG_ASSIGN_OR_RAISE(auto cipher, AesGcmCipher::Make(key_));
+ ICEBERG_ASSIGN_OR_RAISE(auto source, source_->Open());
+ auto stream = std::make_unique<AesGcmInputStream>(
+ std::move(source), std::move(cipher), aad_prefix_, encrypted_length_,
plain_size);
+ ICEBERG_RETURN_UNEXPECTED(stream->ValidateHeader());
+ ICEBERG_RETURN_UNEXPECTED(stream->ValidateEmptyStream());
+ return stream;
+}
+
+AesGcmOutputFile::AesGcmOutputFile(std::unique_ptr<OutputFile> target,
+ std::vector<uint8_t> key,
+ std::vector<uint8_t> aad_prefix)
+ : target_(std::move(target)),
+ key_(std::move(key)),
+ aad_prefix_(std::move(aad_prefix)) {}
+
+Result<std::unique_ptr<PositionOutputStream>> AesGcmOutputFile::Create() {
+ ICEBERG_ASSIGN_OR_RAISE(auto cipher, encryption::AesGcmCipher::Make(key_));
+ ICEBERG_ASSIGN_OR_RAISE(auto target, target_->Create());
+ return std::make_unique<AesGcmOutputStream>(std::move(target),
std::move(cipher),
+ aad_prefix_);
+}
+
+Result<std::unique_ptr<PositionOutputStream>>
AesGcmOutputFile::CreateOrOverwrite() {
+ ICEBERG_ASSIGN_OR_RAISE(auto cipher, encryption::AesGcmCipher::Make(key_));
+ ICEBERG_ASSIGN_OR_RAISE(auto target, target_->CreateOrOverwrite());
+ return std::make_unique<AesGcmOutputStream>(std::move(target),
std::move(cipher),
+ aad_prefix_);
+}
+
+} // namespace iceberg
diff --git a/src/iceberg/encryption/aes_gcm_stream.h
b/src/iceberg/encryption/aes_gcm_stream.h
new file mode 100644
index 00000000..766eb85e
--- /dev/null
+++ b/src/iceberg/encryption/aes_gcm_stream.h
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#pragma once
+
+/// \file iceberg/encryption/aes_gcm_stream.h
+/// AES GCM Stream (AGS1) files, see format/gcm-stream-spec.md.
+
+#include <array>
+#include <cstdint>
+#include <memory>
+#include <span>
+#include <vector>
+
+#include "iceberg/file_io.h"
+#include "iceberg/iceberg_bundle_export.h"
+#include "iceberg/result.h"
+
+namespace iceberg {
+
+/// \brief AES GCM Stream format constants.
+///
+/// AGS1 stores the block length in its header. This implementation only reads
and
+/// writes 1 MiB blocks, matching Java, rather than supporting other block
lengths.
+///
+/// For Java compatibility, an empty file contains one zero-length plaintext
block
+/// with a nonce and tag. The AGS1 spec describes a non-empty last block and
does not
+/// explicitly define this empty-file representation.
+struct AesGcmStream {
+ static constexpr int32_t kPlainBlockSize = 1024 * 1024;
+ static constexpr int32_t kNonceLength = 12;
+ static constexpr int32_t kGcmTagLength = 16;
+ static constexpr int32_t kCipherBlockSize =
+ kPlainBlockSize + kNonceLength + kGcmTagLength;
+ static constexpr std::array<char, 4> kMagic = {'A', 'G', 'S', '1'};
+ static constexpr int32_t kHeaderLength = 8; // magic + plain block size (LE
int32)
+ static constexpr int64_t kMinStreamLength =
+ kHeaderLength + kNonceLength + kGcmTagLength;
+
+ /// \brief Plaintext length of a stream with the given encrypted length.
+ ///
+ /// Returns an error if the length is shorter than an empty stream, implies
more
+ /// than int32 max blocks, or ends in a block too short to hold a nonce and
tag.
+ ICEBERG_BUNDLE_EXPORT static Result<int64_t> PlaintextLength(int64_t
encrypted_length);
+};
+
+/// \brief An InputFile that decrypts an AGS1 file.
+///
+/// The encrypted length must come from a trusted source (the key metadata),
so that
+/// truncation of the file is detected.
+class ICEBERG_BUNDLE_EXPORT AesGcmInputFile : public InputFile {
+ public:
+ AesGcmInputFile(std::unique_ptr<InputFile> source, std::vector<uint8_t> key,
+ std::vector<uint8_t> aad_prefix, int64_t encrypted_length);
+
+ std::string_view location() const override { return source_->location(); }
+ Result<int64_t> Size() const override;
+ Result<std::unique_ptr<SeekableInputStream>> Open() override;
+
+ private:
+ std::unique_ptr<InputFile> source_;
+ std::vector<uint8_t> key_;
+ std::vector<uint8_t> aad_prefix_;
+ int64_t encrypted_length_;
+};
+
+/// \brief An OutputFile that writes an AGS1 file.
+class ICEBERG_BUNDLE_EXPORT AesGcmOutputFile : public OutputFile {
+ public:
+ AesGcmOutputFile(std::unique_ptr<OutputFile> target, std::vector<uint8_t>
key,
+ std::vector<uint8_t> aad_prefix);
+
+ std::string_view location() const override { return target_->location(); }
+ Result<std::unique_ptr<PositionOutputStream>> Create() override;
+ Result<std::unique_ptr<PositionOutputStream>> CreateOrOverwrite() override;
+
+ private:
+ std::unique_ptr<OutputFile> target_;
+ std::vector<uint8_t> key_;
+ std::vector<uint8_t> aad_prefix_;
+};
+
+} // namespace iceberg
diff --git a/src/iceberg/encryption/standard_key_metadata.h
b/src/iceberg/encryption/standard_key_metadata.h
index ebad2d80..7e788eae 100644
--- a/src/iceberg/encryption/standard_key_metadata.h
+++ b/src/iceberg/encryption/standard_key_metadata.h
@@ -38,6 +38,10 @@ namespace iceberg {
/// and (encrypted by a key encryption key) in the table metadata
`encryption-keys` for
/// manifest lists.
///
+/// The Iceberg table spec leaves the key metadata representation to the
encryption
+/// scheme; it mandates neither this encoding nor AES-GCM. The schema below
follows
+/// Java's standard scheme.
+///
/// The serialized form is compatible with Java's StandardKeyMetadata: a
one-byte schema
/// version (1) followed by an Avro binary encoded record with the fields
/// `encryption_key: bytes`, `aad_prefix: union{null, bytes}` and
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index 8b9dd2e9..8622a1a6 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -229,6 +229,10 @@ if(ICEBERG_BUILD_BUNDLE)
target_sources(parquet_test PRIVATE parquet_encryption_test.cc)
endif()
+ if(ICEBERG_ENCRYPTION)
+ add_iceberg_test(crypto_test USE_BUNDLE SOURCES aes_gcm_test.cc)
+ endif()
+
add_iceberg_test(scan_test
USE_BUNDLE
SOURCES
@@ -410,6 +414,10 @@ if(ICEBERG_CXX20_COMPAT)
)
target_compile_definitions(cpp20_compatibility_test
PRIVATE ICEBERG_PUBLIC_HEADERS_BUNDLE)
+ if(ICEBERG_ENCRYPTION)
+ target_compile_definitions(cpp20_compatibility_test
+ PRIVATE ICEBERG_PUBLIC_HEADERS_ENCRYPTION)
+ endif()
endif()
if(ICEBERG_BUILD_REST)
target_link_libraries(cpp20_compatibility_test
diff --git a/src/iceberg/test/aes_gcm_test.cc b/src/iceberg/test/aes_gcm_test.cc
new file mode 100644
index 00000000..9b21a2c4
--- /dev/null
+++ b/src/iceberg/test/aes_gcm_test.cc
@@ -0,0 +1,376 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <cstring>
+#include <filesystem>
+#include <fstream>
+#include <limits>
+#include <span>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include "iceberg/arrow/arrow_io_util.h"
+#include "iceberg/encryption/aes_gcm_internal.h"
+#include "iceberg/encryption/aes_gcm_stream.h"
+#include "iceberg/encryption/standard_key_metadata.h"
+#include "iceberg/test/matchers.h"
+#include "iceberg/test/temp_file_test_base.h"
+
+namespace iceberg {
+
+namespace {
+
+using encryption::AesGcmCipher;
+
+/// \brief Deterministic test bytes: byte i is (i * 31 + seed).
+std::vector<uint8_t> TestBytes(size_t length, int seed) {
+ std::vector<uint8_t> bytes(length);
+ for (size_t i = 0; i < length; ++i) {
+ bytes[i] = static_cast<uint8_t>(static_cast<int>(i) * 31 + seed);
+ }
+ return bytes;
+}
+
+std::span<const uint8_t> AsBytes(std::string_view s) {
+ return {reinterpret_cast<const uint8_t*>(s.data()), s.size()};
+}
+
+// The fixtures below were produced by Iceberg Java. It used TestBytes(16, 1)
as the
+// data key, TestBytes(16, 2) as the file AAD prefix and TestBytes(16, 3) as
the key
+// encryption key.
+const std::vector<uint8_t> kDataKey = TestBytes(16, 1);
+const std::vector<uint8_t> kAadPrefix = TestBytes(16, 2);
+const std::vector<uint8_t> kKek = TestBytes(16, 3);
+
+// StandardEncryptionManager encrypts manifest list key metadata under the key
+// encryption key, with the KEK timestamp as AAD.
+constexpr std::string_view kKekTimestamp = "1791455219054";
+
+// Manifest list key metadata encrypted by Java under kKek with kKekTimestamp
+// as AAD: encryption key TestBytes(16, 1), AAD prefix TestBytes(16, 2),
+// file length 4242.
+const std::vector<uint8_t> kJavaManifestListKeyMetadata = {
+ 0xf2, 0x80, 0x57, 0xac, 0x14, 0xdc, 0x8d, 0xa7, 0xb2, 0x25, 0x58, 0x24,
0xa3, 0xe1,
+ 0x88, 0xb0, 0xde, 0x2e, 0x94, 0x09, 0x9b, 0x7d, 0xe4, 0x7d, 0x0e, 0xd6,
0xb0, 0x7a,
+ 0xb1, 0x86, 0x7b, 0xca, 0x61, 0x11, 0x2a, 0x11, 0x4e, 0xc1, 0xa8, 0x23,
0xd8, 0xa5,
+ 0xca, 0x43, 0x9c, 0x33, 0x6a, 0xe9, 0x6b, 0xf2, 0xc6, 0xdb, 0xa8, 0xe0,
0x2e, 0x7f,
+ 0xc0, 0xcc, 0x0e, 0xf0, 0xb7, 0xec, 0xb9, 0x1f, 0x06, 0x3a, 0xe7};
+// AES GCM stream of TestBytes(0, 5) written by Java's AesGcmOutputStream
+// with kDataKey and kAadPrefix.
+const std::vector<uint8_t> kJavaAgs1Empty = {
+ 0x41, 0x47, 0x53, 0x31, 0x00, 0x00, 0x10, 0x00, 0x92, 0xd6, 0x53, 0x8c,
+ 0x2b, 0x71, 0x45, 0x37, 0x37, 0x86, 0x76, 0x8f, 0xef, 0xd4, 0xdc, 0x98,
+ 0x15, 0xf1, 0x93, 0x9d, 0x6c, 0x56, 0x4b, 0xae, 0x24, 0xff, 0xdc, 0xb4};
+// AES GCM stream of TestBytes(1, 5) written by Java's AesGcmOutputStream
+// with kDataKey and kAadPrefix.
+const std::vector<uint8_t> kJavaAgs1OneByte = {
+ 0x41, 0x47, 0x53, 0x31, 0x00, 0x00, 0x10, 0x00, 0xf8, 0xde, 0xe0, 0x44,
0x00,
+ 0x8d, 0xbb, 0x2b, 0x6b, 0x29, 0x58, 0xb2, 0x70, 0x62, 0x46, 0xbb, 0xb1,
0xf8,
+ 0x76, 0x29, 0x69, 0x86, 0x14, 0x6c, 0x08, 0x98, 0x84, 0xdc, 0x3b};
+// AES GCM stream of TestBytes(100, 5) written by Java's AesGcmOutputStream
+// with kDataKey and kAadPrefix.
+const std::vector<uint8_t> kJavaAgs1Hundred = {
+ 0x41, 0x47, 0x53, 0x31, 0x00, 0x00, 0x10, 0x00, 0x02, 0xb0, 0x1c, 0x1f,
0x7e, 0x37,
+ 0xeb, 0x85, 0xd3, 0x4b, 0x14, 0x75, 0x91, 0x83, 0xcf, 0x68, 0xde, 0xdd,
0xe0, 0xff,
+ 0xd3, 0xe0, 0xeb, 0x31, 0x10, 0x4b, 0x95, 0x86, 0x5c, 0x67, 0x1f, 0x68,
0xdf, 0x24,
+ 0xbd, 0x0a, 0x07, 0xf1, 0x06, 0xc7, 0x13, 0x1e, 0x42, 0xd4, 0x7a, 0xff,
0x87, 0x32,
+ 0xc3, 0xe5, 0xea, 0x75, 0x6a, 0x53, 0xaa, 0xab, 0xab, 0xe0, 0x81, 0x2c,
0x71, 0x4c,
+ 0xb4, 0x89, 0x32, 0x89, 0xc6, 0xbb, 0x84, 0x5d, 0x58, 0x93, 0x6b, 0x80,
0xbd, 0xa3,
+ 0xe6, 0xc3, 0xef, 0x53, 0x83, 0xa4, 0x00, 0x9c, 0x30, 0x02, 0xd4, 0xca,
0x7e, 0x0e,
+ 0xb3, 0xa3, 0xff, 0x4c, 0x96, 0xe3, 0x31, 0xa1, 0x6a, 0xc1, 0xf2, 0xb7,
0xa0, 0x40,
+ 0xc4, 0x0b, 0x5c, 0x45, 0x09, 0xb7, 0x35, 0xda, 0x6c, 0x03, 0xce, 0xd5,
0x59, 0xd0,
+ 0x47, 0xf9, 0x0c, 0x9d, 0x1f, 0x89, 0x2f, 0x1b, 0xcb, 0x44};
+
+Result<std::vector<uint8_t>> ReadAll(InputFile& file) {
+ ICEBERG_ASSIGN_OR_RAISE(auto size, file.Size());
+ ICEBERG_ASSIGN_OR_RAISE(auto stream, file.Open());
+ std::vector<uint8_t> out(size);
+ if (size > 0) {
+ ICEBERG_RETURN_UNEXPECTED(
+ stream->ReadFully(0, std::as_writable_bytes(std::span(out))));
+ }
+ return out;
+}
+
+} // namespace
+
+TEST(AesGcmCipherTest, RoundTrip) {
+ for (size_t key_length : {16, 24, 32}) {
+ ICEBERG_UNWRAP_OR_FAIL(auto cipher,
AesGcmCipher::Make(TestBytes(key_length, 1)));
+ auto plaintext = TestBytes(100, 2);
+ ICEBERG_UNWRAP_OR_FAIL(auto ciphertext, cipher->Encrypt(plaintext,
AsBytes("aad")));
+ EXPECT_EQ(ciphertext.size(), plaintext.size() + 28);
+ ICEBERG_UNWRAP_OR_FAIL(auto decrypted, cipher->Decrypt(ciphertext,
AsBytes("aad")));
+ EXPECT_EQ(decrypted, plaintext);
+
+ // Random nonces: encrypting twice gives different ciphertexts
+ ICEBERG_UNWRAP_OR_FAIL(auto again, cipher->Encrypt(plaintext,
AsBytes("aad")));
+ EXPECT_NE(again, ciphertext);
+
+ // Wrong AAD, tampered ciphertext and truncated ciphertext fail
authentication
+ EXPECT_THAT(cipher->Decrypt(ciphertext, AsBytes("other")),
+ IsError(ErrorKind::kInvalid));
+ ciphertext[20] ^= 1;
+ EXPECT_THAT(cipher->Decrypt(ciphertext, AsBytes("aad")),
+ IsError(ErrorKind::kInvalid));
+ EXPECT_THAT(cipher->Decrypt(std::span(ciphertext).first(27),
AsBytes("aad")),
+ IsError(ErrorKind::kInvalid));
+ }
+ EXPECT_THAT(AesGcmCipher::Make(TestBytes(15, 1)),
IsError(ErrorKind::kInvalidArgument));
+}
+
+TEST(AesGcmCipherTest, DecryptsJavaManifestListKeyMetadata) {
+ ICEBERG_UNWRAP_OR_FAIL(auto cipher, AesGcmCipher::Make(kKek));
+ ICEBERG_UNWRAP_OR_FAIL(auto decrypted,
cipher->Decrypt(kJavaManifestListKeyMetadata,
+
AsBytes(kKekTimestamp)));
+ ICEBERG_UNWRAP_OR_FAIL(auto metadata, StandardKeyMetadata::Parse(decrypted));
+ EXPECT_EQ(metadata.encryption_key, TestBytes(16, 1));
+ EXPECT_EQ(metadata.aad_prefix, TestBytes(16, 2));
+ EXPECT_EQ(metadata.file_length, 4242);
+}
+
+class AesGcmStreamTest : public TempFileTestBase {
+ protected:
+ void SetUp() override {
+ TempFileTestBase::SetUp();
+ io_ = std::shared_ptr<FileIO>(arrow::MakeLocalFileIO());
+ }
+
+ // Write `plain` as an AGS1 stream and return the stored length.
+ int64_t Write(const std::string& path, std::span<const uint8_t> plain,
+ const std::vector<uint8_t>& key, const std::vector<uint8_t>&
aad,
+ size_t chunk = 1 << 20) {
+ auto out = io_->NewOutputFile(path);
+ EXPECT_THAT(out, IsOk());
+ AesGcmOutputFile file(std::move(out.value()), key, aad);
+ auto stream = file.CreateOrOverwrite();
+ EXPECT_THAT(stream, IsOk());
+ for (size_t pos = 0; pos < plain.size(); pos += chunk) {
+ auto part = plain.subspan(pos, std::min(chunk, plain.size() - pos));
+ EXPECT_THAT((*stream)->Write(std::as_bytes(part)), IsOk());
+ }
+ EXPECT_EQ((*stream)->Position().value(),
static_cast<int64_t>(plain.size()));
+ EXPECT_THAT((*stream)->Close(), IsOk());
+ // Java's AesGcmOutputStream keeps reporting the plaintext length once
closed
+ EXPECT_EQ((*stream)->Position().value(),
static_cast<int64_t>(plain.size()));
+ auto stored = (*stream)->StoredLength().value();
+ EXPECT_EQ(stored, static_cast<int64_t>(std::filesystem::file_size(path)));
+ return stored;
+ }
+
+ std::unique_ptr<AesGcmInputFile> Open(const std::string& path,
+ const std::vector<uint8_t>& key,
+ const std::vector<uint8_t>& aad,
int64_t length) {
+ auto in = io_->NewInputFile(path);
+ EXPECT_THAT(in, IsOk());
+ return std::make_unique<AesGcmInputFile>(std::move(in.value()), key, aad,
length);
+ }
+
+ // Materialize an inlined Java fixture as a file the reader can open.
+ std::string WriteRaw(const std::vector<uint8_t>& bytes) {
+ auto path = CreateNewTempFilePath();
+ std::ofstream out(path, std::ios::binary);
+ out.write(reinterpret_cast<const char*>(bytes.data()),
+ static_cast<std::streamsize>(bytes.size()));
+ out.close();
+ return path;
+ }
+
+ std::shared_ptr<FileIO> io_;
+};
+
+TEST_F(AesGcmStreamTest, ReadsJavaVectors) {
+ // Streams of TestBytes(0, 5), TestBytes(1, 5) and TestBytes(100, 5) written
by Java
+ const std::vector<std::pair<size_t, const std::vector<uint8_t>*>> vectors = {
+ {0, &kJavaAgs1Empty}, {1, &kJavaAgs1OneByte}, {100, &kJavaAgs1Hundred}};
+ for (const auto& [length, encrypted] : vectors) {
+ auto path = WriteRaw(*encrypted);
+ auto file = Open(path, kDataKey, kAadPrefix,
static_cast<int64_t>(encrypted->size()));
+ ICEBERG_UNWRAP_OR_FAIL(auto plain, ReadAll(*file));
+ EXPECT_EQ(plain, TestBytes(length, 5)) << "plaintext length " << length;
+ }
+}
+
+TEST_F(AesGcmStreamTest, WritesJavaCompatibleLayout) {
+ // Same plaintext, key and AAD prefix as Java's 100-byte vector: same header
and
+ // length (the ciphertext differs only by the random nonce).
+ auto path = CreateNewTempFilePath();
+ auto stored = Write(path, TestBytes(100, 5), kDataKey, kAadPrefix);
+ ASSERT_EQ(stored, static_cast<int64_t>(kJavaAgs1Hundred.size()));
+ std::ifstream in(path, std::ios::binary);
+ std::vector<uint8_t> header(8);
+ in.read(reinterpret_cast<char*>(header.data()), 8);
+ EXPECT_EQ(header,
+ std::vector<uint8_t>(kJavaAgs1Hundred.begin(),
kJavaAgs1Hundred.begin() + 8));
+}
+
+TEST_F(AesGcmStreamTest, RoundTripAcrossBlocks) {
+ auto key = TestBytes(32, 7);
+ auto aad = TestBytes(16, 9);
+ constexpr size_t kBlock = AesGcmStream::kPlainBlockSize;
+ for (size_t size :
+ {size_t{0}, size_t{1}, kBlock - 1, kBlock, kBlock + 1, 2 * kBlock +
17}) {
+ auto path = CreateNewTempFilePath();
+ auto plain = TestBytes(size, 5);
+ // Odd write sizes exercise writes that straddle blocks
+ int64_t stored = Write(path, plain, key, aad, /*chunk=*/333331);
+ size_t blocks = size == 0 ? 1 : (size + kBlock - 1) / kBlock;
+ EXPECT_EQ(stored, static_cast<int64_t>(8 + size + blocks * 28));
+ ICEBERG_UNWRAP_OR_FAIL(auto plain_size,
AesGcmStream::PlaintextLength(stored));
+ EXPECT_EQ(plain_size, static_cast<int64_t>(size));
+
+ auto file = Open(path, key, aad, stored);
+ ICEBERG_UNWRAP_OR_FAIL(auto read, ReadAll(*file));
+ EXPECT_EQ(read, plain);
+
+ if (size > kBlock + 10) {
+ // Random access across a block boundary
+ ICEBERG_UNWRAP_OR_FAIL(auto stream, file->Open());
+ std::vector<std::byte> window(10);
+ ASSERT_THAT(stream->ReadFully(kBlock - 5, window), IsOk());
+ EXPECT_EQ(std::memcmp(window.data(), plain.data() + kBlock - 5, 10), 0);
+ }
+ }
+}
+
+TEST_F(AesGcmStreamTest, DetectsTampering) {
+ auto key = TestBytes(16, 7);
+ auto aad = TestBytes(16, 9);
+ auto path = CreateNewTempFilePath();
+ auto plain = TestBytes(AesGcmStream::kPlainBlockSize + 100, 5);
+ int64_t stored = Write(path, plain, key, aad);
+
+ // Wrong key or AAD prefix (a file swapped in from another manifest entry)
+ EXPECT_THAT(ReadAll(*Open(path, TestBytes(16, 8), aad, stored)),
+ IsError(ErrorKind::kInvalid));
+ EXPECT_THAT(ReadAll(*Open(path, key, TestBytes(16, 10), stored)),
+ IsError(ErrorKind::kInvalid));
+
+ // Truncation: the trusted length from the key metadata no longer matches
the file
+ std::filesystem::resize_file(path, 8 + AesGcmStream::kCipherBlockSize);
+ EXPECT_THAT(ReadAll(*Open(path, key, aad, stored)),
IsError(ErrorKind::kIOError));
+ // AGS1 blocks do not mark the end of the stream: a truncated file read with
its own
+ // (untrusted) length still decrypts. This is why readers must take the
length from
+ // the key metadata.
+ auto truncated = Open(path, key, aad, 8 + AesGcmStream::kCipherBlockSize);
+ ICEBERG_UNWRAP_OR_FAIL(auto first_block, ReadAll(*truncated));
+ EXPECT_EQ(first_block.size(), AesGcmStream::kPlainBlockSize);
+
+ // Bad magic
+ std::filesystem::resize_file(path, 0);
+ std::ofstream(path, std::ios::binary) << "XXXX0000" << std::string(28, '\0');
+ EXPECT_THAT(Open(path, key, aad, 36)->Open(), IsError(ErrorKind::kInvalid));
+ // Shorter than an empty stream
+ EXPECT_THAT(Open(path, key, aad, 35)->Open(), IsError(ErrorKind::kInvalid));
+}
+
+TEST_F(AesGcmStreamTest, AuthenticatesEmptyStream) {
+ auto key = TestBytes(16, 7);
+ auto aad = TestBytes(16, 9);
+ auto path = CreateNewTempFilePath();
+ int64_t stored = Write(path, {}, key, aad);
+ ASSERT_EQ(stored, AesGcmStream::kMinStreamLength);
+
+ // An empty stream still carries one block with a nonce and a tag, so
opening it
+ // authenticates that block even though there is no plaintext to read.
+ ICEBERG_UNWRAP_OR_FAIL(auto plain, ReadAll(*Open(path, key, aad, stored)));
+ EXPECT_TRUE(plain.empty());
+
+ // Wrong key or AAD prefix (a file swapped in from another manifest entry)
+ EXPECT_THAT(Open(path, TestBytes(16, 8), aad, stored)->Open(),
+ IsError(ErrorKind::kInvalid));
+ EXPECT_THAT(Open(path, key, TestBytes(16, 10), stored)->Open(),
+ IsError(ErrorKind::kInvalid));
+
+ // Truncated to its header behind a trusted length that says otherwise
+ std::filesystem::resize_file(path, AesGcmStream::kHeaderLength);
+ EXPECT_THAT(Open(path, key, aad, stored)->Open(),
IsError(ErrorKind::kIOError));
+
+ // A corrupt tag
+ auto corrupt = WriteRaw(kJavaAgs1Empty);
+ {
+ std::fstream out(corrupt, std::ios::binary | std::ios::in | std::ios::out);
+ out.seekp(static_cast<std::streamoff>(kJavaAgs1Empty.size() - 1));
+ out.put(static_cast<char>(kJavaAgs1Empty.back() ^ 0xFF));
+ }
+ EXPECT_THAT(
+ Open(corrupt, kDataKey, kAadPrefix,
static_cast<int64_t>(kJavaAgs1Empty.size()))
+ ->Open(),
+ IsError(ErrorKind::kInvalid));
+}
+
+TEST_F(AesGcmStreamTest, RejectsOutOfRangeReadFully) {
+ auto key = TestBytes(16, 7);
+ auto aad = TestBytes(16, 9);
+ auto path = CreateNewTempFilePath();
+ int64_t stored = Write(path, TestBytes(100, 5), key, aad);
+ ICEBERG_UNWRAP_OR_FAIL(auto stream, Open(path, key, aad, stored)->Open());
+
+ // A position near int64 max must be rejected without overflowing the range
check
+ std::vector<std::byte> one(1);
+ EXPECT_THAT(stream->ReadFully(std::numeric_limits<int64_t>::max(), one),
+ IsError(ErrorKind::kIOError));
+ EXPECT_THAT(stream->ReadFully(-1, one), IsError(ErrorKind::kIOError));
+
+ // Reading up to the last byte is allowed; one byte past it is not
+ EXPECT_THAT(stream->ReadFully(99, one), IsOk());
+ EXPECT_THAT(stream->ReadFully(100, one), IsError(ErrorKind::kIOError));
+ EXPECT_THAT(stream->ReadFully(100, std::span<std::byte>{}), IsOk());
+}
+
+TEST_F(AesGcmStreamTest, RejectsInvalidEncryptedLength) {
+ constexpr int64_t kHeader = AesGcmStream::kHeaderLength;
+ constexpr int64_t kCipherBlock = AesGcmStream::kCipherBlockSize;
+ constexpr int64_t kOverhead = AesGcmStream::kNonceLength +
AesGcmStream::kGcmTagLength;
+
+ // Shorter than an empty stream
+ EXPECT_THAT(AesGcmStream::PlaintextLength(-1), IsError(ErrorKind::kInvalid));
+ EXPECT_THAT(AesGcmStream::PlaintextLength(0), IsError(ErrorKind::kInvalid));
+ EXPECT_THAT(AesGcmStream::PlaintextLength(AesGcmStream::kMinStreamLength -
1),
+ IsError(ErrorKind::kInvalid));
+ EXPECT_THAT(AesGcmStream::PlaintextLength(AesGcmStream::kMinStreamLength),
+ HasValue(::testing::Eq(0)));
+
+ // A last block too short to hold a nonce and a tag
+ EXPECT_THAT(AesGcmStream::PlaintextLength(kHeader + kCipherBlock + kOverhead
- 1),
+ IsError(ErrorKind::kInvalid));
+ EXPECT_THAT(AesGcmStream::PlaintextLength(kHeader + kCipherBlock +
kOverhead),
+ HasValue(::testing::Eq(AesGcmStream::kPlainBlockSize)));
+
+ // More blocks than the int32 block index in the block AAD can address. The
check
+ // must happen here rather than when a block is decrypted, so that a caller
asking
+ // only for the size never sees a length it could not read back.
+ constexpr int64_t kMaxBlocks = std::numeric_limits<int32_t>::max();
+ EXPECT_THAT(AesGcmStream::PlaintextLength(kHeader + kMaxBlocks *
kCipherBlock),
+ HasValue(::testing::Eq(kMaxBlocks *
AesGcmStream::kPlainBlockSize)));
+ EXPECT_THAT(AesGcmStream::PlaintextLength(kHeader + kMaxBlocks *
kCipherBlock + 1),
+ IsError(ErrorKind::kInvalid));
+
+ // Both Size() and Open() reject it, rather than Open() alone
+ auto path = CreateNewTempFilePath();
+ Write(path, TestBytes(10, 5), kDataKey, kAadPrefix);
+ auto file = Open(path, kDataKey, kAadPrefix, kHeader + kMaxBlocks *
kCipherBlock + 1);
+ EXPECT_THAT(file->Size(), IsError(ErrorKind::kInvalid));
+ EXPECT_THAT(file->Open(), IsError(ErrorKind::kInvalid));
+}
+
+} // namespace iceberg