sealed-fields 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sealed-fields
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
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
5
  Project-URL: Homepage, https://github.com/mpointer/sealed-fields
6
6
  Project-URL: Issues, https://github.com/mpointer/sealed-fields/issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sealed-fields"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Field-level encryption for PII at rest: AES-256-GCM with versioned key rotation, key namespaces, HMAC blind indexes, and row helpers with systemic-failure detection. Wire-compatible with the sealed-fields npm package."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -313,6 +313,8 @@ class SealedFields:
313
313
  return value
314
314
  raise UnknownKeyVersionError(version, ns)
315
315
  raw = _decode_blob(m.group(2))
316
+ if len(raw) < NONCE_BYTES + TAG_BYTES:
317
+ raise SealedFieldsError("unseal: token is too short to be a sealed value.")
316
318
  nonce, ct = raw[:NONCE_BYTES], raw[NONCE_BYTES:]
317
319
  return AESGCM(key).decrypt(nonce, ct, version.encode("ascii")).decode("utf-8")
318
320
  if ring is not None and self._legacy:
@@ -379,8 +381,8 @@ class SealedFields:
379
381
  v = out.get(field)
380
382
  if not isinstance(v, str) or v == "":
381
383
  continue
382
- if is_sealed(v):
383
- continue
384
+ # Seal unconditionally: a value that merely looks like a token is
385
+ # untrusted input, not ciphertext. Call seal_row once per write.
384
386
  out[field] = self.seal(v, namespace)
385
387
  return out
386
388
 
@@ -393,7 +395,9 @@ class SealedFields:
393
395
  attempts = 0
394
396
  for field in (*fields, *json_fields):
395
397
  v = out.get(field)
396
- if not isinstance(v, str) or v == "" or not is_sealed(v):
398
+ if not isinstance(v, str) or v == "":
399
+ continue
400
+ if not (is_sealed(v) or any(_match_legacy(v, f) for f in self._legacy)):
397
401
  continue
398
402
  attempts += 1
399
403
  try:
@@ -154,11 +154,20 @@ class TestRows:
154
154
  assert out["extra"] == "untouched"
155
155
  assert row["name"] == "Ada"
156
156
 
157
- def test_no_double_seal(self):
157
+ def test_token_shaped_input_is_sealed_not_trusted(self):
158
+ # Regression: seal_row used to skip values that looked sealed, storing
159
+ # attacker-chosen "tokens" unencrypted and tripping SystemicUnsealError.
158
160
  sf = make_sf()
159
- once = sf.seal_row({"name": "Ada"}, self.FIELDS)
160
- twice = sf.seal_row(once, self.FIELDS)
161
- assert twice["name"] == once["name"]
161
+ evil = "v1:" + base64.urlsafe_b64encode(os.urandom(30)).decode().rstrip("=")
162
+ assert is_sealed(evil)
163
+ out = sf.seal_row({"name": evil}, self.FIELDS)
164
+ assert out["name"] != evil
165
+ assert sf.unseal_row(out, self.FIELDS)["name"] == evil
166
+ rows = [
167
+ sf.seal_row({"name": "v1:" + base64.urlsafe_b64encode(os.urandom(30)).decode()}, self.FIELDS)
168
+ for _ in range(5)
169
+ ]
170
+ sf.unseal_rows(rows, self.FIELDS) # must not raise
162
171
 
163
172
  def test_mixed_plaintext_rows_mid_migration(self):
164
173
  sf = make_sf()
@@ -245,6 +254,19 @@ class TestLegacy:
245
254
  token = self._iv_ct_tag("x", self._key_bytes(KEY_A))
246
255
  assert sf.unseal(token) == token
247
256
 
257
+ def test_row_helpers_decode_legacy_tokens(self):
258
+ sf = make_sf(legacy=["iv-ct-tag", "v1-iv-tag-ct"], logger=lambda m: None)
259
+ k = self._key_bytes(KEY_A)
260
+ row = {"a": self._iv_ct_tag("one", k), "b": self._v1_iv_tag_ct("two", k)}
261
+ assert sf.unseal_row(row, ["a", "b"]) == {"a": "one", "b": "two"}
262
+
263
+ def test_row_helper_legacy_failures_count_as_systemic(self):
264
+ sf = make_sf(legacy=["iv-ct-tag"], logger=lambda m: None)
265
+ rows = [{"a": self._iv_ct_tag(f"p{i}", self._key_bytes(KEY_B))} for i in range(5)]
266
+ assert sf.unseal_row(rows[0], ["a"])["a"] is None
267
+ with pytest.raises(SystemicUnsealError):
268
+ sf.unseal_rows(rows, ["a"])
269
+
248
270
  def test_ordinary_colon_text_not_mistaken(self):
249
271
  sf = make_sf(legacy=["iv-ct-tag", "v1-iv-tag-ct"])
250
272
  for s in ["a:b:c", "12:30:45", "v1:looks:like:legacy"]:
@@ -323,3 +345,10 @@ class TestCrossLanguage:
323
345
  for name, token in VECTORS["tokens"].items():
324
346
  assert sf.unseal(token) == VECTORS["plaintext"], name
325
347
  assert sf.blind_index(VECTORS["blindIndex"]["value"]) == VECTORS["blindIndex"]["hex"]
348
+
349
+
350
+ def test_short_canonical_token_rejected():
351
+ sf = make_sf()
352
+ for t in ["v1:AAAAAA", "v1:AAAAAAAAAAA", "v1:" + "A" * 36]:
353
+ with pytest.raises(SealedFieldsError, match="too short"):
354
+ sf.unseal(t)
File without changes
File without changes