sealed-fields 0.2.0__tar.gz → 0.4.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.
- {sealed_fields-0.2.0 → sealed_fields-0.4.0}/.gitignore +1 -0
- sealed_fields-0.4.0/PKG-INFO +141 -0
- sealed_fields-0.4.0/README.md +122 -0
- {sealed_fields-0.2.0 → sealed_fields-0.4.0}/pyproject.toml +4 -1
- {sealed_fields-0.2.0 → sealed_fields-0.4.0}/src/sealed_fields/__init__.py +20 -5
- sealed_fields-0.4.0/src/sealed_fields/sqlalchemy.py +191 -0
- {sealed_fields-0.2.0 → sealed_fields-0.4.0}/tests/test_sealed_fields.py +43 -4
- sealed_fields-0.4.0/tests/test_sqlalchemy.py +308 -0
- sealed_fields-0.2.0/PKG-INFO +0 -45
- sealed_fields-0.2.0/README.md +0 -28
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sealed-fields
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Field-level encryption for PII at rest: AES-256-GCM with versioned key rotation, key namespaces, HMAC blind indexes, and row helpers with systemic-failure detection. Wire-compatible with the sealed-fields npm package.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mpointer/sealed-fields
|
|
6
|
+
Project-URL: Issues, https://github.com/mpointer/sealed-fields/issues
|
|
7
|
+
Author-email: Michael Pointer <mpointer@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: aes-256-gcm,blind-index,encryption,encryption-at-rest,field-encryption,key-rotation,pii,searchable-encryption,sqlalchemy
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security :: Cryptography
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: cryptography>=41
|
|
16
|
+
Provides-Extra: sqlalchemy
|
|
17
|
+
Requires-Dist: sqlalchemy>=1.4; extra == 'sqlalchemy'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# sealed-fields (Python)
|
|
21
|
+
|
|
22
|
+
Field-level encryption for PII at rest. This is the Python twin of the
|
|
23
|
+
[sealed-fields npm package](https://www.npmjs.com/package/sealed-fields):
|
|
24
|
+
identical wire format, identical API shape, shared cross-language test
|
|
25
|
+
vectors. Data sealed by either implementation unseals in the other.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
pip install sealed-fields
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from sealed_fields import create_sealed_fields, generate_key
|
|
33
|
+
|
|
34
|
+
sf = create_sealed_fields(
|
|
35
|
+
namespaces={"PII": {"key": os.environ["SEALED_KEY_PII"]}},
|
|
36
|
+
blind_index_key=os.environ.get("SEALED_BLIND_INDEX_KEY"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
token = sf.seal("free-text note about a real person") # "v1:..."
|
|
40
|
+
sf.unseal(token) # plaintext
|
|
41
|
+
sf.blind_index("alice@example.com") # HMAC lookup token
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
See the [repository README](https://github.com/mpointer/sealed-fields) for
|
|
45
|
+
the full design: key rotation without a flag day, key namespaces, blind
|
|
46
|
+
index entropy rules, row helpers, legacy-format decoders, and the
|
|
47
|
+
fail-closed default.
|
|
48
|
+
|
|
49
|
+
## SQLAlchemy column types
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
pip install "sealed-fields[sqlalchemy]"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Declare the sealing on the column and the ORM does the rest: sealed on
|
|
56
|
+
write, unsealed on read, plaintext rows from before the backfill read
|
|
57
|
+
through unchanged.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from sqlalchemy import Column, Integer, String
|
|
61
|
+
from sealed_fields import sealed_fields_from_env
|
|
62
|
+
from sealed_fields.sqlalchemy import SealedJSON, SealedString, SealedText
|
|
63
|
+
|
|
64
|
+
sf = sealed_fields_from_env(["PII"])
|
|
65
|
+
|
|
66
|
+
class User(Base):
|
|
67
|
+
__tablename__ = "users"
|
|
68
|
+
id = Column(Integer, primary_key=True)
|
|
69
|
+
email = Column(SealedText(sf))
|
|
70
|
+
email_idx = Column(String(64), index=True) # sf.blind_index(email), set by the app
|
|
71
|
+
display_name = Column(SealedString(sf))
|
|
72
|
+
preferences = Column(SealedJSON(sf)) # list or dict, stored as sealed JSON text
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Each type takes a provider, either a `SealedFields` instance or a
|
|
76
|
+
zero-argument callable returning one, and an optional `namespace`. A
|
|
77
|
+
callable is resolved on every read and write, so keys loaded after the
|
|
78
|
+
models are declared, or swapped between tests, are picked up without
|
|
79
|
+
redeclaring any column. Keep the callable cheap: return a cached instance.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from functools import lru_cache
|
|
83
|
+
from sealed_fields import SealedFields, sealed_fields_from_env
|
|
84
|
+
|
|
85
|
+
@lru_cache(maxsize=1)
|
|
86
|
+
def current_sf() -> SealedFields:
|
|
87
|
+
return sealed_fields_from_env(["PII"]) # tests call current_sf.cache_clear()
|
|
88
|
+
|
|
89
|
+
class Note(Base):
|
|
90
|
+
body = Column(SealedText(current_sf, namespace="PII"))
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
What happens at the boundary, each one a production lesson:
|
|
94
|
+
|
|
95
|
+
- `None` is SQL `NULL` both ways. An empty string is sealed like any other
|
|
96
|
+
value, so `""` and `NULL` stay distinct.
|
|
97
|
+
- A non-string value raises `TypeError` on write. Sealing needs text; a
|
|
98
|
+
silent `str()` would hide a bug.
|
|
99
|
+
- With no key configured, the `SealedFields` mode decides. `"strict"` (the
|
|
100
|
+
default) raises on write, so a dropped environment variable cannot store
|
|
101
|
+
PII in the clear. `"dormant"` writes plaintext and logs one warning per
|
|
102
|
+
process. Use dormant only for the deploy window before keys exist, and
|
|
103
|
+
never in production:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
mode = "strict" if os.environ.get("APP_ENV") == "production" else "dormant"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- A tampered, truncated or wrong-key token raises on read. These types do
|
|
110
|
+
not null the value the way the row helpers do: a single column has no
|
|
111
|
+
batch to measure a systemic failure against, and a quiet `None` in an
|
|
112
|
+
ORM attribute is how a wrong key goes unnoticed for months.
|
|
113
|
+
- A token naming a key version that is not in the keyring raises
|
|
114
|
+
`UnknownKeyVersionError`. During a rotation, build the `SealedFields`
|
|
115
|
+
with `on_unknown_version="passthrough"` and the token comes back as-is.
|
|
116
|
+
- `SealedJSON` serializes with compact separators and `ensure_ascii=False`
|
|
117
|
+
before sealing, and parses after unsealing. The backing column is `Text`,
|
|
118
|
+
not `json` or `jsonb`. If a column is still `json`/`jsonb` because that
|
|
119
|
+
migration has not run yet, the driver hands back an already-parsed value
|
|
120
|
+
and the type returns it untouched. Like `JSON`, SQLAlchemy persists a
|
|
121
|
+
reassigned value but not an in-place mutation.
|
|
122
|
+
- Ciphertext is about 1.4x the UTF-8 byte length plus roughly 50 bytes.
|
|
123
|
+
Size `SealedString(sf, length=...)` for that, or use `SealedText`.
|
|
124
|
+
|
|
125
|
+
A project-local alias so columns can say `Column(EncryptedText)` with no
|
|
126
|
+
arguments: subclass, set `provider`, and restate `cache_ok` (SQLAlchemy
|
|
127
|
+
requires every `TypeDecorator` subclass to declare it, or it stops caching
|
|
128
|
+
statements that use the type and warns).
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
class EncryptedText(SealedText):
|
|
132
|
+
cache_ok = True
|
|
133
|
+
provider = staticmethod(current_sf)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The provider and namespace are part of the SQLAlchemy cache key. Bind and
|
|
137
|
+
result processors are cached with the compiled statement, so two columns
|
|
138
|
+
sealed by different keyrings never share one; the test suite proves it with
|
|
139
|
+
two identical statements and two providers.
|
|
140
|
+
|
|
141
|
+
Tested against SQLAlchemy 1.4 and 2.x on every push.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# sealed-fields (Python)
|
|
2
|
+
|
|
3
|
+
Field-level encryption for PII at rest. This is the Python twin of the
|
|
4
|
+
[sealed-fields npm package](https://www.npmjs.com/package/sealed-fields):
|
|
5
|
+
identical wire format, identical API shape, shared cross-language test
|
|
6
|
+
vectors. Data sealed by either implementation unseals in the other.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
pip install sealed-fields
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from sealed_fields import create_sealed_fields, generate_key
|
|
14
|
+
|
|
15
|
+
sf = create_sealed_fields(
|
|
16
|
+
namespaces={"PII": {"key": os.environ["SEALED_KEY_PII"]}},
|
|
17
|
+
blind_index_key=os.environ.get("SEALED_BLIND_INDEX_KEY"),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
token = sf.seal("free-text note about a real person") # "v1:..."
|
|
21
|
+
sf.unseal(token) # plaintext
|
|
22
|
+
sf.blind_index("alice@example.com") # HMAC lookup token
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
See the [repository README](https://github.com/mpointer/sealed-fields) for
|
|
26
|
+
the full design: key rotation without a flag day, key namespaces, blind
|
|
27
|
+
index entropy rules, row helpers, legacy-format decoders, and the
|
|
28
|
+
fail-closed default.
|
|
29
|
+
|
|
30
|
+
## SQLAlchemy column types
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install "sealed-fields[sqlalchemy]"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Declare the sealing on the column and the ORM does the rest: sealed on
|
|
37
|
+
write, unsealed on read, plaintext rows from before the backfill read
|
|
38
|
+
through unchanged.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from sqlalchemy import Column, Integer, String
|
|
42
|
+
from sealed_fields import sealed_fields_from_env
|
|
43
|
+
from sealed_fields.sqlalchemy import SealedJSON, SealedString, SealedText
|
|
44
|
+
|
|
45
|
+
sf = sealed_fields_from_env(["PII"])
|
|
46
|
+
|
|
47
|
+
class User(Base):
|
|
48
|
+
__tablename__ = "users"
|
|
49
|
+
id = Column(Integer, primary_key=True)
|
|
50
|
+
email = Column(SealedText(sf))
|
|
51
|
+
email_idx = Column(String(64), index=True) # sf.blind_index(email), set by the app
|
|
52
|
+
display_name = Column(SealedString(sf))
|
|
53
|
+
preferences = Column(SealedJSON(sf)) # list or dict, stored as sealed JSON text
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Each type takes a provider, either a `SealedFields` instance or a
|
|
57
|
+
zero-argument callable returning one, and an optional `namespace`. A
|
|
58
|
+
callable is resolved on every read and write, so keys loaded after the
|
|
59
|
+
models are declared, or swapped between tests, are picked up without
|
|
60
|
+
redeclaring any column. Keep the callable cheap: return a cached instance.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from functools import lru_cache
|
|
64
|
+
from sealed_fields import SealedFields, sealed_fields_from_env
|
|
65
|
+
|
|
66
|
+
@lru_cache(maxsize=1)
|
|
67
|
+
def current_sf() -> SealedFields:
|
|
68
|
+
return sealed_fields_from_env(["PII"]) # tests call current_sf.cache_clear()
|
|
69
|
+
|
|
70
|
+
class Note(Base):
|
|
71
|
+
body = Column(SealedText(current_sf, namespace="PII"))
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
What happens at the boundary, each one a production lesson:
|
|
75
|
+
|
|
76
|
+
- `None` is SQL `NULL` both ways. An empty string is sealed like any other
|
|
77
|
+
value, so `""` and `NULL` stay distinct.
|
|
78
|
+
- A non-string value raises `TypeError` on write. Sealing needs text; a
|
|
79
|
+
silent `str()` would hide a bug.
|
|
80
|
+
- With no key configured, the `SealedFields` mode decides. `"strict"` (the
|
|
81
|
+
default) raises on write, so a dropped environment variable cannot store
|
|
82
|
+
PII in the clear. `"dormant"` writes plaintext and logs one warning per
|
|
83
|
+
process. Use dormant only for the deploy window before keys exist, and
|
|
84
|
+
never in production:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
mode = "strict" if os.environ.get("APP_ENV") == "production" else "dormant"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- A tampered, truncated or wrong-key token raises on read. These types do
|
|
91
|
+
not null the value the way the row helpers do: a single column has no
|
|
92
|
+
batch to measure a systemic failure against, and a quiet `None` in an
|
|
93
|
+
ORM attribute is how a wrong key goes unnoticed for months.
|
|
94
|
+
- A token naming a key version that is not in the keyring raises
|
|
95
|
+
`UnknownKeyVersionError`. During a rotation, build the `SealedFields`
|
|
96
|
+
with `on_unknown_version="passthrough"` and the token comes back as-is.
|
|
97
|
+
- `SealedJSON` serializes with compact separators and `ensure_ascii=False`
|
|
98
|
+
before sealing, and parses after unsealing. The backing column is `Text`,
|
|
99
|
+
not `json` or `jsonb`. If a column is still `json`/`jsonb` because that
|
|
100
|
+
migration has not run yet, the driver hands back an already-parsed value
|
|
101
|
+
and the type returns it untouched. Like `JSON`, SQLAlchemy persists a
|
|
102
|
+
reassigned value but not an in-place mutation.
|
|
103
|
+
- Ciphertext is about 1.4x the UTF-8 byte length plus roughly 50 bytes.
|
|
104
|
+
Size `SealedString(sf, length=...)` for that, or use `SealedText`.
|
|
105
|
+
|
|
106
|
+
A project-local alias so columns can say `Column(EncryptedText)` with no
|
|
107
|
+
arguments: subclass, set `provider`, and restate `cache_ok` (SQLAlchemy
|
|
108
|
+
requires every `TypeDecorator` subclass to declare it, or it stops caching
|
|
109
|
+
statements that use the type and warns).
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
class EncryptedText(SealedText):
|
|
113
|
+
cache_ok = True
|
|
114
|
+
provider = staticmethod(current_sf)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The provider and namespace are part of the SQLAlchemy cache key. Bind and
|
|
118
|
+
result processors are cached with the compiled statement, so two columns
|
|
119
|
+
sealed by different keyrings never share one; the test suite proves it with
|
|
120
|
+
two identical statements and two providers.
|
|
121
|
+
|
|
122
|
+
Tested against SQLAlchemy 1.4 and 2.x on every push.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sealed-fields"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Field-level encryption for PII at rest: AES-256-GCM with versioned key rotation, key namespaces, HMAC blind indexes, and row helpers with systemic-failure detection. Wire-compatible with the sealed-fields npm package."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -22,6 +22,9 @@ classifiers = [
|
|
|
22
22
|
"Topic :: Security :: Cryptography",
|
|
23
23
|
]
|
|
24
24
|
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
sqlalchemy = ["SQLAlchemy>=1.4"]
|
|
27
|
+
|
|
25
28
|
[project.urls]
|
|
26
29
|
Homepage = "https://github.com/mpointer/sealed-fields"
|
|
27
30
|
Issues = "https://github.com/mpointer/sealed-fields/issues"
|
|
@@ -313,6 +313,8 @@ class SealedFields:
|
|
|
313
313
|
return value
|
|
314
314
|
raise UnknownKeyVersionError(version, ns)
|
|
315
315
|
raw = _decode_blob(m.group(2))
|
|
316
|
+
if len(raw) < NONCE_BYTES + TAG_BYTES:
|
|
317
|
+
raise SealedFieldsError("unseal: token is too short to be a sealed value.")
|
|
316
318
|
nonce, ct = raw[:NONCE_BYTES], raw[NONCE_BYTES:]
|
|
317
319
|
return AESGCM(key).decrypt(nonce, ct, version.encode("ascii")).decode("utf-8")
|
|
318
320
|
if ring is not None and self._legacy:
|
|
@@ -331,7 +333,9 @@ class SealedFields:
|
|
|
331
333
|
return None
|
|
332
334
|
return self.unseal(value, namespace)
|
|
333
335
|
|
|
334
|
-
def blind_index(
|
|
336
|
+
def blind_index(
|
|
337
|
+
self, value: str, *, normalize: bool | Callable[[str], str] = True
|
|
338
|
+
) -> str:
|
|
335
339
|
"""Deterministic HMAC-SHA256 lookup token for an equality query on an
|
|
336
340
|
otherwise-sealed column. ONLY for high-entropy values (email, username,
|
|
337
341
|
phone). Never index a low-entropy field: a deterministic MAC over a
|
|
@@ -339,12 +343,21 @@ class SealedFields:
|
|
|
339
343
|
|
|
340
344
|
Normalization (default on) is trim + lowercase, matching the npm
|
|
341
345
|
package's String.prototype.toLowerCase for cross-language equality.
|
|
346
|
+
Pass a callable to normalize differently, for example
|
|
347
|
+
``lambda s: s.strip().casefold()`` when existing index tokens were
|
|
348
|
+
built with casefold. Whatever you pick, every writer and every reader
|
|
349
|
+
of a column must use the same normalization, or lookups silently miss.
|
|
342
350
|
"""
|
|
343
351
|
if self._index_key is None:
|
|
344
352
|
raise KeyNotConfiguredError(
|
|
345
353
|
"blind_index_key is not configured; cannot build a blind index."
|
|
346
354
|
)
|
|
347
|
-
|
|
355
|
+
if callable(normalize):
|
|
356
|
+
normalized = normalize(value)
|
|
357
|
+
elif normalize:
|
|
358
|
+
normalized = value.strip().lower()
|
|
359
|
+
else:
|
|
360
|
+
normalized = value
|
|
348
361
|
return hmac_mod.new(self._index_key, normalized.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
349
362
|
|
|
350
363
|
# -- row helpers ---------------------------------------------------------
|
|
@@ -368,8 +381,8 @@ class SealedFields:
|
|
|
368
381
|
v = out.get(field)
|
|
369
382
|
if not isinstance(v, str) or v == "":
|
|
370
383
|
continue
|
|
371
|
-
|
|
372
|
-
|
|
384
|
+
# Seal unconditionally: a value that merely looks like a token is
|
|
385
|
+
# untrusted input, not ciphertext. Call seal_row once per write.
|
|
373
386
|
out[field] = self.seal(v, namespace)
|
|
374
387
|
return out
|
|
375
388
|
|
|
@@ -382,7 +395,9 @@ class SealedFields:
|
|
|
382
395
|
attempts = 0
|
|
383
396
|
for field in (*fields, *json_fields):
|
|
384
397
|
v = out.get(field)
|
|
385
|
-
if not isinstance(v, str) or v == ""
|
|
398
|
+
if not isinstance(v, str) or v == "":
|
|
399
|
+
continue
|
|
400
|
+
if not (is_sealed(v) or any(_match_legacy(v, f) for f in self._legacy)):
|
|
386
401
|
continue
|
|
387
402
|
attempts += 1
|
|
388
403
|
try:
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""SQLAlchemy column types that seal on write and unseal on read.
|
|
2
|
+
|
|
3
|
+
pip install "sealed-fields[sqlalchemy]"
|
|
4
|
+
|
|
5
|
+
Three ``TypeDecorator`` subclasses: :class:`SealedText` (backed by ``Text``),
|
|
6
|
+
:class:`SealedString` (backed by ``String``) and :class:`SealedJSON` (a JSON
|
|
7
|
+
value serialized to text, then sealed, backed by ``Text``). Each takes a
|
|
8
|
+
*provider*: either a :class:`~sealed_fields.SealedFields` instance or a
|
|
9
|
+
zero-argument callable returning one, plus an optional namespace::
|
|
10
|
+
|
|
11
|
+
from sealed_fields import sealed_fields_from_env
|
|
12
|
+
from sealed_fields.sqlalchemy import SealedText, SealedJSON
|
|
13
|
+
|
|
14
|
+
sf = sealed_fields_from_env(["PII"])
|
|
15
|
+
|
|
16
|
+
class User(Base):
|
|
17
|
+
__tablename__ = "users"
|
|
18
|
+
id = Column(Integer, primary_key=True)
|
|
19
|
+
email = Column(SealedText(sf))
|
|
20
|
+
preferences = Column(SealedJSON(sf))
|
|
21
|
+
|
|
22
|
+
A callable provider is resolved on every write and read, so keys that are
|
|
23
|
+
loaded lazily (after models are declared, or re-read between tests) just
|
|
24
|
+
work. The callable should be cheap: return a cached instance.
|
|
25
|
+
|
|
26
|
+
Behavior at the boundaries, learned in production and kept deliberately:
|
|
27
|
+
|
|
28
|
+
* ``None`` is stored as SQL NULL and read back as ``None``. An empty string
|
|
29
|
+
is sealed like any other value, so ``""`` and ``NULL`` stay distinct.
|
|
30
|
+
* A column may hold plaintext rows mid-migration: ``unseal`` passes any
|
|
31
|
+
non-token string through unchanged, so swapping a column to these types
|
|
32
|
+
is a safe deploy before the backfill runs.
|
|
33
|
+
* With no key configured, the SealedFields ``mode`` decides what a write
|
|
34
|
+
does. ``"strict"`` (the default) raises, so a dropped environment variable
|
|
35
|
+
cannot silently store PII in the clear. ``"dormant"`` writes plaintext and
|
|
36
|
+
logs one loud warning per process; choose it only for the deploy window
|
|
37
|
+
before keys are provisioned, and never in production.
|
|
38
|
+
* A tampered, truncated or wrong-key token raises on read. These types do
|
|
39
|
+
not null the value the way the row helpers do, because a per-column type
|
|
40
|
+
has no batch to measure a systemic failure against, and a silent ``None``
|
|
41
|
+
in an ORM attribute is how ciphertext problems go unnoticed for months.
|
|
42
|
+
* Ciphertext is longer than plaintext (about 1.4x the UTF-8 byte length plus
|
|
43
|
+
roughly 50 bytes of nonce, tag and version). Size the backing column for
|
|
44
|
+
that, not for the plaintext.
|
|
45
|
+
"""
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import json
|
|
49
|
+
from typing import Any, Callable, Optional, Union
|
|
50
|
+
|
|
51
|
+
from sqlalchemy.types import String, Text, TypeDecorator
|
|
52
|
+
|
|
53
|
+
from . import SealedFields, SealedFieldsError
|
|
54
|
+
|
|
55
|
+
__all__ = ["SealedText", "SealedString", "SealedJSON", "Provider"]
|
|
56
|
+
|
|
57
|
+
Provider = Union[SealedFields, Callable[[], SealedFields]]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _SealedType(TypeDecorator):
|
|
61
|
+
"""Shared plumbing: provider resolution and the seal/unseal calls."""
|
|
62
|
+
|
|
63
|
+
cache_ok = True
|
|
64
|
+
|
|
65
|
+
#: A subclass may set this so the type can be used without arguments
|
|
66
|
+
#: (``Column(MyText)``), the way a project-local alias usually is. Every
|
|
67
|
+
#: TypeDecorator subclass must restate ``cache_ok``, or SQLAlchemy stops
|
|
68
|
+
#: caching statements that use it and warns::
|
|
69
|
+
#:
|
|
70
|
+
#: class EncryptedText(SealedText):
|
|
71
|
+
#: cache_ok = True
|
|
72
|
+
#: provider = staticmethod(get_sealed_fields)
|
|
73
|
+
provider: Optional[Provider] = None
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
provider: Optional[Provider] = None,
|
|
78
|
+
namespace: Optional[str] = None,
|
|
79
|
+
**impl_kwargs: Any,
|
|
80
|
+
) -> None:
|
|
81
|
+
# `namespace` is positional-or-keyword on purpose: SQLAlchemy builds
|
|
82
|
+
# the cache key from positional-or-keyword __init__ names only, and a
|
|
83
|
+
# keyword-only one would be left out of it.
|
|
84
|
+
super().__init__(**impl_kwargs)
|
|
85
|
+
resolved = provider if provider is not None else type(self).provider
|
|
86
|
+
if resolved is None:
|
|
87
|
+
raise TypeError(
|
|
88
|
+
f"{type(self).__name__} needs a provider: a SealedFields instance or a "
|
|
89
|
+
"zero-argument callable returning one."
|
|
90
|
+
)
|
|
91
|
+
if not isinstance(resolved, SealedFields) and not callable(resolved):
|
|
92
|
+
raise TypeError(
|
|
93
|
+
f"{type(self).__name__} provider must be a SealedFields instance or a "
|
|
94
|
+
f"callable, not {type(resolved).__name__}."
|
|
95
|
+
)
|
|
96
|
+
# Both attributes are part of the SQLAlchemy cache key (cache_ok=True
|
|
97
|
+
# keys on the __init__ argument names found in __dict__). Bind and
|
|
98
|
+
# result processors are cached with the compiled statement, so two
|
|
99
|
+
# columns sealed by different providers must never share one.
|
|
100
|
+
self.provider = resolved
|
|
101
|
+
self.namespace = namespace
|
|
102
|
+
|
|
103
|
+
def _sf(self) -> SealedFields:
|
|
104
|
+
p = self.provider
|
|
105
|
+
sf = p if isinstance(p, SealedFields) else p() # type: ignore[operator]
|
|
106
|
+
if not isinstance(sf, SealedFields):
|
|
107
|
+
raise SealedFieldsError(
|
|
108
|
+
f"{type(self).__name__} provider returned {type(sf).__name__}, "
|
|
109
|
+
"expected a SealedFields instance."
|
|
110
|
+
)
|
|
111
|
+
return sf
|
|
112
|
+
|
|
113
|
+
def _seal(self, value: str) -> str:
|
|
114
|
+
return self._sf().seal(value, self.namespace)
|
|
115
|
+
|
|
116
|
+
def _unseal(self, value: str) -> str:
|
|
117
|
+
return self._sf().unseal(value, self.namespace)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class SealedText(_SealedType):
|
|
121
|
+
"""Text column sealed at rest.
|
|
122
|
+
|
|
123
|
+
Writes seal the string with the namespace's active key. Reads unseal a
|
|
124
|
+
token and pass plaintext (a row written before the backfill) through
|
|
125
|
+
unchanged.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
impl = Text
|
|
129
|
+
cache_ok = True
|
|
130
|
+
|
|
131
|
+
def process_bind_param(self, value: Any, dialect: Any) -> Optional[str]:
|
|
132
|
+
if value is None:
|
|
133
|
+
return None
|
|
134
|
+
if not isinstance(value, str):
|
|
135
|
+
raise TypeError(
|
|
136
|
+
f"{type(self).__name__} expects str or None, got {type(value).__name__}."
|
|
137
|
+
)
|
|
138
|
+
return self._seal(value)
|
|
139
|
+
|
|
140
|
+
def process_result_value(self, value: Any, dialect: Any) -> Optional[str]:
|
|
141
|
+
if value is None:
|
|
142
|
+
return None
|
|
143
|
+
if not isinstance(value, str):
|
|
144
|
+
return value
|
|
145
|
+
return self._unseal(value)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SealedString(SealedText):
|
|
149
|
+
""":class:`SealedText` backed by ``String`` instead of ``Text``, for call
|
|
150
|
+
sites that previously declared ``String``. Pass ``length=`` sized for
|
|
151
|
+
the ciphertext, not the plaintext, or leave it unset.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
impl = String
|
|
155
|
+
cache_ok = True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class SealedJSON(_SealedType):
|
|
159
|
+
"""JSON value (list or dict) sealed at rest.
|
|
160
|
+
|
|
161
|
+
The value is serialized to compact JSON text, then sealed, so the backing
|
|
162
|
+
column is ``Text``, not ``json`` or ``jsonb``. On read, a value the driver
|
|
163
|
+
already parsed (a column still typed ``json``/``jsonb`` because the
|
|
164
|
+
migration to text has not run yet) is returned as-is; only strings are
|
|
165
|
+
unsealed and decoded.
|
|
166
|
+
|
|
167
|
+
Like ``JSON``, SQLAlchemy persists a reassigned value (``obj.col = new``)
|
|
168
|
+
but does not track an in-place mutation (``obj.col.append(x)``).
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
impl = Text
|
|
172
|
+
cache_ok = True
|
|
173
|
+
|
|
174
|
+
def process_bind_param(self, value: Any, dialect: Any) -> Optional[str]:
|
|
175
|
+
if value is None:
|
|
176
|
+
return None
|
|
177
|
+
payload = json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
|
178
|
+
return self._seal(payload)
|
|
179
|
+
|
|
180
|
+
def process_result_value(self, value: Any, dialect: Any) -> Any:
|
|
181
|
+
if value is None:
|
|
182
|
+
return None
|
|
183
|
+
if not isinstance(value, str):
|
|
184
|
+
return value
|
|
185
|
+
decoded = self._unseal(value)
|
|
186
|
+
if decoded is None or decoded == "":
|
|
187
|
+
return None
|
|
188
|
+
try:
|
|
189
|
+
return json.loads(decoded)
|
|
190
|
+
except ValueError:
|
|
191
|
+
return decoded # legacy text that was never JSON; never lose it
|
|
@@ -154,11 +154,20 @@ class TestRows:
|
|
|
154
154
|
assert out["extra"] == "untouched"
|
|
155
155
|
assert row["name"] == "Ada"
|
|
156
156
|
|
|
157
|
-
def
|
|
157
|
+
def test_token_shaped_input_is_sealed_not_trusted(self):
|
|
158
|
+
# Regression: seal_row used to skip values that looked sealed, storing
|
|
159
|
+
# attacker-chosen "tokens" unencrypted and tripping SystemicUnsealError.
|
|
158
160
|
sf = make_sf()
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
evil = "v1:" + base64.urlsafe_b64encode(os.urandom(30)).decode().rstrip("=")
|
|
162
|
+
assert is_sealed(evil)
|
|
163
|
+
out = sf.seal_row({"name": evil}, self.FIELDS)
|
|
164
|
+
assert out["name"] != evil
|
|
165
|
+
assert sf.unseal_row(out, self.FIELDS)["name"] == evil
|
|
166
|
+
rows = [
|
|
167
|
+
sf.seal_row({"name": "v1:" + base64.urlsafe_b64encode(os.urandom(30)).decode()}, self.FIELDS)
|
|
168
|
+
for _ in range(5)
|
|
169
|
+
]
|
|
170
|
+
sf.unseal_rows(rows, self.FIELDS) # must not raise
|
|
162
171
|
|
|
163
172
|
def test_mixed_plaintext_rows_mid_migration(self):
|
|
164
173
|
sf = make_sf()
|
|
@@ -245,6 +254,19 @@ class TestLegacy:
|
|
|
245
254
|
token = self._iv_ct_tag("x", self._key_bytes(KEY_A))
|
|
246
255
|
assert sf.unseal(token) == token
|
|
247
256
|
|
|
257
|
+
def test_row_helpers_decode_legacy_tokens(self):
|
|
258
|
+
sf = make_sf(legacy=["iv-ct-tag", "v1-iv-tag-ct"], logger=lambda m: None)
|
|
259
|
+
k = self._key_bytes(KEY_A)
|
|
260
|
+
row = {"a": self._iv_ct_tag("one", k), "b": self._v1_iv_tag_ct("two", k)}
|
|
261
|
+
assert sf.unseal_row(row, ["a", "b"]) == {"a": "one", "b": "two"}
|
|
262
|
+
|
|
263
|
+
def test_row_helper_legacy_failures_count_as_systemic(self):
|
|
264
|
+
sf = make_sf(legacy=["iv-ct-tag"], logger=lambda m: None)
|
|
265
|
+
rows = [{"a": self._iv_ct_tag(f"p{i}", self._key_bytes(KEY_B))} for i in range(5)]
|
|
266
|
+
assert sf.unseal_row(rows[0], ["a"])["a"] is None
|
|
267
|
+
with pytest.raises(SystemicUnsealError):
|
|
268
|
+
sf.unseal_rows(rows, ["a"])
|
|
269
|
+
|
|
248
270
|
def test_ordinary_colon_text_not_mistaken(self):
|
|
249
271
|
sf = make_sf(legacy=["iv-ct-tag", "v1-iv-tag-ct"])
|
|
250
272
|
for s in ["a:b:c", "12:30:45", "v1:looks:like:legacy"]:
|
|
@@ -263,6 +285,16 @@ class TestBlindIndexAndUtils:
|
|
|
263
285
|
expected = hmac_mod.new(raw, b"alice@example.com", hashlib.sha256).hexdigest()
|
|
264
286
|
assert sf.blind_index("alice@example.com") == expected
|
|
265
287
|
|
|
288
|
+
def test_blind_index_custom_normalizer(self):
|
|
289
|
+
sf = make_sf(blind_index_key=KEY_A)
|
|
290
|
+
casefold = lambda s: s.strip().casefold()
|
|
291
|
+
# "ß".casefold() is "ss" while lower() leaves it alone, so the two
|
|
292
|
+
# policies produce different tokens for the same input.
|
|
293
|
+
assert sf.blind_index(" Straße ", normalize=casefold) == sf.blind_index(
|
|
294
|
+
"strasse", normalize=False
|
|
295
|
+
)
|
|
296
|
+
assert sf.blind_index("Straße", normalize=casefold) != sf.blind_index("Straße")
|
|
297
|
+
|
|
266
298
|
def test_blind_index_requires_key(self):
|
|
267
299
|
with pytest.raises(KeyNotConfiguredError):
|
|
268
300
|
make_sf().blind_index("x")
|
|
@@ -313,3 +345,10 @@ class TestCrossLanguage:
|
|
|
313
345
|
for name, token in VECTORS["tokens"].items():
|
|
314
346
|
assert sf.unseal(token) == VECTORS["plaintext"], name
|
|
315
347
|
assert sf.blind_index(VECTORS["blindIndex"]["value"]) == VECTORS["blindIndex"]["hex"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_short_canonical_token_rejected():
|
|
351
|
+
sf = make_sf()
|
|
352
|
+
for t in ["v1:AAAAAA", "v1:AAAAAAAAAAA", "v1:" + "A" * 36]:
|
|
353
|
+
with pytest.raises(SealedFieldsError, match="too short"):
|
|
354
|
+
sf.unseal(t)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""SQLAlchemy column types: sealed at rest, plaintext in the ORM."""
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import sqlalchemy as sa
|
|
7
|
+
from sqlalchemy import Column, Integer, MetaData, Table, Text, bindparam, insert, select, text
|
|
8
|
+
from sqlalchemy.orm import Session, declarative_base
|
|
9
|
+
|
|
10
|
+
from sealed_fields import (
|
|
11
|
+
KeyNotConfiguredError,
|
|
12
|
+
SealedFields,
|
|
13
|
+
SealedFieldsError,
|
|
14
|
+
UnknownKeyVersionError,
|
|
15
|
+
create_sealed_fields,
|
|
16
|
+
generate_key,
|
|
17
|
+
is_sealed,
|
|
18
|
+
)
|
|
19
|
+
from sealed_fields.sqlalchemy import SealedJSON, SealedString, SealedText
|
|
20
|
+
|
|
21
|
+
KEY_A = generate_key()
|
|
22
|
+
KEY_B = generate_key()
|
|
23
|
+
|
|
24
|
+
SF_A = create_sealed_fields(namespaces={"PII": {"key": KEY_A}})
|
|
25
|
+
SF_B = create_sealed_fields(namespaces={"PII": {"key": KEY_B}})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Current:
|
|
29
|
+
"""Stand-in for an app's lazily loaded keyring: tests swap `.sf`."""
|
|
30
|
+
|
|
31
|
+
sf = SF_A
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def current_sf():
|
|
35
|
+
return Current.sf
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
Base = declarative_base()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Note(Base):
|
|
42
|
+
__tablename__ = "notes"
|
|
43
|
+
id = Column(Integer, primary_key=True)
|
|
44
|
+
body = Column(SealedText(SF_A))
|
|
45
|
+
author = Column(SealedString(SF_A))
|
|
46
|
+
snapshot = Column(SealedJSON(SF_A))
|
|
47
|
+
lazy = Column(SealedText(current_sf))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def engine():
|
|
52
|
+
eng = sa.create_engine("sqlite://")
|
|
53
|
+
Base.metadata.create_all(eng)
|
|
54
|
+
yield eng
|
|
55
|
+
eng.dispose()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def session(engine):
|
|
60
|
+
with Session(engine) as s:
|
|
61
|
+
yield s
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.fixture(autouse=True)
|
|
65
|
+
def reset_current():
|
|
66
|
+
Current.sf = SF_A
|
|
67
|
+
yield
|
|
68
|
+
Current.sf = SF_A
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def raw(session, column, note_id):
|
|
72
|
+
return session.execute(
|
|
73
|
+
text(f"SELECT {column} FROM notes WHERE id = :id"), {"id": note_id}
|
|
74
|
+
).scalar()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestSealedText:
|
|
78
|
+
def test_stored_sealed_read_plain(self, session):
|
|
79
|
+
secret = "slept nine hours, anxiety way down"
|
|
80
|
+
n = Note(body=secret, author="Ada")
|
|
81
|
+
session.add(n)
|
|
82
|
+
session.commit()
|
|
83
|
+
stored = raw(session, "body", n.id)
|
|
84
|
+
assert is_sealed(stored)
|
|
85
|
+
assert secret not in stored
|
|
86
|
+
assert is_sealed(raw(session, "author", n.id))
|
|
87
|
+
session.expire(n)
|
|
88
|
+
assert n.body == secret
|
|
89
|
+
assert n.author == "Ada"
|
|
90
|
+
|
|
91
|
+
def test_none_stays_null_and_empty_string_is_sealed(self, session):
|
|
92
|
+
n = Note(body=None, author="")
|
|
93
|
+
session.add(n)
|
|
94
|
+
session.commit()
|
|
95
|
+
assert raw(session, "body", n.id) is None
|
|
96
|
+
stored_author = raw(session, "author", n.id)
|
|
97
|
+
assert is_sealed(stored_author) # "" and NULL stay distinct
|
|
98
|
+
session.expire(n)
|
|
99
|
+
assert n.body is None
|
|
100
|
+
assert n.author == ""
|
|
101
|
+
|
|
102
|
+
def test_legacy_plaintext_row_reads_through(self, session):
|
|
103
|
+
session.execute(
|
|
104
|
+
text("INSERT INTO notes (body) VALUES (:b)"), {"b": "written before sealing"}
|
|
105
|
+
)
|
|
106
|
+
session.commit()
|
|
107
|
+
n = session.execute(select(Note)).scalar_one()
|
|
108
|
+
assert n.body == "written before sealing"
|
|
109
|
+
|
|
110
|
+
def test_non_string_write_raises(self, session):
|
|
111
|
+
session.add(Note(body=123))
|
|
112
|
+
with pytest.raises(sa.exc.StatementError) as excinfo:
|
|
113
|
+
session.commit()
|
|
114
|
+
assert isinstance(excinfo.value.orig, TypeError)
|
|
115
|
+
|
|
116
|
+
def test_tampered_token_raises_on_read(self, session):
|
|
117
|
+
n = Note(body="secret")
|
|
118
|
+
session.add(n)
|
|
119
|
+
session.commit()
|
|
120
|
+
stored = raw(session, "body", n.id)
|
|
121
|
+
version, blob = stored.split(":", 1)
|
|
122
|
+
bad = bytearray(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
|
|
123
|
+
bad[-1] ^= 0x01
|
|
124
|
+
forged = f"{version}:{base64.urlsafe_b64encode(bytes(bad)).decode().rstrip('=')}"
|
|
125
|
+
session.execute(text("UPDATE notes SET body = :b WHERE id = :id"), {"b": forged, "id": n.id})
|
|
126
|
+
session.commit()
|
|
127
|
+
session.expire(n)
|
|
128
|
+
with pytest.raises(Exception):
|
|
129
|
+
n.body
|
|
130
|
+
|
|
131
|
+
def test_lazy_provider_resolves_on_every_call(self, session):
|
|
132
|
+
n = Note(lazy="under key A")
|
|
133
|
+
session.add(n)
|
|
134
|
+
session.commit()
|
|
135
|
+
stored = raw(session, "lazy", n.id)
|
|
136
|
+
assert SF_A.unseal(stored) == "under key A"
|
|
137
|
+
|
|
138
|
+
Current.sf = SF_B # rotate: the column must follow, no re-declaration
|
|
139
|
+
m = Note(lazy="under key B")
|
|
140
|
+
session.add(m)
|
|
141
|
+
session.commit()
|
|
142
|
+
assert SF_B.unseal(raw(session, "lazy", m.id)) == "under key B"
|
|
143
|
+
session.expire(n)
|
|
144
|
+
with pytest.raises(Exception): # key A rows are unreadable under B alone
|
|
145
|
+
n.lazy
|
|
146
|
+
|
|
147
|
+
def test_unknown_version_raises_unless_passthrough(self, session):
|
|
148
|
+
future = create_sealed_fields(namespaces={"PII": {"key": KEY_A, "version": "v7"}})
|
|
149
|
+
n = Note(lazy="x")
|
|
150
|
+
session.add(n)
|
|
151
|
+
session.commit()
|
|
152
|
+
token = future.seal("from the future")
|
|
153
|
+
session.execute(text("UPDATE notes SET lazy = :t WHERE id = :id"), {"t": token, "id": n.id})
|
|
154
|
+
session.commit()
|
|
155
|
+
session.expire(n)
|
|
156
|
+
with pytest.raises(UnknownKeyVersionError):
|
|
157
|
+
n.lazy
|
|
158
|
+
Current.sf = create_sealed_fields(
|
|
159
|
+
namespaces={"PII": {"key": KEY_A}}, on_unknown_version="passthrough"
|
|
160
|
+
)
|
|
161
|
+
session.expire(n)
|
|
162
|
+
assert n.lazy == token # migration window: the token comes back as-is
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestModes:
|
|
166
|
+
def test_strict_without_key_refuses_to_write(self, session):
|
|
167
|
+
Current.sf = create_sealed_fields(namespaces={"PII": {}})
|
|
168
|
+
session.add(Note(lazy="pii"))
|
|
169
|
+
with pytest.raises(sa.exc.StatementError) as excinfo:
|
|
170
|
+
session.commit()
|
|
171
|
+
assert isinstance(excinfo.value.orig, KeyNotConfiguredError)
|
|
172
|
+
|
|
173
|
+
def test_dormant_without_key_writes_plaintext_and_warns_once(self, session):
|
|
174
|
+
warnings = []
|
|
175
|
+
Current.sf = create_sealed_fields(
|
|
176
|
+
namespaces={"PII": {}}, mode="dormant", logger=warnings.append
|
|
177
|
+
)
|
|
178
|
+
n = Note(lazy="not yet keyed")
|
|
179
|
+
m = Note(lazy="still not keyed")
|
|
180
|
+
session.add_all([n, m])
|
|
181
|
+
session.commit()
|
|
182
|
+
assert raw(session, "lazy", n.id) == "not yet keyed"
|
|
183
|
+
assert len(warnings) == 1 and "PLAINTEXT" in warnings[0]
|
|
184
|
+
session.expire(n)
|
|
185
|
+
assert n.lazy == "not yet keyed"
|
|
186
|
+
|
|
187
|
+
def test_strict_without_key_cannot_read_a_token(self, session):
|
|
188
|
+
n = Note(lazy="sealed under A")
|
|
189
|
+
session.add(n)
|
|
190
|
+
session.commit()
|
|
191
|
+
Current.sf = create_sealed_fields(namespaces={"PII": {}})
|
|
192
|
+
session.expire(n)
|
|
193
|
+
with pytest.raises(KeyNotConfiguredError):
|
|
194
|
+
n.lazy
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class TestSealedJSON:
|
|
198
|
+
def test_round_trips_list_and_dict(self, session):
|
|
199
|
+
conditions = ["anxiety", "chronic pain", {"nested": [1, 2.5, None, "ü"]}]
|
|
200
|
+
n = Note(snapshot=conditions)
|
|
201
|
+
session.add(n)
|
|
202
|
+
session.commit()
|
|
203
|
+
stored = raw(session, "snapshot", n.id)
|
|
204
|
+
assert is_sealed(stored)
|
|
205
|
+
assert "anxiety" not in stored
|
|
206
|
+
assert json.loads(SF_A.unseal(stored)) == conditions
|
|
207
|
+
session.expire(n)
|
|
208
|
+
assert n.snapshot == conditions
|
|
209
|
+
|
|
210
|
+
def test_empty_list_and_none(self, session):
|
|
211
|
+
n = Note(snapshot=[])
|
|
212
|
+
m = Note(snapshot=None)
|
|
213
|
+
session.add_all([n, m])
|
|
214
|
+
session.commit()
|
|
215
|
+
assert raw(session, "snapshot", m.id) is None
|
|
216
|
+
session.expire(n)
|
|
217
|
+
session.expire(m)
|
|
218
|
+
assert n.snapshot == []
|
|
219
|
+
assert m.snapshot is None
|
|
220
|
+
|
|
221
|
+
def test_legacy_json_text_row_decodes(self, session):
|
|
222
|
+
session.execute(
|
|
223
|
+
text("INSERT INTO notes (snapshot) VALUES (:s)"), {"s": '["couch-lock","paranoia"]'}
|
|
224
|
+
)
|
|
225
|
+
session.commit()
|
|
226
|
+
n = session.execute(select(Note)).scalar_one()
|
|
227
|
+
assert n.snapshot == ["couch-lock", "paranoia"]
|
|
228
|
+
|
|
229
|
+
def test_already_parsed_value_passes_through(self):
|
|
230
|
+
# A column still typed json/jsonb hands the ORM a parsed value.
|
|
231
|
+
t = SealedJSON(SF_A)
|
|
232
|
+
assert t.process_result_value(["a", "b"], None) == ["a", "b"]
|
|
233
|
+
assert t.process_result_value({"k": 1}, None) == {"k": 1}
|
|
234
|
+
assert t.process_result_value(None, None) is None
|
|
235
|
+
assert t.process_result_value("", None) is None
|
|
236
|
+
assert t.process_result_value("not json", None) == "not json"
|
|
237
|
+
|
|
238
|
+
def test_string_value_survives_as_string(self):
|
|
239
|
+
t = SealedJSON(SF_A)
|
|
240
|
+
stored = t.process_bind_param("free text", None)
|
|
241
|
+
assert t.process_result_value(stored, None) == "free text"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class TestProviderAndCaching:
|
|
245
|
+
def test_requires_a_provider(self):
|
|
246
|
+
with pytest.raises(TypeError):
|
|
247
|
+
SealedText()
|
|
248
|
+
with pytest.raises(TypeError):
|
|
249
|
+
SealedText(provider=42)
|
|
250
|
+
|
|
251
|
+
def test_class_attribute_provider_allows_bare_use(self, engine):
|
|
252
|
+
class EncryptedText(SealedText):
|
|
253
|
+
cache_ok = True
|
|
254
|
+
provider = staticmethod(current_sf)
|
|
255
|
+
|
|
256
|
+
md = MetaData()
|
|
257
|
+
t = Table("bare", md, Column("id", Integer, primary_key=True), Column("v", EncryptedText))
|
|
258
|
+
md.create_all(engine)
|
|
259
|
+
with engine.begin() as conn:
|
|
260
|
+
conn.execute(t.insert().values(v="hello"))
|
|
261
|
+
stored = conn.execute(text("SELECT v FROM bare")).scalar()
|
|
262
|
+
assert is_sealed(stored)
|
|
263
|
+
assert conn.execute(select(t.c.v)).scalar() == "hello"
|
|
264
|
+
|
|
265
|
+
def test_provider_must_return_sealed_fields(self):
|
|
266
|
+
t = SealedText(lambda: "nope")
|
|
267
|
+
with pytest.raises(SealedFieldsError):
|
|
268
|
+
t.process_bind_param("x", None)
|
|
269
|
+
|
|
270
|
+
def test_namespace_is_honored(self):
|
|
271
|
+
sf = create_sealed_fields(namespaces={"PII": {"key": KEY_A}, "TOTP": {"key": KEY_B}})
|
|
272
|
+
t = SealedText(sf, namespace="TOTP")
|
|
273
|
+
token = t.process_bind_param("secret", None)
|
|
274
|
+
assert sf.unseal(token, "TOTP") == "secret"
|
|
275
|
+
with pytest.raises(Exception):
|
|
276
|
+
sf.unseal(token, "PII")
|
|
277
|
+
assert t.process_result_value(token, None) == "secret"
|
|
278
|
+
|
|
279
|
+
def test_cache_key_includes_provider_and_namespace(self):
|
|
280
|
+
assert SealedText(SF_A)._static_cache_key == SealedText(SF_A)._static_cache_key
|
|
281
|
+
assert SealedText(SF_A)._static_cache_key != SealedText(SF_B)._static_cache_key
|
|
282
|
+
sf = create_sealed_fields(namespaces={"PII": {"key": KEY_A}, "TOTP": {"key": KEY_B}})
|
|
283
|
+
assert (
|
|
284
|
+
SealedText(sf, namespace="PII")._static_cache_key
|
|
285
|
+
!= SealedText(sf, namespace="TOTP")._static_cache_key
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def test_compiled_cache_does_not_cross_providers(self, engine):
|
|
289
|
+
# Bind processors are cached with the compiled statement. Two
|
|
290
|
+
# statements with identical shape but different providers must each
|
|
291
|
+
# seal under their own key.
|
|
292
|
+
md = MetaData()
|
|
293
|
+
t = Table("plain", md, Column("id", Integer, primary_key=True), Column("v", Text))
|
|
294
|
+
md.create_all(engine)
|
|
295
|
+
with engine.begin() as conn:
|
|
296
|
+
conn.execute(
|
|
297
|
+
insert(t).values(id=1, v=bindparam("v", type_=SealedText(SF_A))), {"v": "a"}
|
|
298
|
+
)
|
|
299
|
+
conn.execute(
|
|
300
|
+
insert(t).values(id=2, v=bindparam("v", type_=SealedText(SF_B))), {"v": "b"}
|
|
301
|
+
)
|
|
302
|
+
rows = dict(conn.execute(text("SELECT id, v FROM plain")).all())
|
|
303
|
+
assert SF_A.unseal(rows[1]) == "a"
|
|
304
|
+
assert SF_B.unseal(rows[2]) == "b"
|
|
305
|
+
|
|
306
|
+
def test_string_impl_kwargs_pass_through(self):
|
|
307
|
+
t = SealedString(SF_A, length=512)
|
|
308
|
+
assert t.impl.length == 512
|
sealed_fields-0.2.0/PKG-INFO
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: sealed-fields
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: Field-level encryption for PII at rest: AES-256-GCM with versioned key rotation, key namespaces, HMAC blind indexes, and row helpers with systemic-failure detection. Wire-compatible with the sealed-fields npm package.
|
|
5
|
-
Project-URL: Homepage, https://github.com/mpointer/sealed-fields
|
|
6
|
-
Project-URL: Issues, https://github.com/mpointer/sealed-fields/issues
|
|
7
|
-
Author-email: Michael Pointer <mpointer@gmail.com>
|
|
8
|
-
License-Expression: MIT
|
|
9
|
-
Keywords: aes-256-gcm,blind-index,encryption,encryption-at-rest,field-encryption,key-rotation,pii,searchable-encryption,sqlalchemy
|
|
10
|
-
Classifier: Development Status :: 4 - Beta
|
|
11
|
-
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Topic :: Security :: Cryptography
|
|
14
|
-
Requires-Python: >=3.10
|
|
15
|
-
Requires-Dist: cryptography>=41
|
|
16
|
-
Description-Content-Type: text/markdown
|
|
17
|
-
|
|
18
|
-
# sealed-fields (Python)
|
|
19
|
-
|
|
20
|
-
Field-level encryption for PII at rest. This is the Python twin of the
|
|
21
|
-
[sealed-fields npm package](https://www.npmjs.com/package/sealed-fields):
|
|
22
|
-
identical wire format, identical API shape, shared cross-language test
|
|
23
|
-
vectors. Data sealed by either implementation unseals in the other.
|
|
24
|
-
|
|
25
|
-
```
|
|
26
|
-
pip install sealed-fields
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
```python
|
|
30
|
-
from sealed_fields import create_sealed_fields, generate_key
|
|
31
|
-
|
|
32
|
-
sf = create_sealed_fields(
|
|
33
|
-
namespaces={"PII": {"key": os.environ["SEALED_KEY_PII"]}},
|
|
34
|
-
blind_index_key=os.environ.get("SEALED_BLIND_INDEX_KEY"),
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
token = sf.seal("free-text note about a real person") # "v1:..."
|
|
38
|
-
sf.unseal(token) # plaintext
|
|
39
|
-
sf.blind_index("alice@example.com") # HMAC lookup token
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
See the [repository README](https://github.com/mpointer/sealed-fields) for
|
|
43
|
-
the full design: key rotation without a flag day, key namespaces, blind
|
|
44
|
-
index entropy rules, row helpers, legacy-format decoders, and the
|
|
45
|
-
fail-closed default.
|
sealed_fields-0.2.0/README.md
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# sealed-fields (Python)
|
|
2
|
-
|
|
3
|
-
Field-level encryption for PII at rest. This is the Python twin of the
|
|
4
|
-
[sealed-fields npm package](https://www.npmjs.com/package/sealed-fields):
|
|
5
|
-
identical wire format, identical API shape, shared cross-language test
|
|
6
|
-
vectors. Data sealed by either implementation unseals in the other.
|
|
7
|
-
|
|
8
|
-
```
|
|
9
|
-
pip install sealed-fields
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
```python
|
|
13
|
-
from sealed_fields import create_sealed_fields, generate_key
|
|
14
|
-
|
|
15
|
-
sf = create_sealed_fields(
|
|
16
|
-
namespaces={"PII": {"key": os.environ["SEALED_KEY_PII"]}},
|
|
17
|
-
blind_index_key=os.environ.get("SEALED_BLIND_INDEX_KEY"),
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
token = sf.seal("free-text note about a real person") # "v1:..."
|
|
21
|
-
sf.unseal(token) # plaintext
|
|
22
|
-
sf.blind_index("alice@example.com") # HMAC lookup token
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
See the [repository README](https://github.com/mpointer/sealed-fields) for
|
|
26
|
-
the full design: key rotation without a flag day, key namespaces, blind
|
|
27
|
-
index entropy rules, row helpers, legacy-format decoders, and the
|
|
28
|
-
fail-closed default.
|