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.
Files changed (33) hide show
  1. hyperscale_crypto-0.1.0/LICENSE +21 -0
  2. hyperscale_crypto-0.1.0/PKG-INFO +390 -0
  3. hyperscale_crypto-0.1.0/README.md +363 -0
  4. hyperscale_crypto-0.1.0/pyproject.toml +94 -0
  5. hyperscale_crypto-0.1.0/pyproject.toml.orig +83 -0
  6. hyperscale_crypto-0.1.0/src/hyperscale/crypto/__init__.py +3 -0
  7. hyperscale_crypto-0.1.0/src/hyperscale/crypto/apps.py +42 -0
  8. hyperscale_crypto-0.1.0/src/hyperscale/crypto/canary.py +125 -0
  9. hyperscale_crypto-0.1.0/src/hyperscale/crypto/conf.py +103 -0
  10. hyperscale_crypto-0.1.0/src/hyperscale/crypto/encoding.py +26 -0
  11. hyperscale_crypto-0.1.0/src/hyperscale/crypto/exceptions.py +10 -0
  12. hyperscale_crypto-0.1.0/src/hyperscale/crypto/fields.py +558 -0
  13. hyperscale_crypto-0.1.0/src/hyperscale/crypto/keyset_files.py +147 -0
  14. hyperscale_crypto-0.1.0/src/hyperscale/crypto/keysets.py +291 -0
  15. hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/__init__.py +0 -0
  16. hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/base.py +35 -0
  17. hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/__init__.py +0 -0
  18. hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_add_key.py +34 -0
  19. hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_destroy.py +26 -0
  20. hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_init.py +37 -0
  21. hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_reencrypt.py +46 -0
  22. hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_retire.py +28 -0
  23. hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_rotate.py +92 -0
  24. hyperscale_crypto-0.1.0/src/hyperscale/crypto/management/commands/keyset_status.py +55 -0
  25. hyperscale_crypto-0.1.0/src/hyperscale/crypto/migrations/0001_initial.py +72 -0
  26. hyperscale_crypto-0.1.0/src/hyperscale/crypto/migrations/__init__.py +0 -0
  27. hyperscale_crypto-0.1.0/src/hyperscale/crypto/models.py +57 -0
  28. hyperscale_crypto-0.1.0/src/hyperscale/crypto/py.typed +0 -0
  29. hyperscale_crypto-0.1.0/src/hyperscale/crypto/registry.py +30 -0
  30. hyperscale_crypto-0.1.0/src/hyperscale/crypto/rotation.py +409 -0
  31. hyperscale_crypto-0.1.0/src/hyperscale/crypto/state.py +36 -0
  32. hyperscale_crypto-0.1.0/src/hyperscale/crypto/tasks.py +33 -0
  33. 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).