babelqueue 1.12.0__tar.gz → 1.13.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.
Files changed (77) hide show
  1. {babelqueue-1.12.0 → babelqueue-1.13.0}/CHANGELOG.md +31 -0
  2. {babelqueue-1.12.0 → babelqueue-1.13.0}/PKG-INFO +62 -1
  3. {babelqueue-1.12.0 → babelqueue-1.13.0}/README.md +59 -0
  4. {babelqueue-1.12.0 → babelqueue-1.13.0}/pyproject.toml +4 -1
  5. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/__init__.py +9 -3
  6. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/exceptions.py +12 -0
  7. babelqueue-1.13.0/src/babelqueue/gdpr.py +245 -0
  8. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/schema.py +59 -1
  9. babelqueue-1.13.0/tests/test_gdpr.py +328 -0
  10. {babelqueue-1.12.0 → babelqueue-1.13.0}/.github/FUNDING.yml +0 -0
  11. {babelqueue-1.12.0 → babelqueue-1.13.0}/.github/workflows/ci.yml +0 -0
  12. {babelqueue-1.12.0 → babelqueue-1.13.0}/.github/workflows/release.yml +0 -0
  13. {babelqueue-1.12.0 → babelqueue-1.13.0}/.gitignore +0 -0
  14. {babelqueue-1.12.0 → babelqueue-1.13.0}/LICENSE +0 -0
  15. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/app.py +0 -0
  16. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/artemis_transport.py +0 -0
  17. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/asb_transport.py +0 -0
  18. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/celery.py +0 -0
  19. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/codec.py +0 -0
  20. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/contracts.py +0 -0
  21. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/dead_letter.py +0 -0
  22. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/django/__init__.py +0 -0
  23. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/django/apps.py +0 -0
  24. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/django/management/__init__.py +0 -0
  25. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  26. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  27. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/headers.py +0 -0
  28. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/idempotency.py +0 -0
  29. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/kafka_transport.py +0 -0
  30. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/otel.py +0 -0
  31. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/outbox.py +0 -0
  32. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/pika_transport.py +0 -0
  33. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/pulsar_transport.py +0 -0
  34. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/py.typed +0 -0
  35. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/redis_transport.py +0 -0
  36. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/redrive.py +0 -0
  37. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/replay.py +0 -0
  38. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/routing.py +0 -0
  39. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/sqs_transport.py +0 -0
  40. {babelqueue-1.12.0 → babelqueue-1.13.0}/src/babelqueue/transport.py +0 -0
  41. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  42. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  43. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  44. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/order-created.json +0 -0
  45. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  46. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  47. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/manifest.json +0 -0
  48. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  49. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/fixtures/dead-lettered.json +0 -0
  50. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/fixtures/order-created.json +0 -0
  51. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_app.py +0 -0
  52. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_artemis_conformance.py +0 -0
  53. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_artemis_transport.py +0 -0
  54. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_asb_conformance.py +0 -0
  55. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_asb_transport.py +0 -0
  56. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_celery.py +0 -0
  57. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_codec.py +0 -0
  58. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_conformance.py +0 -0
  59. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_dead_letter.py +0 -0
  60. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_django.py +0 -0
  61. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_headers.py +0 -0
  62. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_idempotency.py +0 -0
  63. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_kafka_conformance.py +0 -0
  64. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_kafka_transport.py +0 -0
  65. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_otel.py +0 -0
  66. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_outbox.py +0 -0
  67. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_overhead.py +0 -0
  68. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_pika_transport.py +0 -0
  69. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_pulsar_conformance.py +0 -0
  70. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_pulsar_transport.py +0 -0
  71. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_redis_transport.py +0 -0
  72. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_redrive.py +0 -0
  73. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_replay.py +0 -0
  74. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_schema.py +0 -0
  75. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_schema_conformance.py +0 -0
  76. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_sqs_conformance.py +0 -0
  77. {babelqueue-1.12.0 → babelqueue-1.13.0}/tests/test_sqs_transport.py +0 -0
