sealed-fields 0.2.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.
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ dist/
3
+ *.tgz
4
+ .env
5
+ .env.*
6
+ __pycache__/
7
+ *.pyc
8
+ .pytest_cache/
@@ -0,0 +1,45 @@
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.
@@ -0,0 +1,28 @@
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.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sealed-fields"
7
+ version = "0.2.0"
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
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{ name = "Michael Pointer", email = "mpointer@gmail.com" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = ["cryptography>=41"]
14
+ keywords = [
15
+ "encryption", "field-encryption", "pii", "aes-256-gcm", "blind-index",
16
+ "encryption-at-rest", "key-rotation", "searchable-encryption", "sqlalchemy",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Security :: Cryptography",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/mpointer/sealed-fields"
27
+ Issues = "https://github.com/mpointer/sealed-fields/issues"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/sealed_fields"]
@@ -0,0 +1,470 @@
1
+ """sealed-fields: field-level encryption for PII at rest.
2
+
3
+ Python twin of the sealed-fields npm package. Wire format, semantics, and
4
+ API shape are kept deliberately identical; the two implementations share
5
+ cross-language test vectors and data sealed by either unseals in the other.
6
+
7
+ Canonical wire format:
8
+
9
+ v<N>:<base64url(nonce | ciphertext | tag)>
10
+
11
+ AES-256-GCM, random 12-byte nonce per value, 16-byte tag, key version label
12
+ bound as additional authenticated data (AAD). Tokens are emitted as unpadded
13
+ base64url; padded and standard-alphabet blobs are accepted on read.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import binascii
19
+ import hashlib
20
+ import hmac as hmac_mod
21
+ import json
22
+ import re
23
+ import secrets
24
+ from typing import Any, Callable, Iterable, Mapping, Sequence
25
+
26
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
27
+
28
+ __all__ = [
29
+ "SealedFields",
30
+ "create_sealed_fields",
31
+ "sealed_fields_from_env",
32
+ "is_sealed",
33
+ "generate_key",
34
+ "hash_token",
35
+ "SealedFieldsError",
36
+ "KeyNotConfiguredError",
37
+ "UnknownKeyVersionError",
38
+ "SystemicUnsealError",
39
+ ]
40
+
41
+ KEY_BYTES = 32
42
+ NONCE_BYTES = 12
43
+ TAG_BYTES = 16
44
+
45
+ _TOKEN_RE = re.compile(r"^(v\d+):([A-Za-z0-9_-]+={0,2})$")
46
+ _VERSION_RE = re.compile(r"^v\d+$")
47
+
48
+
49
+ class SealedFieldsError(Exception):
50
+ """Base class for every error this library raises deliberately."""
51
+
52
+
53
+ class KeyNotConfiguredError(SealedFieldsError):
54
+ """An operation needed a key that is not configured (strict mode)."""
55
+
56
+
57
+ class UnknownKeyVersionError(SealedFieldsError):
58
+ """A token names a key version that is not in the namespace's keyring."""
59
+
60
+ def __init__(self, version: str, namespace: str) -> None:
61
+ super().__init__(
62
+ f'unseal: token names key version "{version}" which is not in the '
63
+ f'"{namespace}" keyring. Add it to retired keys, or set '
64
+ f'on_unknown_version="passthrough" during an active migration.'
65
+ )
66
+ self.version = version
67
+
68
+
69
+ class SystemicUnsealError(SealedFieldsError):
70
+ """More than half of attempted unseals across a batch failed."""
71
+
72
+ def __init__(self, failures: int, attempts: int) -> None:
73
+ super().__init__(
74
+ f"systemic unseal failure: {failures}/{attempts} attempted unseals "
75
+ f"failed. This usually means the wrong key is configured for this namespace."
76
+ )
77
+ self.failures = failures
78
+ self.attempts = attempts
79
+
80
+
81
+ def _parse_key(raw: str | bytes, label: str) -> bytes:
82
+ """Accept a 64-char hex, base64, base64url, or raw 32-byte key."""
83
+ if isinstance(raw, (bytes, bytearray)):
84
+ if len(raw) != KEY_BYTES:
85
+ raise KeyNotConfiguredError(f"{label} must be {KEY_BYTES} bytes (got {len(raw)}).")
86
+ return bytes(raw)
87
+ s = raw.strip()
88
+ if re.fullmatch(r"[0-9a-fA-F]{64}", s):
89
+ return bytes.fromhex(s)
90
+ try:
91
+ normalized = s.replace("-", "+").replace("_", "/")
92
+ decoded = base64.b64decode(normalized + "=" * (-len(normalized) % 4), validate=False)
93
+ if len(decoded) == KEY_BYTES:
94
+ return decoded
95
+ except (binascii.Error, ValueError):
96
+ pass
97
+ raise KeyNotConfiguredError(
98
+ f"{label} must be a {KEY_BYTES}-byte key as 64-char hex, base64, or "
99
+ f"base64url. Generate one with generate_key()."
100
+ )
101
+
102
+
103
+ def _decode_blob(blob: str) -> bytes:
104
+ normalized = blob.replace("-", "+").replace("_", "/").rstrip("=")
105
+ return base64.b64decode(normalized + "=" * (-len(normalized) % 4))
106
+
107
+
108
+ def is_sealed(value: Any) -> bool:
109
+ """True when a value structurally matches the canonical sealed format."""
110
+ if not isinstance(value, str):
111
+ return False
112
+ m = _TOKEN_RE.match(value)
113
+ if not m:
114
+ return False
115
+ try:
116
+ raw = _decode_blob(m.group(2))
117
+ except (binascii.Error, ValueError):
118
+ return False
119
+ return len(raw) >= NONCE_BYTES + TAG_BYTES # empty plaintext is valid GCM
120
+
121
+
122
+ def generate_key() -> str:
123
+ """Fresh 32-byte key, base64url without padding. Suitable for any key slot."""
124
+ return base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES)).decode("ascii").rstrip("=")
125
+
126
+
127
+ def hash_token(token: str) -> str:
128
+ """SHA-256 hex digest of a one-time token for safe storage."""
129
+ if not isinstance(token, str) or not token:
130
+ raise SealedFieldsError("hash_token requires a non-empty string.")
131
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
132
+
133
+
134
+ class _Keyring:
135
+ def __init__(self, active_version: str, keys: dict[str, bytes]) -> None:
136
+ self.active_version = active_version
137
+ self.keys = keys
138
+
139
+
140
+ def _build_keyring(cfg: Mapping[str, Any], namespace: str) -> _Keyring | None:
141
+ raw_key = cfg.get("key")
142
+ if raw_key is None or (isinstance(raw_key, str) and raw_key.strip() == ""):
143
+ return None
144
+ active_version = (cfg.get("version") or "v1").strip() or "v1"
145
+ if not _VERSION_RE.match(active_version):
146
+ raise SealedFieldsError(
147
+ f'namespace "{namespace}": version must look like "v1", "v2", ... (got "{active_version}").'
148
+ )
149
+ keys = {active_version: _parse_key(raw_key, f'namespace "{namespace}" key')}
150
+ for ver, raw in (cfg.get("retired") or {}).items():
151
+ if not _VERSION_RE.match(ver):
152
+ raise SealedFieldsError(
153
+ f'namespace "{namespace}": retired key version "{ver}" must look like "v1", "v2", ...'
154
+ )
155
+ if ver == active_version:
156
+ raise SealedFieldsError(
157
+ f'namespace "{namespace}": retired key version "{ver}" collides with the active version.'
158
+ )
159
+ keys[ver] = _parse_key(raw, f'namespace "{namespace}" retired key {ver}')
160
+ return _Keyring(active_version, keys)
161
+
162
+
163
+ # Legacy wire formats (read-only migration aids), matching the npm package:
164
+ # "iv-ct-tag" -> <b64 iv>:<b64 ciphertext>:<b64 tag> (no AAD)
165
+ # "v1-iv-tag-ct" -> v1:<b64 iv>:<b64 tag>:<b64 ciphertext> (no AAD)
166
+ _LEGACY_FORMATS = ("iv-ct-tag", "v1-iv-tag-ct")
167
+
168
+
169
+ def _match_legacy(token: str, fmt: str) -> tuple[bytes, bytes, bytes] | None:
170
+ parts = token.split(":")
171
+ try:
172
+ if fmt == "iv-ct-tag":
173
+ if len(parts) != 3:
174
+ return None
175
+ iv, ct, tag = (base64.b64decode(p, validate=False) for p in parts)
176
+ elif fmt == "v1-iv-tag-ct":
177
+ if len(parts) != 4 or parts[0] != "v1":
178
+ return None
179
+ iv, tag, ct = (base64.b64decode(p, validate=False) for p in parts[1:])
180
+ else:
181
+ return None
182
+ except (binascii.Error, ValueError):
183
+ return None
184
+ if len(iv) != NONCE_BYTES or len(tag) != TAG_BYTES or len(ct) == 0:
185
+ return None
186
+ return iv, ct, tag
187
+
188
+
189
+ class SealedFields:
190
+ def __init__(
191
+ self,
192
+ namespaces: Mapping[str, Mapping[str, Any]],
193
+ *,
194
+ default_namespace: str | None = None,
195
+ blind_index_key: str | bytes | None = None,
196
+ mode: str = "strict",
197
+ legacy: Sequence[str] = (),
198
+ on_unknown_version: str = "throw",
199
+ logger: Callable[[str], None] | None = None,
200
+ ) -> None:
201
+ if not namespaces:
202
+ raise SealedFieldsError("at least one namespace must be configured.")
203
+ self._rings: dict[str, _Keyring | None] = {
204
+ name: _build_keyring(cfg, name) for name, cfg in namespaces.items()
205
+ }
206
+ if default_namespace is not None and default_namespace not in self._rings:
207
+ raise SealedFieldsError(
208
+ f'default_namespace "{default_namespace}" is not a configured namespace.'
209
+ )
210
+ self._default_ns = default_namespace or (
211
+ next(iter(self._rings)) if len(self._rings) == 1 else None
212
+ )
213
+ if blind_index_key is not None and not (
214
+ isinstance(blind_index_key, str) and blind_index_key.strip() == ""
215
+ ):
216
+ self._index_key: bytes | None = _parse_key(blind_index_key, "blind_index_key")
217
+ else:
218
+ self._index_key = None
219
+ if mode not in ("strict", "dormant"):
220
+ raise SealedFieldsError('mode must be "strict" or "dormant".')
221
+ self._mode = mode
222
+ for fmt in legacy:
223
+ if fmt not in _LEGACY_FORMATS:
224
+ raise SealedFieldsError(f'unknown legacy format "{fmt}".')
225
+ self._legacy = tuple(legacy)
226
+ if on_unknown_version not in ("throw", "passthrough"):
227
+ raise SealedFieldsError('on_unknown_version must be "throw" or "passthrough".')
228
+ self._on_unknown_version = on_unknown_version
229
+ self._warn = logger or (lambda m: print(m))
230
+ self._warned_dormant = False
231
+
232
+ # -- internals ---------------------------------------------------------
233
+
234
+ def _resolve_ns(self, namespace: str | None) -> str:
235
+ ns = namespace or self._default_ns
236
+ if ns is None:
237
+ raise SealedFieldsError(
238
+ "no namespace given and no default_namespace configured (multiple namespaces exist)."
239
+ )
240
+ if ns not in self._rings:
241
+ raise SealedFieldsError(f'unknown namespace "{ns}".')
242
+ return ns
243
+
244
+ def _warn_dormant_once(self, ns: str) -> None:
245
+ if self._warned_dormant:
246
+ return
247
+ self._warned_dormant = True
248
+ self._warn(
249
+ f'[sealed-fields] SECURITY: namespace "{ns}" has no key configured — sealed '
250
+ f'columns are being written in PLAINTEXT (mode: "dormant"). '
251
+ f"Provision a key before production traffic."
252
+ )
253
+
254
+ def _try_legacy(self, token: str, ring: _Keyring) -> str | None:
255
+ matched = False
256
+ last_error: Exception | None = None
257
+ for fmt in self._legacy:
258
+ parts = _match_legacy(token, fmt)
259
+ if parts is None:
260
+ continue
261
+ matched = True
262
+ iv, ct, tag = parts
263
+ ordered = [ring.keys[ring.active_version]] + [
264
+ k for v, k in ring.keys.items() if v != ring.active_version
265
+ ]
266
+ for key in ordered:
267
+ try:
268
+ return AESGCM(key).decrypt(iv, ct + tag, None).decode("utf-8")
269
+ except Exception as err: # InvalidTag
270
+ last_error = err
271
+ if matched:
272
+ assert last_error is not None
273
+ raise last_error
274
+ return None
275
+
276
+ # -- public API --------------------------------------------------------
277
+
278
+ def is_configured(self, namespace: str | None = None) -> bool:
279
+ return self._rings[self._resolve_ns(namespace)] is not None
280
+
281
+ def seal(self, plaintext: str, namespace: str | None = None) -> str:
282
+ ns = self._resolve_ns(namespace)
283
+ ring = self._rings[ns]
284
+ if ring is None:
285
+ if self._mode == "strict":
286
+ raise KeyNotConfiguredError(
287
+ f'namespace "{ns}" has no key configured; cannot seal (mode: "strict").'
288
+ )
289
+ self._warn_dormant_once(ns)
290
+ return plaintext
291
+ nonce = secrets.token_bytes(NONCE_BYTES)
292
+ aes = AESGCM(ring.keys[ring.active_version])
293
+ ct = aes.encrypt(nonce, plaintext.encode("utf-8"), ring.active_version.encode("ascii"))
294
+ blob = base64.urlsafe_b64encode(nonce + ct).decode("ascii").rstrip("=")
295
+ return f"{ring.active_version}:{blob}"
296
+
297
+ def unseal(self, value: str, namespace: str | None = None) -> str:
298
+ ns = self._resolve_ns(namespace)
299
+ ring = self._rings[ns]
300
+ m = _TOKEN_RE.match(value)
301
+ if m:
302
+ if ring is None:
303
+ if self._mode == "strict":
304
+ raise KeyNotConfiguredError(
305
+ f'namespace "{ns}" has no key configured; cannot unseal a sealed value.'
306
+ )
307
+ self._warn_dormant_once(ns)
308
+ return value
309
+ version = m.group(1)
310
+ key = ring.keys.get(version)
311
+ if key is None:
312
+ if self._on_unknown_version == "passthrough":
313
+ return value
314
+ raise UnknownKeyVersionError(version, ns)
315
+ raw = _decode_blob(m.group(2))
316
+ nonce, ct = raw[:NONCE_BYTES], raw[NONCE_BYTES:]
317
+ return AESGCM(key).decrypt(nonce, ct, version.encode("ascii")).decode("utf-8")
318
+ if ring is not None and self._legacy:
319
+ decoded = self._try_legacy(value, ring)
320
+ if decoded is not None:
321
+ return decoded
322
+ return value # plaintext passthrough
323
+
324
+ def maybe_seal(self, value: str | None, namespace: str | None = None) -> str | None:
325
+ if value is None or value == "":
326
+ return None
327
+ return self.seal(value, namespace)
328
+
329
+ def maybe_unseal(self, value: str | None, namespace: str | None = None) -> str | None:
330
+ if value is None or value == "":
331
+ return None
332
+ return self.unseal(value, namespace)
333
+
334
+ def blind_index(self, value: str, *, normalize: bool = True) -> str:
335
+ """Deterministic HMAC-SHA256 lookup token for an equality query on an
336
+ otherwise-sealed column. ONLY for high-entropy values (email, username,
337
+ phone). Never index a low-entropy field: a deterministic MAC over a
338
+ small domain is trivially dictionary-attacked.
339
+
340
+ Normalization (default on) is trim + lowercase, matching the npm
341
+ package's String.prototype.toLowerCase for cross-language equality.
342
+ """
343
+ if self._index_key is None:
344
+ raise KeyNotConfiguredError(
345
+ "blind_index_key is not configured; cannot build a blind index."
346
+ )
347
+ normalized = value.strip().lower() if normalize else value
348
+ return hmac_mod.new(self._index_key, normalized.encode("utf-8"), hashlib.sha256).hexdigest()
349
+
350
+ # -- row helpers ---------------------------------------------------------
351
+
352
+ @staticmethod
353
+ def _normalize_spec(spec: Any) -> tuple[tuple[str, ...], tuple[str, ...]]:
354
+ if isinstance(spec, Mapping):
355
+ return tuple(spec.get("fields") or ()), tuple(spec.get("json_fields") or ())
356
+ return tuple(spec), ()
357
+
358
+ def seal_row(
359
+ self, row: Mapping[str, Any], spec: Any, namespace: str | None = None
360
+ ) -> dict[str, Any]:
361
+ fields, json_fields = self._normalize_spec(spec)
362
+ out = dict(row)
363
+ for field in json_fields:
364
+ v = out.get(field)
365
+ if v is not None:
366
+ out[field] = json.dumps(v, separators=(",", ":"), ensure_ascii=False)
367
+ for field in (*fields, *json_fields):
368
+ v = out.get(field)
369
+ if not isinstance(v, str) or v == "":
370
+ continue
371
+ if is_sealed(v):
372
+ continue
373
+ out[field] = self.seal(v, namespace)
374
+ return out
375
+
376
+ def _unseal_row_internal(
377
+ self, row: Mapping[str, Any], spec: Any, namespace: str | None
378
+ ) -> tuple[dict[str, Any], int, int]:
379
+ fields, json_fields = self._normalize_spec(spec)
380
+ out = dict(row)
381
+ failures = 0
382
+ attempts = 0
383
+ for field in (*fields, *json_fields):
384
+ v = out.get(field)
385
+ if not isinstance(v, str) or v == "" or not is_sealed(v):
386
+ continue
387
+ attempts += 1
388
+ try:
389
+ out[field] = self.unseal(v, namespace)
390
+ except Exception as err:
391
+ # Never hand raw ciphertext to a caller; None is a safe sentinel.
392
+ self._warn(f'[sealed-fields] unseal failed for field "{field}": {err}')
393
+ out[field] = None
394
+ failures += 1
395
+ for field in json_fields:
396
+ v = out.get(field)
397
+ if isinstance(v, str) and v:
398
+ try:
399
+ out[field] = json.loads(v)
400
+ except ValueError:
401
+ pass # leave as-is; never null data that decrypted fine
402
+ return out, failures, attempts
403
+
404
+ def unseal_row(
405
+ self, row: Mapping[str, Any], spec: Any, namespace: str | None = None
406
+ ) -> dict[str, Any]:
407
+ return self._unseal_row_internal(row, spec, namespace)[0]
408
+
409
+ def unseal_rows(
410
+ self, rows: Iterable[Mapping[str, Any]], spec: Any, namespace: str | None = None
411
+ ) -> list[dict[str, Any]]:
412
+ total_failures = 0
413
+ total_attempts = 0
414
+ result = []
415
+ for row in rows:
416
+ out, failures, attempts = self._unseal_row_internal(row, spec, namespace)
417
+ total_failures += failures
418
+ total_attempts += attempts
419
+ result.append(out)
420
+ if total_attempts >= 5 and total_failures / total_attempts > 0.5:
421
+ raise SystemicUnsealError(total_failures, total_attempts)
422
+ return result
423
+
424
+
425
+ def create_sealed_fields(**kwargs: Any) -> SealedFields:
426
+ return SealedFields(kwargs.pop("namespaces"), **kwargs)
427
+
428
+
429
+ def sealed_fields_from_env(
430
+ namespaces: Sequence[str],
431
+ *,
432
+ env: Mapping[str, str] | None = None,
433
+ **options: Any,
434
+ ) -> SealedFields:
435
+ """Build a SealedFields from environment variables, by convention:
436
+
437
+ SEALED_KEY_<NS> active key for namespace <NS>
438
+ SEALED_KEY_<NS>_VERSION active key version label (default "v1")
439
+ SEALED_KEY_<NS>_RETIRED comma-separated "v1:<key>,v2:<key>"
440
+ SEALED_BLIND_INDEX_KEY blind index HMAC key
441
+
442
+ Namespaces are listed explicitly; the environment is never scanned.
443
+ """
444
+ import os
445
+
446
+ e = env if env is not None else os.environ
447
+ ns_config: dict[str, dict[str, Any]] = {}
448
+ for ns in namespaces:
449
+ retired: dict[str, str] = {}
450
+ retired_raw = (e.get(f"SEALED_KEY_{ns}_RETIRED") or "").strip()
451
+ if retired_raw:
452
+ for pair in retired_raw.split(","):
453
+ pair = pair.strip()
454
+ if not pair:
455
+ continue
456
+ ver, sep, key = pair.partition(":")
457
+ if not sep or not ver.strip() or not key.strip():
458
+ raise SealedFieldsError(
459
+ f'SEALED_KEY_{ns}_RETIRED entries must look like "v1:<key>" (got "{pair}").'
460
+ )
461
+ retired[ver.strip()] = key.strip()
462
+ ns_config[ns] = {
463
+ "key": e.get(f"SEALED_KEY_{ns}"),
464
+ "version": e.get(f"SEALED_KEY_{ns}_VERSION"),
465
+ "retired": retired,
466
+ }
467
+ blind = e.get("SEALED_BLIND_INDEX_KEY")
468
+ if blind is not None:
469
+ options.setdefault("blind_index_key", blind)
470
+ return SealedFields(ns_config, **options)
@@ -0,0 +1,315 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac as hmac_mod
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
10
+
11
+ from sealed_fields import (
12
+ KeyNotConfiguredError,
13
+ SealedFieldsError,
14
+ SystemicUnsealError,
15
+ UnknownKeyVersionError,
16
+ create_sealed_fields,
17
+ generate_key,
18
+ hash_token,
19
+ is_sealed,
20
+ sealed_fields_from_env,
21
+ )
22
+
23
+ KEY_A = generate_key()
24
+ KEY_B = generate_key()
25
+ VECTORS = json.loads((Path(__file__).parents[2] / "test" / "vectors.json").read_text())
26
+
27
+
28
+ def make_sf(**overrides):
29
+ cfg = {"namespaces": {"PII": {"key": KEY_A}}}
30
+ cfg.update(overrides)
31
+ return create_sealed_fields(**cfg)
32
+
33
+
34
+ class TestSealUnseal:
35
+ def test_round_trips_utf8(self):
36
+ sf = make_sf()
37
+ for text in ["hello", "héllo wörld — 你好 🚀", "a" * 10_000]:
38
+ assert sf.unseal(sf.seal(text)) == text
39
+
40
+ def test_canonical_format_and_is_sealed(self):
41
+ token = make_sf().seal("secret")
42
+ assert token.startswith("v1:")
43
+ assert is_sealed(token)
44
+
45
+ def test_random_nonce(self):
46
+ sf = make_sf()
47
+ assert sf.seal("same") != sf.seal("same")
48
+
49
+ def test_plaintext_passthrough(self):
50
+ sf = make_sf()
51
+ assert sf.unseal("just some text") == "just some text"
52
+ assert sf.unseal("colon:separated:text") == "colon:separated:text"
53
+
54
+ def test_sealed_empty_string_round_trips_and_is_recognized(self):
55
+ sf = make_sf()
56
+ token = sf.seal("")
57
+ assert is_sealed(token) # nonce+tag, zero ciphertext bytes — valid GCM
58
+ assert sf.unseal(token) == ""
59
+
60
+ def test_aad_binding_rejects_version_reattribution(self):
61
+ sf = create_sealed_fields(
62
+ namespaces={"PII": {"key": KEY_A, "version": "v2", "retired": {"v1": KEY_A}}}
63
+ )
64
+ token = sf.seal("secret")
65
+ forged = "v1:" + token.split(":", 1)[1]
66
+ with pytest.raises(Exception):
67
+ sf.unseal(forged)
68
+
69
+ def test_tampered_ciphertext_raises(self):
70
+ sf = make_sf()
71
+ token = sf.seal("secret")
72
+ corrupted = token[:-4] + "AAAA"
73
+ with pytest.raises(Exception):
74
+ sf.unseal(corrupted)
75
+
76
+ def test_key_encodings_equivalent(self):
77
+ raw = base64.urlsafe_b64decode(KEY_A + "=" * (-len(KEY_A) % 4))
78
+ for key in [raw.hex(), base64.b64encode(raw).decode(), KEY_A, raw]:
79
+ token = create_sealed_fields(namespaces={"PII": {"key": key}}).seal("x")
80
+ assert make_sf().unseal(token) == "x"
81
+
82
+ def test_malformed_key_rejected(self):
83
+ with pytest.raises(KeyNotConfiguredError):
84
+ create_sealed_fields(namespaces={"PII": {"key": "tooshort"}})
85
+
86
+
87
+ class TestRotation:
88
+ def test_retired_keys_read_active_key_writes(self):
89
+ old_token = make_sf().seal("legacy row")
90
+ rotated = create_sealed_fields(
91
+ namespaces={"PII": {"key": KEY_B, "version": "v2", "retired": {"v1": KEY_A}}}
92
+ )
93
+ assert rotated.unseal(old_token) == "legacy row"
94
+ assert rotated.seal("new row").startswith("v2:")
95
+
96
+ def test_unknown_version_raises_by_default(self):
97
+ token = create_sealed_fields(namespaces={"PII": {"key": KEY_A, "version": "v9"}}).seal("x")
98
+ with pytest.raises(UnknownKeyVersionError):
99
+ make_sf().unseal(token)
100
+
101
+ def test_unknown_version_passthrough_when_configured(self):
102
+ token = create_sealed_fields(namespaces={"PII": {"key": KEY_A, "version": "v9"}}).seal("x")
103
+ migrating = make_sf(on_unknown_version="passthrough")
104
+ assert migrating.unseal(token) == token
105
+
106
+ def test_retired_version_collision_rejected(self):
107
+ with pytest.raises(SealedFieldsError):
108
+ create_sealed_fields(
109
+ namespaces={"PII": {"key": KEY_A, "version": "v1", "retired": {"v1": KEY_B}}}
110
+ )
111
+
112
+
113
+ class TestNamespaces:
114
+ def test_cryptographic_separation(self):
115
+ sf = create_sealed_fields(namespaces={"PII": {"key": KEY_A}, "TOTP": {"key": KEY_B}})
116
+ token = sf.seal("secret", "PII")
117
+ assert sf.unseal(token, "PII") == "secret"
118
+ with pytest.raises(Exception):
119
+ sf.unseal(token, "TOTP")
120
+
121
+ def test_explicit_namespace_required_without_default(self):
122
+ sf = create_sealed_fields(namespaces={"PII": {"key": KEY_A}, "TOTP": {"key": KEY_B}})
123
+ with pytest.raises(SealedFieldsError):
124
+ sf.seal("x")
125
+
126
+
127
+ class TestModes:
128
+ def test_strict_raises_without_key(self):
129
+ sf = create_sealed_fields(namespaces={"PII": {}})
130
+ with pytest.raises(KeyNotConfiguredError):
131
+ sf.seal("x")
132
+ assert not sf.is_configured()
133
+
134
+ def test_dormant_passthrough_warns_once(self):
135
+ warnings = []
136
+ sf = create_sealed_fields(
137
+ namespaces={"PII": {}}, mode="dormant", logger=warnings.append
138
+ )
139
+ assert sf.seal("x") == "x"
140
+ assert sf.seal("y") == "y"
141
+ assert len(warnings) == 1
142
+ assert "PLAINTEXT" in warnings[0]
143
+
144
+
145
+ class TestRows:
146
+ FIELDS = ["name", "notes"]
147
+
148
+ def test_seal_row_skips_and_copies(self):
149
+ sf = make_sf()
150
+ row = {"id": 7, "name": "Ada", "notes": "", "extra": "untouched"}
151
+ out = sf.seal_row(row, self.FIELDS)
152
+ assert is_sealed(out["name"])
153
+ assert out["notes"] == ""
154
+ assert out["extra"] == "untouched"
155
+ assert row["name"] == "Ada"
156
+
157
+ def test_no_double_seal(self):
158
+ 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"]
162
+
163
+ def test_mixed_plaintext_rows_mid_migration(self):
164
+ sf = make_sf()
165
+ rows = sf.unseal_rows(
166
+ [sf.seal_row({"name": "Ada"}, self.FIELDS), {"name": "Grace"}], self.FIELDS
167
+ )
168
+ assert [r["name"] for r in rows] == ["Ada", "Grace"]
169
+
170
+ def test_isolated_failure_nulls_field(self):
171
+ writer = make_sf()
172
+ reader = create_sealed_fields(
173
+ namespaces={"PII": {"key": KEY_B}}, logger=lambda m: None
174
+ )
175
+ out = reader.unseal_row(writer.seal_row({"name": "Ada"}, self.FIELDS), self.FIELDS)
176
+ assert out["name"] is None
177
+
178
+ def test_systemic_failure_raises(self):
179
+ writer = make_sf()
180
+ reader = create_sealed_fields(
181
+ namespaces={"PII": {"key": KEY_B}}, logger=lambda m: None
182
+ )
183
+ rows = [writer.seal_row({"name": f"p{i}"}, self.FIELDS) for i in range(5)]
184
+ with pytest.raises(SystemicUnsealError):
185
+ reader.unseal_rows(rows, self.FIELDS)
186
+
187
+ def test_plaintext_does_not_dilute_failure_rate(self):
188
+ writer = make_sf()
189
+ reader = create_sealed_fields(
190
+ namespaces={"PII": {"key": KEY_B}}, logger=lambda m: None
191
+ )
192
+ rows = [writer.seal_row({"name": f"p{i}"}, self.FIELDS) for i in range(5)]
193
+ rows += [{"name": f"plain{i}"} for i in range(20)]
194
+ with pytest.raises(SystemicUnsealError):
195
+ reader.unseal_rows(rows, self.FIELDS)
196
+
197
+ def test_json_fields_true_inverses(self):
198
+ sf = make_sf()
199
+ spec = {"fields": ["name"], "json_fields": ["snapshot"]}
200
+ obj_row = sf.unseal_row(sf.seal_row({"name": "Ada", "snapshot": {"a": 1}}, spec), spec)
201
+ assert obj_row["snapshot"] == {"a": 1}
202
+ # The regression this pins: string values must survive as strings.
203
+ str_row = sf.unseal_row(sf.seal_row({"name": "Ada", "snapshot": "free text"}, spec), spec)
204
+ assert str_row["snapshot"] == "free text"
205
+
206
+
207
+ class TestLegacy:
208
+ @staticmethod
209
+ def _iv_ct_tag(plaintext: str, key: bytes) -> str:
210
+ nonce = os.urandom(12)
211
+ ct_tag = AESGCM(key).encrypt(nonce, plaintext.encode(), None)
212
+ ct, tag = ct_tag[:-16], ct_tag[-16:]
213
+ b64 = lambda b: base64.b64encode(b).decode()
214
+ return f"{b64(nonce)}:{b64(ct)}:{b64(tag)}"
215
+
216
+ @staticmethod
217
+ def _v1_iv_tag_ct(plaintext: str, key: bytes) -> str:
218
+ nonce = os.urandom(12)
219
+ ct_tag = AESGCM(key).encrypt(nonce, plaintext.encode(), None)
220
+ ct, tag = ct_tag[:-16], ct_tag[-16:]
221
+ b64 = lambda b: base64.b64encode(b).decode()
222
+ return f"v1:{b64(nonce)}:{b64(tag)}:{b64(ct)}"
223
+
224
+ def _key_bytes(self, key_str):
225
+ return base64.urlsafe_b64decode(key_str + "=" * (-len(key_str) % 4))
226
+
227
+ def test_iv_ct_tag_decodes(self):
228
+ sf = make_sf(legacy=["iv-ct-tag"])
229
+ assert sf.unseal(self._iv_ct_tag("legacy value", self._key_bytes(KEY_A))) == "legacy value"
230
+
231
+ def test_v1_iv_tag_ct_decodes_despite_prefix(self):
232
+ sf = make_sf(legacy=["v1-iv-tag-ct"])
233
+ token = self._v1_iv_tag_ct("older value", self._key_bytes(KEY_A))
234
+ assert not is_sealed(token)
235
+ assert sf.unseal(token) == "older value"
236
+
237
+ def test_unauthenticated_legacy_raises(self):
238
+ sf = make_sf(legacy=["iv-ct-tag"])
239
+ foreign = self._iv_ct_tag("data", self._key_bytes(KEY_B))
240
+ with pytest.raises(Exception):
241
+ sf.unseal(foreign)
242
+
243
+ def test_disabled_formats_pass_through(self):
244
+ sf = make_sf()
245
+ token = self._iv_ct_tag("x", self._key_bytes(KEY_A))
246
+ assert sf.unseal(token) == token
247
+
248
+ def test_ordinary_colon_text_not_mistaken(self):
249
+ sf = make_sf(legacy=["iv-ct-tag", "v1-iv-tag-ct"])
250
+ for s in ["a:b:c", "12:30:45", "v1:looks:like:legacy"]:
251
+ assert sf.unseal(s) == s
252
+
253
+
254
+ class TestBlindIndexAndUtils:
255
+ def test_blind_index_deterministic_and_normalized(self):
256
+ sf = make_sf(blind_index_key=KEY_A)
257
+ assert sf.blind_index("alice@example.com") == sf.blind_index(" Alice@Example.COM ")
258
+ assert sf.blind_index("Alice", normalize=False) != sf.blind_index("alice", normalize=False)
259
+
260
+ def test_blind_index_reference(self):
261
+ sf = make_sf(blind_index_key=KEY_A)
262
+ raw = base64.urlsafe_b64decode(KEY_A + "=" * (-len(KEY_A) % 4))
263
+ expected = hmac_mod.new(raw, b"alice@example.com", hashlib.sha256).hexdigest()
264
+ assert sf.blind_index("alice@example.com") == expected
265
+
266
+ def test_blind_index_requires_key(self):
267
+ with pytest.raises(KeyNotConfiguredError):
268
+ make_sf().blind_index("x")
269
+
270
+ def test_generate_key_and_hash_token(self):
271
+ key = generate_key()
272
+ assert len(base64.urlsafe_b64decode(key + "=" * (-len(key) % 4))) == 32
273
+ assert hash_token("abc") == hashlib.sha256(b"abc").hexdigest()
274
+ with pytest.raises(SealedFieldsError):
275
+ hash_token("")
276
+
277
+ def test_is_sealed_rejects_lookalikes(self):
278
+ assert not is_sealed("v1:not-base64!!!")
279
+ assert not is_sealed("v1:aGk")
280
+ assert not is_sealed("plain text")
281
+ assert not is_sealed(None)
282
+ assert not is_sealed(42)
283
+
284
+
285
+ class TestFromEnv:
286
+ def test_builds_from_env_mapping(self):
287
+ env = {
288
+ "SEALED_KEY_PII": KEY_B,
289
+ "SEALED_KEY_PII_VERSION": "v2",
290
+ "SEALED_KEY_PII_RETIRED": f"v1:{KEY_A}",
291
+ "SEALED_BLIND_INDEX_KEY": KEY_A,
292
+ }
293
+ sf = sealed_fields_from_env(["PII"], env=env)
294
+ token = sf.seal("x")
295
+ assert token.startswith("v2:")
296
+ assert sf.unseal(token) == "x"
297
+ old = sealed_fields_from_env(["PII"], env={"SEALED_KEY_PII": KEY_A})
298
+ assert sf.unseal(old.seal("old")) == "old"
299
+
300
+ def test_malformed_retired_entry_rejected(self):
301
+ with pytest.raises(SealedFieldsError):
302
+ sealed_fields_from_env(
303
+ ["PII"], env={"SEALED_KEY_PII": KEY_A, "SEALED_KEY_PII_RETIRED": "nocolon"}
304
+ )
305
+
306
+
307
+ class TestCrossLanguage:
308
+ def test_decodes_every_shared_vector(self):
309
+ sf = create_sealed_fields(
310
+ namespaces={"PII": {"key": VECTORS["key"]}},
311
+ blind_index_key=VECTORS["blindIndex"]["key"],
312
+ )
313
+ for name, token in VECTORS["tokens"].items():
314
+ assert sf.unseal(token) == VECTORS["plaintext"], name
315
+ assert sf.blind_index(VECTORS["blindIndex"]["value"]) == VECTORS["blindIndex"]["hex"]