py-hamt 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_hamt/__init__.py +13 -0
- py_hamt/encryption_hamt_store.py +182 -0
- py_hamt/hamt.py +696 -0
- py_hamt/store.py +269 -0
- py_hamt/zarr_hamt_store.py +252 -0
- py_hamt-3.0.0.dist-info/METADATA +129 -0
- py_hamt-3.0.0.dist-info/RECORD +9 -0
- py_hamt-3.0.0.dist-info/WHEEL +4 -0
- py_hamt-3.0.0.dist-info/licenses/LICENSE +201 -0
py_hamt/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .encryption_hamt_store import SimpleEncryptedZarrHAMTStore
|
|
2
|
+
from .hamt import HAMT, blake3_hashfn
|
|
3
|
+
from .store import ContentAddressedStore, KuboCAS
|
|
4
|
+
from .zarr_hamt_store import ZarrHAMTStore
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"blake3_hashfn",
|
|
8
|
+
"HAMT",
|
|
9
|
+
"ContentAddressedStore",
|
|
10
|
+
"KuboCAS",
|
|
11
|
+
"ZarrHAMTStore",
|
|
12
|
+
"SimpleEncryptedZarrHAMTStore",
|
|
13
|
+
]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from typing import cast
|
|
2
|
+
|
|
3
|
+
import zarr.abc.store
|
|
4
|
+
import zarr.core.buffer
|
|
5
|
+
from Crypto.Cipher import ChaCha20_Poly1305
|
|
6
|
+
from Crypto.Random import get_random_bytes
|
|
7
|
+
|
|
8
|
+
from py_hamt.hamt import HAMT
|
|
9
|
+
from py_hamt.zarr_hamt_store import ZarrHAMTStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SimpleEncryptedZarrHAMTStore(ZarrHAMTStore):
|
|
13
|
+
"""
|
|
14
|
+
Write and read Zarr v3s with a HAMT, encrypting *everything* for maximum privacy.
|
|
15
|
+
|
|
16
|
+
This store uses ChaCha20-Poly1305 to encrypt every single key-value pair
|
|
17
|
+
stored in the Zarr, including all metadata (`zarr.json`, `.zarray`, etc.)
|
|
18
|
+
and data chunks. This provides strong privacy but means the Zarr store is
|
|
19
|
+
completely opaque and unusable without the correct encryption key and header.
|
|
20
|
+
|
|
21
|
+
Note: For standard zarr encryption and decryption where metadata is available use the method in https://github.com/dClimate/jupyter-notebooks/blob/main/notebooks/202b%20-%20Encryption%20Example%20(Encryption%20with%20Zarr%20Codecs).ipynb
|
|
22
|
+
|
|
23
|
+
#### Encryption Details
|
|
24
|
+
- Uses XChaCha20_Poly1305 (via pycryptodome's ChaCha20_Poly1305 with a 24-byte nonce).
|
|
25
|
+
- Requires a 32-byte encryption key and a header.
|
|
26
|
+
- Each encrypted value includes a 24-byte nonce and a 16-byte tag.
|
|
27
|
+
|
|
28
|
+
#### Important Considerations
|
|
29
|
+
- Since metadata is encrypted, standard Zarr tools cannot inspect the
|
|
30
|
+
dataset without prior decryption using this store class.
|
|
31
|
+
- There is no support for partial encryption or excluding variables.
|
|
32
|
+
- There is no metadata caching.
|
|
33
|
+
|
|
34
|
+
#### Sample Code
|
|
35
|
+
```python
|
|
36
|
+
import xarray
|
|
37
|
+
from py_hamt import HAMT, KuboCAS # Assuming an KuboCAS or similar
|
|
38
|
+
from Crypto.Random import get_random_bytes
|
|
39
|
+
import numpy as np
|
|
40
|
+
|
|
41
|
+
# Setup
|
|
42
|
+
ds = xarray.Dataset(
|
|
43
|
+
{"data": (("y", "x"), np.arange(12).reshape(3, 4))},
|
|
44
|
+
coords={"y": [1, 2, 3], "x": [10, 20, 30, 40]}
|
|
45
|
+
)
|
|
46
|
+
cas = KuboCAS() # Example ContentAddressedStore
|
|
47
|
+
encryption_key = get_random_bytes(32)
|
|
48
|
+
header = b"fully-encrypted-zarr"
|
|
49
|
+
|
|
50
|
+
# --- Write ---
|
|
51
|
+
hamt_write = await HAMT.build(cas=cas, values_are_bytes=True)
|
|
52
|
+
ezhs_write = SimpleEncryptedZarrHAMTStore(
|
|
53
|
+
hamt_write, False, encryption_key, header
|
|
54
|
+
)
|
|
55
|
+
print("Writing fully encrypted Zarr...")
|
|
56
|
+
ds.to_zarr(store=ezhs_write, mode="w")
|
|
57
|
+
await hamt_write.make_read_only()
|
|
58
|
+
root_node_id = hamt_write.root_node_id
|
|
59
|
+
print(f"Wrote Zarr with root: {root_node_id}")
|
|
60
|
+
|
|
61
|
+
# --- Read ---
|
|
62
|
+
hamt_read = await HAMT.build(
|
|
63
|
+
cas=cas, root_node_id=root_node_id, values_are_bytes=True, read_only=True
|
|
64
|
+
)
|
|
65
|
+
ezhs_read = SimpleEncryptedZarrHAMTStore(
|
|
66
|
+
hamt_read, True, encryption_key, header
|
|
67
|
+
)
|
|
68
|
+
print("\nReading fully encrypted Zarr...")
|
|
69
|
+
ds_read = xarray.open_zarr(store=ezhs_read)
|
|
70
|
+
print("Read back dataset:")
|
|
71
|
+
print(ds_read)
|
|
72
|
+
xarray.testing.assert_identical(ds, ds_read)
|
|
73
|
+
print("Read successful and data verified.")
|
|
74
|
+
|
|
75
|
+
# --- Read with wrong key (demonstrates failure) ---
|
|
76
|
+
wrong_key = get_random_bytes(32)
|
|
77
|
+
hamt_bad = await HAMT.build(
|
|
78
|
+
cas=cas, root_node_id=root_node_id, read_only=True, values_are_bytes=True
|
|
79
|
+
)
|
|
80
|
+
ezhs_bad = SimpleEncryptedZarrHAMTStore(
|
|
81
|
+
hamt_bad, True, wrong_key, header
|
|
82
|
+
)
|
|
83
|
+
print("\nAttempting to read with wrong key...")
|
|
84
|
+
try:
|
|
85
|
+
ds_bad = xarray.open_zarr(store=ezhs_bad)
|
|
86
|
+
print(ds_bad)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
print(f"Failed to read as expected: {type(e).__name__} - {e}")
|
|
89
|
+
```
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self, hamt: HAMT, read_only: bool, encryption_key: bytes, header: bytes
|
|
94
|
+
) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Initializes the SimpleEncryptedZarrHAMTStore.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
hamt: The HAMT instance for storage. Must have `values_are_bytes=True`.
|
|
100
|
+
Its `read_only` status must match the `read_only` argument.
|
|
101
|
+
read_only: If True, the store is in read-only mode.
|
|
102
|
+
encryption_key: A 32-byte key for ChaCha20-Poly1305.
|
|
103
|
+
header: A header (bytes) used as associated data in encryption.
|
|
104
|
+
"""
|
|
105
|
+
super().__init__(hamt, read_only=read_only)
|
|
106
|
+
|
|
107
|
+
if len(encryption_key) != 32:
|
|
108
|
+
raise ValueError("Encryption key must be exactly 32 bytes long.")
|
|
109
|
+
self.encryption_key = encryption_key
|
|
110
|
+
self.header = header
|
|
111
|
+
self.metadata_read_cache: dict[str, bytes] = {}
|
|
112
|
+
|
|
113
|
+
def _encrypt(self, val: bytes) -> bytes:
|
|
114
|
+
"""Encrypts data using ChaCha20-Poly1305."""
|
|
115
|
+
nonce = get_random_bytes(24) # XChaCha20 uses a 24-byte nonce
|
|
116
|
+
cipher = ChaCha20_Poly1305.new(key=self.encryption_key, nonce=nonce)
|
|
117
|
+
cipher.update(self.header)
|
|
118
|
+
ciphertext, tag = cipher.encrypt_and_digest(val)
|
|
119
|
+
return nonce + tag + ciphertext
|
|
120
|
+
|
|
121
|
+
def _decrypt(self, val: bytes) -> bytes:
|
|
122
|
+
"""Decrypts data using ChaCha20-Poly1305."""
|
|
123
|
+
try:
|
|
124
|
+
# Extract nonce (24), tag (16), and ciphertext
|
|
125
|
+
nonce, tag, ciphertext = val[:24], val[24:40], val[40:]
|
|
126
|
+
cipher = ChaCha20_Poly1305.new(key=self.encryption_key, nonce=nonce)
|
|
127
|
+
cipher.update(self.header)
|
|
128
|
+
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
|
|
129
|
+
return plaintext
|
|
130
|
+
except Exception as e:
|
|
131
|
+
# Catching a broad exception as various issues (key, tag, length) can occur.
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"Decryption failed. Check key, header, or data integrity."
|
|
134
|
+
) from e
|
|
135
|
+
|
|
136
|
+
def __eq__(self, other: object) -> bool:
|
|
137
|
+
"""@private"""
|
|
138
|
+
if not isinstance(other, SimpleEncryptedZarrHAMTStore):
|
|
139
|
+
return False
|
|
140
|
+
return (
|
|
141
|
+
self.hamt.root_node_id == other.hamt.root_node_id
|
|
142
|
+
and self.encryption_key == other.encryption_key
|
|
143
|
+
and self.header == other.header
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
async def get(
|
|
147
|
+
self,
|
|
148
|
+
key: str,
|
|
149
|
+
prototype: zarr.core.buffer.BufferPrototype,
|
|
150
|
+
byte_range: zarr.abc.store.ByteRequest | None = None,
|
|
151
|
+
) -> zarr.core.buffer.Buffer | None:
|
|
152
|
+
"""@private"""
|
|
153
|
+
try:
|
|
154
|
+
decrypted_val: bytes
|
|
155
|
+
is_metadata: bool = (
|
|
156
|
+
len(key) >= 9 and key[-9:] == "zarr.json"
|
|
157
|
+
) # if path ends with zarr.json
|
|
158
|
+
|
|
159
|
+
if is_metadata and key in self.metadata_read_cache:
|
|
160
|
+
decrypted_val = self.metadata_read_cache[key]
|
|
161
|
+
else:
|
|
162
|
+
raw_val = cast(
|
|
163
|
+
bytes, await self.hamt.get(key)
|
|
164
|
+
) # We know values received will always be bytes since we only store bytes in the HAMT
|
|
165
|
+
decrypted_val = self._decrypt(raw_val)
|
|
166
|
+
if is_metadata:
|
|
167
|
+
self.metadata_read_cache[key] = decrypted_val
|
|
168
|
+
return prototype.buffer.from_bytes(decrypted_val)
|
|
169
|
+
except KeyError:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None:
|
|
173
|
+
"""@private"""
|
|
174
|
+
if self.read_only:
|
|
175
|
+
raise Exception("Cannot write to a read-only store.")
|
|
176
|
+
|
|
177
|
+
raw_bytes = value.to_bytes()
|
|
178
|
+
if key in self.metadata_read_cache:
|
|
179
|
+
self.metadata_read_cache[key] = raw_bytes
|
|
180
|
+
# Encrypt it
|
|
181
|
+
encrypted_bytes = self._encrypt(raw_bytes)
|
|
182
|
+
await self.hamt.set(key, encrypted_bytes)
|