@@ -9,6 +9,37 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.13.0] - 2026-06-21
13
+
14
+ ### Added
15
+ - **Runtime GDPR field encryption** (ADR-0030) — the optional `babelqueue.gdpr` module ports the Go
16
+ reference to Python: the SDK-enforcement half of governed PII. The babelqueue-registry only
17
+ **declares** and **audits** `x-gdpr-sensitive` fields (and can **mask** them for safe logging);
18
+ this module **enforces** them on the wire — a producer encrypts each marked leaf before publish, a
19
+ consumer decrypts it after decode. Strictly **opt-in**: a producer/consumer that never calls
20
+ `protect`/`unprotect` is unchanged. The **envelope stays frozen** (GR-1): only **values inside
21
+ `data`** change — a sensitive leaf's value becomes a ciphertext **string** — so `data` stays pure
22
+ JSON (an SDK without the key still carries the envelope), `meta.schema_version` stays `1`, and
23
+ `trace_id` is untouched (GR-4). The core stays **stdlib-only** (GR-7): Python's standard library
24
+ has **no AES-GCM**, so the module ships **no** concrete cipher and pulls **no** crypto dependency
25
+ — `Cipher` is a caller-provided `typing.Protocol` (`encrypt(bytes) -> str` / `decrypt(str) ->
26
+ bytes`) bound to a KMS / Vault / HSM / tokenisation service, or to a local AES-256-GCM via the
27
+ optional `cryptography` library (new `babelqueue[gdpr]` extra, documented in the README — still
28
+ **not** a core dependency). `protect(data, schema, cipher)` canonically JSON-encodes each marked
29
+ leaf and replaces it with the cipher's ciphertext string **in place**; `unprotect(...)` is the
30
+ exact inverse, restoring `data` **byte-for-byte**. The marked paths come from the **same per-URN
31
+ schema** the validator already loads — `babelqueue.schema.sensitive_paths` walks nested objects
32
+ (`profile.full_name`), array items (`addresses[].line`), container, and root marks, and the
33
+ `x-gdpr-sensitive` keyword is **validation-neutral** (annotating a schema is never breaking). An
34
+ absent marked field is skipped (not an error); a re-run `unprotect` on already-cleartext data is a
35
+ no-op (non-string leaves are left alone); a wrong key / tampered / non-ciphertext value raises the
36
+ new `babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
37
+ handle unreadable PII. Validate cleartext **before** `protect` / **after** `unprotect`, since a
38
+ schema constraining a sensitive field would reject the ciphertext string. Unit-tested without any
39
+ crypto dependency (a stdlib-only authenticated fake cipher) for round-trip exactness across
40
+ nested/array/container/root marks, skips, idempotent re-runs, frozen-envelope decode/validate, and
41
+ wrong-key failure. The envelope is unchanged (`schema_version: 1`); this is purely additive.
42
+
12
43
  ## [1.12.0] - 2026-06-21
13
44
 
14
45
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelqueue
3
- Version: 1.12.0
3
+ Version: 1.13.0
4
4
  Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
5
5
  Project-URL: Homepage, https://babelqueue.com
6
6
  Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
@@ -38,6 +38,8 @@ Requires-Dist: pytest>=7; extra == 'dev'
38
38
  Requires-Dist: ruff>=0.5; extra == 'dev'
39
39
  Provides-Extra: django
40
40
  Requires-Dist: django>=4.2; extra == 'django'
41
+ Provides-Extra: gdpr
42
+ Requires-Dist: cryptography>=42; extra == 'gdpr'
41
43
  Provides-Extra: kafka
42
44
  Requires-Dist: confluent-kafka>=2.3; extra == 'kafka'
43
45
  Provides-Extra: otel
@@ -311,6 +313,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
311
313
  transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
312
314
  correlation with no error.
313
315
 
316
+ ## GDPR field encryption (optional)
317
+
318
+ The optional `babelqueue.gdpr` module is the runtime, SDK-enforcement half of ADR-0030: it
319
+ encrypts the `data` fields a registry declared `x-gdpr-sensitive`. The babelqueue-registry only
320
+ **declares** and **audits** sensitivity (and can **mask** for safe logging); this module
321
+ **enforces** it on the wire — a producer encrypts each marked leaf before publish, a consumer
322
+ decrypts it after decode. It is strictly **opt-in**: if you never call `protect`/`unprotect`,
323
+ nothing changes.
324
+
325
+ The envelope stays **frozen** (`schema_version: 1`): only the **values inside `data`** change — a
326
+ sensitive leaf's value becomes a ciphertext **string**. `data` stays pure JSON, so an SDK without
327
+ the key still carries the envelope (it just can't read the protected fields), and `trace_id` is
328
+ never touched. Which fields are sensitive lives in the **schema**, not the message.
329
+
330
+ Because Python's standard library has **no AES-GCM**, the core ships **no** cipher and pulls **no**
331
+ crypto dependency (GR-7). `Cipher` is a caller-provided `Protocol` you bind to a KMS / Vault / HSM /
332
+ tokenisation service, or to a local AES-256-GCM via the optional `cryptography` library
333
+ (`pip install "babelqueue[gdpr]"` — still **not** a core dependency):
334
+
335
+ ```python
336
+ import base64, os
337
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
338
+
339
+ class AesGcmCipher:
340
+ def __init__(self, key: bytes) -> None: # 16/24/32-byte key — the CALLER's
341
+ self._aead = AESGCM(key)
342
+
343
+ def encrypt(self, plaintext: bytes) -> str:
344
+ nonce = os.urandom(12)
345
+ return base64.b64encode(nonce + self._aead.encrypt(nonce, plaintext, None)).decode("ascii")
346
+
347
+ def decrypt(self, ciphertext: str) -> bytes:
348
+ raw = base64.b64decode(ciphertext)
349
+ return self._aead.decrypt(raw[:12], raw[12:], None) # raises on wrong key / tamper
350
+ ```
351
+
352
+ Wire it around the codec — validate **cleartext** (before `protect`, after `unprotect`), since a
353
+ schema that constrains a sensitive field would reject the ciphertext string:
354
+
355
+ ```python
356
+ from babelqueue import EnvelopeCodec
357
+ from babelqueue.gdpr import protect, unprotect
358
+
359
+ # producer: encrypt marked leaves in place, then encode
360
+ protect(data, schema, cipher)
361
+ body = EnvelopeCodec.encode(EnvelopeCodec.make("urn:babel:orders:created", data, queue="orders"))
362
+
363
+ # consumer: decode, then decrypt marked leaves in place before the handler reads data
364
+ envelope = EnvelopeCodec.decode(body)
365
+ unprotect(envelope["data"], schema, cipher)
366
+ ```
367
+
368
+ `schema` is the same per-URN JSON Schema the validator uses; `babelqueue.schema.sensitive_paths`
369
+ locates every `x-gdpr-sensitive` leaf (nested objects, `addresses[].line` array items, container or
370
+ root marks). An absent marked field is skipped; a re-run `unprotect` on cleartext is a no-op
371
+ (non-string leaves are left alone); a wrong key / tampered ciphertext raises
372
+ `babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
373
+ process unreadable PII. The round-trip restores `data` **byte-for-byte**.
374
+
314
375
  ## What's here
315
376
 
316
377
  The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
@@ -259,6 +259,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
259
259
  transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
260
260
  correlation with no error.
261
261
 
