Skip to content

Commit 5202ea5

Browse files
xanderbaileykevinjqliuCopilot
authored
feat(encryption): [2/N] Add standard key metadata (#3948)
* feat(encryption): [2/N] Add standard key metadata * fix(encryption): Redact key metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Kevin Liu <kevin.jq.liu@gmail.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 5ba0b43 commit 5202ea5

3 files changed

Lines changed: 196 additions & 0 deletions

File tree

pyiceberg/encryption/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
"""Key metadata for encrypted manifests, manifest lists and data files."""
18+
19+
from __future__ import annotations
20+
21+
import io
22+
from dataclasses import dataclass, field
23+
24+
from pyiceberg.avro.decoder import new_decoder
25+
from pyiceberg.avro.encoder import BinaryEncoder
26+
from pyiceberg.avro.resolver import construct_reader, construct_writer
27+
from pyiceberg.schema import Schema
28+
from pyiceberg.typedef import Record
29+
from pyiceberg.types import BinaryType, LongType, NestedField
30+
31+
KEY_METADATA_V1 = 1
32+
33+
AES_KEY_LENGTHS = (16, 24, 32)
34+
35+
KEY_METADATA_SCHEMA_V1 = Schema(
36+
NestedField(field_id=0, name="encryption_key", field_type=BinaryType(), required=True),
37+
NestedField(field_id=1, name="aad_prefix", field_type=BinaryType(), required=False),
38+
NestedField(field_id=2, name="file_length", field_type=LongType(), required=False),
39+
)
40+
41+
42+
@dataclass(frozen=True)
43+
class StandardKeyMetadata:
44+
"""The key and AAD prefix needed to decrypt a single file.
45+
46+
Wire format is a version byte followed by an Avro datum of `KEY_METADATA_SCHEMA_V1`,
47+
byte-compatible with Java's `StandardKeyMetadata`.
48+
"""
49+
50+
encryption_key: bytes = field(repr=False)
51+
aad_prefix: bytes | None = None
52+
file_length: int | None = None
53+
54+
def __post_init__(self) -> None:
55+
"""Reject invalid key lengths here rather than only on decode, so an invalid instance cannot exist."""
56+
if len(self.encryption_key) not in AES_KEY_LENGTHS:
57+
raise ValueError(
58+
f"Invalid encryption key in key metadata: expected one of {AES_KEY_LENGTHS} bytes, got {len(self.encryption_key)}"
59+
)
60+
61+
@classmethod
62+
def from_bytes(cls, data: bytes) -> StandardKeyMetadata:
63+
"""Decode key metadata from its wire format."""
64+
if not data:
65+
raise ValueError("Empty key metadata")
66+
67+
if (version := data[0]) != KEY_METADATA_V1:
68+
raise ValueError(f"Unsupported key metadata version: {version}")
69+
70+
record = construct_reader(KEY_METADATA_SCHEMA_V1).read(new_decoder(data[1:]))
71+
return cls(encryption_key=record[0], aad_prefix=record[1], file_length=record[2])
72+
73+
def to_bytes(self) -> bytes:
74+
"""Encode key metadata to its wire format."""
75+
output = io.BytesIO()
76+
encoder = BinaryEncoder(output)
77+
encoder.write(bytes([KEY_METADATA_V1]))
78+
record = Record(self.encryption_key, self.aad_prefix, self.file_length)
79+
construct_writer(KEY_METADATA_SCHEMA_V1).write(encoder, record)
80+
return output.getvalue()
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import io
19+
20+
import pytest
21+
22+
from pyiceberg.avro.encoder import BinaryEncoder
23+
from pyiceberg.encryption.key_metadata import KEY_METADATA_V1, StandardKeyMetadata
24+
25+
AES128_KEY = b"0123456789012345"
26+
27+
# Version byte, then the Avro-encoded encryption_key: zigzag length 16 followed by the key
28+
ENCODED_PREFIX = b"\x01\x20" + AES128_KEY
29+
30+
31+
def encode_key_metadata(encryption_key: bytes) -> bytes:
32+
"""Encode key metadata directly, bypassing StandardKeyMetadata's validation."""
33+
output = io.BytesIO()
34+
encoder = BinaryEncoder(output)
35+
encoder.write(bytes([KEY_METADATA_V1]))
36+
encoder.write_bytes(encryption_key)
37+
encoder.write_int(0)
38+
encoder.write_int(0)
39+
return output.getvalue()
40+
41+
42+
@pytest.mark.parametrize(
43+
"key_metadata, encoded",
44+
[
45+
(StandardKeyMetadata(encryption_key=AES128_KEY), ENCODED_PREFIX + b"\x00\x00"),
46+
(StandardKeyMetadata(encryption_key=AES128_KEY, aad_prefix=b"ad"), ENCODED_PREFIX + b"\x02\x04ad\x00"),
47+
(
48+
StandardKeyMetadata(encryption_key=AES128_KEY, aad_prefix=b"ad", file_length=1024),
49+
ENCODED_PREFIX + b"\x02\x04ad\x02\x80\x10",
50+
),
51+
(StandardKeyMetadata(encryption_key=AES128_KEY, aad_prefix=b""), ENCODED_PREFIX + b"\x02\x00\x00"),
52+
],
53+
)
54+
def test_key_metadata_serialization(key_metadata: StandardKeyMetadata, encoded: bytes) -> None:
55+
assert key_metadata.to_bytes() == encoded
56+
assert StandardKeyMetadata.from_bytes(encoded) == key_metadata
57+
58+
59+
def test_key_metadata_defaults() -> None:
60+
key_metadata = StandardKeyMetadata(encryption_key=AES128_KEY)
61+
62+
assert key_metadata.aad_prefix is None
63+
assert key_metadata.file_length is None
64+
65+
66+
def test_key_metadata_repr_redacts_encryption_key() -> None:
67+
key_metadata = StandardKeyMetadata(encryption_key=AES128_KEY)
68+
69+
assert "encryption_key" not in repr(key_metadata)
70+
assert repr(AES128_KEY) not in repr(key_metadata)
71+
72+
73+
def test_key_metadata_empty_buffer() -> None:
74+
with pytest.raises(ValueError, match="Empty key metadata"):
75+
StandardKeyMetadata.from_bytes(b"")
76+
77+
78+
@pytest.mark.parametrize("data", [b"\x02", b"\x02" + ENCODED_PREFIX[1:] + b"\x00\x00"])
79+
def test_key_metadata_unsupported_version(data: bytes) -> None:
80+
with pytest.raises(ValueError, match="Unsupported key metadata version: 2"):
81+
StandardKeyMetadata.from_bytes(data)
82+
83+
84+
@pytest.mark.parametrize("key_length", [16, 24, 32])
85+
def test_key_metadata_accepts_aes_key_lengths(key_length: int) -> None:
86+
key_metadata = StandardKeyMetadata(encryption_key=bytes(key_length))
87+
88+
assert StandardKeyMetadata.from_bytes(key_metadata.to_bytes()) == key_metadata
89+
90+
91+
@pytest.mark.parametrize("key_length", [0, 4, 15, 20, 33])
92+
def test_key_metadata_rejects_invalid_key_length(key_length: int) -> None:
93+
with pytest.raises(ValueError, match="Invalid encryption key in key metadata"):
94+
StandardKeyMetadata(encryption_key=bytes(key_length))
95+
96+
97+
@pytest.mark.parametrize("key_length", [0, 4, 15, 20, 33])
98+
def test_key_metadata_decode_rejects_invalid_key_length(key_length: int) -> None:
99+
with pytest.raises(ValueError, match="Invalid encryption key in key metadata"):
100+
StandardKeyMetadata.from_bytes(encode_key_metadata(bytes(key_length)))

0 commit comments

Comments
 (0)