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.
@@ -6,3 +6,4 @@ dist/
6
6
  __pycache__/
7
7
  *.pyc
8
8
  .pytest_cache/
9
+ .venv/
@@ -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.2.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(self, value: str, *, normalize: bool = True) -> str:
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
- normalized = value.strip().lower() if normalize else value
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
- if is_sealed(v):
372
- continue
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 == "" or not is_sealed(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 test_no_double_seal(self):
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
- once = sf.seal_row({"name": "Ada"}, self.FIELDS)
160
- twice = sf.seal_row(once, self.FIELDS)
161
- assert twice["name"] == once["name"]
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
@@ -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.
@@ -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.