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,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"]
|