django-nativemojo 0.1.10__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.
- django_nativemojo-0.1.16.dist-info/METADATA +138 -0
- django_nativemojo-0.1.16.dist-info/RECORD +302 -0
- mojo/__init__.py +1 -1
- mojo/apps/account/management/__init__.py +5 -0
- mojo/apps/account/management/commands/__init__.py +6 -0
- mojo/apps/account/management/commands/serializer_admin.py +651 -0
- mojo/apps/account/migrations/0004_user_avatar.py +20 -0
- mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
- mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
- mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
- mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
- mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
- mojo/apps/account/migrations/0010_group_avatar.py +20 -0
- mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
- mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
- mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
- mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
- mojo/apps/account/models/__init__.py +2 -0
- mojo/apps/account/models/device.py +281 -0
- mojo/apps/account/models/group.py +319 -15
- mojo/apps/account/models/member.py +29 -5
- mojo/apps/account/models/push/__init__.py +4 -0
- mojo/apps/account/models/push/config.py +112 -0
- mojo/apps/account/models/push/delivery.py +93 -0
- mojo/apps/account/models/push/device.py +66 -0
- mojo/apps/account/models/push/template.py +99 -0
- mojo/apps/account/models/user.py +369 -19
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +9 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +100 -6
- mojo/apps/account/services/__init__.py +1 -0
- mojo/apps/account/services/push.py +363 -0
- mojo/apps/aws/migrations/0001_initial.py +206 -0
- mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
- mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
- mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
- mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
- mojo/apps/aws/models/__init__.py +19 -0
- mojo/apps/aws/models/email_attachment.py +99 -0
- mojo/apps/aws/models/email_domain.py +218 -0
- mojo/apps/aws/models/email_template.py +132 -0
- mojo/apps/aws/models/incoming_email.py +197 -0
- mojo/apps/aws/models/mailbox.py +288 -0
- mojo/apps/aws/models/sent_message.py +175 -0
- mojo/apps/aws/rest/__init__.py +7 -0
- mojo/apps/aws/rest/email.py +33 -0
- mojo/apps/aws/rest/email_ops.py +183 -0
- mojo/apps/aws/rest/messages.py +32 -0
- mojo/apps/aws/rest/s3.py +64 -0
- mojo/apps/aws/rest/send.py +101 -0
- mojo/apps/aws/rest/sns.py +403 -0
- mojo/apps/aws/rest/templates.py +19 -0
- mojo/apps/aws/services/__init__.py +32 -0
- mojo/apps/aws/services/email.py +390 -0
- mojo/apps/aws/services/email_ops.py +548 -0
- mojo/apps/docit/__init__.py +6 -0
- mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
- mojo/apps/docit/markdown_plugins/toc.py +12 -0
- mojo/apps/docit/migrations/0001_initial.py +113 -0
- mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
- mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
- mojo/apps/docit/models/__init__.py +17 -0
- mojo/apps/docit/models/asset.py +231 -0
- mojo/apps/docit/models/book.py +227 -0
- mojo/apps/docit/models/page.py +319 -0
- mojo/apps/docit/models/page_revision.py +203 -0
- mojo/apps/docit/rest/__init__.py +10 -0
- mojo/apps/docit/rest/asset.py +17 -0
- mojo/apps/docit/rest/book.py +22 -0
- mojo/apps/docit/rest/page.py +22 -0
- mojo/apps/docit/rest/page_revision.py +17 -0
- mojo/apps/docit/services/__init__.py +11 -0
- mojo/apps/docit/services/docit.py +315 -0
- mojo/apps/docit/services/markdown.py +44 -0
- mojo/apps/fileman/README.md +8 -8
- mojo/apps/fileman/backends/base.py +76 -70
- mojo/apps/fileman/backends/filesystem.py +86 -86
- mojo/apps/fileman/backends/s3.py +409 -108
- mojo/apps/fileman/migrations/0001_initial.py +106 -0
- mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
- mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
- mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
- mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
- mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
- mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
- mojo/apps/fileman/migrations/0008_file_category.py +18 -0
- mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
- mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
- mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
- mojo/apps/fileman/models/__init__.py +1 -5
- mojo/apps/fileman/models/file.py +240 -58
- mojo/apps/fileman/models/manager.py +427 -31
- mojo/apps/fileman/models/rendition.py +118 -0
- mojo/apps/fileman/renderer/__init__.py +111 -0
- mojo/apps/fileman/renderer/audio.py +403 -0
- mojo/apps/fileman/renderer/base.py +205 -0
- mojo/apps/fileman/renderer/document.py +404 -0
- mojo/apps/fileman/renderer/image.py +222 -0
- mojo/apps/fileman/renderer/utils.py +297 -0
- mojo/apps/fileman/renderer/video.py +304 -0
- mojo/apps/fileman/rest/__init__.py +1 -18
- mojo/apps/fileman/rest/upload.py +22 -32
- mojo/apps/fileman/signals.py +58 -0
- mojo/apps/fileman/tasks.py +254 -0
- mojo/apps/fileman/utils/__init__.py +40 -16
- mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
- mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
- mojo/apps/incident/migrations/0007_event_uid.py +18 -0
- mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
- mojo/apps/incident/migrations/0009_incident_status.py +18 -0
- mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
- mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
- mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
- mojo/apps/incident/models/__init__.py +2 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/history.py +36 -0
- mojo/apps/incident/models/incident.py +3 -1
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -1
- mojo/apps/incident/rest/__init__.py +1 -0
- mojo/apps/incident/rest/event.py +7 -1
- mojo/apps/incident/rest/ticket.py +43 -0
- mojo/apps/jobs/__init__.py +489 -0
- mojo/apps/jobs/adapters.py +24 -0
- mojo/apps/jobs/cli.py +616 -0
- mojo/apps/jobs/daemon.py +370 -0
- mojo/apps/jobs/examples/sample_jobs.py +376 -0
- mojo/apps/jobs/examples/webhook_examples.py +203 -0
- mojo/apps/jobs/handlers/__init__.py +5 -0
- mojo/apps/jobs/handlers/webhook.py +317 -0
- mojo/apps/jobs/job_engine.py +734 -0
- mojo/apps/jobs/keys.py +203 -0
- mojo/apps/jobs/local_queue.py +363 -0
- mojo/apps/jobs/management/__init__.py +3 -0
- mojo/apps/jobs/management/commands/__init__.py +3 -0
- mojo/apps/jobs/manager.py +1327 -0
- mojo/apps/jobs/migrations/0001_initial.py +97 -0
- mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
- mojo/apps/jobs/models/__init__.py +6 -0
- mojo/apps/jobs/models/job.py +441 -0
- mojo/apps/jobs/rest/__init__.py +2 -0
- mojo/apps/jobs/rest/control.py +466 -0
- mojo/apps/jobs/rest/jobs.py +421 -0
- mojo/apps/jobs/scheduler.py +571 -0
- mojo/apps/jobs/services/__init__.py +6 -0
- mojo/apps/jobs/services/job_actions.py +465 -0
- mojo/apps/jobs/settings.py +209 -0
- mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
- mojo/apps/logit/models/log.py +7 -1
- mojo/apps/metrics/__init__.py +8 -1
- mojo/apps/metrics/redis_metrics.py +198 -0
- mojo/apps/metrics/rest/__init__.py +3 -0
- mojo/apps/metrics/rest/categories.py +266 -0
- mojo/apps/metrics/rest/helpers.py +48 -0
- mojo/apps/metrics/rest/permissions.py +99 -0
- mojo/apps/metrics/rest/values.py +277 -0
- mojo/apps/metrics/utils.py +19 -2
- mojo/decorators/auth.py +6 -1
- mojo/decorators/http.py +47 -3
- mojo/helpers/aws/__init__.py +45 -0
- mojo/helpers/aws/ec2.py +804 -0
- mojo/helpers/aws/iam.py +748 -0
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/s3.py +451 -11
- mojo/helpers/aws/ses.py +483 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/aws/sns.py +461 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -0
- mojo/helpers/dates.py +18 -0
- mojo/helpers/location/__init__.py +2 -0
- mojo/helpers/location/countries.py +262 -0
- mojo/helpers/location/geolocation.py +196 -0
- mojo/helpers/logit.py +37 -0
- mojo/helpers/redis/__init__.py +2 -0
- mojo/helpers/redis/adapter.py +606 -0
- mojo/helpers/redis/client.py +48 -0
- mojo/helpers/redis/pool.py +225 -0
- mojo/helpers/request.py +8 -0
- mojo/helpers/response.py +14 -2
- mojo/helpers/settings/__init__.py +2 -0
- mojo/helpers/{settings.py → settings/helper.py} +1 -37
- mojo/helpers/settings/parser.py +132 -0
- mojo/middleware/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +10 -0
- mojo/models/rest.py +494 -65
- mojo/models/secrets.py +98 -3
- mojo/serializers/__init__.py +106 -0
- mojo/serializers/core/__init__.py +90 -0
- mojo/serializers/core/cache/__init__.py +121 -0
- mojo/serializers/core/cache/backends.py +518 -0
- mojo/serializers/core/cache/base.py +102 -0
- mojo/serializers/core/cache/disabled.py +181 -0
- mojo/serializers/core/cache/memory.py +287 -0
- mojo/serializers/core/cache/redis.py +533 -0
- mojo/serializers/core/cache/utils.py +454 -0
- mojo/serializers/core/manager.py +550 -0
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/examples/settings.py +322 -0
- mojo/serializers/formats/csv.py +393 -0
- mojo/serializers/formats/localizers.py +509 -0
- mojo/serializers/{models.py → simple.py} +38 -15
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +35 -4
- testit/runner.py +23 -6
- django_nativemojo-0.1.10.dist-info/METADATA +0 -96
- django_nativemojo-0.1.10.dist-info/RECORD +0 -194
- mojo/apps/metrics/rest/db.py +0 -0
- mojo/apps/notify/README.md +0 -91
- mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
- mojo/apps/notify/admin.py +0 -52
- mojo/apps/notify/handlers/example_handlers.py +0 -516
- mojo/apps/notify/handlers/ses/__init__.py +0 -25
- mojo/apps/notify/handlers/ses/bounce.py +0 -0
- mojo/apps/notify/handlers/ses/complaint.py +0 -25
- mojo/apps/notify/handlers/ses/message.py +0 -86
- mojo/apps/notify/management/commands/__init__.py +0 -1
- mojo/apps/notify/management/commands/process_notifications.py +0 -370
- mojo/apps/notify/mod +0 -0
- mojo/apps/notify/models/__init__.py +0 -12
- mojo/apps/notify/models/account.py +0 -128
- mojo/apps/notify/models/attachment.py +0 -24
- mojo/apps/notify/models/bounce.py +0 -68
- mojo/apps/notify/models/complaint.py +0 -40
- mojo/apps/notify/models/inbox.py +0 -113
- mojo/apps/notify/models/inbox_message.py +0 -173
- mojo/apps/notify/models/outbox.py +0 -129
- mojo/apps/notify/models/outbox_message.py +0 -288
- mojo/apps/notify/models/template.py +0 -30
- mojo/apps/notify/providers/aws.py +0 -73
- mojo/apps/notify/rest/ses.py +0 -0
- mojo/apps/notify/utils/__init__.py +0 -2
- mojo/apps/notify/utils/notifications.py +0 -404
- mojo/apps/notify/utils/parsing.py +0 -202
- mojo/apps/notify/utils/render.py +0 -144
- mojo/apps/tasks/README.md +0 -118
- mojo/apps/tasks/__init__.py +0 -11
- mojo/apps/tasks/manager.py +0 -489
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -62
- mojo/apps/tasks/runner.py +0 -174
- mojo/apps/tasks/tq_handlers.py +0 -14
- mojo/helpers/aws/setup_email.py +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- mojo/ws4redis/README.md +0 -174
- mojo/ws4redis/__init__.py +0 -2
- mojo/ws4redis/client.py +0 -283
- mojo/ws4redis/connection.py +0 -327
- mojo/ws4redis/exceptions.py +0 -32
- mojo/ws4redis/redis.py +0 -183
- mojo/ws4redis/servers/base.py +0 -86
- mojo/ws4redis/servers/django.py +0 -171
- mojo/ws4redis/servers/uwsgi.py +0 -63
- mojo/ws4redis/settings.py +0 -45
- mojo/ws4redis/utf8validator.py +0 -128
- mojo/ws4redis/websocket.py +0 -403
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
- /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
- /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /mojo/{apps/fileman/rest/__init__ → serializers/formats/__init__.py} +0 -0
mojo/helpers/aws/kms.py
ADDED
@@ -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
|