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 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)