truhe 1.0.0__tar.gz
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.
- truhe-1.0.0/.gitignore +10 -0
- truhe-1.0.0/CHANGES.rst +10 -0
- truhe-1.0.0/LICENSE.rst +24 -0
- truhe-1.0.0/PKG-INFO +298 -0
- truhe-1.0.0/README.rst +226 -0
- truhe-1.0.0/pyproject.toml +98 -0
- truhe-1.0.0/src/truhe/__init__.py +9 -0
- truhe-1.0.0/src/truhe/_s3.py +118 -0
- truhe-1.0.0/src/truhe/aio/__init__.py +2 -0
- truhe-1.0.0/src/truhe/aio/s3.py +148 -0
- truhe-1.0.0/src/truhe/aio/sealed.py +89 -0
- truhe-1.0.0/src/truhe/aio/store.py +116 -0
- truhe-1.0.0/src/truhe/filesystem.py +116 -0
- truhe-1.0.0/src/truhe/format.py +361 -0
- truhe-1.0.0/src/truhe/s3.py +163 -0
- truhe-1.0.0/src/truhe/sealed.py +83 -0
- truhe-1.0.0/src/truhe/sigv4.py +177 -0
- truhe-1.0.0/src/truhe/store.py +184 -0
- truhe-1.0.0/src/truhe/tests/__init__.py +132 -0
- truhe-1.0.0/src/truhe/tests/test_aio.py +316 -0
- truhe-1.0.0/src/truhe/tests/test_format.py +228 -0
- truhe-1.0.0/src/truhe/tests/test_s3.py +309 -0
- truhe-1.0.0/src/truhe/tests/test_sealed.py +189 -0
- truhe-1.0.0/src/truhe/tests/test_sigv4.py +169 -0
- truhe-1.0.0/src/truhe/tests/test_store.py +137 -0
truhe-1.0.0/.gitignore
ADDED
truhe-1.0.0/CHANGES.rst
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Changes
|
|
2
|
+
=======
|
|
3
|
+
|
|
4
|
+
1.0.0 (2026-11-04)
|
|
5
|
+
------------------
|
|
6
|
+
|
|
7
|
+
- Initial release. Encrypted blob store with stores for memory, the
|
|
8
|
+
filesystem and S3, synchronous over ``urllib3``, asynchronous over
|
|
9
|
+
``httpx``. AES-256-GCM in 64 KiB blocks, streaming in both directions.
|
|
10
|
+
[rnix]
|
truhe-1.0.0/LICENSE.rst
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
License
|
|
2
|
+
=======
|
|
3
|
+
|
|
4
|
+
MIT License
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2026 Conestack
|
|
7
|
+
|
|
8
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
10
|
+
in the Software without restriction, including without limitation the rights
|
|
11
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
12
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
13
|
+
furnished to do so, subject to the following conditions:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice shall be included in all
|
|
16
|
+
copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
21
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
22
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
23
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
24
|
+
SOFTWARE.
|
truhe-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: truhe
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Encrypted blob stores for S3, the filesystem and memory, synchronous and asynchronous.
|
|
5
|
+
Project-URL: Homepage, http://github.com/conestack/truhe
|
|
6
|
+
Author-email: Conestack Contributors <dev@conestack.org>
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE.rst
|
|
9
|
+
Keywords: aes-gcm,blob,encryption,s3,sigv4,storage
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
21
|
+
Classifier: Topic :: Security :: Cryptography
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: cryptography
|
|
25
|
+
Provides-Extra: httpx
|
|
26
|
+
Requires-Dist: httpx<1; extra == 'httpx'
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: httpx<1; extra == 'test'
|
|
29
|
+
Requires-Dist: pytest; extra == 'test'
|
|
30
|
+
Requires-Dist: urllib3>=2; extra == 'test'
|
|
31
|
+
Provides-Extra: urllib3
|
|
32
|
+
Requires-Dist: urllib3>=2; extra == 'urllib3'
|
|
33
|
+
Description-Content-Type: text/x-rst
|
|
34
|
+
|
|
35
|
+
.. image:: https://img.shields.io/pypi/v/truhe.svg
|
|
36
|
+
:target: https://pypi.python.org/pypi/truhe
|
|
37
|
+
:alt: Latest PyPI version
|
|
38
|
+
|
|
39
|
+
.. image:: https://img.shields.io/pypi/dm/truhe.svg
|
|
40
|
+
:target: https://pypi.python.org/pypi/truhe
|
|
41
|
+
:alt: Number of PyPI downloads
|
|
42
|
+
|
|
43
|
+
.. image:: https://github.com/conestack/truhe/actions/workflows/test.yml/badge.svg
|
|
44
|
+
:target: https://github.com/conestack/truhe/actions/workflows/test.yml
|
|
45
|
+
:alt: Test truhe
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
truhe
|
|
49
|
+
=====
|
|
50
|
+
|
|
51
|
+
Encrypted blob stores. An application puts its bytes into a store and gets
|
|
52
|
+
them back; the store, S3, a directory or memory, only ever sees ciphertext.
|
|
53
|
+
|
|
54
|
+
- AES-256-GCM in 64 KiB blocks, so objects of any size stream in and out
|
|
55
|
+
without being held in memory.
|
|
56
|
+
- Stores for S3 compatible object storage, the filesystem and memory.
|
|
57
|
+
- Synchronous over ``urllib3``, asynchronous over ``httpx``.
|
|
58
|
+
- AWS Signature Version 4 of its own, no SDK.
|
|
59
|
+
- Keys rotate without rewriting the store.
|
|
60
|
+
|
|
61
|
+
.. contents::
|
|
62
|
+
:depth: 2
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Installation
|
|
66
|
+
------------
|
|
67
|
+
|
|
68
|
+
The S3 store needs an HTTP client, chosen with an extra:
|
|
69
|
+
|
|
70
|
+
.. code-block:: shell
|
|
71
|
+
|
|
72
|
+
pip install truhe[urllib3] # synchronous
|
|
73
|
+
pip install truhe[httpx] # asynchronous
|
|
74
|
+
|
|
75
|
+
Without extra, the filesystem and the memory store are available.
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Usage
|
|
79
|
+
-----
|
|
80
|
+
|
|
81
|
+
A store wrapped in ``EncryptedBlobStore`` encrypts on the way in and decrypts
|
|
82
|
+
on the way out:
|
|
83
|
+
|
|
84
|
+
.. code-block:: python
|
|
85
|
+
|
|
86
|
+
import os
|
|
87
|
+
|
|
88
|
+
from truhe import EncryptedBlobStore
|
|
89
|
+
from truhe import Keyring
|
|
90
|
+
from truhe.s3 import S3BlobStore
|
|
91
|
+
from truhe.sigv4 import Credentials
|
|
92
|
+
|
|
93
|
+
credentials = Credentials(
|
|
94
|
+
access_key='...',
|
|
95
|
+
secret_key='...',
|
|
96
|
+
region='fsn1',
|
|
97
|
+
)
|
|
98
|
+
store = EncryptedBlobStore(
|
|
99
|
+
S3BlobStore(credentials, 'https://fsn1.your-objectstorage.com', 'bucket'),
|
|
100
|
+
Keyring.from_setting(keys),
|
|
101
|
+
magic=b'MYAPP',
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# bytes
|
|
105
|
+
store.put('reports/2026/q3.pdf', data, 'application/pdf')
|
|
106
|
+
|
|
107
|
+
# a stream, with its length up front
|
|
108
|
+
with open('intro.mp4', 'rb') as f:
|
|
109
|
+
store.put(
|
|
110
|
+
'videos/intro.mp4',
|
|
111
|
+
iter(lambda: f.read(65536), b''),
|
|
112
|
+
'video/mp4',
|
|
113
|
+
content_length=os.fstat(f.fileno()).st_size,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
with store.open('videos/intro.mp4') as stream:
|
|
117
|
+
for chunk in stream.chunks:
|
|
118
|
+
...
|
|
119
|
+
|
|
120
|
+
store.head('videos/intro.mp4') # StoredBlob(size=..., content_type=...)
|
|
121
|
+
list(store.list('videos/')) # ListedBlob(key, size, modified)
|
|
122
|
+
store.delete('videos/intro.mp4')
|
|
123
|
+
|
|
124
|
+
The asynchronous stores have the same operations as coroutines, ``open`` is an
|
|
125
|
+
async context manager, ``list`` an async iterator, a stream to ``put`` an
|
|
126
|
+
async iterable:
|
|
127
|
+
|
|
128
|
+
.. code-block:: python
|
|
129
|
+
|
|
130
|
+
import httpx
|
|
131
|
+
|
|
132
|
+
from truhe.aio import EncryptedBlobStore
|
|
133
|
+
from truhe.aio.s3 import S3BlobStore
|
|
134
|
+
|
|
135
|
+
async with httpx.AsyncClient() as client:
|
|
136
|
+
store = EncryptedBlobStore(
|
|
137
|
+
S3BlobStore(credentials, endpoint, 'bucket', client),
|
|
138
|
+
keyring,
|
|
139
|
+
)
|
|
140
|
+
await store.put('k', data, 'application/octet-stream')
|
|
141
|
+
async with store.open('k') as stream:
|
|
142
|
+
async for chunk in stream.chunks:
|
|
143
|
+
...
|
|
144
|
+
|
|
145
|
+
Sizes are always plaintext sizes, the framing stays inside
|
|
146
|
+
``EncryptedBlobStore``.
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
Streams
|
|
150
|
+
-------
|
|
151
|
+
|
|
152
|
+
``put`` takes bytes, or an iterable of bytes together with ``content_length``.
|
|
153
|
+
A stream which delivers more or fewer bytes than announced fails, and nothing
|
|
154
|
+
is stored: the piece which would complete the object is only passed on once
|
|
155
|
+
the stream is known to end there.
|
|
156
|
+
|
|
157
|
+
Bytes go to S3 with their SHA-256 in the signature and ``Content-MD5``, so the
|
|
158
|
+
store verifies what arrived. A stream goes out with ``UNSIGNED-PAYLOAD`` and
|
|
159
|
+
its ``Content-Length``; hashing it first would mean holding it. One request
|
|
160
|
+
per object, so an object is at most 5 GiB, the limit of a single ``PUT``.
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
Keys
|
|
164
|
+
----
|
|
165
|
+
|
|
166
|
+
A ``Keyring`` holds 32 byte keys, newest first. New objects are sealed with the
|
|
167
|
+
first, every object names its key in its header. To rotate, put a new key in
|
|
168
|
+
front, old objects stay readable. Dropping a key makes its objects
|
|
169
|
+
unreadable.
|
|
170
|
+
|
|
171
|
+
.. code-block:: shell
|
|
172
|
+
|
|
173
|
+
openssl rand -hex 32
|
|
174
|
+
|
|
175
|
+
``Keyring.from_setting`` reads a comma separated list of hex keys, the form of
|
|
176
|
+
an environment variable. ``Keyring.primary_id`` is the id of the current key,
|
|
177
|
+
worth recording next to every object, so the progress of a rotation is a
|
|
178
|
+
query.
|
|
179
|
+
|
|
180
|
+
**The key is the data.** Lose it, and every object is gone while the store is
|
|
181
|
+
perfectly healthy.
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
S3 compatible stores
|
|
185
|
+
--------------------
|
|
186
|
+
|
|
187
|
+
``S3BlobStore`` addresses the bucket virtual hosted,
|
|
188
|
+
``{bucket}.{endpoint host}``, the form most providers document. Pass
|
|
189
|
+
``path_style=True`` for ``{endpoint}/{bucket}``, e.g. MinIO or Garage without
|
|
190
|
+
a domain. The region is the location code of the provider.
|
|
191
|
+
|
|
192
|
+
The stores do not retry. A retried upload after a lost answer is the
|
|
193
|
+
application's decision.
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
Format
|
|
197
|
+
------
|
|
198
|
+
|
|
199
|
+
An object is a header and its sealed blocks:
|
|
200
|
+
|
|
201
|
+
=============== ======= =====================================================
|
|
202
|
+
Field Bytes Content
|
|
203
|
+
=============== ======= =====================================================
|
|
204
|
+
magic 5 names the application, ``TRUHE`` by default
|
|
205
|
+
version 1 ``1``
|
|
206
|
+
key id 8 truncated SHA-256 of the key
|
|
207
|
+
nonce prefix 8 random per object
|
|
208
|
+
blocks n 64 KiB plaintext each, the last one shorter, sealed
|
|
209
|
+
with AES-256-GCM, 16 byte tag
|
|
210
|
+
=============== ======= =====================================================
|
|
211
|
+
|
|
212
|
+
The nonce of a block is the prefix and the block counter, the associated data
|
|
213
|
+
is the header and a flag for the last block. So a block cannot be reordered
|
|
214
|
+
or moved to another object, a truncated object does not verify, and a header
|
|
215
|
+
cannot be swapped. An empty object has one sealed empty block.
|
|
216
|
+
|
|
217
|
+
``truhe.format`` has the format without I/O: ``Sealer`` and ``Opener`` take
|
|
218
|
+
bytes as they arrive and return complete blocks, ``ciphertext_length`` gives
|
|
219
|
+
the stored size before the first byte.
|
|
220
|
+
|
|
221
|
+
Not supported: HTTP ``Range``, multipart uploads, presigned URLs.
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
Tests
|
|
225
|
+
-----
|
|
226
|
+
|
|
227
|
+
.. code-block:: shell
|
|
228
|
+
|
|
229
|
+
make install
|
|
230
|
+
make test
|
|
231
|
+
|
|
232
|
+
The S3 stores are tested offline. The tests against a store are opt-in, ``make
|
|
233
|
+
test`` skips them. To run them against a throwaway MinIO:
|
|
234
|
+
|
|
235
|
+
.. code-block:: shell
|
|
236
|
+
|
|
237
|
+
make test-s3
|
|
238
|
+
|
|
239
|
+
This starts the container from ``testing/compose.minio.yml``, runs the S3 tests
|
|
240
|
+
and discards the container, also when a test fails. To keep it running while
|
|
241
|
+
working on the tests:
|
|
242
|
+
|
|
243
|
+
.. code-block:: shell
|
|
244
|
+
|
|
245
|
+
./scripts/minio.sh up
|
|
246
|
+
eval "$(./scripts/minio.sh env)"
|
|
247
|
+
make test
|
|
248
|
+
./scripts/minio.sh down
|
|
249
|
+
|
|
250
|
+
The store is selected by ``TRUHE_TEST_S3_ENDPOINT``,
|
|
251
|
+
``TRUHE_TEST_S3_ACCESS_KEY``, ``TRUHE_TEST_S3_SECRET_KEY``,
|
|
252
|
+
``TRUHE_TEST_S3_REGION`` and ``TRUHE_TEST_S3_BUCKET``, plus
|
|
253
|
+
``TRUHE_TEST_S3_PATH_STYLE=1`` if needed. **The bucket is emptied before each
|
|
254
|
+
test**, so it must be one which holds nothing else.
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
Contributors
|
|
258
|
+
------------
|
|
259
|
+
|
|
260
|
+
- Robert Niederreiter
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
Changes
|
|
264
|
+
=======
|
|
265
|
+
|
|
266
|
+
1.0.0 (2026-11-04)
|
|
267
|
+
------------------
|
|
268
|
+
|
|
269
|
+
- Initial release. Encrypted blob store with stores for memory, the
|
|
270
|
+
filesystem and S3, synchronous over ``urllib3``, asynchronous over
|
|
271
|
+
``httpx``. AES-256-GCM in 64 KiB blocks, streaming in both directions.
|
|
272
|
+
[rnix]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
License
|
|
276
|
+
=======
|
|
277
|
+
|
|
278
|
+
MIT License
|
|
279
|
+
|
|
280
|
+
Copyright (c) 2026 Conestack
|
|
281
|
+
|
|
282
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
283
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
284
|
+
in the Software without restriction, including without limitation the rights
|
|
285
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
286
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
287
|
+
furnished to do so, subject to the following conditions:
|
|
288
|
+
|
|
289
|
+
The above copyright notice and this permission notice shall be included in all
|
|
290
|
+
copies or substantial portions of the Software.
|
|
291
|
+
|
|
292
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
293
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
294
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
295
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
296
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
297
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
298
|
+
SOFTWARE.
|
truhe-1.0.0/README.rst
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
.. image:: https://img.shields.io/pypi/v/truhe.svg
|
|
2
|
+
:target: https://pypi.python.org/pypi/truhe
|
|
3
|
+
:alt: Latest PyPI version
|
|
4
|
+
|
|
5
|
+
.. image:: https://img.shields.io/pypi/dm/truhe.svg
|
|
6
|
+
:target: https://pypi.python.org/pypi/truhe
|
|
7
|
+
:alt: Number of PyPI downloads
|
|
8
|
+
|
|
9
|
+
.. image:: https://github.com/conestack/truhe/actions/workflows/test.yml/badge.svg
|
|
10
|
+
:target: https://github.com/conestack/truhe/actions/workflows/test.yml
|
|
11
|
+
:alt: Test truhe
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
truhe
|
|
15
|
+
=====
|
|
16
|
+
|
|
17
|
+
Encrypted blob stores. An application puts its bytes into a store and gets
|
|
18
|
+
them back; the store, S3, a directory or memory, only ever sees ciphertext.
|
|
19
|
+
|
|
20
|
+
- AES-256-GCM in 64 KiB blocks, so objects of any size stream in and out
|
|
21
|
+
without being held in memory.
|
|
22
|
+
- Stores for S3 compatible object storage, the filesystem and memory.
|
|
23
|
+
- Synchronous over ``urllib3``, asynchronous over ``httpx``.
|
|
24
|
+
- AWS Signature Version 4 of its own, no SDK.
|
|
25
|
+
- Keys rotate without rewriting the store.
|
|
26
|
+
|
|
27
|
+
.. contents::
|
|
28
|
+
:depth: 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
Installation
|
|
32
|
+
------------
|
|
33
|
+
|
|
34
|
+
The S3 store needs an HTTP client, chosen with an extra:
|
|
35
|
+
|
|
36
|
+
.. code-block:: shell
|
|
37
|
+
|
|
38
|
+
pip install truhe[urllib3] # synchronous
|
|
39
|
+
pip install truhe[httpx] # asynchronous
|
|
40
|
+
|
|
41
|
+
Without extra, the filesystem and the memory store are available.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
Usage
|
|
45
|
+
-----
|
|
46
|
+
|
|
47
|
+
A store wrapped in ``EncryptedBlobStore`` encrypts on the way in and decrypts
|
|
48
|
+
on the way out:
|
|
49
|
+
|
|
50
|
+
.. code-block:: python
|
|
51
|
+
|
|
52
|
+
import os
|
|
53
|
+
|
|
54
|
+
from truhe import EncryptedBlobStore
|
|
55
|
+
from truhe import Keyring
|
|
56
|
+
from truhe.s3 import S3BlobStore
|
|
57
|
+
from truhe.sigv4 import Credentials
|
|
58
|
+
|
|
59
|
+
credentials = Credentials(
|
|
60
|
+
access_key='...',
|
|
61
|
+
secret_key='...',
|
|
62
|
+
region='fsn1',
|
|
63
|
+
)
|
|
64
|
+
store = EncryptedBlobStore(
|
|
65
|
+
S3BlobStore(credentials, 'https://fsn1.your-objectstorage.com', 'bucket'),
|
|
66
|
+
Keyring.from_setting(keys),
|
|
67
|
+
magic=b'MYAPP',
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# bytes
|
|
71
|
+
store.put('reports/2026/q3.pdf', data, 'application/pdf')
|
|
72
|
+
|
|
73
|
+
# a stream, with its length up front
|
|
74
|
+
with open('intro.mp4', 'rb') as f:
|
|
75
|
+
store.put(
|
|
76
|
+
'videos/intro.mp4',
|
|
77
|
+
iter(lambda: f.read(65536), b''),
|
|
78
|
+
'video/mp4',
|
|
79
|
+
content_length=os.fstat(f.fileno()).st_size,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
with store.open('videos/intro.mp4') as stream:
|
|
83
|
+
for chunk in stream.chunks:
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
store.head('videos/intro.mp4') # StoredBlob(size=..., content_type=...)
|
|
87
|
+
list(store.list('videos/')) # ListedBlob(key, size, modified)
|
|
88
|
+
store.delete('videos/intro.mp4')
|
|
89
|
+
|
|
90
|
+
The asynchronous stores have the same operations as coroutines, ``open`` is an
|
|
91
|
+
async context manager, ``list`` an async iterator, a stream to ``put`` an
|
|
92
|
+
async iterable:
|
|
93
|
+
|
|
94
|
+
.. code-block:: python
|
|
95
|
+
|
|
96
|
+
import httpx
|
|
97
|
+
|
|
98
|
+
from truhe.aio import EncryptedBlobStore
|
|
99
|
+
from truhe.aio.s3 import S3BlobStore
|
|
100
|
+
|
|
101
|
+
async with httpx.AsyncClient() as client:
|
|
102
|
+
store = EncryptedBlobStore(
|
|
103
|
+
S3BlobStore(credentials, endpoint, 'bucket', client),
|
|
104
|
+
keyring,
|
|
105
|
+
)
|
|
106
|
+
await store.put('k', data, 'application/octet-stream')
|
|
107
|
+
async with store.open('k') as stream:
|
|
108
|
+
async for chunk in stream.chunks:
|
|
109
|
+
...
|
|
110
|
+
|
|
111
|
+
Sizes are always plaintext sizes, the framing stays inside
|
|
112
|
+
``EncryptedBlobStore``.
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
Streams
|
|
116
|
+
-------
|
|
117
|
+
|
|
118
|
+
``put`` takes bytes, or an iterable of bytes together with ``content_length``.
|
|
119
|
+
A stream which delivers more or fewer bytes than announced fails, and nothing
|
|
120
|
+
is stored: the piece which would complete the object is only passed on once
|
|
121
|
+
the stream is known to end there.
|
|
122
|
+
|
|
123
|
+
Bytes go to S3 with their SHA-256 in the signature and ``Content-MD5``, so the
|
|
124
|
+
store verifies what arrived. A stream goes out with ``UNSIGNED-PAYLOAD`` and
|
|
125
|
+
its ``Content-Length``; hashing it first would mean holding it. One request
|
|
126
|
+
per object, so an object is at most 5 GiB, the limit of a single ``PUT``.
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
Keys
|
|
130
|
+
----
|
|
131
|
+
|
|
132
|
+
A ``Keyring`` holds 32 byte keys, newest first. New objects are sealed with the
|
|
133
|
+
first, every object names its key in its header. To rotate, put a new key in
|
|
134
|
+
front, old objects stay readable. Dropping a key makes its objects
|
|
135
|
+
unreadable.
|
|
136
|
+
|
|
137
|
+
.. code-block:: shell
|
|
138
|
+
|
|
139
|
+
openssl rand -hex 32
|
|
140
|
+
|
|
141
|
+
``Keyring.from_setting`` reads a comma separated list of hex keys, the form of
|
|
142
|
+
an environment variable. ``Keyring.primary_id`` is the id of the current key,
|
|
143
|
+
worth recording next to every object, so the progress of a rotation is a
|
|
144
|
+
query.
|
|
145
|
+
|
|
146
|
+
**The key is the data.** Lose it, and every object is gone while the store is
|
|
147
|
+
perfectly healthy.
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
S3 compatible stores
|
|
151
|
+
--------------------
|
|
152
|
+
|
|
153
|
+
``S3BlobStore`` addresses the bucket virtual hosted,
|
|
154
|
+
``{bucket}.{endpoint host}``, the form most providers document. Pass
|
|
155
|
+
``path_style=True`` for ``{endpoint}/{bucket}``, e.g. MinIO or Garage without
|
|
156
|
+
a domain. The region is the location code of the provider.
|
|
157
|
+
|
|
158
|
+
The stores do not retry. A retried upload after a lost answer is the
|
|
159
|
+
application's decision.
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
Format
|
|
163
|
+
------
|
|
164
|
+
|
|
165
|
+
An object is a header and its sealed blocks:
|
|
166
|
+
|
|
167
|
+
=============== ======= =====================================================
|
|
168
|
+
Field Bytes Content
|
|
169
|
+
=============== ======= =====================================================
|
|
170
|
+
magic 5 names the application, ``TRUHE`` by default
|
|
171
|
+
version 1 ``1``
|
|
172
|
+
key id 8 truncated SHA-256 of the key
|
|
173
|
+
nonce prefix 8 random per object
|
|
174
|
+
blocks n 64 KiB plaintext each, the last one shorter, sealed
|
|
175
|
+
with AES-256-GCM, 16 byte tag
|
|
176
|
+
=============== ======= =====================================================
|
|
177
|
+
|
|
178
|
+
The nonce of a block is the prefix and the block counter, the associated data
|
|
179
|
+
is the header and a flag for the last block. So a block cannot be reordered
|
|
180
|
+
or moved to another object, a truncated object does not verify, and a header
|
|
181
|
+
cannot be swapped. An empty object has one sealed empty block.
|
|
182
|
+
|
|
183
|
+
``truhe.format`` has the format without I/O: ``Sealer`` and ``Opener`` take
|
|
184
|
+
bytes as they arrive and return complete blocks, ``ciphertext_length`` gives
|
|
185
|
+
the stored size before the first byte.
|
|
186
|
+
|
|
187
|
+
Not supported: HTTP ``Range``, multipart uploads, presigned URLs.
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
Tests
|
|
191
|
+
-----
|
|
192
|
+
|
|
193
|
+
.. code-block:: shell
|
|
194
|
+
|
|
195
|
+
make install
|
|
196
|
+
make test
|
|
197
|
+
|
|
198
|
+
The S3 stores are tested offline. The tests against a store are opt-in, ``make
|
|
199
|
+
test`` skips them. To run them against a throwaway MinIO:
|
|
200
|
+
|
|
201
|
+
.. code-block:: shell
|
|
202
|
+
|
|
203
|
+
make test-s3
|
|
204
|
+
|
|
205
|
+
This starts the container from ``testing/compose.minio.yml``, runs the S3 tests
|
|
206
|
+
and discards the container, also when a test fails. To keep it running while
|
|
207
|
+
working on the tests:
|
|
208
|
+
|
|
209
|
+
.. code-block:: shell
|
|
210
|
+
|
|
211
|
+
./scripts/minio.sh up
|
|
212
|
+
eval "$(./scripts/minio.sh env)"
|
|
213
|
+
make test
|
|
214
|
+
./scripts/minio.sh down
|
|
215
|
+
|
|
216
|
+
The store is selected by ``TRUHE_TEST_S3_ENDPOINT``,
|
|
217
|
+
``TRUHE_TEST_S3_ACCESS_KEY``, ``TRUHE_TEST_S3_SECRET_KEY``,
|
|
218
|
+
``TRUHE_TEST_S3_REGION`` and ``TRUHE_TEST_S3_BUCKET``, plus
|
|
219
|
+
``TRUHE_TEST_S3_PATH_STYLE=1`` if needed. **The bucket is emptied before each
|
|
220
|
+
test**, so it must be one which holds nothing else.
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
Contributors
|
|
224
|
+
------------
|
|
225
|
+
|
|
226
|
+
- Robert Niederreiter
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-fancy-pypi-readme"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "truhe"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Encrypted blob stores for S3, the filesystem and memory, synchronous and asynchronous."
|
|
9
|
+
dynamic = ["readme"]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Conestack Contributors", email = "dev@conestack.org"}]
|
|
13
|
+
keywords = ["s3", "blob", "storage", "encryption", "aes-gcm", "sigv4"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Security :: Cryptography",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
"Programming Language :: Python",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Programming Language :: Python :: 3.15",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"cryptography",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
urllib3 = [
|
|
35
|
+
"urllib3>=2",
|
|
36
|
+
]
|
|
37
|
+
httpx = [
|
|
38
|
+
"httpx<1",
|
|
39
|
+
]
|
|
40
|
+
test = [
|
|
41
|
+
"httpx<1",
|
|
42
|
+
"pytest",
|
|
43
|
+
"urllib3>=2",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "http://github.com/conestack/truhe"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.metadata.hooks.fancy-pypi-readme]
|
|
50
|
+
content-type = "text/x-rst"
|
|
51
|
+
fragments = [
|
|
52
|
+
{path = "README.rst"},
|
|
53
|
+
{text = "\n\n"},
|
|
54
|
+
{path = "CHANGES.rst"},
|
|
55
|
+
{text = "\n\n"},
|
|
56
|
+
{path = "LICENSE.rst"},
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.sdist]
|
|
60
|
+
exclude = [
|
|
61
|
+
"/.github/",
|
|
62
|
+
"/include.mk",
|
|
63
|
+
"/Makefile",
|
|
64
|
+
"/mx.ini",
|
|
65
|
+
"/scripts/",
|
|
66
|
+
"/testing/",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.hatch.build.targets.wheel]
|
|
70
|
+
packages = ["src/truhe"]
|
|
71
|
+
|
|
72
|
+
[tool.pytest.ini_options]
|
|
73
|
+
consider_namespace_packages = true
|
|
74
|
+
addopts = ["--import-mode=importlib"]
|
|
75
|
+
pythonpath = "src"
|
|
76
|
+
|
|
77
|
+
# The S3 tests are opt-in, without a bucket their bodies never run.
|
|
78
|
+
[tool.coverage.run]
|
|
79
|
+
omit = ["*/truhe/tests/*"]
|
|
80
|
+
|
|
81
|
+
[tool.ruff]
|
|
82
|
+
line-length = 90
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint]
|
|
85
|
+
select = ["E4", "E7", "E9", "F", "I", "UP"]
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint.isort]
|
|
88
|
+
force-single-line = true
|
|
89
|
+
from-first = true
|
|
90
|
+
no-sections = true
|
|
91
|
+
lines-after-imports = 2
|
|
92
|
+
order-by-type = false
|
|
93
|
+
|
|
94
|
+
[tool.ruff.format]
|
|
95
|
+
quote-style = "single"
|
|
96
|
+
|
|
97
|
+
[tool.zest-releaser]
|
|
98
|
+
create-wheel = true
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from truhe.filesystem import FileSystemBlobStore # noqa
|
|
2
|
+
from truhe.format import EncryptionError # noqa
|
|
3
|
+
from truhe.format import Keyring # noqa
|
|
4
|
+
from truhe.sealed import EncryptedBlobStore # noqa
|
|
5
|
+
from truhe.store import BlobStoreError # noqa
|
|
6
|
+
from truhe.store import BlobStream # noqa
|
|
7
|
+
from truhe.store import ListedBlob # noqa
|
|
8
|
+
from truhe.store import MemoryBlobStore # noqa
|
|
9
|
+
from truhe.store import StoredBlob # noqa
|