hyperscale-crypto 0.1.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.
- hyperscale_crypto-0.1.0/LICENSE +21 -0
- hyperscale_crypto-0.1.0/PKG-INFO +390 -0
- hyperscale_crypto-0.1.0/README.md +363 -0
- hyperscale_crypto-0.1.0/pyproject.toml +94 -0
- hyperscale_crypto-0.1.0/pyproject.toml.orig +83 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/__init__.py +3 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/apps.py +42 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/canary.py +125 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/conf.py +103 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/encoding.py +26 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/exceptions.py +10 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/fields.py +558 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/keyset_files.py +147 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/keysets.py +291 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/__init__.py +0 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/base.py +35 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/__init__.py +0 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_add_key.py +34 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_destroy.py +26 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_init.py +37 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_reencrypt.py +46 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_retire.py +28 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_rotate.py +92 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_status.py +55 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/migrations/0001_initial.py +72 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/migrations/__init__.py +0 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/models.py +57 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/py.typed +0 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/registry.py +30 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/rotation.py +409 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/state.py +36 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/tasks.py +33 -0
- hyperscale_crypto-0.1.0/src/hyperscale/crypto/testing.py +35 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hyperscale Consulting
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hyperscale-crypto
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Application-tier encrypted Django fields backed by Tink keysets and AWS KMS
|
|
5
|
+
Keywords: django,encryption,tink,kms,fields
|
|
6
|
+
Author: Andy Caine
|
|
7
|
+
Author-email: Andy Caine <andy@hyperscale.consulting>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 5.2
|
|
13
|
+
Classifier: Framework :: Django :: 6.1
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Security :: Cryptography
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: django>=5.2
|
|
20
|
+
Requires-Dist: tink[awskms]>=1.16.0
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Project-URL: Homepage, https://github.com/hyperscale-consulting/hyperscale-crypto
|
|
23
|
+
Project-URL: Repository, https://github.com/hyperscale-consulting/hyperscale-crypto
|
|
24
|
+
Project-URL: Changelog, https://github.com/hyperscale-consulting/hyperscale-crypto/blob/main/CHANGELOG.md
|
|
25
|
+
Project-URL: Issues, https://github.com/hyperscale-consulting/hyperscale-crypto/issues
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# hyperscale-crypto
|
|
29
|
+
|
|
30
|
+
Application-tier encrypted Django fields backed by Tink keysets and AWS KMS.
|
|
31
|
+
|
|
32
|
+
Install the `hyperscale-crypto` package; import it as `hyperscale.crypto`.
|
|
33
|
+
|
|
34
|
+
Values are encrypted in Python before they reach the database. Two Tink
|
|
35
|
+
keysets hold the data keys: `aead` (AES256-GCM, randomised) and `daead`
|
|
36
|
+
(AES256-SIV, deterministic). Both are stored in your repository, wrapped by a
|
|
37
|
+
KMS key, and unwrapped in memory at startup.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv add hyperscale-crypto
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
INSTALLED_APPS = [
|
|
47
|
+
# ...
|
|
48
|
+
"hyperscale.crypto",
|
|
49
|
+
]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then run `python manage.py migrate`. The app ships migrations for three
|
|
53
|
+
tables: `KeysetState` (the active deterministic key), `KeysetCanary` (the
|
|
54
|
+
startup check) and `KeyRotationEvent` (the rotation log).
|
|
55
|
+
|
|
56
|
+
## Settings
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import os
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
|
|
62
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
63
|
+
|
|
64
|
+
HYPERSCALE_CRYPTO = {
|
|
65
|
+
"mode": os.environ.get("CRYPTO_MODE", "kms"), # "kms" or "cleartext"
|
|
66
|
+
"kms_key_uri": os.environ.get("CRYPTO_KMS_KEY_URI", ""),
|
|
67
|
+
"keyset_dir": BASE_DIR / "keysets", # required
|
|
68
|
+
"reunwrap_seconds": 900,
|
|
69
|
+
"revoke_on_reunwrap_failure": False,
|
|
70
|
+
"retention_days": 35,
|
|
71
|
+
"git_sha": os.environ.get("GIT_SHA", ""),
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Key | Default | Meaning |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `mode` | `"kms"` | `"kms"` reads KMS-wrapped keysets; `"cleartext"` reads the dev files |
|
|
78
|
+
| `kms_key_uri` | `""` | `aws-kms://arn:aws:kms:<region>:<account>:key/<id>`; required in `kms` mode |
|
|
79
|
+
| `keyset_dir` | none (required) | directory holding the keyset files |
|
|
80
|
+
| `reunwrap_seconds` | `900` | how often a running process re-reads and re-unwraps the keysets |
|
|
81
|
+
| `revoke_on_reunwrap_failure` | `False` | drop cached keys when a re-unwrap fails (see [Startup](#startup)) |
|
|
82
|
+
| `retention_days` | `35` | minimum days between retiring a key and destroying it |
|
|
83
|
+
| `git_sha` | `$GIT_SHA` or `""` | recorded on every `KeyRotationEvent` |
|
|
84
|
+
|
|
85
|
+
`reunwrap_seconds` and `retention_days` must be positive integers and
|
|
86
|
+
`revoke_on_reunwrap_failure` a real `bool` (not the string `"false"`). In
|
|
87
|
+
`kms` mode, `kms_key_uri` must start with `aws-kms://` (or `fake-kms://` for
|
|
88
|
+
tests). `mode="cleartext"` and `fake-kms://` key URIs are only accepted when
|
|
89
|
+
the `ENV` environment variable is `development` or `test`. Any
|
|
90
|
+
misconfiguration (missing or unknown keys, a bad value, cleartext outside
|
|
91
|
+
those environments) raises `ImproperlyConfigured` during Django setup, not on
|
|
92
|
+
first use.
|
|
93
|
+
|
|
94
|
+
## Keysets
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
python manage.py keyset_init # both keysets; --keyset aead|daead|all
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
In `kms` mode this writes `<keyset_dir>/aead.json` and
|
|
101
|
+
`<keyset_dir>/daead.json`, each wrapped by the KMS key. Commit them: the
|
|
102
|
+
wrapped files are safe to store, and every deploy ships the keys it needs.
|
|
103
|
+
Protect the keyset path with branch protection and signed commits, since
|
|
104
|
+
whoever can change these files can change which keys the application trusts.
|
|
105
|
+
|
|
106
|
+
In `cleartext` mode the files are `<keyset_dir>/dev/aead.json` and
|
|
107
|
+
`<keyset_dir>/dev/daead.json`, unwrapped, for development and tests only.
|
|
108
|
+
|
|
109
|
+
`keyset_init` refuses to overwrite a keyset that already exists.
|
|
110
|
+
|
|
111
|
+
## Fields
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from django.db import models
|
|
115
|
+
|
|
116
|
+
from hyperscale.crypto import fields as ef
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Customer(models.Model):
|
|
120
|
+
owner_id = models.IntegerField()
|
|
121
|
+
notes = ef.EncryptedTextField(blank=True, default="")
|
|
122
|
+
token = ef.EncryptedCharField(
|
|
123
|
+
max_length=64, context=lambda obj: f"owner:{obj.owner_id}"
|
|
124
|
+
)
|
|
125
|
+
ni_number = ef.DeterministicEncryptedCharField(max_length=9, unique=True)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
| Field | Underlying | Deterministic variant |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `EncryptedCharField` | `CharField` | `DeterministicEncryptedCharField` |
|
|
131
|
+
| `EncryptedTextField` | `TextField` | |
|
|
132
|
+
| `EncryptedEmailField` | `EmailField` | `DeterministicEncryptedEmailField` |
|
|
133
|
+
| `EncryptedDateField` | `DateField` | |
|
|
134
|
+
| `EncryptedDecimalField` | `DecimalField` | |
|
|
135
|
+
| `EncryptedIntegerField` | `IntegerField` | `DeterministicEncryptedIntegerField` |
|
|
136
|
+
| `EncryptedBooleanField` | `BooleanField` | |
|
|
137
|
+
|
|
138
|
+
- **Storage.** Every field is a `text` column holding `hc1:` followed by the
|
|
139
|
+
URL-safe base64 Tink ciphertext. `None` and `""` are stored as they are.
|
|
140
|
+
`max_length` and the other validators apply to the plaintext.
|
|
141
|
+
- **Reading.** Values decrypt lazily on first attribute access.
|
|
142
|
+
`.values()` and `.values_list()` return the stored value wrapped in the
|
|
143
|
+
`hyperscale.crypto.fields.Ciphertext` marker (a `str` subclass), not the
|
|
144
|
+
plaintext. To copy a stored value unchanged, assign that `Ciphertext`
|
|
145
|
+
instance; a plain `str` is always encrypted.
|
|
146
|
+
- **Lookups.** Randomised fields support `isnull` only. Deterministic fields
|
|
147
|
+
support `exact`, `in` and `isnull`; any other lookup or transform raises
|
|
148
|
+
`FieldError`. Ordering is unsupported: it would sort by ciphertext.
|
|
149
|
+
`db_index` is rejected on every encrypted field, and `unique=True` on a
|
|
150
|
+
randomised field (equal values encrypt differently, so the database cannot
|
|
151
|
+
enforce it); use the deterministic variant. `auto_now` and `auto_now_add`
|
|
152
|
+
are rejected on `EncryptedDateField`.
|
|
153
|
+
- **Bulk writes.** `QuerySet.update()`, `bulk_update()` and raw saves (such
|
|
154
|
+
as `loaddata`) encrypt plaintext values too. A field with a `context`
|
|
155
|
+
cannot be written that way (there is no instance to compute the context
|
|
156
|
+
from) and raises `FieldError`; save the instances instead. Query
|
|
157
|
+
expressions are refused (`FieldError`) because their result would be
|
|
158
|
+
stored unencrypted or would corrupt the ciphertext; the exceptions are
|
|
159
|
+
`F()` of the same field, `Value(x, output_field=<the field>)` and the
|
|
160
|
+
`Case`/`When` tree `bulk_update()` builds from such values.
|
|
161
|
+
- **`context`** (randomised fields only) is a callable from the model instance
|
|
162
|
+
to a string that is bound into the ciphertext. It must be a stable property
|
|
163
|
+
of the row, such as an owner or tenant id. If a context input must change,
|
|
164
|
+
read the field before changing it and save both together. Saving a row
|
|
165
|
+
whose context changed while the field was never read raises
|
|
166
|
+
`DecryptionError` (naming the model, field and pk) instead of storing a
|
|
167
|
+
value that would no longer decrypt. Context fields cannot be read or
|
|
168
|
+
written from a migration (historical models do not carry the callable);
|
|
169
|
+
that raises `FieldError`. Use a management command with the live model.
|
|
170
|
+
- **Deterministic fields** require `unique=True` and reject `context`. Every
|
|
171
|
+
deterministic write (`save()` of a new or loaded row, `QuerySet.update()`,
|
|
172
|
+
`bulk_update()`) must run inside a transaction, on every backend; outside
|
|
173
|
+
one it raises `TransactionManagementError`. On PostgreSQL the write also
|
|
174
|
+
takes a share lock on the `KeysetState` row. Set `ATOMIC_REQUESTS = True`
|
|
175
|
+
on the database and wrap background writers in `transaction.atomic()`.
|
|
176
|
+
|
|
177
|
+
## Rotation
|
|
178
|
+
|
|
179
|
+
Randomised (`aead`) keys rotate online:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
python manage.py keyset_rotate --keyset aead # new primary key
|
|
183
|
+
# commit aead.json and deploy everywhere
|
|
184
|
+
python manage.py keyset_reencrypt # --batch-size 500 (>0); resumable
|
|
185
|
+
python manage.py keyset_retire aead <old_key_id> # refuses while rows use it
|
|
186
|
+
# after retention_days:
|
|
187
|
+
python manage.py keyset_destroy aead <old_key_id>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Deterministic (`daead`) keys switch in one step, because lookups must find
|
|
191
|
+
every row under a single key:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
python manage.py keyset_add_key --keyset daead # enabled, not active
|
|
195
|
+
# commit daead.json and deploy everywhere
|
|
196
|
+
python manage.py keyset_rotate --keyset daead --to <new_key_id> [--yes] [--lock-timeout 30]
|
|
197
|
+
python manage.py keyset_retire daead <old_key_id>
|
|
198
|
+
# after retention_days:
|
|
199
|
+
python manage.py keyset_destroy daead <old_key_id>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The daead switch prints the row counts, asks you to type `confirm` (unless
|
|
203
|
+
`--yes`), then re-encrypts every deterministic value in one transaction. It
|
|
204
|
+
locks the `KeysetState` row for that transaction (on PostgreSQL, the table in
|
|
205
|
+
`EXCLUSIVE` mode first, so the switch queues fairly behind in-flight writers
|
|
206
|
+
instead of starving), and every deterministic writer waits until it commits.
|
|
207
|
+
Reads are not blocked. `--lock-timeout <seconds>` (PostgreSQL) gives up
|
|
208
|
+
cleanly, changing nothing, if in-flight writers hold the lock longer than
|
|
209
|
+
that. On large tables, run it in a maintenance window.
|
|
210
|
+
|
|
211
|
+
`keyset_retire` refuses while any row is still encrypted under the key,
|
|
212
|
+
counting the startup canary row as one: after an aead rotation run
|
|
213
|
+
`keyset_reencrypt` (it moves the canary too) even if no model has data yet.
|
|
214
|
+
|
|
215
|
+
`keyset_destroy` refuses until `retention_days` have passed since the key was
|
|
216
|
+
retired. Set `retention_days` to cover your database backup window: a backup
|
|
217
|
+
that still holds values under a destroyed key cannot be decrypted.
|
|
218
|
+
|
|
219
|
+
Every command records a `KeyRotationEvent` row (`init`, `add_key`, `rotate`,
|
|
220
|
+
`reencrypt`, `retire`, `destroy`) with the key id, the actor, details such as
|
|
221
|
+
row counts, and `git_sha`. Pass `--actor <name>` to record who ran it; it
|
|
222
|
+
defaults to the OS user.
|
|
223
|
+
|
|
224
|
+
## Evidence
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
python manage.py keyset_status # human-readable
|
|
228
|
+
python manage.py keyset_status --json # machine-readable
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The JSON has these top-level keys:
|
|
232
|
+
|
|
233
|
+
- `keysets`: per keyset, every key id with its `status` (`ENABLED`,
|
|
234
|
+
`DISABLED`, `DESTROYED`) and whether it is `primary` and (daead) `active`.
|
|
235
|
+
- `rows_by_key`: per keyset, per field (`app.Model.field`), the number of
|
|
236
|
+
rows under each key id, read from ciphertext prefixes without decrypting.
|
|
237
|
+
The canary row is listed too, as
|
|
238
|
+
`hyperscale_crypto.KeysetCanary.aead_value` / `.daead_value`.
|
|
239
|
+
- `last_events`: the time of the most recent event of each action, or `null`.
|
|
240
|
+
- `canary_ok`, and `canary_error` when the canary check failed.
|
|
241
|
+
- `state_matches_file`: whether the database's active daead key is an enabled
|
|
242
|
+
key in the shipped `daead.json`.
|
|
243
|
+
|
|
244
|
+
## Startup
|
|
245
|
+
|
|
246
|
+
When the app is ready, it runs a canary check: it decrypts a known value
|
|
247
|
+
stored in `KeysetCanary` with both keysets (writing the value on first run)
|
|
248
|
+
and raises `ImproperlyConfigured`, naming the keyset, if either keyset
|
|
249
|
+
cannot be loaded (a missing file, or one that does not unwrap) or cannot
|
|
250
|
+
decrypt it. This
|
|
251
|
+
catches a wrong KMS key, a swapped keyset file or a database restored under
|
|
252
|
+
different keys before any request is served. The check is skipped for
|
|
253
|
+
`makemigrations`, `migrate`, `collectstatic`, `check`, `showmigrations` and
|
|
254
|
+
`sqlmigrate`, under pytest, and before the canary table has been migrated.
|
|
255
|
+
|
|
256
|
+
Startup neither arms the re-unwrap timer nor keeps its database
|
|
257
|
+
connection: under a pre-fork server (gunicorn, uWSGI) it runs in the parent,
|
|
258
|
+
and neither survives a fork. Instead, the first use of the keys in each
|
|
259
|
+
process starts a daemon timer that re-reads and re-unwraps the keysets every
|
|
260
|
+
`reunwrap_seconds`, so a revoked KMS grant takes effect without a restart. A
|
|
261
|
+
forked worker resets the inherited timer state and starts its own. If a
|
|
262
|
+
re-unwrap fails, the default is to keep the cached keys and log the error,
|
|
263
|
+
favouring availability during a KMS outage. With
|
|
264
|
+
`revoke_on_reunwrap_failure = True` the cached keys are dropped and every
|
|
265
|
+
later use must unwrap through KMS again, so revoking KMS access stops
|
|
266
|
+
decryption within one interval, at the cost of failing requests while KMS is
|
|
267
|
+
unreachable.
|
|
268
|
+
|
|
269
|
+
## Testing your app
|
|
270
|
+
|
|
271
|
+
Set `ENV=test` and point the settings at throwaway keysets from
|
|
272
|
+
`hyperscale.crypto.testing`:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
# conftest.py
|
|
276
|
+
import os
|
|
277
|
+
from collections.abc import Iterator
|
|
278
|
+
from pathlib import Path
|
|
279
|
+
|
|
280
|
+
import pytest
|
|
281
|
+
|
|
282
|
+
from hyperscale.crypto import keysets, testing
|
|
283
|
+
|
|
284
|
+
os.environ.setdefault("ENV", "test")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@pytest.fixture(autouse=True)
|
|
288
|
+
def crypto(tmp_path: Path, settings) -> Iterator[None]:
|
|
289
|
+
testing.write_dev_keysets(tmp_path) # cleartext dev/aead.json, dev/daead.json
|
|
290
|
+
settings.HYPERSCALE_CRYPTO = {"mode": "cleartext", "keyset_dir": tmp_path}
|
|
291
|
+
keysets.reset()
|
|
292
|
+
yield
|
|
293
|
+
keysets.reset()
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
To exercise the KMS code path without AWS, use `uri = testing.fake_kms_uri()`
|
|
297
|
+
and `testing.write_kms_keysets(tmp_path, uri)`, with
|
|
298
|
+
`{"mode": "kms", "kms_key_uri": uri, "keyset_dir": tmp_path}`.
|
|
299
|
+
`keysets.reset()` drops the cached keysets so each test sees its own.
|
|
300
|
+
|
|
301
|
+
## Security notes
|
|
302
|
+
|
|
303
|
+
- **Deterministic encryption leaks equality.** Equal plaintexts in the same
|
|
304
|
+
column give equal ciphertexts, so anyone with database access can see which
|
|
305
|
+
rows share a value and how often. On a unique column there is nothing to
|
|
306
|
+
count, which is why deterministic fields require `unique=True`. Use them
|
|
307
|
+
only where you must look rows up by value.
|
|
308
|
+
- **Associated data.** Every ciphertext is bound to
|
|
309
|
+
`<app_label>.<model_name>.<field>` (plus `|<context>` when set). A value
|
|
310
|
+
copied to another column, model or context fails to decrypt instead of
|
|
311
|
+
being read in the wrong place.
|
|
312
|
+
- **Only `Ciphertext` values pass through.** A value is stored without
|
|
313
|
+
encryption only when it is a `hyperscale.crypto.fields.Ciphertext`, the
|
|
314
|
+
marker that `.values()`, `.values_list()` and unread loaded fields carry.
|
|
315
|
+
Every plain `str`, including one that starts with `hc1:`, is plaintext and
|
|
316
|
+
is encrypted. A ciphertext copied from another row and submitted as text is
|
|
317
|
+
stored encrypted as that text; it never decrypts to the other row's value.
|
|
318
|
+
- **The canary** stops a process from serving with keys that cannot read the
|
|
319
|
+
data (see [Startup](#startup)).
|
|
320
|
+
|
|
321
|
+
## Limitations
|
|
322
|
+
|
|
323
|
+
- **One database.** Keyset state, the canary and the rotation log live in the
|
|
324
|
+
`default` database, and rotation reads and writes models there; a `using`
|
|
325
|
+
argument or database router is not honoured.
|
|
326
|
+
- **PostgreSQL or sqlite.** Other backends are untested. The share and table
|
|
327
|
+
locks that serialise deterministic writes against a daead switch exist
|
|
328
|
+
only on PostgreSQL; on sqlite the database's own write lock serialises
|
|
329
|
+
them.
|
|
330
|
+
- **`dumpdata` emits plaintext.** Serialisation reads fields through the
|
|
331
|
+
model, so fixtures and dumps contain decrypted values. Treat them as
|
|
332
|
+
sensitive. `loaddata` re-encrypts on the way in, but cannot load a field
|
|
333
|
+
with a `context` (raw saves have no instance for it).
|
|
334
|
+
- **`keyset_status` / `rotation.status()` write the canary** on first run if
|
|
335
|
+
it does not exist yet, like startup does.
|
|
336
|
+
- **Where each command runs.** Every command records its `KeyRotationEvent`
|
|
337
|
+
in the database it is pointed at, so run them against the database whose
|
|
338
|
+
evidence you want. `keyset_retire` and `keyset_destroy` read row counts and
|
|
339
|
+
the retire date from the production database and edit the keyset files, so
|
|
340
|
+
they need both the production database and a checkout of the repository
|
|
341
|
+
(commit the changed file afterwards). `keyset_rotate` and `keyset_add_key`
|
|
342
|
+
edit the keyset files too; `keyset_rotate --keyset daead` also rewrites
|
|
343
|
+
production rows.
|
|
344
|
+
|
|
345
|
+
## Optional: background tasks
|
|
346
|
+
|
|
347
|
+
`hyperscale.crypto.tasks.reencrypt_aead_task` runs `keyset_reencrypt` as a
|
|
348
|
+
background task. It uses Django's built-in tasks framework (`django.tasks`,
|
|
349
|
+
Django 6.0 and later) and, on older Django, the
|
|
350
|
+
[django-tasks](https://github.com/RealOrangeOne/django-tasks) backport if it
|
|
351
|
+
is installed. With neither, the module defines nothing. Configure a task
|
|
352
|
+
backend in `TASKS` as usual.
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
from hyperscale.crypto.tasks import reencrypt_aead_task
|
|
356
|
+
|
|
357
|
+
reencrypt_aead_task.enqueue(batch_size=500, actor="ops")
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## Development
|
|
361
|
+
|
|
362
|
+
Requires [uv](https://docs.astral.sh/uv/) and Python 3.14+.
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
uv sync # create the venv and install dependencies
|
|
366
|
+
uv run pytest # run the tests
|
|
367
|
+
uv run pre-commit run --all-files # lint, format and lockfile checks
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full set of checks.
|
|
371
|
+
|
|
372
|
+
## Releasing
|
|
373
|
+
|
|
374
|
+
Bump `version` in `pyproject.toml` and `__version__` in
|
|
375
|
+
`src/hyperscale/crypto/__init__.py`, update `CHANGELOG.md`, then publish a
|
|
376
|
+
GitHub release tagged `v<version>`. The `publish` workflow checks the tag
|
|
377
|
+
matches the package version, builds, runs `twine check` and publishes to PyPI
|
|
378
|
+
with trusted publishing.
|
|
379
|
+
|
|
380
|
+
Before the first release, register the project on PyPI with this repository
|
|
381
|
+
and `publish.yml` as a trusted publisher, and create a `pypi` environment in
|
|
382
|
+
the repository settings.
|
|
383
|
+
|
|
384
|
+
## Design
|
|
385
|
+
|
|
386
|
+
Design spec: [`docs/superpowers/specs/2026-09-24-encryption-primitive-design.md`](docs/superpowers/specs/2026-09-24-encryption-primitive-design.md)
|
|
387
|
+
|
|
388
|
+
## License
|
|
389
|
+
|
|
390
|
+
MIT. See [LICENSE](LICENSE).
|