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 0bdf7ce9 feat: add standard encryption key metadata (#991)
0bdf7ce9 is described below

commit 0bdf7ce9961d7ef8e1c2b8670ae412e86526c53b
Author: Xinli Shang <[email protected]>
AuthorDate: Fri Oct 16 15:37:16 2026 +0900

    feat: add standard encryption key metadata (#991)
    
    StandardKeyMetadata is the per-file key metadata of the standard Iceberg
    encryption scheme: the data encryption key, the AAD prefix (file ID) and,
    for AES GCM streams, the encrypted file length. It is stored in the
    key_metadata field of data, delete and manifest files, and (encrypted) in
    the table metadata encryption-keys for manifest lists.
    
    The serialized form is Java's: a schema version byte (1) followed by an
    Avro binary record. Tests check byte-for-byte compatibility with vectors
    written by Java (checked in under test/resources/encryption/vectors,
    excluded from the text-fixing pre-commit hooks).
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 src/iceberg/CMakeLists.txt                      |   1 +
 src/iceberg/cpp20_compatibility_internal.h      |   1 +
 src/iceberg/encryption/standard_key_metadata.cc | 168 ++++++++++++++++++++++++
 src/iceberg/encryption/standard_key_metadata.h  |  68 ++++++++++
 src/iceberg/test/CMakeLists.txt                 |   2 +
 src/iceberg/test/standard_key_metadata_test.cc  | 139 ++++++++++++++++++++
 6 files changed, 379 insertions(+)

diff --git a/src/iceberg/CMakeLists.txt b/src/iceberg/CMakeLists.txt
index 717f1da4..c283b8af 100644
--- a/src/iceberg/CMakeLists.txt
+++ b/src/iceberg/CMakeLists.txt
@@ -33,6 +33,7 @@ set(ICEBERG_SOURCES
     deletes/position_delete_index.cc
     deletes/position_delete_range_consumer.cc
     deletes/roaring_position_bitmap.cc
+    encryption/standard_key_metadata.cc
     expression/aggregate.cc
     expression/binder.cc
     expression/evaluator.cc
diff --git a/src/iceberg/cpp20_compatibility_internal.h 
b/src/iceberg/cpp20_compatibility_internal.h
index 1b719f1b..359e5ddf 100644
--- a/src/iceberg/cpp20_compatibility_internal.h
+++ b/src/iceberg/cpp20_compatibility_internal.h
@@ -49,6 +49,7 @@
 #include "iceberg/deletes/position_delete_range_consumer.h"
 #include "iceberg/deletes/roaring_position_bitmap.h"
 #include "iceberg/encryption/encrypted_key.h"
+#include "iceberg/encryption/standard_key_metadata.h"
 #include "iceberg/exception.h"
 #include "iceberg/expression/aggregate.h"
 #include "iceberg/expression/binder.h"
diff --git a/src/iceberg/encryption/standard_key_metadata.cc 
b/src/iceberg/encryption/standard_key_metadata.cc
new file mode 100644
index 00000000..18f2d92e
--- /dev/null
+++ b/src/iceberg/encryption/standard_key_metadata.cc
@@ -0,0 +1,168 @@
+/*
+ * 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/standard_key_metadata.h"
+
+#include <utility>
+
+#include "iceberg/util/macros.h"
+
+namespace iceberg {
+
+namespace {
+
+void WriteLong(int64_t value, std::vector<uint8_t>& out) {
+  // Avro zig-zag varint encoding
+  auto n = (static_cast<uint64_t>(value) << 1) ^ static_cast<uint64_t>(value 
>> 63);
+  while (n & ~0x7FULL) {
+    out.push_back(static_cast<uint8_t>((n & 0x7F) | 0x80));
+    n >>= 7;
+  }
+  out.push_back(static_cast<uint8_t>(n));
+}
+
+void WriteBytes(std::span<const uint8_t> bytes, std::vector<uint8_t>& out) {
+  WriteLong(static_cast<int64_t>(bytes.size()), out);
+  out.insert(out.end(), bytes.begin(), bytes.end());
+}
+
+class Decoder {
+ public:
+  explicit Decoder(std::span<const uint8_t> buffer) : buffer_(buffer) {}
+
+  Result<int64_t> ReadLong() {
+    uint64_t n = 0;
+    int shift = 0;
+    while (true) {
+      if (pos_ >= buffer_.size()) {
+        return Invalid("Truncated key metadata");
+      }
+      if (shift > 63) {
+        return Invalid("Invalid varint in key metadata");
+      }
+      uint8_t b = buffer_[pos_++];
+      n |= static_cast<uint64_t>(b & 0x7F) << shift;
+      if ((b & 0x80) == 0) {
+        break;
+      }
+      shift += 7;
+    }
+    return static_cast<int64_t>((n >> 1) ^ -(n & 1));
+  }
+
+  /// \brief Read a zig-zag varint, limited to the five bytes of an Avro int.
+  Result<int32_t> ReadInt() {
+    uint32_t n = 0;
+    int shift = 0;
+    while (true) {
+      if (pos_ >= buffer_.size()) {
+        return Invalid("Truncated key metadata");
+      }
+      if (shift > 28) {
+        return Invalid("Invalid int varint in key metadata");
+      }
+      uint8_t b = buffer_[pos_++];
+      n |= static_cast<uint32_t>(b & 0x7F) << shift;
+      if ((b & 0x80) == 0) {
+        break;
+      }
+      shift += 7;
+    }
+    return static_cast<int32_t>((n >> 1) ^ -(n & 1));
+  }
+
+  Result<std::vector<uint8_t>> ReadBytes() {
+    ICEBERG_ASSIGN_OR_RAISE(auto length, ReadLong());
+    if (length < 0 || std::cmp_greater(length, buffer_.size() - pos_)) {
+      return Invalid("Invalid bytes length in key metadata: {}", length);
+    }
+    std::vector<uint8_t> bytes(buffer_.begin() + pos_, buffer_.begin() + pos_ 
+ length);
+    pos_ += length;
+    return bytes;
+  }
+
+  /// \brief Read the branch index of a ["null", T] union; true if T is 
present.
+  ///
+  /// Avro encodes the branch index as an int, so this rejects the longer
+  /// encodings that ReadLong accepts, as Java's readIndex does.
+  Result<bool> ReadOptionalBranch() {
+    ICEBERG_ASSIGN_OR_RAISE(auto branch, ReadInt());
+    if (branch != 0 && branch != 1) {
+      return Invalid("Invalid union branch in key metadata: {}", branch);
+    }
+    return branch == 1;
+  }
+
+ private:
+  std::span<const uint8_t> buffer_;
+  size_t pos_ = 0;
+};
+
+}  // namespace
+
+Result<StandardKeyMetadata> StandardKeyMetadata::Parse(std::span<const 
uint8_t> buffer) {
+  if (buffer.empty()) {
+    return Invalid("Empty key metadata");
+  }
+  if (buffer[0] != kV1) {
+    return NotSupported("Unsupported key metadata schema version: {}",
+                        static_cast<int>(buffer[0]));
+  }
+
+  Decoder decoder(buffer.subspan(1));
+  StandardKeyMetadata metadata;
+  ICEBERG_ASSIGN_OR_RAISE(metadata.encryption_key, decoder.ReadBytes());
+  ICEBERG_ASSIGN_OR_RAISE(auto has_aad_prefix, decoder.ReadOptionalBranch());
+  if (has_aad_prefix) {
+    ICEBERG_ASSIGN_OR_RAISE(metadata.aad_prefix, decoder.ReadBytes());
+  }
+  ICEBERG_ASSIGN_OR_RAISE(auto has_file_length, decoder.ReadOptionalBranch());
+  if (has_file_length) {
+    ICEBERG_ASSIGN_OR_RAISE(metadata.file_length, decoder.ReadLong());
+  }
+  // Trailing bytes are ignored, matching Java, which decodes a single record.
+  return metadata;
+}
+
+std::vector<uint8_t> StandardKeyMetadata::Serialize() const {
+  std::vector<uint8_t> out;
+  out.push_back(kV1);
+  WriteBytes(encryption_key, out);
+  if (aad_prefix.has_value()) {
+    WriteLong(1, out);
+    WriteBytes(*aad_prefix, out);
+  } else {
+    WriteLong(0, out);
+  }
+  if (file_length.has_value()) {
+    WriteLong(1, out);
+    WriteLong(*file_length, out);
+  } else {
+    WriteLong(0, out);
+  }
+  return out;
+}
+
+StandardKeyMetadata StandardKeyMetadata::WithFileLength(int64_t length) const {
+  StandardKeyMetadata copy = *this;
+  copy.file_length = length;
+  return copy;
+}
+
+}  // namespace iceberg
diff --git a/src/iceberg/encryption/standard_key_metadata.h 
b/src/iceberg/encryption/standard_key_metadata.h
new file mode 100644
index 00000000..ebad2d80
--- /dev/null
+++ b/src/iceberg/encryption/standard_key_metadata.h
@@ -0,0 +1,68 @@
+/*
+ * 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/standard_key_metadata.h
+/// Key metadata of the standard Iceberg encryption scheme.
+
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <vector>
+
+#include "iceberg/iceberg_export.h"
+#include "iceberg/result.h"
+
+namespace iceberg {
+
+/// \brief Per-file key metadata of the standard encryption scheme.
+///
+/// Stored in the `key_metadata` field of data files, delete files and 
manifest files,
+/// and (encrypted by a key encryption key) in the table metadata 
`encryption-keys` for
+/// manifest lists.
+///
+/// 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
+/// `file_length: union{null, long}`.
+struct ICEBERG_EXPORT StandardKeyMetadata {
+  static constexpr uint8_t kV1 = 1;
+
+  /// \brief The data encryption key (16, 24 or 32 bytes).
+  std::vector<uint8_t> encryption_key;
+  /// \brief The AAD prefix (file ID) used to bind ciphertext to this file.
+  std::optional<std::vector<uint8_t>> aad_prefix;
+  /// \brief The encrypted (stored) file length, required to read AES GCM 
streams.
+  std::optional<int64_t> file_length;
+
+  /// \brief Parse serialized key metadata.
+  static Result<StandardKeyMetadata> Parse(std::span<const uint8_t> buffer);
+
+  /// \brief Serialize to the version-prefixed Avro binary form.
+  std::vector<uint8_t> Serialize() const;
+
+  /// \brief Return a copy with the given encrypted file length.
+  StandardKeyMetadata WithFileLength(int64_t length) const;
+
+  friend bool operator==(const StandardKeyMetadata&,
+                         const StandardKeyMetadata&) = default;
+};
+
+}  // namespace iceberg
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index 292132bb..a86bc908 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -116,6 +116,8 @@ add_iceberg_test(expression_test
                  residual_evaluator_test.cc
                  strict_metrics_evaluator_test.cc)
 
+add_iceberg_test(encryption_test SOURCES standard_key_metadata_test.cc)
+
 add_iceberg_test(json_serde_test
                  SOURCES
                  json_serde_test.cc
diff --git a/src/iceberg/test/standard_key_metadata_test.cc 
b/src/iceberg/test/standard_key_metadata_test.cc
new file mode 100644
index 00000000..dd83c1ce
--- /dev/null
+++ b/src/iceberg/test/standard_key_metadata_test.cc
@@ -0,0 +1,139 @@
+/*
+ * 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/standard_key_metadata.h"
+
+#include <limits>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include "iceberg/test/matchers.h"
+
+namespace iceberg {
+
+namespace {
+
+/// \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;
+}
+
+// Serialized by Iceberg Java's StandardKeyMetadata, with encryption key
+// TestBytes(16, 1), AAD prefix TestBytes(16, 2) and no file length.
+const std::vector<uint8_t> kJavaKeyMetadata = {
+    0x01, 0x20, 0x01, 0x20, 0x3f, 0x5e, 0x7d, 0x9c, 0xbb, 0xda, 0xf9, 0x18, 
0x37,
+    0x56, 0x75, 0x94, 0xb3, 0xd2, 0x02, 0x20, 0x02, 0x21, 0x40, 0x5f, 0x7e, 
0x9d,
+    0xbc, 0xdb, 0xfa, 0x19, 0x38, 0x57, 0x76, 0x95, 0xb4, 0xd3, 0x00};
+
+// The same, with file length 1234567.
+const std::vector<uint8_t> kJavaKeyMetadataWithLength = {
+    0x01, 0x20, 0x01, 0x20, 0x3f, 0x5e, 0x7d, 0x9c, 0xbb, 0xda, 0xf9, 0x18, 
0x37, 0x56,
+    0x75, 0x94, 0xb3, 0xd2, 0x02, 0x20, 0x02, 0x21, 0x40, 0x5f, 0x7e, 0x9d, 
0xbc, 0xdb,
+    0xfa, 0x19, 0x38, 0x57, 0x76, 0x95, 0xb4, 0xd3, 0x02, 0x8e, 0xda, 0x96, 
0x01};
+
+}  // namespace
+
+TEST(StandardKeyMetadataTest, ParseJavaVectors) {
+  auto key = TestBytes(16, 1);
+  auto aad_prefix = TestBytes(16, 2);
+
+  const auto& with_length = kJavaKeyMetadataWithLength;
+  ICEBERG_UNWRAP_OR_FAIL(auto parsed, StandardKeyMetadata::Parse(with_length));
+  EXPECT_EQ(parsed.encryption_key, key);
+  EXPECT_EQ(parsed.aad_prefix, aad_prefix);
+  EXPECT_EQ(parsed.file_length, 1234567);
+  // Serialization is byte-for-byte identical to Java
+  EXPECT_EQ(parsed.Serialize(), with_length);
+
+  const auto& without_length = kJavaKeyMetadata;
+  ICEBERG_UNWRAP_OR_FAIL(auto parsed2, 
StandardKeyMetadata::Parse(without_length));
+  EXPECT_EQ(parsed2.encryption_key, key);
+  EXPECT_FALSE(parsed2.file_length.has_value());
+  EXPECT_EQ(parsed2.Serialize(), without_length);
+  EXPECT_EQ(parsed2.WithFileLength(1234567).Serialize(), with_length);
+}
+
+TEST(StandardKeyMetadataTest, RoundTrip) {
+  StandardKeyMetadata metadata{.encryption_key = TestBytes(32, 7)};
+  ICEBERG_UNWRAP_OR_FAIL(auto parsed, 
StandardKeyMetadata::Parse(metadata.Serialize()));
+  EXPECT_EQ(parsed, metadata);
+
+  // Zig-zag varint boundaries, including negative values and ten-byte 
encodings
+  for (int64_t length :
+       {int64_t{0}, int64_t{63}, int64_t{64}, int64_t{1} << 40, int64_t{-1},
+        std::numeric_limits<int64_t>::min(), 
std::numeric_limits<int64_t>::max()}) {
+    auto with_length = metadata.WithFileLength(length);
+    ICEBERG_UNWRAP_OR_FAIL(auto round_trip,
+                           
StandardKeyMetadata::Parse(with_length.Serialize()));
+    EXPECT_EQ(round_trip, with_length);
+  }
+
+  // A present but empty AAD prefix is distinct from an absent one
+  StandardKeyMetadata empty_aad{.encryption_key = TestBytes(16, 7),
+                                .aad_prefix = std::vector<uint8_t>{}};
+  ICEBERG_UNWRAP_OR_FAIL(auto parsed_empty,
+                         StandardKeyMetadata::Parse(empty_aad.Serialize()));
+  ASSERT_TRUE(parsed_empty.aad_prefix.has_value());
+  EXPECT_TRUE(parsed_empty.aad_prefix->empty());
+  ICEBERG_UNWRAP_OR_FAIL(auto parsed_null,
+                         StandardKeyMetadata::Parse(metadata.Serialize()));
+  EXPECT_FALSE(parsed_null.aad_prefix.has_value());
+}
+
+TEST(StandardKeyMetadataTest, InvalidInput) {
+  EXPECT_THAT(StandardKeyMetadata::Parse(std::vector<uint8_t>{}),
+              IsError(ErrorKind::kInvalid));
+  // Unknown schema version
+  EXPECT_THAT(StandardKeyMetadata::Parse(std::vector<uint8_t>{2, 0, 0, 0}),
+              IsError(ErrorKind::kNotSupported));
+  // Key length beyond the buffer
+  EXPECT_THAT(StandardKeyMetadata::Parse(std::vector<uint8_t>{1, 0x20, 1, 2}),
+              IsError(ErrorKind::kInvalid));
+  // Invalid union branch
+  EXPECT_THAT(StandardKeyMetadata::Parse(std::vector<uint8_t>{1, 0x02, 9, 
0x04}),
+              IsError(ErrorKind::kInvalid));
+  // Truncated optional fields
+  EXPECT_THAT(StandardKeyMetadata::Parse(std::vector<uint8_t>{1, 0x02, 9}),
+              IsError(ErrorKind::kInvalid));
+  // Negative key length (zig-zag 0x01 is -1)
+  EXPECT_THAT(StandardKeyMetadata::Parse(std::vector<uint8_t>{1, 0x01, 9}),
+              IsError(ErrorKind::kInvalid));
+  // Truncated varint
+  EXPECT_THAT(StandardKeyMetadata::Parse(std::vector<uint8_t>{1, 0x80}),
+              IsError(ErrorKind::kInvalid));
+  // Varint longer than ten bytes, rejected as by Java's Avro decoder
+  std::vector<uint8_t> long_varint{1};
+  long_varint.insert(long_varint.end(), 10, 0x80);
+  long_varint.push_back(0x01);
+  EXPECT_THAT(StandardKeyMetadata::Parse(long_varint), 
IsError(ErrorKind::kInvalid));
+  // Union index encoded in six bytes: a valid long but not a valid int, so
+  // Java's readIndex rejects it even though it decodes to branch zero. The
+  // trailing 0x00 is a well-formed file_length branch, so the record is
+  // otherwise complete and only the index encoding makes it invalid.
+  EXPECT_THAT(StandardKeyMetadata::Parse(std::vector<uint8_t>{
+                  1, 0x02, 9, 0x80, 0x80, 0x80, 0x80, 0x80, 0x00, 0x00}),
+              IsError(ErrorKind::kInvalid));
+}
+
+}  // namespace iceberg

Reply via email to