262
+ ## GDPR field encryption (optional)
263
+
264
+ The optional `babelqueue.gdpr` module is the runtime, SDK-enforcement half of ADR-0030: it
265
+ encrypts the `data` fields a registry declared `x-gdpr-sensitive`. The babelqueue-registry only
266
+ **declares** and **audits** sensitivity (and can **mask** for safe logging); this module
267
+ **enforces** it on the wire — a producer encrypts each marked leaf before publish, a consumer
268
+ decrypts it after decode. It is strictly **opt-in**: if you never call `protect`/`unprotect`,
269
+ nothing changes.
270
+
271
+ The envelope stays **frozen** (`schema_version: 1`): only the **values inside `data`** change — a
272
+ sensitive leaf's value becomes a ciphertext **string**. `data` stays pure JSON, so an SDK without
273
+ the key still carries the envelope (it just can't read the protected fields), and `trace_id` is
274
+ never touched. Which fields are sensitive lives in the **schema**, not the message.
275
+
276
+ Because Python's standard library has **no AES-GCM**, the core ships **no** cipher and pulls **no**
277
+ crypto dependency (GR-7). `Cipher` is a caller-provided `Protocol` you bind to a KMS / Vault / HSM /
278
+ tokenisation service, or to a local AES-256-GCM via the optional `cryptography` library
279
+ (`pip install "babelqueue[gdpr]"` — still **not** a core dependency):
280
+
281
+ ```python
282
+ import base64, os
283
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
284
+
285
+ class AesGcmCipher:
286
+ def __init__(self, key: bytes) -> None: # 16/24/32-byte key — the CALLER's
287
+ self._aead = AESGCM(key)
288
+
289
+ def encrypt(self, plaintext: bytes) -> str:
290
+ nonce = os.urandom(12)
291
+ return base64.b64encode(nonce + self._aead.encrypt(nonce, plaintext, None)).decode("ascii")
292
+
293
+ def decrypt(self, ciphertext: str) -> bytes:
294
+ raw = base64.b64decode(ciphertext)
295
+ return self._aead.decrypt(raw[:12], raw[12:], None) # raises on wrong key / tamper
296
+ ```
297
+
298
+ Wire it around the codec — validate **cleartext** (before `protect`, after `unprotect`), since a
299
+ schema that constrains a sensitive field would reject the ciphertext string:
300
+
301
+ ```python
302
+ from babelqueue import EnvelopeCodec
303
+ from babelqueue.gdpr import protect, unprotect
304
+
305
+ # producer: encrypt marked leaves in place, then encode
306
+ protect(data, schema, cipher)
307
+ body = EnvelopeCodec.encode(EnvelopeCodec.make("urn:babel:orders:created", data, queue="orders"))
308
+
309
+ # consumer: decode, then decrypt marked leaves in place before the handler reads data
310
+ envelope = EnvelopeCodec.decode(body)
311
+ unprotect(envelope["data"], schema, cipher)
312
+ ```
313
+
314
+ `schema` is the same per-URN JSON Schema the validator uses; `babelqueue.schema.sensitive_paths`
315
+ locates every `x-gdpr-sensitive` leaf (nested objects, `addresses[].line` array items, container or
316
+ root marks). An absent marked field is skipped; a re-run `unprotect` on cleartext is a no-op
317
+ (non-string leaves are left alone); a wrong key / tampered ciphertext raises
318
+ `babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
319
+ process unreadable PII. The round-trip restores `data` **byte-for-byte**.
320
+
262
321
  ## What's here
263
322
 
264
323
  The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.12.0"
7
+ version = "1.13.0"
8
8
  description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -37,6 +37,9 @@ pulsar = ["pulsar-client>=3.4"]
37
37
  kafka = ["confluent-kafka>=2.3"]
38
38
  artemis = ["python-qpid-proton>=0.39"]
39
39
  otel = ["opentelemetry-api>=1.20"]
40
+ # Only for the documented bring-your-own AES-256-GCM reference cipher (ADR-0030). The core never
41
+ # imports it; protect/unprotect take a caller-provided Cipher Protocol, so this is NOT a core dep.
42
+ gdpr = ["cryptography>=42"]
40
43
  celery = ["celery>=5"]
41
44
  django = ["django>=4.2"]
42
45
  dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5"]
@@ -11,10 +11,11 @@ and dead-letter helpers. Framework adapters (Celery, Django, ...) build on this.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- from . import dead_letter, headers, idempotency, outbox, redrive, replay
14
+ from . import dead_letter, gdpr, headers, idempotency, outbox, redrive, replay
15
15
  from .app import BabelQueue
16
16
  from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
17
17
  from .contracts import HasTraceId, PolyglotMessage
18
+ from .gdpr import Cipher, protect, unprotect
18
19
  from .headers import headers_from_context
19
20
  from .idempotency import IdempotencyStore, InMemoryStore
20
21
  from .outbox import (
@@ -25,12 +26,12 @@ from .outbox import (
25
26
  OutboxRelayResult,
26
27
  OutboxStore,
27
28
  )
28
- from .exceptions import BabelQueueError, UnknownUrnError
29
+ from .exceptions import BabelQueueError, DecryptError, UnknownUrnError
29
30
  from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
30
31
  from .routing import UnknownUrnStrategy
31
32
  from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
32
33
 
33
- __version__ = "1.12.0"
34
+ __version__ = "1.13.0"
34
35
 
35
36
  __all__ = [
36
37
  "BabelQueue",
@@ -46,6 +47,11 @@ __all__ = [
46
47
  "HeaderPublisher",
47
48
  "BabelQueueError",
48
49
  "UnknownUrnError",
50
+ "DecryptError",
51
+ "gdpr",
52
+ "Cipher",
53
+ "protect",
54
+ "unprotect",
49
55
  "dead_letter",
50
56
  "headers",
51
57
  "idempotency",
@@ -23,3 +23,15 @@ class InvalidPayloadError(BabelQueueError):
23
23
  super().__init__(f"data for {urn!r} does not match its URN schema: {violation}")
24
24
  self.urn = urn
25
25
  self.violation = violation
26
+
27
+
28
+ class DecryptError(BabelQueueError):
29
+ """A protected ``x-gdpr-sensitive`` field could not be restored on the consume side (ADR-0030).
30
+
31
+ Raised by :func:`babelqueue.gdpr.unprotect` when a marked leaf is a ciphertext string the
32
+ :class:`~babelqueue.gdpr.Cipher` cannot open — a wrong key, a tampered/garbled ciphertext, or a
33
+ value whose decrypted bytes are not the JSON the producer encoded. The Python mirror of the Go
34
+ ``gdpr.ErrDecrypt``: a missing field is skipped (not an error), but an *unreadable* one stops
35
+ ``unprotect`` so the consumer fails the message (retry / dead-letter) rather than handle
36
+ unreadable PII.
37
+ """
@@ -0,0 +1,245 @@
1
+ """Optional runtime GDPR field encryption (ADR-0030): the SDK-enforcement half.
2
+
3
+ The Python mirror of the Go ``gdpr`` package — the reference the SDKs share. The
4
+ babelqueue-registry only **declares** and **audits** which ``data`` fields are sensitive (the
5
+ ``x-gdpr-sensitive`` schema keyword) and can **mask** them for safe logging; this module
6
+ **enforces** that on the wire — a producer encrypts each marked leaf before publish, a consumer
7
+ decrypts it after decode. It is strictly **opt-in**: a producer/consumer that never calls
8
+ :func:`protect`/:func:`unprotect` behaves exactly as before.
9
+
10
+ The contract is deliberately tight so every SDK stays byte-compatible:
11
+
12
+ - **The envelope stays frozen (GR-1).** :func:`protect` mutates only **values inside ``data``**: a
13
+ sensitive leaf's value becomes a ciphertext **string**. It never adds, renames, removes or
14
+ retypes an envelope field; ``meta.schema_version`` stays ``1``; ``trace_id`` is untouched (GR-4).
15
+ ``data`` stays **pure JSON** (GR-3) — a JSON string is still pure JSON, so any SDK can carry the
16
+ envelope even without the key (it just can't read the protected fields).
17
+ - **Zero heavy dependencies (GR-7).** Python's standard library has **no AES-GCM**, so the core
18
+ ships **no** concrete cipher and pulls **no** crypto dependency. :class:`Cipher` is a
19
+ caller-provided :class:`typing.Protocol` — bind it to a KMS, Vault transit, an HSM, a
20
+ tokenisation service, or a local AES-GCM built on the optional ``cryptography`` library (see the
21
+ bring-your-own-cipher example below). The crypto dependency lives in the *caller's* code, never
22
+ in this core.
23
+
24
+ The sensitive paths come from the **same per-URN schema** the produce/consume validation path
25
+ already loads (:func:`babelqueue.schema.sensitive_paths`) — the ``x-gdpr-sensitive`` marks ride on
26
+ it. Validate **cleartext**: run :func:`babelqueue.schema.validate` **before** :func:`protect` on
27
+ the producer and **after** :func:`unprotect` on the consumer, because a schema that constrains a
28
+ sensitive field (``minLength``, ``enum``, …) would otherwise reject the ciphertext string.
29
+
30
+ Typical wiring (producer)::
31
+
32
+ from babelqueue import EnvelopeCodec
33
+ from babelqueue.gdpr import protect
34
+
35
+ data = {"order_id": 1042, "email": "alice@example.com"}
36
+ # validate(provider, urn, data) # optional: validate cleartext first
37
+ protect(data, schema, cipher) # encrypt marked leaves IN PLACE
38
+ body = EnvelopeCodec.encode(EnvelopeCodec.make(urn, data)) # ciphertext rides inside data
39
+
40
+ and the inverse on the consumer, after decode and before the handler reads ``data``::
41
+
42
+ from babelqueue.gdpr import unprotect
43
+
44
+ envelope = EnvelopeCodec.decode(body)
45
+ unprotect(envelope["data"], schema, cipher) # decrypt marked leaves IN PLACE
46
+ # validate(provider, urn, envelope["data"]) # optional: validate cleartext after
47
+
48
+ Bring-your-own AES-256-GCM cipher (NOT part of this core — ``pip install cryptography``)::
49
+
50
+ import base64, os
51
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
52
+
53
+ class AesGcmCipher:
54
+ '''A reference Cipher: AES-256-GCM, random 12-byte nonce prepended, base64 out.
55
+ The 32-byte key is the CALLER's; this does no key management or rotation.'''
56
+
57
+ def __init__(self, key: bytes) -> None:
58
+ self._aead = AESGCM(key) # key must be 16, 24, or 32 bytes
59
+
60
+ def encrypt(self, plaintext: bytes) -> str:
61
+ nonce = os.urandom(12)
62
+ sealed = self._aead.encrypt(nonce, plaintext, None)
63
+ return base64.b64encode(nonce + sealed).decode("ascii")
64
+
65
+ def decrypt(self, ciphertext: str) -> bytes:
66
+ raw = base64.b64decode(ciphertext)
67
+ nonce, sealed = raw[:12], raw[12:]
68
+ return self._aead.decrypt(nonce, sealed, None) # raises on wrong key/tamper
69
+
70
+ The same plaintext MAY encrypt to a different string each call (the random nonce is expected and
71
+ good); :meth:`Cipher.decrypt` restores the exact bytes :meth:`Cipher.encrypt` was given, and a
72
+ wrong-key / tampered input MUST raise (GCM authentication) — :func:`unprotect` turns any such raise
73
+ into :class:`~babelqueue.exceptions.DecryptError`.
74
+ """
75
+
76
+ from __future__ import annotations
77
+
78
+ import json
79
+ from typing import Any, Callable, List, MutableMapping, MutableSequence, Optional, Protocol, Tuple, runtime_checkable
80
+
81
+ from .exceptions import DecryptError
82
+ from .schema import sensitive_paths
83
+
84
+ __all__ = ["Cipher", "protect", "unprotect"]
85
+
86
+
87
+ @runtime_checkable
88
+ class Cipher(Protocol):
89
+ """The field-level protection primitive the **caller** provides — a seam onto a KMS, Vault
90
+ transit, an HSM, a tokenisation service, or a local AES-GCM (see the module docstring). Keeping
91
+ this a Protocol is what holds GR-7: this core never imports a crypto library; only the caller's
92
+ concrete cipher does.
93
+
94
+ Contract for an implementation:
95
+
96
+ - :meth:`encrypt` takes the canonical JSON bytes of one field value (see :func:`protect`) and
97
+ returns the ciphertext as a **str** that is valid for placement inside a JSON document (e.g.
98
+ base64). The same plaintext MAY encrypt to a different string each call (a random nonce/IV is
99
+ expected and good).
100
+ - :meth:`decrypt` is the exact inverse: given a string :meth:`encrypt` produced it returns the
101
+ original JSON bytes byte-for-byte. A string it did not produce, or one produced under a
102
+ different key, MUST **raise** rather than return silent garbage, so a wrong-key consume fails
103
+ loudly (:func:`unprotect` wraps the raise in :class:`~babelqueue.exceptions.DecryptError`).
104
+ - Both SHOULD be safe for concurrent use; a producer/consumer may fan one cipher across threads.
105
+ """
106
+
107
+ def encrypt(self, plaintext: bytes) -> str:
108
+ """Protect one field value (its canonical JSON bytes) → a JSON-safe ciphertext string."""
109
+ ...
110
+
111
+ def decrypt(self, ciphertext: str) -> bytes:
112
+ """Reverse :meth:`encrypt`, returning the original field-value JSON bytes."""
113
+ ...
114
+
115
+
116
+ def protect(data: MutableMapping[str, Any], schema: Any, cipher: Cipher) -> None:
117
+ """Encrypt, in place, every value in ``data`` at a path the schema marked ``x-gdpr-sensitive``.
118
+
119
+ The producer-side step — run it after building ``data`` and before encode/publish. Each marked
120
+ leaf's value is canonically JSON-encoded and replaced by ``cipher.encrypt``'s ciphertext
121
+ **string**; the envelope frame, non-sensitive fields, and everything else are untouched (GR-1).
122
+
123
+ A marked path absent from ``data`` is skipped (not an error) — schemas evolve and a message need
124
+ not carry every optional field. ``data`` may be empty (no-op). A ``None`` schema or one with no
125
+ marks is a no-op (nothing is sensitive). A container mark (a whole object/array marked
126
+ sensitive) is supported: the entire sub-value is encoded and encrypted as one ciphertext string.
127
+
128
+ On any cipher error this propagates and leaves ``data`` partially protected; treat a raised
129
+ error as fatal for that message (do not publish it).
130
+ """
131
+ _walk(data, schema, cipher, _encrypt_leaf)
132
+
133
+
134
+ def unprotect(data: MutableMapping[str, Any], schema: Any, cipher: Cipher) -> None:
135
+ """Decrypt, in place, every value in ``data`` at an ``x-gdpr-sensitive`` path — the consumer-side
136
+ inverse of :func:`protect`. Run it after decode and before the handler reads ``data``.
137
+
138
+ An absent path is skipped. A leaf that is **not a string** — it was never protected, or this is
139
+ a re-run after a successful :func:`unprotect` — is left as-is, so re-invoking on already-cleartext
140
+ data is safe (idempotent for non-string leaves). A string the cipher cannot open (wrong key,
141
+ tampered, or not a ciphertext) raises :class:`~babelqueue.exceptions.DecryptError` — the consumer
142
+ should fail the message (retry / dead-letter) rather than process unreadable PII.
143
+ """
144
+ _walk(data, schema, cipher, _decrypt_leaf)
145
+
146
+
147
+ # A leaf op transforms one sensitive leaf value (encrypt or decrypt). It returns ``(new_value, ok)``:
148
+ # ``ok=False`` means the value should be left in place untouched.
149
+ _LeafOp = Callable[[Any, Cipher], Tuple[Any, bool]]
150
+
151
+
152
+ def _walk(data: Any, schema: Any, cipher: Optional[Cipher], op: _LeafOp) -> None:
153
+ """Drive ``op`` over every ``x-gdpr-sensitive`` path the schema declares, resolving each path
154
+ against ``data`` itself (NOT by re-walking the schema over the value), so the operation touches
155
+ exactly the declared leaves — non-sensitive siblings are never read or copied."""
156
+ if data is None or schema is None or cipher is None:
157
+ return
158
+ for sp in sensitive_paths(schema):
159
+ _apply_at_path(data, _parse_path(sp.path), cipher, op)
160
+
161
+
162
+ # One step of a sensitive path: ``(key, is_array)``. ``"addresses[].line"`` parses to
163
+ # ``[("addresses", True), ("line", False)]``; the ``[]`` marker means array-descent into every
164
+ # element before the next segment.
165
+ _Segment = Tuple[str, bool]
166
+
167
+
168
+ def _parse_path(path: str) -> List[_Segment]:
169
+ """Split a sensitive path (``"email"``, ``"profile.full_name"``, ``"addresses[].line"``) into
170
+ segments. A trailing ``[]`` on a part binds to it as array-descent. A root mark (path ``""``)
171
+ yields no segments — there is no addressable leaf for it inside ``data``, so it is skipped."""
172
+ if path == "":
173
+ return []
174
+ segments: List[_Segment] = []
175
+ for part in path.split("."):
176
+ if len(part) >= 2 and part.endswith("[]"):
177
+ segments.append((part[:-2], True))
178
+ else:
179
+ segments.append((part, False))
180
+ return segments
181
+
182
+
183
+ def _apply_at_path(node: Any, segments: List[_Segment], cipher: Cipher, op: _LeafOp) -> None:
184
+ """Resolve ``segments`` against ``node`` and run ``op`` on the leaf(s). It descends objects by
185
+ key and, when a segment is an array, fans out over every element. An absent key or a type
186
+ mismatch (a path that does not exist in this particular message) is skipped silently — schemas
187
+ describe the union of possible shapes; a given message need not contain every field."""
188
+ if not segments:
189
+ return # root mark or exhausted path with no leaf key — nothing addressable in data
190
+ key, is_array = segments[0]
191
+ if not isinstance(node, MutableMapping):
192
+ return # expected an object here but the message has something else — skip
193
+ if key not in node:
194
+ return # absent field — skip (not an error)
195
+
196
+ child = node[key]
197
+ last = len(segments) == 1
198
+
199
+ if is_array:
200
+ if not isinstance(child, MutableSequence):
201
+ return # declared array but message has a non-array — skip
202
+ for i, elem in enumerate(child):
203
+ if last:
204
+ new_value, ok = op(elem, cipher)
205
+ if ok:
206
+ child[i] = new_value
207
+ else:
208
+ _apply_at_path(elem, segments[1:], cipher, op)
209
+ return
210
+
211
+ if last:
212
+ new_value, ok = op(child, cipher)
213
+ if ok:
214
+ node[key] = new_value
215
+ return
216
+ _apply_at_path(child, segments[1:], cipher, op)
217
+
218
+
219
+ def _encrypt_leaf(value: Any, cipher: Cipher) -> Tuple[Any, bool]:
220
+ """Canonically JSON-encode one field value and replace it with the cipher's ciphertext string.
221
+ The JSON encoding is what makes the round-trip exact: :func:`unprotect`'s ``json.loads`` restores
222
+ the same decoded-JSON value (``float`` for numbers, ``dict`` for objects, …) the codec would
223
+ have produced, so protect → unprotect is byte-for-byte. ``sort_keys`` + compact separators give
224
+ one canonical encoding, mirroring the Go reference's ``json.Marshal``."""
225
+ plaintext = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
226
+ return cipher.encrypt(plaintext), True
227
+
228
+
229
+ def _decrypt_leaf(value: Any, cipher: Cipher) -> Tuple[Any, bool]:
230
+ """Reverse :func:`_encrypt_leaf`. A non-string leaf is left untouched (``ok=False``) so
231
+ :func:`unprotect` is safe to re-run on already-cleartext data; a string that fails to open or to
232
+ JSON-decode raises :class:`~babelqueue.exceptions.DecryptError` so the consumer fails the message
233
+ rather than handle unreadable PII."""
234
+ if not isinstance(value, str):
235
+ # Not a ciphertext string (already cleartext, or never protected) — leave as-is.
236
+ return None, False
237
+ try:
238
+ plaintext = cipher.decrypt(value)
239
+ except Exception as exc: # noqa: BLE001 — any cipher failure is a decrypt failure for the caller
240
+ raise DecryptError(f"cannot decrypt a protected field: {exc}") from exc
241
+ try:
242
+ restored = json.loads(plaintext)
243
+ except (ValueError, TypeError) as exc:
244
+ raise DecryptError(f"decrypted plaintext is not JSON: {exc}") from exc
245
+ return restored, True
@@ -35,7 +35,7 @@ import functools
35
35
  import json
36
36
  import os
37
37
  import threading
38
- from typing import Any, Callable, Mapping, Optional, Protocol, runtime_checkable
38
+ from typing import Any, Callable, List, Mapping, NamedTuple, Optional, Protocol, runtime_checkable
39
39
 
40
40
  from .exceptions import InvalidPayloadError
41
41
 
@@ -259,3 +259,61 @@ def _violation(path: str, reason: str) -> str:
259
259
 
260
260
  def _join(path: str, key: str) -> str:
261
261
  return key if path == "" else f"{path}.{key}"
262
+
263
+
264
+ class SensitivePath(NamedTuple):
265
+ """One property a schema marked ``x-gdpr-sensitive`` (ADR-0030), located by its dotted path
266
+ from the schema root. Array elements use the ``field[]`` segment the validator and registry
267
+ ``compat`` linter use (e.g. ``addresses[].line``). ``category`` is the optional
268
+ ``"x-gdpr-sensitive": "<category>"`` string, or ``""`` when the keyword was the boolean
269
+ ``true``. A mark on the root schema itself is reported with ``path == ""``."""
270
+
271
+ path: str
272
+ category: str
273
+
274
+
275
+ def _gdpr_mark(schema: Mapping[str, Any]) -> Optional[str]:
276
+ """Read the ``x-gdpr-sensitive`` extension keyword (ADR-0030) from one schema node.
277
+
278
+ Returns the category string for a marked node (``""`` when the keyword was the boolean
279
+ ``true``, a non-empty category when it was a non-empty string), or ``None`` when the node is
280
+ not marked. The keyword is **validation-neutral**: it never makes a value valid or invalid, so
281
+ annotating a schema is never a breaking change (GR-1). Any other shape — ``false``, ``""``, a
282
+ number — leaves the node unmarked. Mirrors the Go ``fromMap`` and the registry's parser.
283
+ """
284
+ mark = schema.get("x-gdpr-sensitive")
285
+ if mark is True:
286
+ return ""
287
+ if isinstance(mark, str) and mark != "":
288
+ return mark
289
+ return None
290
+
291
+
292
+ def sensitive_paths(schema: Mapping[str, Any]) -> List[SensitivePath]:
293
+ """Every property a schema marked ``x-gdpr-sensitive``, in sorted path order (ADR-0030).
294
+
295
+ Descends nested objects (dotted paths like ``profile.full_name``) and array item schemas
296
+ (``addresses[].line``); a mark on the root schema itself is reported with ``path == ""``. It is
297
+ the value-level counterpart to the registry's inventory: :mod:`babelqueue.gdpr` uses these paths
298
+ to locate the leaves it encrypts on produce and decrypts on consume. The Python mirror of the
299
+ Go ``Schema.SensitivePaths``. Non-mapping / malformed nodes are skipped, never raised on.
300
+ """
301
+ out: List[SensitivePath] = []
302
+ _collect_sensitive(schema, "", out)
303
+ out.sort(key=lambda sp: sp.path)
304
+ return out
305
+
306
+
307
+ def _collect_sensitive(schema: Any, path: str, out: List[SensitivePath]) -> None:
308
+ if not isinstance(schema, Mapping):
309
+ return
310
+ category = _gdpr_mark(schema)
311
+ if category is not None:
312
+ out.append(SensitivePath(path, category))
313
+ properties = schema.get("properties")
314
+ if isinstance(properties, Mapping):
315
+ for name, sub in properties.items():
316
+ _collect_sensitive(sub, _join(path, str(name)), out)
317
+ items = schema.get("items")
318
+ if isinstance(items, Mapping):
319
+ _collect_sensitive(items, path + "[]", out)
@@ -0,0 +1,328 @@
1
+ """Tests for the optional runtime GDPR field encryption (ADR-0030).
2
+
3
+ The cipher used here is a STDLIB-ONLY fake — it deliberately imports no ``cryptography`` library,
4
+ proving the core needs no crypto dependency (GR-7). It still honours the full :class:`Cipher`
5
+ contract: a random per-call nonce so the same plaintext encrypts differently each time, a
6
+ byte-for-byte inverse, and a wrong-key ``decrypt`` that raises. A production cipher (AES-256-GCM via
7
+ ``cryptography``) is documented in ``babelqueue/gdpr.py``; the contract these tests pin is the same.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import unittest
17
+ from typing import Any, Dict
18
+
19
+ from babelqueue import EnvelopeCodec
20
+ from babelqueue.exceptions import DecryptError
21
+ from babelqueue.gdpr import Cipher, protect, unprotect
22
+ from babelqueue.schema import SensitivePath, sensitive_paths
23
+
24
+
25
+ class XorCipher:
26
+ """A reversible, authenticated, STDLIB-ONLY reference :class:`Cipher` for tests.
27
+
28
+ NOT cryptographically strong — it is a keystream XOR with a keyed digest tag — but it exercises
29
+ every property the contract depends on: a random per-call nonce (same plaintext → different
30
+ ciphertext), a byte-exact inverse, base64 output that drops into JSON, and a wrong-key/tampered
31
+ ``decrypt`` that raises (the tag check fails). This is the seam :func:`protect`/:func:`unprotect`
32
+ rely on; the production cipher is the caller's AES-GCM.
33
+ """
34
+
35
+ _NONCE = 16
36
+
37
+ def __init__(self, key: bytes) -> None:
38
+ self._key = key
39
+
40
+ def _keystream(self, nonce: bytes, length: int) -> bytes:
41
+ out = bytearray()
42
+ counter = 0
43
+ while len(out) < length:
44
+ out += hashlib.sha256(self._key + nonce + counter.to_bytes(4, "big")).digest()
45
+ counter += 1
46
+ return bytes(out[:length])
47
+
48
+ def _tag(self, nonce: bytes, ciphertext: bytes) -> bytes:
49
+ return hashlib.sha256(self._key + b"tag" + nonce + ciphertext).digest()[:16]
50
+
51
+ def encrypt(self, plaintext: bytes) -> str:
52
+ nonce = os.urandom(self._NONCE)
53
+ ks = self._keystream(nonce, len(plaintext))
54
+ ct = bytes(p ^ k for p, k in zip(plaintext, ks))
55
+ sealed = nonce + ct + self._tag(nonce, ct)
56
+ return base64.b64encode(sealed).decode("ascii")
57
+
58
+ def decrypt(self, ciphertext: str) -> bytes:
59
+ raw = base64.b64decode(ciphertext)
60
+ nonce, body, tag = raw[: self._NONCE], raw[self._NONCE : -16], raw[-16:]
61
+ if self._tag(nonce, body) != tag:
62
+ raise ValueError("authentication failed (wrong key or tampered ciphertext)")
63
+ ks = self._keystream(nonce, len(body))
64
+ return bytes(c ^ k for c, k in zip(body, ks))
65
+
66
+
67
+ KEY = b"0123456789abcdef0123456789abcdef"
68
+
69
+
70
+ def _cipher() -> Cipher:
71
+ return XorCipher(KEY)
72
+
73
+
74
+ # A schema marking the leaf `email`, the nested `profile.full_name`, the array-item
75
+ # `addresses[].line`, and a container `secret_blob` (a whole object marked sensitive).
76
+ SCHEMA: Dict[str, Any] = {
77
+ "type": "object",
78
+ "properties": {
79
+ "order_id": {"type": "integer"},
80
+ "email": {"type": "string", "x-gdpr-sensitive": "email"},
81
+ "profile": {
82
+ "type": "object",
83
+ "properties": {
84
+ "full_name": {"type": "string", "x-gdpr-sensitive": True},
85
+ "tier": {"type": "string"},
86
+ },
87
+ },
88
+ "addresses": {
89
+ "type": "array",
90
+ "items": {
91
+ "type": "object",
92
+ "properties": {
93
+ "line": {"type": "string", "x-gdpr-sensitive": True},
94
+ "city": {"type": "string"},
95
+ },
96
+ },
97
+ },
98
+ "secret_blob": {"type": "object", "x-gdpr-sensitive": True},
99
+ },
100
+ }
101
+
102
+
103
+ def _data() -> Dict[str, Any]:
104
+ return {
105
+ "order_id": 1042,
106
+ "email": "alice@example.com",
107
+ "profile": {"full_name": "Alice Liddell", "tier": "gold"},
108
+ "addresses": [
109
+ {"line": "1 Rabbit Hole", "city": "Wonderland"},
110
+ {"line": "2 Looking Glass", "city": "Mirror"},
111
+ ],
112
+ "secret_blob": {"pan": "4111111111111111", "exp": 1230},
113
+ }
114
+
115
+
116
+ class SensitivePathsTest(unittest.TestCase):
117
+ def test_walks_nested_object_array_item_and_container(self) -> None:
118
+ paths = sensitive_paths(SCHEMA)
119
+ # Sorted by path, with the array-item using the "field[]" segment.
120
+ self.assertEqual(
121
+ paths,
122
+ [
123
+ SensitivePath("addresses[].line", ""),
124
+ SensitivePath("email", "email"),
125
+ SensitivePath("profile.full_name", ""),
126
+ SensitivePath("secret_blob", ""),
127
+ ],
128
+ )
129
+
130
+ def test_root_mark_reports_empty_path(self) -> None:
131
+ self.assertEqual(sensitive_paths({"x-gdpr-sensitive": True}), [SensitivePath("", "")])
132
+
133
+ def test_validation_neutral_shapes_are_ignored(self) -> None:
134
+ # false, "", and a number never mark a property (mirrors the Go parser).
135
+ schema = {
136
+ "type": "object",
137
+ "properties": {
138
+ "a": {"type": "string", "x-gdpr-sensitive": False},
139
+ "b": {"type": "string", "x-gdpr-sensitive": ""},
140
+ "c": {"type": "string", "x-gdpr-sensitive": 1},
141
+ "d": {"type": "string"},
142
+ },
143
+ }
144
+ self.assertEqual(sensitive_paths(schema), [])
145
+
146
+ def test_malformed_schema_is_skipped_not_raised(self) -> None:
147
+ self.assertEqual(sensitive_paths({"properties": "nope", "items": 5}), [])
148
+
149
+
150
+ class ProtectRoundTripTest(unittest.TestCase):
151
+ def test_round_trip_restores_data_byte_for_byte(self) -> None:
152
+ original = _data()
153
+ data = _data()
154
+ protect(data, SCHEMA, _cipher())
155
+
156
+ # Marked leaves became ciphertext STRINGS; everything else is untouched.
157
+ self.assertIsInstance(data["email"], str)
158
+ self.assertNotEqual(data["email"], original["email"])
159
+ self.assertIsInstance(data["profile"]["full_name"], str)
160
+ self.assertIsInstance(data["addresses"][0]["line"], str)
161
+ self.assertIsInstance(data["secret_blob"], str) # the whole container is one ciphertext
162
+ # Non-sensitive siblings are bit-identical.
163
+ self.assertEqual(data["order_id"], 1042)
164
+ self.assertEqual(data["profile"]["tier"], "gold")
165
+ self.assertEqual(data["addresses"][0]["city"], "Wonderland")
166
+
167
+ unprotect(data, SCHEMA, _cipher())
168
+ self.assertEqual(data, original)
169
+
170
+ def test_nested_object_only(self) -> None:
171
+ schema = {"type": "object", "properties": {"profile": {"type": "object",
172
+ "properties": {"full_name": {"type": "string", "x-gdpr-sensitive": True}}}}}
173
+ data = {"profile": {"full_name": "Bob", "tier": "silver"}}
174
+ protect(data, schema, _cipher())
175
+ self.assertNotEqual(data["profile"]["full_name"], "Bob")
176
+ self.assertEqual(data["profile"]["tier"], "silver")
177
+ unprotect(data, schema, _cipher())
178
+ self.assertEqual(data, {"profile": {"full_name": "Bob", "tier": "silver"}})
179
+
180
+ def test_array_items(self) -> None:
181
+ schema = {"type": "object", "properties": {"addresses": {"type": "array", "items":
182
+ {"type": "object", "properties": {"line": {"type": "string", "x-gdpr-sensitive": True}}}}}}
183
+ data = {"addresses": [{"line": "a", "city": "x"}, {"line": "b", "city": "y"}]}
184
+ protect(data, schema, _cipher())
185
+ self.assertNotEqual(data["addresses"][0]["line"], "a")
186
+ self.assertNotEqual(data["addresses"][1]["line"], "b")
187
+ self.assertEqual(data["addresses"][0]["city"], "x")
188
+ unprotect(data, schema, _cipher())
189
+ self.assertEqual(data, {"addresses": [{"line": "a", "city": "x"}, {"line": "b", "city": "y"}]})
190
+
191
+ def test_array_leaf_directly_marked(self) -> None:
192
+ # A path ending in "[]" marks the array ELEMENTS themselves (e.g. a list of phone strings).
193
+ schema = {"type": "object", "properties":
194
+ {"phones": {"type": "array", "items": {"type": "string", "x-gdpr-sensitive": True}}}}
195
+ self.assertEqual(sensitive_paths(schema), [SensitivePath("phones[]", "")])
196
+ data = {"phones": ["+1-555-0100", "+1-555-0199"]}
197
+ protect(data, schema, _cipher())
198
+ self.assertNotEqual(data["phones"][0], "+1-555-0100")
199
+ self.assertNotEqual(data["phones"][1], "+1-555-0199")
200
+ unprotect(data, schema, _cipher())
201
+ self.assertEqual(data, {"phones": ["+1-555-0100", "+1-555-0199"]})
202
+
203
+ def test_non_string_values_round_trip_exactly(self) -> None:
204
+ # A sensitive leaf need not be a string: number/bool/null/object restore to the same type.
205
+ schema = {"type": "object", "properties": {k: {"x-gdpr-sensitive": True}
206
+ for k in ("n", "b", "z", "o")}}
207
+ data: Dict[str, Any] = {"n": 1230, "b": True, "z": None, "o": {"k": [1, 2, 3]}}
208
+ protect(data, schema, _cipher())
209
+ for k in ("n", "b", "z", "o"):
210
+ self.assertIsInstance(data[k], str)
211
+ unprotect(data, schema, _cipher())
212
+ self.assertEqual(data, {"n": 1230, "b": True, "z": None, "o": {"k": [1, 2, 3]}})
213
+
214
+ def test_root_mark_is_skipped_no_addressable_leaf(self) -> None:
215
+ # A mark on the root schema has no addressable leaf inside data — a no-op, not an error.
216
+ data = {"a": 1}
217
+ protect(data, {"x-gdpr-sensitive": True}, _cipher())
218
+ self.assertEqual(data, {"a": 1})
219
+
220
+
221
+ class ProtectSkipsTest(unittest.TestCase):
222
+ def test_absent_marked_field_is_skipped(self) -> None:
223
+ data = {"order_id": 7} # no `email`, `profile`, etc.
224
+ protect(data, SCHEMA, _cipher())
225
+ self.assertEqual(data, {"order_id": 7})
226
+
227
+ def test_type_mismatch_is_skipped(self) -> None:
228
+ # Schema says addresses is an array of objects, but the message carries a scalar — skip.
229
+ data = {"addresses": "not-an-array", "profile": "not-an-object"}
230
+ protect(data, SCHEMA, _cipher())
231
+ self.assertEqual(data, {"addresses": "not-an-array", "profile": "not-an-object"})
232
+
233
+ def test_no_marks_or_none_schema_is_noop(self) -> None:
234
+ data = _data()
235
+ snapshot = json.dumps(data, sort_keys=True)
236
+ protect(data, {"type": "object", "properties": {"email": {"type": "string"}}}, _cipher())
237
+ protect(data, None, _cipher())
238
+ self.assertEqual(json.dumps(data, sort_keys=True), snapshot)
239
+
240
+ def test_empty_data_is_noop(self) -> None:
241
+ data: Dict[str, Any] = {}
242
+ protect(data, SCHEMA, _cipher())
243
+ self.assertEqual(data, {})
244
+
245
+
246
+ class UnprotectEdgeTest(unittest.TestCase):
247
+ def test_non_string_leaf_left_untouched_idempotent(self) -> None:
248
+ # Re-running unprotect is a no-op for NON-STRING leaves: once restored to number/bool/null/
249
+ # object they are no longer ciphertext candidates, so a second pass leaves them as-is.
250
+ schema = {"type": "object", "properties": {k: {"x-gdpr-sensitive": True}
251
+ for k in ("n", "b", "z", "o")}}
252
+ data: Dict[str, Any] = {"n": 1230, "b": True, "z": None, "o": {"k": 1}}
253
+ protect(data, schema, _cipher())
254
+ unprotect(data, schema, _cipher()) # first restore
255
+ restored = json.dumps(data, sort_keys=True)
256
+ unprotect(data, schema, _cipher()) # second pass is a no-op (non-string leaves left alone)
257
+ unprotect(data, schema, _cipher()) # and again — still stable
258
+ self.assertEqual(json.dumps(data, sort_keys=True), restored)
259
+
260
+ def test_rerun_on_restored_string_leaf_raises(self) -> None:
261
+ # A restored STRING leaf IS a ciphertext candidate, so a naive double-unprotect on a string
262
+ # field fails cleanly (it is not the cipher's output) rather than silently corrupting data —
263
+ # callers run unprotect exactly once, matching the documented contract.
264
+ data = {"email": "alice@example.com"}
265
+ protect(data, SCHEMA, _cipher())
266
+ unprotect(data, SCHEMA, _cipher())
267
+ self.assertEqual(data, {"email": "alice@example.com"})
268
+ with self.assertRaises(DecryptError):
269
+ unprotect(data, SCHEMA, _cipher())
270
+
271
+ def test_wrong_key_raises_decrypt_error(self) -> None:
272
+ data = _data()
273
+ protect(data, SCHEMA, XorCipher(KEY))
274
+ with self.assertRaises(DecryptError):
275
+ unprotect(data, SCHEMA, XorCipher(b"f" * 32)) # different key → tag check fails
276
+
277
+ def test_decrypted_non_json_raises_decrypt_error(self) -> None:
278
+ # A cipher that opens but yields bytes that are not JSON also fails cleanly as DecryptError —
279
+ # the consumer must never feed corrupt-but-decrypted bytes to the handler.
280
+ class GarbageCipher:
281
+ def encrypt(self, plaintext: bytes) -> str:
282
+ return "garbage"
283
+
284
+ def decrypt(self, ciphertext: str) -> bytes:
285
+ return b"\xff\xfe not json"
286
+
287
+ data = {"email": "ct"}
288
+ with self.assertRaises(DecryptError):
289
+ unprotect(data, SCHEMA, GarbageCipher())
290
+
291
+ def test_non_ciphertext_string_raises_decrypt_error(self) -> None:
292
+ # A marked leaf that is a plain (never-protected) string is still a string, so unprotect
293
+ # tries to open it and fails cleanly rather than silently passing garbage through.
294
+ data = {"email": "alice@example.com"}
295
+ with self.assertRaises(DecryptError):
296
+ unprotect(data, SCHEMA, _cipher())
297
+
298
+
299
+ class FrozenEnvelopeTest(unittest.TestCase):
300
+ def test_protected_envelope_still_decodes_and_preserves_frame(self) -> None:
301
+ data = _data()
302
+ protect(data, SCHEMA, _cipher())
303
+
304
+ envelope = EnvelopeCodec.make("urn:babel:orders:created", data,
305
+ queue="orders", trace_id="trace-xyz")
306
+ body = EnvelopeCodec.encode(envelope)
307
+
308
+ # data stays pure JSON (the ciphertext is just a string) — encode/decode round-trips.
309
+ decoded = EnvelopeCodec.decode(body)
310
+ self.assertTrue(EnvelopeCodec.accepts(decoded))
311
+ self.assertEqual(decoded["meta"]["schema_version"], 1)
312
+ self.assertEqual(decoded["trace_id"], "trace-xyz") # GR-4: trace_id untouched
313
+ self.assertEqual(decoded["job"], "urn:babel:orders:created")
314
+
315
+ # The consumer decrypts in place and recovers the original cleartext exactly.
316
+ unprotect(decoded["data"], SCHEMA, _cipher())
317
+ self.assertEqual(decoded["data"], _data())
318
+
319
+ def test_ciphertext_is_a_json_string(self) -> None:
320
+ # GR-3: a protected value is a JSON string, so an SDK without the key still carries the frame.
321
+ data = {"email": "alice@example.com"}
322
+ protect(data, SCHEMA, _cipher())
323
+ reparsed = json.loads(json.dumps(data))
324
+ self.assertIsInstance(reparsed["email"], str)
325
+
326
+
327
+ if __name__ == "__main__":
328
+ unittest.main()
File without changes
File without changes