django-nativemojo 0.1.15__py3-none-any.whl → 0.1.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/METADATA +3 -1
  2. django_nativemojo-0.1.16.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/commands/serializer_admin.py +121 -1
  5. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  6. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  7. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  8. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  9. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  10. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  11. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  12. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  13. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  14. mojo/apps/account/models/__init__.py +2 -0
  15. mojo/apps/account/models/device.py +281 -0
  16. mojo/apps/account/models/group.py +294 -8
  17. mojo/apps/account/models/member.py +14 -1
  18. mojo/apps/account/models/push/__init__.py +4 -0
  19. mojo/apps/account/models/push/config.py +112 -0
  20. mojo/apps/account/models/push/delivery.py +93 -0
  21. mojo/apps/account/models/push/device.py +66 -0
  22. mojo/apps/account/models/push/template.py +99 -0
  23. mojo/apps/account/models/user.py +190 -17
  24. mojo/apps/account/rest/__init__.py +2 -0
  25. mojo/apps/account/rest/device.py +39 -0
  26. mojo/apps/account/rest/group.py +8 -0
  27. mojo/apps/account/rest/push.py +187 -0
  28. mojo/apps/account/rest/user.py +95 -5
  29. mojo/apps/account/services/__init__.py +1 -0
  30. mojo/apps/account/services/push.py +363 -0
  31. mojo/apps/aws/migrations/0001_initial.py +206 -0
  32. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  33. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  34. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  35. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  36. mojo/apps/aws/models/__init__.py +19 -0
  37. mojo/apps/aws/models/email_attachment.py +99 -0
  38. mojo/apps/aws/models/email_domain.py +218 -0
  39. mojo/apps/aws/models/email_template.py +132 -0
  40. mojo/apps/aws/models/incoming_email.py +197 -0
  41. mojo/apps/aws/models/mailbox.py +288 -0
  42. mojo/apps/aws/models/sent_message.py +175 -0
  43. mojo/apps/aws/rest/__init__.py +6 -0
  44. mojo/apps/aws/rest/email.py +33 -0
  45. mojo/apps/aws/rest/email_ops.py +183 -0
  46. mojo/apps/aws/rest/messages.py +32 -0
  47. mojo/apps/aws/rest/send.py +101 -0
  48. mojo/apps/aws/rest/sns.py +403 -0
  49. mojo/apps/aws/rest/templates.py +19 -0
  50. mojo/apps/aws/services/__init__.py +32 -0
  51. mojo/apps/aws/services/email.py +390 -0
  52. mojo/apps/aws/services/email_ops.py +548 -0
  53. mojo/apps/docit/__init__.py +6 -0
  54. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  55. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  56. mojo/apps/docit/migrations/0001_initial.py +113 -0
  57. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  58. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  59. mojo/apps/docit/models/__init__.py +17 -0
  60. mojo/apps/docit/models/asset.py +231 -0
  61. mojo/apps/docit/models/book.py +227 -0
  62. mojo/apps/docit/models/page.py +319 -0
  63. mojo/apps/docit/models/page_revision.py +203 -0
  64. mojo/apps/docit/rest/__init__.py +10 -0
  65. mojo/apps/docit/rest/asset.py +17 -0
  66. mojo/apps/docit/rest/book.py +22 -0
  67. mojo/apps/docit/rest/page.py +22 -0
  68. mojo/apps/docit/rest/page_revision.py +17 -0
  69. mojo/apps/docit/services/__init__.py +11 -0
  70. mojo/apps/docit/services/docit.py +315 -0
  71. mojo/apps/docit/services/markdown.py +44 -0
  72. mojo/apps/fileman/backends/s3.py +209 -0
  73. mojo/apps/fileman/models/file.py +45 -9
  74. mojo/apps/fileman/models/manager.py +269 -3
  75. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  76. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  77. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  78. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  79. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  80. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  81. mojo/apps/incident/models/__init__.py +1 -0
  82. mojo/apps/incident/models/event.py +35 -0
  83. mojo/apps/incident/models/incident.py +2 -0
  84. mojo/apps/incident/models/ticket.py +62 -0
  85. mojo/apps/incident/reporter.py +21 -3
  86. mojo/apps/incident/rest/__init__.py +1 -0
  87. mojo/apps/incident/rest/ticket.py +43 -0
  88. mojo/apps/jobs/__init__.py +489 -0
  89. mojo/apps/jobs/adapters.py +24 -0
  90. mojo/apps/jobs/cli.py +616 -0
  91. mojo/apps/jobs/daemon.py +370 -0
  92. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  93. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  94. mojo/apps/jobs/handlers/__init__.py +5 -0
  95. mojo/apps/jobs/handlers/webhook.py +317 -0
  96. mojo/apps/jobs/job_engine.py +734 -0
  97. mojo/apps/jobs/keys.py +203 -0
  98. mojo/apps/jobs/local_queue.py +363 -0
  99. mojo/apps/jobs/management/__init__.py +3 -0
  100. mojo/apps/jobs/management/commands/__init__.py +3 -0
  101. mojo/apps/jobs/manager.py +1327 -0
  102. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  103. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  104. mojo/apps/jobs/models/__init__.py +6 -0
  105. mojo/apps/jobs/models/job.py +441 -0
  106. mojo/apps/jobs/rest/__init__.py +2 -0
  107. mojo/apps/jobs/rest/control.py +466 -0
  108. mojo/apps/jobs/rest/jobs.py +421 -0
  109. mojo/apps/jobs/scheduler.py +571 -0
  110. mojo/apps/jobs/services/__init__.py +6 -0
  111. mojo/apps/jobs/services/job_actions.py +465 -0
  112. mojo/apps/jobs/settings.py +209 -0
  113. mojo/apps/logit/models/log.py +3 -0
  114. mojo/apps/metrics/__init__.py +8 -1
  115. mojo/apps/metrics/redis_metrics.py +198 -0
  116. mojo/apps/metrics/rest/__init__.py +3 -0
  117. mojo/apps/metrics/rest/categories.py +266 -0
  118. mojo/apps/metrics/rest/helpers.py +48 -0
  119. mojo/apps/metrics/rest/permissions.py +99 -0
  120. mojo/apps/metrics/rest/values.py +277 -0
  121. mojo/apps/metrics/utils.py +17 -0
  122. mojo/decorators/http.py +40 -1
  123. mojo/helpers/aws/__init__.py +11 -7
  124. mojo/helpers/aws/inbound_email.py +309 -0
  125. mojo/helpers/aws/kms.py +413 -0
  126. mojo/helpers/aws/ses_domain.py +959 -0
  127. mojo/helpers/crypto/__init__.py +1 -1
  128. mojo/helpers/crypto/utils.py +15 -0
  129. mojo/helpers/location/__init__.py +2 -0
  130. mojo/helpers/location/countries.py +262 -0
  131. mojo/helpers/location/geolocation.py +196 -0
  132. mojo/helpers/logit.py +37 -0
  133. mojo/helpers/redis/__init__.py +2 -0
  134. mojo/helpers/redis/adapter.py +606 -0
  135. mojo/helpers/redis/client.py +48 -0
  136. mojo/helpers/redis/pool.py +225 -0
  137. mojo/helpers/request.py +8 -0
  138. mojo/helpers/response.py +8 -0
  139. mojo/middleware/auth.py +1 -1
  140. mojo/middleware/cors.py +40 -0
  141. mojo/middleware/logging.py +131 -12
  142. mojo/middleware/mojo.py +5 -0
  143. mojo/models/rest.py +271 -57
  144. mojo/models/secrets.py +86 -0
  145. mojo/serializers/__init__.py +16 -10
  146. mojo/serializers/core/__init__.py +90 -0
  147. mojo/serializers/core/cache/__init__.py +121 -0
  148. mojo/serializers/core/cache/backends.py +518 -0
  149. mojo/serializers/core/cache/base.py +102 -0
  150. mojo/serializers/core/cache/disabled.py +181 -0
  151. mojo/serializers/core/cache/memory.py +287 -0
  152. mojo/serializers/core/cache/redis.py +533 -0
  153. mojo/serializers/core/cache/utils.py +454 -0
  154. mojo/serializers/{manager.py → core/manager.py} +53 -4
  155. mojo/serializers/core/serializer.py +475 -0
  156. mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
  157. mojo/serializers/suggested_improvements.md +388 -0
  158. testit/client.py +1 -1
  159. testit/helpers.py +14 -0
  160. testit/runner.py +23 -6
  161. django_nativemojo-0.1.15.dist-info/RECORD +0 -234
  162. mojo/apps/notify/README.md +0 -91
  163. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  164. mojo/apps/notify/admin.py +0 -52
  165. mojo/apps/notify/handlers/example_handlers.py +0 -516
  166. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  167. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  168. mojo/apps/notify/handlers/ses/message.py +0 -86
  169. mojo/apps/notify/management/commands/__init__.py +0 -1
  170. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  171. mojo/apps/notify/mod +0 -0
  172. mojo/apps/notify/models/__init__.py +0 -12
  173. mojo/apps/notify/models/account.py +0 -128
  174. mojo/apps/notify/models/attachment.py +0 -24
  175. mojo/apps/notify/models/bounce.py +0 -68
  176. mojo/apps/notify/models/complaint.py +0 -40
  177. mojo/apps/notify/models/inbox.py +0 -113
  178. mojo/apps/notify/models/inbox_message.py +0 -173
  179. mojo/apps/notify/models/outbox.py +0 -129
  180. mojo/apps/notify/models/outbox_message.py +0 -288
  181. mojo/apps/notify/models/template.py +0 -30
  182. mojo/apps/notify/providers/aws.py +0 -73
  183. mojo/apps/notify/rest/ses.py +0 -0
  184. mojo/apps/notify/utils/__init__.py +0 -2
  185. mojo/apps/notify/utils/notifications.py +0 -404
  186. mojo/apps/notify/utils/parsing.py +0 -202
  187. mojo/apps/notify/utils/render.py +0 -144
  188. mojo/apps/tasks/README.md +0 -118
  189. mojo/apps/tasks/__init__.py +0 -44
  190. mojo/apps/tasks/manager.py +0 -644
  191. mojo/apps/tasks/rest/__init__.py +0 -2
  192. mojo/apps/tasks/rest/hooks.py +0 -0
  193. mojo/apps/tasks/rest/tasks.py +0 -76
  194. mojo/apps/tasks/runner.py +0 -439
  195. mojo/apps/tasks/task.py +0 -99
  196. mojo/apps/tasks/tq_handlers.py +0 -132
  197. mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
  198. mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
  199. mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
  200. mojo/helpers/redis.py +0 -10
  201. mojo/models/meta.py +0 -262
  202. mojo/serializers/advanced/README.md +0 -363
  203. mojo/serializers/advanced/__init__.py +0 -247
  204. mojo/serializers/advanced/formats/__init__.py +0 -28
  205. mojo/serializers/advanced/formats/excel.py +0 -516
  206. mojo/serializers/advanced/formats/json.py +0 -239
  207. mojo/serializers/advanced/formats/response.py +0 -485
  208. mojo/serializers/advanced/serializer.py +0 -568
  209. mojo/serializers/optimized.py +0 -618
  210. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
  211. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  212. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
  213. /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
  214. /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
  215. /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
  216. /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
  217. /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
  218. /mojo/{serializers → rest}/openapi.py +0 -0
  219. /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
  220. /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
  221. /mojo/serializers/{advanced/formats → formats}/localizers.py +0 -0
