sealed-fields 0.2.0__py3-none-any.whl
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,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,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,4 @@
|
|
|
1
|
+
sealed_fields/__init__.py,sha256=oYiK2GzaAWG1465dQJ1xzRAlIysu77PBAHsEesL8Hhs,18347
|
|
2
|
+
sealed_fields-0.2.0.dist-info/METADATA,sha256=kQjWmpJCDG_XI4Ejr0mO-BYJtfNuzAFUzAmCFXZ45CI,1925
|
|
3
|
+
sealed_fields-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
4
|
+
sealed_fields-0.2.0.dist-info/RECORD,,
|