@growy/strapi-plugin-encrypted-field 1.0.0
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.
- package/README.md +248 -0
- package/admin/src/components/Input.jsx +92 -0
- package/admin/src/index.js +103 -0
- package/admin/src/translations/en.json +13 -0
- package/admin/src/translations/es.json +13 -0
- package/package.json +47 -0
- package/server/bootstrap.js +81 -0
- package/server/index.js +7 -0
- package/server/register.js +11 -0
- package/server/utils/crypto.js +144 -0
package/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Strapi Plugin - Encrypted Field
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
<img src="https://img.shields.io/npm/v/@growy/strapi-plugin-encrypted-field" alt="npm version" />
|
|
5
|
+
<img src="https://img.shields.io/npm/l/@growy/strapi-plugin-encrypted-field" alt="license" />
|
|
6
|
+
<img src="https://img.shields.io/badge/Strapi-v5-blueviolet" alt="Strapi v5" />
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
Plugin oficial de **Growy AI** para Strapi que proporciona un campo personalizado de texto cifrado con AES-256-GCM. Protege información sensible directamente en tu base de datos con cifrado transparente y validación robusta.
|
|
10
|
+
|
|
11
|
+
## Características
|
|
12
|
+
|
|
13
|
+
- ✅ **Campo personalizado** "Texto Cifrado" en el Content-Type Builder
|
|
14
|
+
- ✅ **Cifrado automático** AES-256-GCM al guardar
|
|
15
|
+
- ✅ **Descifrado transparente** al leer (panel y API)
|
|
16
|
+
- ✅ **Validación backend** con soporte para regex y restricciones
|
|
17
|
+
- ✅ **UI mejorada** con controles de visibilidad, copiar y contador de caracteres
|
|
18
|
+
- ✅ **Gestión de claves robusta** con validación y mensajes de error claros
|
|
19
|
+
- ✅ **Datos cifrados** en base de datos con IV único y Auth Tag
|
|
20
|
+
- ✅ **Reutilizable** en cualquier colección o componente
|
|
21
|
+
|
|
22
|
+
## Instalación
|
|
23
|
+
|
|
24
|
+
### Desde npm
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install @growy/strapi-plugin-encrypted-field
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Desde yarn
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
yarn add @growy/strapi-plugin-encrypted-field
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Configuración
|
|
37
|
+
|
|
38
|
+
### 1. Habilitar el plugin
|
|
39
|
+
|
|
40
|
+
Crea o edita `config/plugins.js` o `config/plugins.ts`:
|
|
41
|
+
|
|
42
|
+
```javascript
|
|
43
|
+
module.exports = {
|
|
44
|
+
'encrypted-field': {
|
|
45
|
+
enabled: true,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. Configurar la clave de cifrado (REQUERIDO)
|
|
51
|
+
|
|
52
|
+
#### Opción A: Variable de entorno (recomendado)
|
|
53
|
+
|
|
54
|
+
Agrega a tu `.env`:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
ENCRYPTION_KEY=tu_clave_de_64_caracteres_hexadecimales_aqui
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
#### Opción B: Archivo de configuración
|
|
61
|
+
|
|
62
|
+
Edita `config/plugins.js`:
|
|
63
|
+
|
|
64
|
+
```javascript
|
|
65
|
+
module.exports = ({ env }) => ({
|
|
66
|
+
'encrypted-field': {
|
|
67
|
+
enabled: true,
|
|
68
|
+
config: {
|
|
69
|
+
encryptionKey: env('ENCRYPTION_KEY'),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
#### Generar clave segura
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Esto generará una clave de 64 caracteres hexadecimales (32 bytes).
|
|
82
|
+
|
|
83
|
+
⚠️ **CRÍTICO - Gestión de claves**:
|
|
84
|
+
- **Guarda la clave de forma segura** (gestor de secretos, variables de entorno cifradas)
|
|
85
|
+
- **Nunca** la incluyas en el control de versiones
|
|
86
|
+
- **Si pierdes la clave**, no podrás descifrar los datos existentes
|
|
87
|
+
- **Usa la misma clave** en todos los entornos que compartan la misma base de datos
|
|
88
|
+
- **Para producción**, considera usar servicios como AWS Secrets Manager, HashiCorp Vault o similar
|
|
89
|
+
|
|
90
|
+
### 3. Rebuild del admin
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm run build
|
|
94
|
+
npm run develop
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Requisitos
|
|
98
|
+
|
|
99
|
+
- **Strapi**: v5.0.0 o superior
|
|
100
|
+
- **Node.js**: 18.x - 22.x
|
|
101
|
+
- **npm**: 6.0.0 o superior
|
|
102
|
+
|
|
103
|
+
## Validación de datos
|
|
104
|
+
|
|
105
|
+
El plugin soporta validación antes del cifrado:
|
|
106
|
+
|
|
107
|
+
### Configurar validación regex
|
|
108
|
+
|
|
109
|
+
1. En el Content-Type Builder, selecciona el campo cifrado
|
|
110
|
+
2. Ve a la pestaña **"Advanced Settings"**
|
|
111
|
+
3. En **"RegEx pattern"**, ingresa tu expresión regular
|
|
112
|
+
4. Guarda los cambios
|
|
113
|
+
|
|
114
|
+
**Ejemplo**: Para validar formato de API key:
|
|
115
|
+
```regex
|
|
116
|
+
^sk-[a-zA-Z0-9]{32}$
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Si el valor no cumple con el patrón, se lanzará un error antes de cifrar.
|
|
120
|
+
|
|
121
|
+
## Uso
|
|
122
|
+
|
|
123
|
+
### 1. Agregar campo cifrado a una colección
|
|
124
|
+
|
|
125
|
+
1. Ve a **Content-Type Builder**
|
|
126
|
+
2. Selecciona una colección o crea una nueva
|
|
127
|
+
3. Click en **"Add another field"**
|
|
128
|
+
4. Busca **"Texto Cifrado"** (con icono 🔒)
|
|
129
|
+
5. Configura el nombre del campo
|
|
130
|
+
6. Guarda y reinicia Strapi
|
|
131
|
+
|
|
132
|
+
### 2. Usar el campo
|
|
133
|
+
|
|
134
|
+
El campo funciona como un campo de texto normal:
|
|
135
|
+
|
|
136
|
+
- **En el panel**: Escribe texto normalmente
|
|
137
|
+
- **Al guardar**: Se cifra automáticamente
|
|
138
|
+
- **Al leer**: Se descifra automáticamente
|
|
139
|
+
- **En la BD**: Se guarda cifrado con formato `iv:authTag:encrypted`
|
|
140
|
+
|
|
141
|
+
### 3. Uso por API
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
# Crear con campo cifrado
|
|
145
|
+
curl -X POST http://localhost:1337/api/usuarios \
|
|
146
|
+
-H "Content-Type: application/json" \
|
|
147
|
+
-d '{
|
|
148
|
+
"data": {
|
|
149
|
+
"nombre": "Juan",
|
|
150
|
+
"apiKey": "mi-clave-secreta-123"
|
|
151
|
+
}
|
|
152
|
+
}'
|
|
153
|
+
|
|
154
|
+
# Leer (devuelve descifrado)
|
|
155
|
+
curl -X GET http://localhost:1337/api/usuarios/1
|
|
156
|
+
# Response: { "nombre": "Juan", "apiKey": "mi-clave-secreta-123" }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Ejemplo de uso
|
|
160
|
+
|
|
161
|
+
### Colección "Usuario" con API Key cifrada
|
|
162
|
+
|
|
163
|
+
**Schema:**
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"nombre": "string",
|
|
167
|
+
"email": "email",
|
|
168
|
+
"apiKey": "plugin::encrypted-field.encrypted-text"
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**En la BD:**
|
|
173
|
+
```
|
|
174
|
+
apiKey: "a1b2c3d4e5f6....:f9e8d7c6b5a4....:9f8e7d6c5b4a3..."
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**En el panel y API:**
|
|
178
|
+
```
|
|
179
|
+
apiKey: "sk-1234567890abcdef"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Seguridad y arquitectura
|
|
183
|
+
|
|
184
|
+
### Especificaciones técnicas
|
|
185
|
+
|
|
186
|
+
- **Algoritmo**: AES-256-GCM (estándar NIST, grado militar)
|
|
187
|
+
- **Tamaño de clave**: 256 bits (32 bytes, 64 caracteres hexadecimales)
|
|
188
|
+
- **IV (Initialization Vector)**: 96 bits (12 bytes) generado aleatoriamente por operación
|
|
189
|
+
- **Auth Tag**: 128 bits (16 bytes) para verificación de integridad
|
|
190
|
+
- **Formato almacenado**: `iv:authTag:encryptedData` (todos en hexadecimal)
|
|
191
|
+
|
|
192
|
+
### Características de seguridad
|
|
193
|
+
|
|
194
|
+
- ✅ **Cifrado autenticado**: GCM proporciona confidencialidad e integridad
|
|
195
|
+
- ✅ **IV único**: Cada operación de cifrado genera un IV aleatorio
|
|
196
|
+
- ✅ **Resistencia a manipulación**: Auth Tag detecta cualquier modificación
|
|
197
|
+
- ✅ **Validación de entrada**: Soporte para regex y restricciones personalizadas
|
|
198
|
+
- ✅ **Manejo de errores seguro**: Logs controlados sin exponer datos sensibles
|
|
199
|
+
|
|
200
|
+
### Mejores prácticas
|
|
201
|
+
|
|
202
|
+
1. **Rotación de claves**: Planifica un proceso de rotación periódica
|
|
203
|
+
2. **Separación de entornos**: Usa claves diferentes para dev/staging/prod
|
|
204
|
+
3. **Auditoría**: Monitorea logs de errores de cifrado/descifrado
|
|
205
|
+
4. **Backup de claves**: Mantén copias seguras de las claves en múltiples ubicaciones
|
|
206
|
+
5. **Campos privados**: Marca campos sensibles como "privados" para excluirlos de la API pública
|
|
207
|
+
|
|
208
|
+
## Casos de uso
|
|
209
|
+
|
|
210
|
+
- 🔑 API Keys de terceros
|
|
211
|
+
- 🔐 Tokens de acceso
|
|
212
|
+
- 🔒 Secretos de webhooks
|
|
213
|
+
- 💳 Información sensible
|
|
214
|
+
- 📧 Credenciales SMTP
|
|
215
|
+
- 🔑 Contraseñas de aplicaciones
|
|
216
|
+
|
|
217
|
+
## Limitaciones conocidas
|
|
218
|
+
|
|
219
|
+
- ❌ **Búsqueda**: No se puede buscar por campos cifrados (datos cifrados en BD)
|
|
220
|
+
- ❌ **Ordenamiento**: No se puede ordenar por campos cifrados
|
|
221
|
+
- ❌ **Filtros**: No se pueden aplicar filtros directos sobre campos cifrados
|
|
222
|
+
- ⚠️ **Rendimiento**: El cifrado/descifrado añade overhead mínimo (~1-2ms por operación)
|
|
223
|
+
- ⚠️ **Sincronización de claves**: Todos los entornos que compartan BD deben usar la misma clave
|
|
224
|
+
|
|
225
|
+
## Soporte
|
|
226
|
+
|
|
227
|
+
Para reportar bugs, solicitar funcionalidades o contribuir:
|
|
228
|
+
|
|
229
|
+
- **Issues**: [GitHub Issues](https://github.com/growy-ai/strapi-plugin-encrypted-field/issues)
|
|
230
|
+
- **Documentación**: [README.md](https://github.com/growy-ai/strapi-plugin-encrypted-field)
|
|
231
|
+
- **Email**: support@growy.ai
|
|
232
|
+
|
|
233
|
+
## Licencia
|
|
234
|
+
|
|
235
|
+
MIT © 2025 Growy AI
|
|
236
|
+
|
|
237
|
+
## Desarrollado por
|
|
238
|
+
|
|
239
|
+
**Growy AI** - Soluciones de IA y automatización empresarial
|
|
240
|
+
|
|
241
|
+
**Autor principal**: Zahir El isaac
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
<div align="center">
|
|
246
|
+
<p>Si este plugin te resulta útil, considera darle una ⭐ en GitHub</p>
|
|
247
|
+
<p>Hecho con ❤️ por el equipo de Growy AI</p>
|
|
248
|
+
</div>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useIntl } from 'react-intl';
|
|
3
|
+
import { Field, Flex, IconButton, Typography } from '@strapi/design-system';
|
|
4
|
+
import { Eye, EyeStriked, Duplicate, Check } from '@strapi/icons';
|
|
5
|
+
|
|
6
|
+
const Input = ({
|
|
7
|
+
attribute,
|
|
8
|
+
description,
|
|
9
|
+
disabled,
|
|
10
|
+
error,
|
|
11
|
+
intlLabel,
|
|
12
|
+
labelAction,
|
|
13
|
+
name,
|
|
14
|
+
onChange,
|
|
15
|
+
required,
|
|
16
|
+
value,
|
|
17
|
+
}) => {
|
|
18
|
+
const { formatMessage } = useIntl();
|
|
19
|
+
const [showValue, setShowValue] = useState(false);
|
|
20
|
+
const [copied, setCopied] = useState(false);
|
|
21
|
+
|
|
22
|
+
const handleCopy = async () => {
|
|
23
|
+
if (value) {
|
|
24
|
+
try {
|
|
25
|
+
await navigator.clipboard.writeText(value);
|
|
26
|
+
setCopied(true);
|
|
27
|
+
setTimeout(() => setCopied(false), 2000);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.error('Error al copiar:', err);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const charCount = value ? value.length : 0;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Field.Root
|
|
38
|
+
name={name}
|
|
39
|
+
id={name}
|
|
40
|
+
error={error}
|
|
41
|
+
hint={description && formatMessage(description)}
|
|
42
|
+
required={required}
|
|
43
|
+
>
|
|
44
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
45
|
+
<Field.Label action={labelAction}>
|
|
46
|
+
{formatMessage(intlLabel)} 🔒
|
|
47
|
+
</Field.Label>
|
|
48
|
+
<Flex gap={2}>
|
|
49
|
+
<IconButton
|
|
50
|
+
label={showValue ? 'Ocultar valor' : 'Mostrar valor'}
|
|
51
|
+
icon={showValue ? <EyeStriked /> : <Eye />}
|
|
52
|
+
onClick={() => setShowValue(!showValue)}
|
|
53
|
+
variant="ghost"
|
|
54
|
+
disabled={!value}
|
|
55
|
+
/>
|
|
56
|
+
<IconButton
|
|
57
|
+
label={copied ? 'Copiado' : 'Copiar valor'}
|
|
58
|
+
icon={copied ? <Check /> : <Duplicate />}
|
|
59
|
+
onClick={handleCopy}
|
|
60
|
+
variant="ghost"
|
|
61
|
+
disabled={!value}
|
|
62
|
+
/>
|
|
63
|
+
</Flex>
|
|
64
|
+
</Flex>
|
|
65
|
+
<Field.Input
|
|
66
|
+
type={showValue ? 'text' : 'password'}
|
|
67
|
+
placeholder={formatMessage({
|
|
68
|
+
id: 'encrypted-field.placeholder',
|
|
69
|
+
defaultMessage: 'Ingresa el texto a cifrar...',
|
|
70
|
+
})}
|
|
71
|
+
value={value || ''}
|
|
72
|
+
onChange={(e) => {
|
|
73
|
+
onChange({
|
|
74
|
+
target: { name, value: e.target.value, type: attribute.type },
|
|
75
|
+
});
|
|
76
|
+
}}
|
|
77
|
+
disabled={disabled}
|
|
78
|
+
/>
|
|
79
|
+
<Flex justifyContent="space-between" alignItems="center" paddingTop={1}>
|
|
80
|
+
<Field.Hint />
|
|
81
|
+
{charCount > 0 && (
|
|
82
|
+
<Typography variant="pi" textColor="neutral600">
|
|
83
|
+
{charCount} {charCount === 1 ? 'carácter' : 'caracteres'}
|
|
84
|
+
</Typography>
|
|
85
|
+
)}
|
|
86
|
+
</Flex>
|
|
87
|
+
<Field.Error />
|
|
88
|
+
</Field.Root>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export default Input;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import Lock from '@strapi/icons/Lock';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
register(app) {
|
|
5
|
+
app.customFields.register({
|
|
6
|
+
name: 'encrypted-text',
|
|
7
|
+
pluginId: 'encrypted-field',
|
|
8
|
+
type: 'text',
|
|
9
|
+
intlLabel: {
|
|
10
|
+
id: 'encrypted-field.label',
|
|
11
|
+
defaultMessage: 'Texto Cifrado',
|
|
12
|
+
},
|
|
13
|
+
intlDescription: {
|
|
14
|
+
id: 'encrypted-field.description',
|
|
15
|
+
defaultMessage: 'Campo de texto que se cifra automáticamente con AES-256-GCM',
|
|
16
|
+
},
|
|
17
|
+
icon: Lock,
|
|
18
|
+
components: {
|
|
19
|
+
Input: async () => import('./components/Input.jsx'),
|
|
20
|
+
},
|
|
21
|
+
options: {
|
|
22
|
+
base: [
|
|
23
|
+
{
|
|
24
|
+
sectionTitle: {
|
|
25
|
+
id: 'encrypted-field.options.advanced.settings',
|
|
26
|
+
defaultMessage: 'Configuración',
|
|
27
|
+
},
|
|
28
|
+
items: [
|
|
29
|
+
{
|
|
30
|
+
name: 'required',
|
|
31
|
+
type: 'checkbox',
|
|
32
|
+
intlLabel: {
|
|
33
|
+
id: 'encrypted-field.options.required.label',
|
|
34
|
+
defaultMessage: 'Campo requerido',
|
|
35
|
+
},
|
|
36
|
+
description: {
|
|
37
|
+
id: 'encrypted-field.options.required.description',
|
|
38
|
+
defaultMessage: 'No se podrá guardar sin este campo',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'private',
|
|
43
|
+
type: 'checkbox',
|
|
44
|
+
intlLabel: {
|
|
45
|
+
id: 'encrypted-field.options.private.label',
|
|
46
|
+
defaultMessage: 'Campo privado',
|
|
47
|
+
},
|
|
48
|
+
description: {
|
|
49
|
+
id: 'encrypted-field.options.private.description',
|
|
50
|
+
defaultMessage: 'Este campo no será devuelto por la API',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
advanced: [
|
|
57
|
+
{
|
|
58
|
+
sectionTitle: {
|
|
59
|
+
id: 'encrypted-field.options.advanced.regex',
|
|
60
|
+
defaultMessage: 'Validación',
|
|
61
|
+
},
|
|
62
|
+
items: [
|
|
63
|
+
{
|
|
64
|
+
name: 'regex',
|
|
65
|
+
type: 'text',
|
|
66
|
+
intlLabel: {
|
|
67
|
+
id: 'encrypted-field.options.regex.label',
|
|
68
|
+
defaultMessage: 'RegEx pattern',
|
|
69
|
+
},
|
|
70
|
+
description: {
|
|
71
|
+
id: 'encrypted-field.options.regex.description',
|
|
72
|
+
defaultMessage: 'Patrón de validación antes de cifrar',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async registerTrads({ locales }) {
|
|
83
|
+
const importedTrads = await Promise.all(
|
|
84
|
+
locales.map((locale) => {
|
|
85
|
+
return import(`./translations/${locale}.json`)
|
|
86
|
+
.then(({ default: data }) => {
|
|
87
|
+
return {
|
|
88
|
+
data,
|
|
89
|
+
locale,
|
|
90
|
+
};
|
|
91
|
+
})
|
|
92
|
+
.catch(() => {
|
|
93
|
+
return {
|
|
94
|
+
data: {},
|
|
95
|
+
locale,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return Promise.resolve(importedTrads);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"encrypted-field.label": "Encrypted Text",
|
|
3
|
+
"encrypted-field.description": "Text field that is automatically encrypted with AES-256-GCM",
|
|
4
|
+
"encrypted-field.placeholder": "Enter text to encrypt...",
|
|
5
|
+
"encrypted-field.options.advanced.settings": "Settings",
|
|
6
|
+
"encrypted-field.options.required.label": "Required field",
|
|
7
|
+
"encrypted-field.options.required.description": "You won't be able to save without this field",
|
|
8
|
+
"encrypted-field.options.private.label": "Private field",
|
|
9
|
+
"encrypted-field.options.private.description": "This field will not be returned by the API",
|
|
10
|
+
"encrypted-field.options.advanced.regex": "Validation",
|
|
11
|
+
"encrypted-field.options.regex.label": "RegEx pattern",
|
|
12
|
+
"encrypted-field.options.regex.description": "Validation pattern before encryption"
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"encrypted-field.label": "Texto Cifrado",
|
|
3
|
+
"encrypted-field.description": "Campo de texto que se cifra automáticamente con AES-256-GCM",
|
|
4
|
+
"encrypted-field.placeholder": "Ingresa el texto a cifrar...",
|
|
5
|
+
"encrypted-field.options.advanced.settings": "Configuración",
|
|
6
|
+
"encrypted-field.options.required.label": "Campo requerido",
|
|
7
|
+
"encrypted-field.options.required.description": "No se podrá guardar sin este campo",
|
|
8
|
+
"encrypted-field.options.private.label": "Campo privado",
|
|
9
|
+
"encrypted-field.options.private.description": "Este campo no será devuelto por la API",
|
|
10
|
+
"encrypted-field.options.advanced.regex": "Validación",
|
|
11
|
+
"encrypted-field.options.regex.label": "RegEx pattern",
|
|
12
|
+
"encrypted-field.options.regex.description": "Patrón de validación antes de cifrar"
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@growy/strapi-plugin-encrypted-field",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Campo personalizado de texto cifrado para Strapi",
|
|
5
|
+
"strapi": {
|
|
6
|
+
"name": "encrypted-field",
|
|
7
|
+
"displayName": "Encrypted Field",
|
|
8
|
+
"description": "Agrega un campo de texto cifrado con AES-256-GCM",
|
|
9
|
+
"kind": "plugin"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@strapi/strapi": "^5.0.0"
|
|
14
|
+
},
|
|
15
|
+
"main": "server/index.js",
|
|
16
|
+
"author": {
|
|
17
|
+
"name": "Growy AI",
|
|
18
|
+
"email": "support@growy.ai",
|
|
19
|
+
"url": "https://getgrowy.ai"
|
|
20
|
+
},
|
|
21
|
+
"contributors": [
|
|
22
|
+
{
|
|
23
|
+
"name": "Zahir El isaac"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0 <=22.x.x",
|
|
28
|
+
"npm": ">=6.0.0"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"keywords": [
|
|
32
|
+
"strapi",
|
|
33
|
+
"strapi-plugin",
|
|
34
|
+
"encryption",
|
|
35
|
+
"aes-256-gcm",
|
|
36
|
+
"security",
|
|
37
|
+
"custom-field",
|
|
38
|
+
"encrypted-field",
|
|
39
|
+
"growy-ai",
|
|
40
|
+
"data-protection",
|
|
41
|
+
"cryptography",
|
|
42
|
+
"strapi-v5"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const { encrypt, decrypt, validateValue, isEncryptedField } = require('./utils/crypto');
|
|
2
|
+
|
|
3
|
+
module.exports = ({ strapi }) => {
|
|
4
|
+
strapi.db.lifecycles.subscribe({
|
|
5
|
+
async beforeCreate(event) {
|
|
6
|
+
const { data } = event.params;
|
|
7
|
+
const model = strapi.getModel(event.model.uid);
|
|
8
|
+
|
|
9
|
+
for (const [key, attribute] of Object.entries(model.attributes)) {
|
|
10
|
+
if (!isEncryptedField(attribute)) continue;
|
|
11
|
+
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
12
|
+
const value = data[key];
|
|
13
|
+
if (value === null || value === undefined) continue;
|
|
14
|
+
|
|
15
|
+
const validation = validateValue(value, attribute);
|
|
16
|
+
if (!validation.valid) {
|
|
17
|
+
throw new Error(`Validación fallida para el campo "${key}": ${validation.error}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
data[key] = encrypt(value, strapi);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async beforeUpdate(event) {
|
|
26
|
+
const { data } = event.params;
|
|
27
|
+
const model = strapi.getModel(event.model.uid);
|
|
28
|
+
|
|
29
|
+
for (const [key, attribute] of Object.entries(model.attributes)) {
|
|
30
|
+
if (!isEncryptedField(attribute)) continue;
|
|
31
|
+
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
32
|
+
const value = data[key];
|
|
33
|
+
if (value === null || value === undefined) continue;
|
|
34
|
+
|
|
35
|
+
const validation = validateValue(value, attribute);
|
|
36
|
+
if (!validation.valid) {
|
|
37
|
+
throw new Error(`Validación fallida para el campo "${key}": ${validation.error}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
data[key] = encrypt(value, strapi);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async afterFindOne(event) {
|
|
46
|
+
const { result } = event;
|
|
47
|
+
if (!result) return;
|
|
48
|
+
|
|
49
|
+
const model = strapi.getModel(event.model.uid);
|
|
50
|
+
|
|
51
|
+
for (const [key, attribute] of Object.entries(model.attributes)) {
|
|
52
|
+
if (!isEncryptedField(attribute)) continue;
|
|
53
|
+
if (Object.prototype.hasOwnProperty.call(result, key)) {
|
|
54
|
+
const value = result[key];
|
|
55
|
+
if (typeof value === 'string') {
|
|
56
|
+
result[key] = decrypt(value, strapi);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async afterFindMany(event) {
|
|
63
|
+
const { result } = event;
|
|
64
|
+
if (!result || !Array.isArray(result)) return;
|
|
65
|
+
|
|
66
|
+
const model = strapi.getModel(event.model.uid);
|
|
67
|
+
|
|
68
|
+
for (const item of result) {
|
|
69
|
+
for (const [key, attribute] of Object.entries(model.attributes)) {
|
|
70
|
+
if (!isEncryptedField(attribute)) continue;
|
|
71
|
+
if (Object.prototype.hasOwnProperty.call(item, key)) {
|
|
72
|
+
const value = item[key];
|
|
73
|
+
if (typeof value === 'string') {
|
|
74
|
+
item[key] = decrypt(value, strapi);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
};
|
package/server/index.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
4
|
+
const IV_LENGTH = 12;
|
|
5
|
+
const AUTH_TAG_LENGTH = 16;
|
|
6
|
+
const KEY_LENGTH = 32;
|
|
7
|
+
|
|
8
|
+
function getEncryptionKey(strapi) {
|
|
9
|
+
const key = process.env.ENCRYPTION_KEY || strapi?.config?.get('plugin.encrypted-field.encryptionKey');
|
|
10
|
+
|
|
11
|
+
if (!key) {
|
|
12
|
+
const errorMsg = '⚠️ ENCRYPTION_KEY no configurada. Debe establecer una clave de 64 caracteres hexadecimales en las variables de entorno o configuración de Strapi.';
|
|
13
|
+
if (strapi?.log?.error) {
|
|
14
|
+
strapi.log.error(errorMsg);
|
|
15
|
+
}
|
|
16
|
+
throw new Error(errorMsg);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof key !== 'string' || key.length !== 64) {
|
|
20
|
+
throw new Error(`ENCRYPTION_KEY debe tener exactamente 64 caracteres hexadecimales (32 bytes). Actual: ${key?.length || 0}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!/^[0-9a-fA-F]{64}$/.test(key)) {
|
|
24
|
+
throw new Error('ENCRYPTION_KEY debe contener solo caracteres hexadecimales (0-9, a-f, A-F)');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return Buffer.from(key, 'hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function encrypt(text, strapi) {
|
|
31
|
+
if (typeof text !== 'string') return text;
|
|
32
|
+
|
|
33
|
+
if (text === '') return text;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const ENCRYPTION_KEY = getEncryptionKey(strapi);
|
|
37
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
38
|
+
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
39
|
+
|
|
40
|
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
41
|
+
encrypted += cipher.final('hex');
|
|
42
|
+
|
|
43
|
+
const authTag = cipher.getAuthTag();
|
|
44
|
+
|
|
45
|
+
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (strapi?.log?.error) {
|
|
48
|
+
strapi.log.error(`Error al cifrar: ${error.message}`);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function decrypt(encryptedText, strapi) {
|
|
55
|
+
if (!encryptedText || typeof encryptedText !== 'string') return encryptedText;
|
|
56
|
+
|
|
57
|
+
const parts = encryptedText.split(':');
|
|
58
|
+
if (parts.length !== 3) {
|
|
59
|
+
return encryptedText;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const [ivHex, authTagHex, encrypted] = parts;
|
|
64
|
+
|
|
65
|
+
if (ivHex.length !== IV_LENGTH * 2 || authTagHex.length !== AUTH_TAG_LENGTH * 2) {
|
|
66
|
+
return encryptedText;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ENCRYPTION_KEY = getEncryptionKey(strapi);
|
|
70
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
71
|
+
const authTag = Buffer.from(authTagHex, 'hex');
|
|
72
|
+
|
|
73
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
74
|
+
decipher.setAuthTag(authTag);
|
|
75
|
+
|
|
76
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
77
|
+
decrypted += decipher.final('utf8');
|
|
78
|
+
|
|
79
|
+
return decrypted;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (strapi?.log?.debug) {
|
|
82
|
+
strapi.log.debug(`Error al descifrar: ${error.message}. Retornando texto original.`);
|
|
83
|
+
}
|
|
84
|
+
return encryptedText;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateValue(value, attribute) {
|
|
89
|
+
if (value === null || value === undefined || value === '') {
|
|
90
|
+
return { valid: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (typeof value !== 'string') {
|
|
94
|
+
return {
|
|
95
|
+
valid: false,
|
|
96
|
+
error: 'El valor debe ser una cadena de texto'
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (attribute.regex) {
|
|
101
|
+
try {
|
|
102
|
+
const regex = new RegExp(attribute.regex);
|
|
103
|
+
if (!regex.test(value)) {
|
|
104
|
+
return {
|
|
105
|
+
valid: false,
|
|
106
|
+
error: `El valor no cumple con el patrón de validación: ${attribute.regex}`
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return {
|
|
111
|
+
valid: false,
|
|
112
|
+
error: `Patrón regex inválido: ${error.message}`
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (attribute.maxLength && value.length > attribute.maxLength) {
|
|
118
|
+
return {
|
|
119
|
+
valid: false,
|
|
120
|
+
error: `El valor excede la longitud máxima de ${attribute.maxLength} caracteres`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (attribute.minLength && value.length < attribute.minLength) {
|
|
125
|
+
return {
|
|
126
|
+
valid: false,
|
|
127
|
+
error: `El valor debe tener al menos ${attribute.minLength} caracteres`
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { valid: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isEncryptedField(attribute) {
|
|
135
|
+
return attribute?.customField === 'plugin::encrypted-field.encrypted-text';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
encrypt,
|
|
140
|
+
decrypt,
|
|
141
|
+
validateValue,
|
|
142
|
+
isEncryptedField,
|
|
143
|
+
getEncryptionKey,
|
|
144
|
+
};
|