freedec 1.0.0__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.
freedec/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ Freedec Application Package.
3
+ Servicio seguro de gestión, cifrado y recuperación descentralizada de documentos y contraseñas.
4
+ """
5
+
6
+ __version__ = "1.0.0"
7
+ default_app_config = "freedec.apps.FreedecConfig"
freedec/admin.py ADDED
@@ -0,0 +1,486 @@
1
+ from django import forms
2
+ from django.contrib import admin, messages
3
+ from django.urls import reverse
4
+ from django.utils.html import format_html
5
+ from django.utils.safestring import mark_safe
6
+ from django.utils.translation import gettext_lazy as _
7
+
8
+ from freedec.models import DocumentAccessLog, EncryptedDocument
9
+ from freedec.services import DocumentManagementService
10
+ from freedec.validators import normalize_and_validate_email_list, validate_document_file
11
+
12
+
13
+ class EmailListAdminWidget(forms.Widget):
14
+ """
15
+ Widget interactivo para Django Admin que permite gestionar correos electrónicos
16
+ mediante campos de texto individuales con botones '+' y '✕', evitando la edición
17
+ manual de arrays JSON.
18
+ """
19
+
20
+ def render(self, name, value, attrs=None, renderer=None):
21
+ if value is None:
22
+ email_list = []
23
+ elif isinstance(value, list):
24
+ email_list = value
25
+ elif isinstance(value, str):
26
+ try:
27
+ import json
28
+ parsed = json.loads(value)
29
+ email_list = parsed if isinstance(parsed, list) else [value]
30
+ except Exception:
31
+ email_list = [e.strip() for e in value.split(",") if e.strip()]
32
+ else:
33
+ email_list = []
34
+
35
+ if not email_list:
36
+ email_list = [""]
37
+
38
+ rows_html = []
39
+ for val in email_list:
40
+ safe_val = str(val).replace('"', '"')
41
+ rows_html.append(
42
+ f'<div class="admin-email-row" style="display:flex; gap:8px; margin-bottom:6px; align-items:center;">'
43
+ f'<input type="email" name="{name}" value="{safe_val}" class="vTextField" style="max-width:380px; width:100%;" placeholder="usuario@empresa.com" required>'
44
+ f'<button type="button" class="button btn-remove-row" onclick="removeAdminRow(this)" style="background:#ba2121; color:#fff; border:none; border-radius:4px; padding:4px 10px; cursor:pointer;" title="Eliminar correo">✕</button>'
45
+ f'</div>'
46
+ )
47
+
48
+ rows_rendered = "\n".join(rows_html)
49
+
50
+ html = f"""
51
+ <div id="{name}_admin_wrapper" style="width:100%; max-width:500px;">
52
+ <div id="{name}_rows_container">
53
+ {rows_rendered}
54
+ </div>
55
+ <div style="margin-top:8px;">
56
+ <button type="button" class="button" onclick="addAdminRow('{name}')" style="background:#417690; color:#fff; border:none; border-radius:4px; padding:5px 12px; cursor:pointer; font-weight:bold;">
57
+ ➕ Añadir otro correo
58
+ </button>
59
+ </div>
60
+ </div>
61
+ <script>
62
+ function addAdminRow(fieldName) {{
63
+ const container = document.getElementById(fieldName + '_rows_container');
64
+ if (!container) return;
65
+ const row = document.createElement('div');
66
+ row.className = 'admin-email-row';
67
+ row.style.cssText = 'display:flex; gap:8px; margin-bottom:6px; align-items:center;';
68
+ row.innerHTML = `
69
+ <input type="email" name="${{fieldName}}" value="" class="vTextField" style="max-width:380px; width:100%;" placeholder="usuario@empresa.com" required>
70
+ <button type="button" class="button btn-remove-row" onclick="removeAdminRow(this)" style="background:#ba2121; color:#fff; border:none; border-radius:4px; padding:4px 10px; cursor:pointer;" title="Eliminar correo">✕</button>
71
+ `;
72
+ container.appendChild(row);
73
+ updateAdminRowControls(fieldName);
74
+ const input = row.querySelector('input');
75
+ if (input) input.focus();
76
+ }}
77
+
78
+ function removeAdminRow(btn) {{
79
+ const row = btn.closest('.admin-email-row');
80
+ const container = row.parentElement;
81
+ const rows = container.getElementsByClassName('admin-email-row');
82
+ if (rows.length > 1) {{
83
+ row.remove();
84
+ const wrapper = container.closest('[id$="_admin_wrapper"]');
85
+ if (wrapper) {{
86
+ const fieldName = wrapper.id.replace('_admin_wrapper', '');
87
+ updateAdminRowControls(fieldName);
88
+ }}
89
+ }}
90
+ }}
91
+
92
+ function updateAdminRowControls(fieldName) {{
93
+ const container = document.getElementById(fieldName + '_rows_container');
94
+ if (!container) return;
95
+ const rows = container.getElementsByClassName('admin-email-row');
96
+ for (let i = 0; i < rows.length; i++) {{
97
+ const btn = rows[i].querySelector('.btn-remove-row');
98
+ if (btn) {{
99
+ btn.style.display = rows.length === 1 ? 'none' : 'inline-block';
100
+ }}
101
+ }}
102
+ }}
103
+
104
+ document.addEventListener('DOMContentLoaded', function() {{
105
+ updateAdminRowControls('{name}');
106
+ }});
107
+ </script>
108
+ """
109
+ return mark_safe(html)
110
+
111
+ def value_from_datadict(self, data, files, name):
112
+ if hasattr(data, "getlist"):
113
+ return data.getlist(name)
114
+ val = data.get(name)
115
+ return [val] if val else []
116
+
117
+
118
+ class EncryptedDocumentAddForm(forms.ModelForm):
119
+ """
120
+ Formulario de alta para Django Admin:
121
+ El admin sube el archivo original, la contraseña en plano y los correos autorizados.
122
+ El sistema se encarga de aplicar el cifrado AES-Fernet, computar el hash SHA-256
123
+ y derivar el código Zero-Knowledge.
124
+ """
125
+
126
+ original_file = forms.FileField(
127
+ label=_("Archivo Documental Original"),
128
+ help_text=_(
129
+ "Formatos admitidos: PDF, LibreOffice (.odt, .ods, .odp, .odg) o Microsoft Office (.docx, .xlsx, .pptx, .doc, .xls, .ppt)."
130
+ ),
131
+ )
132
+ plain_password = forms.CharField(
133
+ label=_("Contraseña (Opcional)"),
134
+ required=False,
135
+ widget=forms.PasswordInput(
136
+ attrs={"placeholder": _("Dejar en blanco para autogenerar una clave segura...")}
137
+ ),
138
+ help_text=_(
139
+ "Opcional. Si se deja en blanco, el sistema genera automáticamente una clave de 24 caracteres."
140
+ ),
141
+ )
142
+ allowed_emails = forms.CharField(
143
+ label=_("Correos Autorizados"),
144
+ required=True,
145
+ widget=EmailListAdminWidget(),
146
+ help_text=_("Destinatarios autorizados. Use '+' para añadir más direcciones (sin JSON)."),
147
+ )
148
+
149
+ class Meta:
150
+ model = EncryptedDocument
151
+ fields = ("original_file", "plain_password", "allowed_emails")
152
+
153
+ def clean_original_file(self):
154
+ file_obj = self.cleaned_data.get("original_file")
155
+ if not file_obj:
156
+ raise forms.ValidationError("Debe seleccionar un archivo.")
157
+ return validate_document_file(file_obj)
158
+
159
+ def clean_allowed_emails(self):
160
+ if hasattr(self.data, "getlist"):
161
+ raw_entries = self.data.getlist("allowed_emails")
162
+ else:
163
+ raw_val = self.cleaned_data.get("allowed_emails") or self.data.get("allowed_emails") or ""
164
+ raw_entries = [raw_val] if isinstance(raw_val, str) else list(raw_val)
165
+
166
+ return normalize_and_validate_email_list(raw_entries)
167
+
168
+
169
+ class EncryptedDocumentChangeForm(forms.ModelForm):
170
+ """
171
+ Formulario de consulta/modificación en Django Admin:
172
+ Permite modificar o añadir correos con el widget '+' y consultar los datos criptográficos.
173
+ """
174
+
175
+ allowed_emails = forms.CharField(
176
+ label=_("Correos Autorizados"),
177
+ required=True,
178
+ widget=EmailListAdminWidget(),
179
+ help_text=_("Destinatarios autorizados. Use '+' para añadir o modificar direcciones."),
180
+ )
181
+
182
+ class Meta:
183
+ model = EncryptedDocument
184
+ fields = "__all__"
185
+
186
+ def clean_allowed_emails(self):
187
+ if hasattr(self.data, "getlist"):
188
+ raw_entries = self.data.getlist("allowed_emails")
189
+ else:
190
+ raw_val = self.cleaned_data.get("allowed_emails") or self.data.get("allowed_emails") or ""
191
+ raw_entries = [raw_val] if isinstance(raw_val, str) else list(raw_val)
192
+
193
+ return normalize_and_validate_email_list(raw_entries)
194
+
195
+
196
+ class DocumentAccessLogInline(admin.TabularInline):
197
+ """Muestra el historial y la auditoría de accesos dentro de la vista del documento."""
198
+
199
+ model = DocumentAccessLog
200
+ extra = 0
201
+ can_delete = False
202
+ readonly_fields = ("email", "action", "ip_address", "timestamp")
203
+ fields = ("timestamp", "email", "action", "ip_address")
204
+ verbose_name = _("Registro de acceso")
205
+ verbose_name_plural = _("Historial de accesos y trazabilidad de clave")
206
+
207
+ def has_add_permission(self, request, obj=None):
208
+ return False
209
+
210
+
211
+ @admin.register(EncryptedDocument)
212
+ class EncryptedDocumentAdmin(admin.ModelAdmin):
213
+ """
214
+ Panel de administración para EncryptedDocument integrado nativamente en Django Admin.
215
+ """
216
+
217
+ inlines = [DocumentAccessLogInline]
218
+
219
+ list_display = (
220
+ "original_filename",
221
+ "short_file_hash",
222
+ "access_count",
223
+ "last_accessed_at",
224
+ "last_accessed_by",
225
+ "download_link",
226
+ "delete_action_button",
227
+ )
228
+ list_filter = ("created_at", "last_accessed_at")
229
+ search_fields = ("original_filename", "file_hash", "last_accessed_by")
230
+
231
+ def get_form(self, request, obj=None, **kwargs):
232
+ if obj is None:
233
+ return EncryptedDocumentAddForm
234
+ return EncryptedDocumentChangeForm
235
+
236
+ def get_fieldsets(self, request, obj=None):
237
+ if obj is None:
238
+ return (
239
+ (
240
+ _("Cifrado y Registro de Nuevo Documento"),
241
+ {
242
+ "fields": ("original_file", "plain_password", "allowed_emails"),
243
+ "description": _(
244
+ "Suba el archivo original. La contraseña y el código de acceso se generarán automáticamente mediante algoritmos de alta entropía."
245
+ ),
246
+ },
247
+ ),
248
+ )
249
+ return (
250
+ (
251
+ _("Nombre e Identificación Criptográfica"),
252
+ {
253
+ "fields": ("original_filename", "file_hash"),
254
+ "description": _("Nombre original y hash SHA-256 del archivo calculado en la subida."),
255
+ },
256
+ ),
257
+ (
258
+ _("Almacenamiento Cifrado"),
259
+ {
260
+ "fields": ("encrypted_file", "encrypted_password"),
261
+ "description": _(
262
+ "El archivo y la contraseña se encuentran cifrados en reposo con Fernet (AES-128-CBC + HMAC)."
263
+ ),
264
+ },
265
+ ),
266
+ (
267
+ _("Seguridad y Control de Acceso"),
268
+ {
269
+ "fields": ("access_code", "allowed_emails"),
270
+ "description": _(
271
+ "El código de acceso se almacena mediante hash PBKDF2 (Zero-Knowledge). Los correos pueden modificarse usando el botón '+'."
272
+ ),
273
+ },
274
+ ),
275
+ (
276
+ _("Auditoría y Trazabilidad de Accesos"),
277
+ {
278
+ "fields": (
279
+ "access_count",
280
+ "last_accessed_at",
281
+ "last_accessed_by",
282
+ "created_at",
283
+ "updated_at",
284
+ ),
285
+ "description": _(
286
+ "Registro de actividad y fecha del último acceso o despacho de contraseña."
287
+ ),
288
+ },
289
+ ),
290
+ )
291
+
292
+ def get_readonly_fields(self, request, obj=None):
293
+ if obj is None:
294
+ return ()
295
+ return (
296
+ "original_filename",
297
+ "file_hash",
298
+ "encrypted_file",
299
+ "encrypted_password",
300
+ "access_code",
301
+ "access_count",
302
+ "last_accessed_at",
303
+ "last_accessed_by",
304
+ "created_at",
305
+ "updated_at",
306
+ )
307
+
308
+ def save_model(self, request, obj, form, change):
309
+ if not change:
310
+ # Creación a través de la tubería criptográfica completa
311
+ service = DocumentManagementService()
312
+ original_file = form.cleaned_data["original_file"]
313
+ plain_password = form.cleaned_data.get("plain_password") or None
314
+ allowed_emails = form.cleaned_data["allowed_emails"]
315
+
316
+ doc, raw_access_code = service.upload_and_encrypt_document(
317
+ original_file=original_file,
318
+ plain_password=plain_password,
319
+ allowed_emails=allowed_emails,
320
+ )
321
+
322
+ obj.pk = doc.pk
323
+ obj.id = doc.id
324
+ obj.original_filename = doc.original_filename
325
+ obj.file_hash = doc.file_hash
326
+ obj.encrypted_file = doc.encrypted_file
327
+ obj.access_code = doc.access_code
328
+ obj.encrypted_password = doc.encrypted_password
329
+ obj.allowed_emails = doc.allowed_emails
330
+ obj.created_at = doc.created_at
331
+ obj.updated_at = doc.updated_at
332
+
333
+ # Adjuntar código y contraseña para el mensaje flash
334
+ request._raw_access_code = raw_access_code
335
+ request._raw_password = getattr(doc, "generated_password", plain_password)
336
+ else:
337
+ super().save_model(request, obj, form, change)
338
+
339
+ def response_add(self, request, obj, post_url_continue=None):
340
+ raw_code = getattr(request, "_raw_access_code", None)
341
+ raw_pwd = getattr(request, "_raw_password", None)
342
+ if raw_code:
343
+ pwd_html = ""
344
+ if raw_pwd:
345
+ pwd_html = format_html(
346
+ "<strong>🔐 CONTRASEÑA ASIGNADA / GENERADA:</strong><br>"
347
+ "<div style='font-size: 1.15rem; font-family: monospace; background: #0f172a; color: #34d399; padding: 10px; border-radius: 6px; margin: 6px 0; border: 1px solid #10b981; user-select: all;'>"
348
+ "<strong>{}</strong>"
349
+ "</div><br>",
350
+ raw_pwd,
351
+ )
352
+
353
+ download_html = ""
354
+ if obj.encrypted_file:
355
+ enc_name = f"{obj.original_filename}.enc"
356
+ download_html = format_html(
357
+ "<strong>📥 ARCHIVO CIFRADO PARA DISTRIBUCIÓN:</strong><br>"
358
+ "<div style='margin: 8px 0;'>"
359
+ "<a href='{}' download='{}' class='button' style='background: #0284c7; color: white; padding: 6px 14px; text-decoration: none; border-radius: 4px; font-weight: bold; display: inline-block;'>"
360
+ "📥 Descargar Archivo Cifrado ({})"
361
+ "</a>"
362
+ "</div><br>",
363
+ obj.encrypted_file.url,
364
+ enc_name,
365
+ enc_name,
366
+ )
367
+
368
+ import urllib.parse
369
+ creds_text = (
370
+ "================================================================================\n"
371
+ "FREEDEC - CREDENCIALES DE RECUPERACIÓN Y CONTROL DE DOCUMENTO\n"
372
+ "================================================================================\n\n"
373
+ f"DOCUMENTO ORIGINAL: {obj.original_filename}\n"
374
+ f"ARCHIVO CIFRADO: {obj.original_filename}.enc\n"
375
+ f"HASH SHA-256: {obj.file_hash}\n"
376
+ f"FECHA DE REGISTRO: {obj.created_at.strftime('%Y-%m-%d %H:%M:%S UTC') if obj.created_at else ''}\n\n"
377
+ "--------------------------------------------------------------------------------\n"
378
+ "1. CÓDIGO SECRETO DE ACCESO (ZERO-KNOWLEDGE):\n"
379
+ "--------------------------------------------------------------------------------\n"
380
+ f"{raw_code}\n\n"
381
+ "* Entregue este código al destinatario autorizado a través de un canal seguro\n"
382
+ " fuera de banda (Signal, SMS o en persona).\n\n"
383
+ "--------------------------------------------------------------------------------\n"
384
+ "2. CONTRASEÑA O CLAVE DE DESCIFRADO:\n"
385
+ "--------------------------------------------------------------------------------\n"
386
+ f"{raw_pwd or '(No definida)'}\n\n"
387
+ "* Esta clave descifra el archivo .enc y permite recuperar el archivo original.\n"
388
+ " El sistema se la enviará automáticamente al destinatario cuando éste la\n"
389
+ " solicite desde el portal público con su código de acceso.\n\n"
390
+ "--------------------------------------------------------------------------------\n"
391
+ "3. DESTINATARIOS AUTORIZADOS:\n"
392
+ "--------------------------------------------------------------------------------\n"
393
+ + "\n".join(f"- {e}" for e in (obj.allowed_emails or [])) + "\n\n"
394
+ "--------------------------------------------------------------------------------\n"
395
+ "4. PORTALES DE ACCESO:\n"
396
+ "--------------------------------------------------------------------------------\n"
397
+ f"- Solicitar Contraseña: {request.build_absolute_uri(reverse('freedec:gui-public-request'))}\n"
398
+ f"- Descifrar Archivo: {request.build_absolute_uri(reverse('freedec:gui-public-decrypt'))}\n"
399
+ f"- Panel de Control: {request.build_absolute_uri(reverse('admin:index'))}\n"
400
+ "================================================================================\n"
401
+ )
402
+ txt_filename = f"{obj.original_filename}_credenciales.txt"
403
+ encoded_creds = urllib.parse.quote(creds_text)
404
+ txt_data_uri = f"data:text/plain;charset=utf-8,{encoded_creds}"
405
+
406
+ txt_download_html = format_html(
407
+ "<strong>📄 ARCHIVO DE CONTROL Y CREDENCIALES (.TXT):</strong><br>"
408
+ "<div style='margin: 8px 0; display: flex; gap: 10px; align-items: center; flex-wrap: wrap;'>"
409
+ "<a id='auto-dl-creds' href='{}' download='{}' class='button' style='background: #10b981; color: #0f172a; padding: 6px 14px; text-decoration: none; border-radius: 4px; font-weight: bold; display: inline-block;'>"
410
+ "📄 Descargar Credenciales ({})"
411
+ "</a>"
412
+ "<span style='color: #94a3b8; font-size: 0.85rem;'>(Se ha iniciado la descarga automática del archivo .txt en tu navegador)</span>"
413
+ "</div>"
414
+ "<script>"
415
+ "(function() {{"
416
+ " setTimeout(function() {{"
417
+ " var el = document.getElementById('auto-dl-creds');"
418
+ " if (el) {{ el.click(); }}"
419
+ " }}, 400);"
420
+ "}})();"
421
+ "</script><br>",
422
+ mark_safe(txt_data_uri),
423
+ txt_filename,
424
+ txt_filename,
425
+ )
426
+
427
+ messages.success(
428
+ request,
429
+ format_html(
430
+ "<strong>✅ Documento '{}' cifrado y registrado exitosamente (SHA-256: {})</strong><br><br>"
431
+ "{}"
432
+ "{}"
433
+ "<strong>🔑 CÓDIGO SECRETO DE ACCESO (Zero-Knowledge):</strong><br>"
434
+ "<div style='font-size: 1.15rem; font-family: monospace; background: #0f172a; color: #fde68a; padding: 10px; border-radius: 6px; margin: 6px 0; border: 1px solid #f59e0b; user-select: all;'>"
435
+ "<strong>{}</strong>"
436
+ "</div><br>"
437
+ "{}"
438
+ "<em>⚠️ Guarde y entregue el código de acceso al destinatario por canal seguro (Signal, SMS o en persona). La contraseña le será enviada por correo cuando la solicite.</em>",
439
+ obj.original_filename,
440
+ obj.file_hash,
441
+ txt_download_html,
442
+ download_html,
443
+ raw_code,
444
+ pwd_html,
445
+ ),
446
+ )
447
+ return super().response_add(request, obj, post_url_continue=post_url_continue)
448
+
449
+ @admin.display(description=_("Hash SHA-256"))
450
+ def short_file_hash(self, obj):
451
+ return f"{obj.file_hash[:12]}...{obj.file_hash[-6:]}"
452
+
453
+ @admin.display(description=_("Descargar Cifrado"))
454
+ def download_link(self, obj):
455
+ if obj.encrypted_file:
456
+ enc_name = f"{obj.original_filename}.enc"
457
+ return format_html(
458
+ '<a href="{}" download="{}" class="button" style="padding: 4px 10px; font-size: 0.8rem; background: #0284c7; color: white; border-radius: 4px; text-decoration: none; font-weight: bold;">📥 {}</a>',
459
+ obj.encrypted_file.url,
460
+ enc_name,
461
+ enc_name,
462
+ )
463
+ return "—"
464
+
465
+ @admin.display(description=_("Eliminar"))
466
+ def delete_action_button(self, obj):
467
+ url = reverse("admin:freedec_encrypteddocument_delete", args=[obj.pk])
468
+ return format_html(
469
+ '<a class="button" href="{}" style="background-color: #ba2121; color: white; padding: 4px 10px; border-radius: 4px; text-decoration: none; font-size: 0.8rem; font-weight: bold;">🗑️ Eliminar</a>',
470
+ url,
471
+ )
472
+
473
+
474
+ @admin.register(DocumentAccessLog)
475
+ class DocumentAccessLogAdmin(admin.ModelAdmin):
476
+ """
477
+ Panel de auditoría histórica para ver todos los accesos a documentos y peticiones de claves.
478
+ """
479
+
480
+ list_display = ("document", "email", "action", "ip_address", "timestamp")
481
+ list_filter = ("action", "timestamp")
482
+ search_fields = ("email", "document__original_filename", "document__file_hash", "ip_address")
483
+ readonly_fields = ("document", "email", "action", "ip_address", "timestamp")
484
+
485
+ def has_add_permission(self, request):
486
+ return False
freedec/apps.py ADDED
@@ -0,0 +1,47 @@
1
+ import base64
2
+ import logging
3
+ from django.apps import AppConfig
4
+ from django.conf import settings
5
+ from django.core.exceptions import ImproperlyConfigured
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class FreedecConfig(AppConfig):
11
+ default_auto_field = "django.db.models.BigAutoField"
12
+ name = "freedec"
13
+ verbose_name = "Freedec - Cifrado y Recuperación Segura"
14
+
15
+ def ready(self):
16
+ """
17
+ Hook de verificación de seguridad en el arranque de la aplicación.
18
+ Garantiza que la clave maestra Fernet (FREEDEC_FERNET_KEY) esté definida y cumpla
19
+ con los requisitos criptográficos (32 bytes codificados en base64 url-safe).
20
+ """
21
+ fernet_key = getattr(settings, "FREEDEC_FERNET_KEY", None)
22
+
23
+ if not fernet_key:
24
+ # En entornos de testing o desarrollo inicial, permitir fallback con advertencia
25
+ if getattr(settings, "TESTING", False):
26
+ logger.warning(
27
+ "[FREEDEC SECURITY WARNING] No se detectó 'FREEDEC_FERNET_KEY'. "
28
+ "Se generará una clave efímera para pruebas."
29
+ )
30
+ return
31
+ raise ImproperlyConfigured(
32
+ "[FREEDEC SECURITY CRITICAL] Falta la configuración 'FREEDEC_FERNET_KEY' en settings.py. "
33
+ "Debe ser una cadena base64 url-safe de 32 bytes generada con cryptography.fernet.Fernet.generate_key()."
34
+ )
35
+
36
+ # Validación estructural de la clave
37
+ try:
38
+ raw_key = base64.urlsafe_b64decode(fernet_key)
39
+ if len(raw_key) != 32:
40
+ raise ImproperlyConfigured(
41
+ f"[FREEDEC SECURITY CRITICAL] 'FREEDEC_FERNET_KEY' debe decodificarse en exactamente "
42
+ f"32 bytes criptográficos (se obtuvieron {len(raw_key)} bytes)."
43
+ )
44
+ except Exception as exc:
45
+ raise ImproperlyConfigured(
46
+ f"[FREEDEC SECURITY CRITICAL] 'FREEDEC_FERNET_KEY' no es una clave válida Fernet/Base64: {exc}"
47
+ ) from exc
freedec/forms.py ADDED
@@ -0,0 +1,112 @@
1
+ from django import forms
2
+ from django.conf import settings
3
+ from django.core.exceptions import ValidationError
4
+ from django.utils.translation import gettext_lazy as _
5
+
6
+ from freedec.validators import validate_document_file, validate_safe_email
7
+
8
+ DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024
9
+
10
+
11
+ class PublicPasswordRequestForm(forms.Form):
12
+ """
13
+ Formulario web para la interfaz gráfica pública.
14
+ El usuario final sube su copia del archivo, el access_code y su correo electrónico.
15
+ """
16
+
17
+ file = forms.FileField(
18
+ label=_("Documento"),
19
+ help_text=_("Seleccione el archivo que desea consultar."),
20
+ widget=forms.FileInput(attrs={"class": "form-file-input", "id": "public_file"}),
21
+ )
22
+ access_code = forms.CharField(
23
+ label=_("Código de Acceso"),
24
+ widget=forms.TextInput(
25
+ attrs={
26
+ "class": "form-control font-mono",
27
+ "placeholder": _("Introduzca su código de acceso..."),
28
+ "id": "access_code",
29
+ "autocomplete": "off",
30
+ }
31
+ ),
32
+ help_text=_("Código facilitado por el emisor del documento."),
33
+ )
34
+ email = forms.CharField(
35
+ label=_("Correo Electrónico"),
36
+ widget=forms.EmailInput(
37
+ attrs={
38
+ "class": "form-control",
39
+ "placeholder": _("su-correo@ejemplo.com"),
40
+ "id": "email",
41
+ }
42
+ ),
43
+ help_text=_("Dirección de correo donde se enviará la contraseña."),
44
+ )
45
+
46
+ def clean_file(self):
47
+ file_obj = self.cleaned_data.get("file")
48
+ if not file_obj:
49
+ raise ValidationError(_("Debe proporcionar un archivo."))
50
+
51
+ max_size = getattr(settings, "FREEDEC_MAX_FILE_SIZE", DEFAULT_MAX_FILE_SIZE)
52
+ if file_obj.size > max_size:
53
+ max_mb = max_size // (1024 * 1024)
54
+ raise ValidationError(
55
+ _("El archivo excede el tamaño máximo permitido de %(max_size)s MB.")
56
+ % {"max_size": max_mb}
57
+ )
58
+
59
+ name_lower = (file_obj.name or "").lower()
60
+ if name_lower.endswith(".enc"):
61
+ return file_obj
62
+
63
+ return validate_document_file(file_obj)
64
+
65
+ def clean_email(self):
66
+ raw_email = self.cleaned_data.get("email", "")
67
+ return validate_safe_email(raw_email)
68
+
69
+
70
+ class PublicDecryptDocumentForm(forms.Form):
71
+ """
72
+ Formulario público para descifrar un archivo .enc proporcionando la contraseña
73
+ recibida por correo electrónico.
74
+ """
75
+
76
+ file = forms.FileField(
77
+ label=_("Archivo Cifrado (.enc)"),
78
+ help_text=_("Seleccione el archivo con extensión .enc que desea descifrar."),
79
+ widget=forms.FileInput(attrs={"class": "form-file-input", "id": "enc_file"}),
80
+ )
81
+ password = forms.CharField(
82
+ label=_("Contraseña de Descifrado"),
83
+ widget=forms.PasswordInput(
84
+ attrs={
85
+ "class": "form-control font-mono",
86
+ "placeholder": _("Pegue la contraseña recibida por correo..."),
87
+ "id": "decrypt_password",
88
+ "autocomplete": "off",
89
+ }
90
+ ),
91
+ help_text=_("Contraseña que le fue remitida a su correo electrónico tras la verificación."),
92
+ )
93
+
94
+ def clean_file(self):
95
+ file_obj = self.cleaned_data.get("file")
96
+ if not file_obj:
97
+ raise ValidationError(_("Debe proporcionar un archivo."))
98
+
99
+ max_size = getattr(settings, "FREEDEC_MAX_FILE_SIZE", DEFAULT_MAX_FILE_SIZE)
100
+ if file_obj.size > max_size:
101
+ max_mb = max_size // (1024 * 1024)
102
+ raise ValidationError(
103
+ _("El archivo excede el tamaño máximo permitido de %(max_size)s MB.")
104
+ % {"max_size": max_mb}
105
+ )
106
+ return file_obj
107
+
108
+ def clean_password(self):
109
+ pwd = self.cleaned_data.get("password", "")
110
+ if not pwd or not pwd.strip():
111
+ raise ValidationError(_("Debe introducir la contraseña de descifrado."))
112
+ return pwd.strip()