@@ -0,0 +1,413 @@
1
+ """
2
+ KMSHelper – Field-level Encryption with AWS KMS
3
+
4
+ Overview
5
+ - Envelope encryption using AWS KMS + AES-256-GCM
6
+ - Per-field data keys; KMS only stores/wraps the data key (CiphertextBlob)
7
+ - AES-GCM provides confidentiality and integrity
8
+ - EncryptionContext binds ciphertexts to a specific logical field key (e.g., "account.User.22.email")
9
+ - Base64(JSON) text blob for direct DB storage
10
+ - All decrypt operations are audited via CloudTrail
11
+ - Plaintext data keys are zeroized in memory after use
12
+
13
+ API
14
+ - KMSHelper(kms_key_id: str, region_name: str, encryption_context_key: str = "ctx")
15
+ Initializes the helper. If kms_key_id is an alias that does not exist, will create a
16
+ symmetric KMS key, bind/update the alias, and enable key rotation (best-effort).
17
+
18
+ - encrypt_field(key: str, value: str | bytes | dict) -> str
19
+ Returns a JSON-safe dict with ct (ciphertext), iv, tag, wrapped data key (dk), and metadata.
20
+
21
+ - decrypt_field(key: str, blob: str) -> str
22
+ Decrypts and returns plaintext as a UTF-8 string. Accepts dict or a JSON string of the dict.
23
+
24
+ - decrypt_dict_field(key: str, blob: str) -> dict
25
+ Decrypts and returns a Python dict (when the original plaintext was JSON/dict).
26
+
27
+ Security Properties
28
+ - Envelope encryption; data keys only exist plaintext in RAM during ops
29
+ - KMS EncryptionContext and AES-GCM AAD both bind to the same logical key (e.g., "account.User.22.email")
30
+ - CloudTrail auditability for KMS Decrypt/ReEncrypt
31
+ - Zeroization of plaintext data keys in RAM
32
+
33
+ Notes
34
+ - This is a framework helper. It expects AWS creds/region via standard AWS SDK resolution
35
+ (env vars, instance profile, etc.) unless provided externally to boto3.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from base64 import b64encode, b64decode
41
+ from typing import Any, Dict, Optional, Union
42
+ import json
43
+ import boto3
44
+ from botocore.exceptions import ClientError
45
+ from Crypto.Cipher import AES
46
+ from Crypto.Random import get_random_bytes
47
+ from datetime import datetime, timezone
48
+
49
+ from mojo.helpers import logit
50
+
51
+
52
+ # --------------------------
53
+ # Exceptions
54
+ # --------------------------
55
+ class KMSHelperError(Exception):
56
+ """Base error for KMSHelper."""
57
+
58
+
59
+ class KMSPermissionError(KMSHelperError):
60
+ """Permission/IAM related errors."""
61
+
62
+
63
+ class KMSBlobError(KMSHelperError):
64
+ """Ciphertext blob format or integrity issues."""
65
+
66
+
67
+ class KMSContextError(KMSHelperError):
68
+ """EncryptionContext / key mismatch errors."""
69
+
70
+
71
+ # --------------------------
72
+ # Utility
73
+ # --------------------------
74
+ def _b64e(data: bytes) -> str:
75
+ return b64encode(data).decode("utf-8")
76
+
77
+
78
+ def _b64d(data: str) -> bytes:
79
+ return b64decode(data.encode("utf-8"))
80
+
81
+
82
+ def _utc_now_iso_z() -> str:
83
+ # e.g., "2025-09-02T15:00:00Z"
84
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
85
+
86
+
87
+ def _as_json_dict(blob: Union[str, Dict[str, Any]]) -> Dict[str, Any]:
88
+ if isinstance(blob, dict):
89
+ return blob
90
+ try:
91
+ # Expect base64(JSON) string; fallback to direct JSON for backward compatibility
92
+ try:
93
+ decoded = _b64d(blob).decode("utf-8")
94
+ return json.loads(decoded)
95
+ except Exception:
96
+ return json.loads(blob)
97
+ except Exception as exc:
98
+ raise KMSBlobError("Encrypted blob must be base64(JSON) or a JSON string of that dict") from exc
99
+
100
+
101
+ # --------------------------
102
+ # KMS Helper
103
+ # --------------------------
104
+ class KMSHelper:
105
+ VERSION = 1
106
+ ALGO = "AES-256-GCM"
107
+ NONCE_LENGTH = 12 # GCM recommended nonce length
108
+ TAG_LENGTH = 16 # 128-bit tag
109
+
110
+ def __init__(
111
+ self,
112
+ kms_key_id: str,
113
+ region_name: str,
114
+ encryption_context_key: str = "ctx",
115
+ *,
116
+ ensure_key: bool = True,
117
+ ):
118
+ """
119
+ :param kms_key_id: ARN, KeyId, or alias (e.g., "alias/app-prod")
120
+ :param region_name: AWS region (e.g., "us-east-1")
121
+ :param encryption_context_key: Field name used in KMS EncryptionContext (default "ctx")
122
+ :param ensure_key: If True and kms_key_id is an alias, ensure key+alias exist and rotation enabled
123
+ """
124
+ self.kms_key_id = kms_key_id
125
+ self.region_name = region_name
126
+ self.context_key = encryption_context_key
127
+
128
+ # Create a KMS client using default AWS credential resolution chain
129
+ self.kms = boto3.client("kms", region_name=region_name)
130
+
131
+ if ensure_key:
132
+ self._ensure_key_and_alias_if_needed()
133
+
134
+ # --------------------------
135
+ # Public API
136
+ # --------------------------
137
+ def encrypt_field(self, key: str, value: Union[str, bytes, Dict[str, Any]]) -> str:
138
+ """
139
+ Encrypt a field under a per-field data key derived from AWS KMS (GenerateDataKey).
140
+ The KMS EncryptionContext and AES-GCM AAD are both bound to the provided logical key.
141
+
142
+ :param key: Logical identifier (AAD), e.g., "account.User.22.email"
143
+ :param value: Plaintext str/bytes or dict (dict will be JSON-encoded)
144
+ :return: base64(JSON) string blob containing ciphertext and metadata
145
+ """
146
+ if isinstance(value, dict):
147
+ # Canonicalize JSON to keep deterministic payloads for auditing/tamper checks
148
+ plaintext = json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8")
149
+ elif isinstance(value, bytes):
150
+ plaintext = value
151
+ elif isinstance(value, str):
152
+ plaintext = value.encode("utf-8")
153
+ else:
154
+ raise KMSHelperError("Value must be str, bytes, or dict")
155
+
156
+ enc_context = {self.context_key: key}
157
+
158
+ # Generate a fresh AES-256 data key wrapped by KMS
159
+ dk_resp = None
160
+ try:
161
+ dk_resp = self.kms.generate_data_key(
162
+ KeyId=self.kms_key_id,
163
+ KeySpec="AES_256",
164
+ EncryptionContext=enc_context,
165
+ )
166
+ except ClientError as ce:
167
+ self._raise_client_error("kms.generate_data_key", ce)
168
+
169
+ # Plaintext data key (bytes). We will zeroize it as soon as we're done.
170
+ assert dk_resp is not None, "kms.generate_data_key did not return a response"
171
+ dk_plain = dk_resp["Plaintext"]
172
+ dk_wrapped = dk_resp["CiphertextBlob"]
173
+
174
+ # AES-GCM with random nonce and AAD bound to the same 'key' for contextual integrity
175
+ iv = get_random_bytes(self.NONCE_LENGTH)
176
+ cipher = AES.new(dk_plain, AES.MODE_GCM, nonce=iv)
177
+ cipher.update(key.encode("utf-8")) # AAD
178
+
179
+ ciphertext, tag = cipher.encrypt_and_digest(plaintext)
180
+
181
+ # Zeroize plaintext data key
182
+ self._zeroize_bytes(dk_plain)
183
+
184
+ blob = {
185
+ "v": self.VERSION,
186
+ "algo": self.ALGO,
187
+ "ct": _b64e(ciphertext),
188
+ "iv": _b64e(iv),
189
+ "tag": _b64e(tag),
190
+ "dk": _b64e(dk_wrapped),
191
+ self.context_key: key,
192
+ "ts": _utc_now_iso_z(),
193
+ "kek": self.kms_key_id, # Helpful metadata for audits/rotation
194
+ }
195
+ return _b64e(json.dumps(blob).encode("utf-8"))
196
+
197
+ def decrypt_field(self, key: str, blob: str) -> str:
198
+ """
199
+ Decrypt a previously encrypted field. Returns plaintext as a UTF-8 string.
200
+
201
+ :param key: Must match the original logical identifier used during encrypt
202
+ :param blob: Base64(JSON) string returned by encrypt_field
203
+ :return: Plaintext string
204
+ """
205
+ b = _as_json_dict(blob)
206
+ self._validate_blob(b)
207
+
208
+ # Validate context integrity at the application layer before KMS call
209
+ ctx_from_blob = b.get(self.context_key)
210
+ if ctx_from_blob != key:
211
+ raise KMSContextError(
212
+ f"Context mismatch. Provided key '{key}' != blob context '{ctx_from_blob}'"
213
+ )
214
+
215
+ enc_context = {self.context_key: key}
216
+
217
+ dk_plain_resp = None
218
+ try:
219
+ dk_plain_resp = self.kms.decrypt(
220
+ CiphertextBlob=_b64d(b["dk"]),
221
+ EncryptionContext=enc_context,
222
+ )
223
+ except ClientError as ce:
224
+ self._raise_client_error("kms.decrypt", ce)
225
+
226
+ assert dk_plain_resp is not None, "kms.decrypt did not return a response"
227
+ dk_plain = dk_plain_resp["Plaintext"]
228
+
229
+ # Local AES-GCM decrypt and integrity check
230
+ iv = _b64d(b["iv"])
231
+ tag = _b64d(b["tag"])
232
+ ct = _b64d(b["ct"])
233
+
234
+ cipher = AES.new(dk_plain, AES.MODE_GCM, nonce=iv)
235
+ cipher.update(key.encode("utf-8")) # AAD must match encrypt
236
+
237
+ try:
238
+ pt = cipher.decrypt_and_verify(ct, tag)
239
+ finally:
240
+ # Zeroize plaintext data key
241
+ self._zeroize_bytes(dk_plain)
242
+
243
+ return pt.decode("utf-8")
244
+
245
+ def decrypt_dict_field(self, key: str, blob: str) -> Dict[str, Any]:
246
+ """
247
+ Decrypt a previously encrypted field where the original plaintext was a dict.
248
+ :param key: Same logical identifier used during encryption
249
+ :param blob: Base64 string returned by encrypt_field
250
+ :return: Dict
251
+ """
252
+ pt_str = self.decrypt_field(key, blob)
253
+ try:
254
+ return json.loads(pt_str)
255
+ except Exception as exc:
256
+ raise KMSBlobError("Decrypted plaintext is not valid JSON") from exc
257
+
258
+ # --------------------------
259
+ # Optional: Re-wrap support (CMK rotation without touching plaintext)
260
+ # --------------------------
261
+ def rewrap_data_key(
262
+ self,
263
+ blob: Union[Dict[str, Any], str],
264
+ *,
265
+ target_kms_key_id: Optional[str] = None,
266
+ ) -> str:
267
+ """
268
+ Re-encrypt (rewrap) the stored data key under a new KMS key without decrypting field ciphertext.
269
+
270
+ :param blob: Base64(JSON) string ciphertext record
271
+ :param target_kms_key_id: Destination key (alias/ARN/KeyId). Defaults to self.kms_key_id.
272
+ :return: base64(JSON) string with updated 'dk' and 'kek'
273
+ """
274
+ b = _as_json_dict(blob)
275
+ self._validate_blob(b)
276
+
277
+ ctx_val = b.get(self.context_key)
278
+ if not isinstance(ctx_val, str) or not ctx_val:
279
+ raise KMSBlobError("Missing or invalid context in blob")
280
+ enc_context = {self.context_key: ctx_val}
281
+
282
+ src_dk = _b64d(b["dk"])
283
+ dest_key = target_kms_key_id or self.kms_key_id
284
+
285
+ resp = None
286
+ try:
287
+ resp = self.kms.re_encrypt(
288
+ CiphertextBlob=src_dk,
289
+ DestinationKeyId=dest_key,
290
+ SourceEncryptionContext=enc_context,
291
+ DestinationEncryptionContext=enc_context,
292
+ )
293
+ except ClientError as ce:
294
+ self._raise_client_error("kms.re_encrypt", ce)
295
+
296
+ b2 = dict(b)
297
+ assert resp is not None, "kms.re_encrypt did not return a response"
298
+ b2["dk"] = _b64e(resp["CiphertextBlob"])
299
+ b2["kek"] = dest_key
300
+ return _b64e(json.dumps(b2).encode("utf-8"))
301
+
302
+ # --------------------------
303
+ # Internal helpers
304
+ # --------------------------
305
+ def _ensure_key_and_alias_if_needed(self):
306
+ """
307
+ If kms_key_id is an alias and it doesn't exist, create a new symmetric key,
308
+ bind the alias to it, and enable key rotation (best-effort). If alias exists,
309
+ ensure rotation is enabled on the target key (best-effort).
310
+ """
311
+ alias_name = self._extract_alias_name(self.kms_key_id)
312
+ if not alias_name:
313
+ # Not an alias; cannot manage creation/rotation here.
314
+ return
315
+
316
+ try:
317
+ meta = self.kms.describe_key(KeyId=alias_name)["KeyMetadata"]
318
+ key_id = meta["KeyId"]
319
+ # Best-effort rotation enable
320
+ self._enable_rotation_best_effort(key_id)
321
+ return
322
+ except ClientError as ce:
323
+ code = ce.response.get("Error", {}).get("Code")
324
+ if code not in ("NotFoundException", "NotFound", "ResourceNotFoundException"):
325
+ # Some other error; don't auto-create
326
+ logit.error("kms.describe_key failed", {"error": str(ce)})
327
+ return
328
+
329
+ # Alias does not exist -> create key and alias
330
+ try:
331
+ create_resp = self.kms.create_key(
332
+ Description=f"MOJO managed key for {alias_name}",
333
+ KeyUsage="ENCRYPT_DECRYPT",
334
+ KeySpec="SYMMETRIC_DEFAULT",
335
+ Origin="AWS_KMS",
336
+ )
337
+ key_id = create_resp["KeyMetadata"]["KeyId"]
338
+ self.kms.create_alias(AliasName=alias_name, TargetKeyId=key_id)
339
+ self._enable_rotation_best_effort(key_id)
340
+ logit.info("KMS key created and alias bound", {"alias": alias_name, "key_id": key_id})
341
+ except ClientError as ce:
342
+ # Do not raise hard errors here to avoid crashing app boot in locked-down environments
343
+ logit.error("Failed to create/bind KMS key alias", {"alias": alias_name, "error": str(ce)})
344
+
345
+ def _enable_rotation_best_effort(self, key_id: str):
346
+ try:
347
+ self.kms.enable_key_rotation(KeyId=key_id)
348
+ except ClientError as ce:
349
+ # Some principals can't call enable_key_rotation; log and move on
350
+ logit.warn("Unable to enable key rotation", {"key_id": key_id, "error": str(ce)})
351
+
352
+ @staticmethod
353
+ def _extract_alias_name(kms_key_id: str) -> Optional[str]:
354
+ """
355
+ Extract an alias name ("alias/xyz") from an input that may be:
356
+ - "alias/xyz"
357
+ - "arn:aws:kms:region:acct:alias/xyz"
358
+ Returns None if not an alias form.
359
+ """
360
+ if kms_key_id.startswith("alias/"):
361
+ return kms_key_id
362
+ # Alias ARN shape: arn:aws:kms:REGION:ACCOUNT:alias/NAME
363
+ parts = kms_key_id.split(":")
364
+ if parts and parts[-1].startswith("alias/"):
365
+ return parts[-1]
366
+ return None
367
+
368
+ @staticmethod
369
+ def _zeroize_bytes(b: bytes):
370
+ try:
371
+ # Convert to mutable and zero in place
372
+ ba = bytearray(b)
373
+ for i in range(len(ba)):
374
+ ba[i] = 0
375
+ except Exception:
376
+ # Best effort zeroization; Python immutability limits guarantees
377
+ pass
378
+
379
+ def _validate_blob(self, b: Dict[str, Any]):
380
+ # Schema check
381
+ if b.get("v") != self.VERSION:
382
+ raise KMSBlobError(f"Unsupported blob version: {b.get('v')}")
383
+ if b.get("algo") != self.ALGO:
384
+ raise KMSBlobError(f"Unsupported algorithm: {b.get('algo')}")
385
+ required = {"ct", "iv", "tag", "dk", self.context_key}
386
+ missing = [k for k in required if k not in b]
387
+ if missing:
388
+ raise KMSBlobError(f"Missing fields in blob: {', '.join(missing)}")
389
+
390
+ # Basic size checks
391
+ try:
392
+ iv = _b64d(b["iv"])
393
+ tag = _b64d(b["tag"])
394
+ if len(iv) != self.NONCE_LENGTH:
395
+ raise KMSBlobError("Invalid IV size")
396
+ if len(tag) != self.TAG_LENGTH:
397
+ raise KMSBlobError("Invalid GCM tag size")
398
+ except Exception as exc:
399
+ raise KMSBlobError("Invalid IV/tag encoding") from exc
400
+
401
+ @staticmethod
402
+ def _raise_client_error(op: str, ce: ClientError):
403
+ code = ce.response.get("Error", {}).get("Code", "Unknown")
404
+ msg = ce.response.get("Error", {}).get("Message", str(ce))
405
+ request_id = ce.response.get("ResponseMetadata", {}).get("RequestId")
406
+ err_detail = {"operation": op, "code": code, "message": msg, "request_id": request_id}
407
+
408
+ if code in ("AccessDeniedException", "UnauthorizedException", "AccessDenied"):
409
+ logit.error("KMS permission error", err_detail)
410
+ raise KMSPermissionError(f"{op} denied: {msg}") from ce
411
+
412
+ logit.error("KMS client error", err_detail)
413
+ raise KMSHelperError(f"{op} failed: {msg}") from ce