@growy/strapi-plugin-encrypted-field 2.2.1 → 2.3.1
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 +84 -171
- package/admin/src/components/Input.jsx +37 -21
- package/admin/src/index.js +8 -17
- package/admin/src/translations/en.json +7 -2
- package/admin/src/translations/es.json +7 -2
- package/package.json +5 -3
- package/server/bootstrap.js +55 -93
- package/server/index.js +4 -0
- package/server/middlewares/decrypt.js +22 -14
- package/server/register.js +6 -2
- package/server/utils/crypto.js +40 -31
package/README.md
CHANGED
|
@@ -6,40 +6,41 @@
|
|
|
6
6
|
<img src="https://img.shields.io/badge/Strapi-v5-blueviolet" alt="Strapi v5" />
|
|
7
7
|
</div>
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
---
|
|
10
10
|
|
|
11
|
+
### [English](#english-version) | [Español](#versión-en-español)
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
- ✅ **Cifrado automático** AES-256-GCM al guardar
|
|
14
|
-
- ✅ **Descifrado transparente** al leer (panel y API)
|
|
15
|
-
- ✅ **Validación backend** con soporte para regex y restricciones
|
|
16
|
-
- ✅ **UI mejorada** con controles de visibilidad y copiar al portapapeles
|
|
17
|
-
- ✅ **Valores ocultos** por defecto con opción de mostrar/ocultar
|
|
18
|
-
- ✅ **Notificaciones** de confirmación al copiar valores
|
|
19
|
-
- ✅ **Gestión de claves robusta** con validación y mensajes de error claros
|
|
20
|
-
- ✅ **Datos cifrados** en base de datos con IV único y Auth Tag
|
|
21
|
-
- ✅ **Reutilizable** en cualquier colección o componente
|
|
22
|
-
- ✅ **Soporte completo** para componentes anidados y estructuras complejas
|
|
13
|
+
---
|
|
23
14
|
|
|
24
|
-
##
|
|
15
|
+
## English Version
|
|
25
16
|
|
|
26
|
-
|
|
17
|
+
Official **Growy AI** plugin for Strapi that provide a custom encrypted text field using AES-256-GCM. Protect sensitive information directly in your database with transparent encryption and robust validation.
|
|
27
18
|
|
|
28
|
-
|
|
29
|
-
npm install @growy/strapi-plugin-encrypted-field
|
|
30
|
-
```
|
|
19
|
+
### Features
|
|
31
20
|
|
|
32
|
-
|
|
21
|
+
- ✅ **Custom Field** "Encrypted Text" in the Content-Type Builder.
|
|
22
|
+
- ✅ **Automatic Encryption** AES-256-GCM when saving.
|
|
23
|
+
- ✅ **Transparent Decryption** when reading (Admin panel and API).
|
|
24
|
+
- ✅ **Backend Validation** with regex support and length constraints.
|
|
25
|
+
- ✅ **Native Strapi v5 UI** with visibility controls and copy to clipboard.
|
|
26
|
+
- ✅ **Multi-language support (i18n)**: English and Spanish.
|
|
27
|
+
- ✅ **Secure Key Management** with validation and clear error messages.
|
|
28
|
+
- ✅ **Encrypted Data** in database with unique IV and Auth Tag.
|
|
29
|
+
- ✅ **Nested Components support** at any depth.
|
|
30
|
+
|
|
31
|
+
### Installation
|
|
33
32
|
|
|
34
33
|
```bash
|
|
34
|
+
npm install @growy/strapi-plugin-encrypted-field
|
|
35
|
+
# or
|
|
35
36
|
yarn add @growy/strapi-plugin-encrypted-field
|
|
36
37
|
```
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
### Configuration
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
#### 1. Enable the plugin
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
Create or edit `config/plugins.js` or `config/plugins.ts`:
|
|
43
44
|
|
|
44
45
|
```javascript
|
|
45
46
|
module.exports = {
|
|
@@ -49,199 +50,111 @@ module.exports = {
|
|
|
49
50
|
};
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
#### 2. Configure Encryption Key (REQUIRED)
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
Agrega a tu `.env`:
|
|
55
|
+
Add to your `.env`:
|
|
57
56
|
|
|
58
57
|
```bash
|
|
59
|
-
ENCRYPTION_KEY=
|
|
58
|
+
ENCRYPTION_KEY=your_64_character_hex_key_here
|
|
60
59
|
```
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Edita `config/plugins.js`:
|
|
65
|
-
|
|
66
|
-
```javascript
|
|
67
|
-
module.exports = ({ env }) => ({
|
|
68
|
-
'encrypted-field': {
|
|
69
|
-
enabled: true,
|
|
70
|
-
config: {
|
|
71
|
-
encryptionKey: env('ENCRYPTION_KEY'),
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
#### Generar clave segura
|
|
78
|
-
|
|
61
|
+
Generate a secure key:
|
|
79
62
|
```bash
|
|
80
63
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
81
64
|
```
|
|
82
65
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
⚠️ **CRÍTICO - Gestión de claves**:
|
|
86
|
-
- **Guarda la clave de forma segura** (gestor de secretos, variables de entorno cifradas)
|
|
87
|
-
- **Nunca** la incluyas en el control de versiones
|
|
88
|
-
- **Si pierdes la clave**, no podrás descifrar los datos existentes
|
|
89
|
-
- **Usa la misma clave** en todos los entornos que compartan la misma base de datos
|
|
90
|
-
- **Para producción**, considera usar servicios como AWS Secrets Manager, HashiCorp Vault o similar
|
|
91
|
-
|
|
92
|
-
### 3. Rebuild del admin
|
|
93
|
-
|
|
94
|
-
```bash
|
|
95
|
-
npm run build
|
|
96
|
-
npm run develop
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
## Requisitos
|
|
100
|
-
|
|
101
|
-
- **Strapi**: v5.0.0 o superior
|
|
102
|
-
- **Node.js**: 18.x - 22.x
|
|
103
|
-
- **npm**: 6.0.0 o superior
|
|
104
|
-
|
|
105
|
-
## Validación de datos
|
|
66
|
+
### Usage
|
|
106
67
|
|
|
107
|
-
|
|
68
|
+
1. Go to **Content-Type Builder**.
|
|
69
|
+
2. Select a collection or create a new one.
|
|
70
|
+
3. Click on **"Add another field"**.
|
|
71
|
+
4. Select **"Encrypted Text"** (look for the 🔒 icon).
|
|
72
|
+
5. Values are hidden by default in the Admin Panel but can be revealed or copied using the inline buttons.
|
|
108
73
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
1. En el Content-Type Builder, selecciona el campo cifrado
|
|
112
|
-
2. Ve a la pestaña **"Advanced Settings"**
|
|
113
|
-
3. En **"RegEx pattern"**, ingresa tu expresión regular
|
|
114
|
-
4. Guarda los cambios
|
|
115
|
-
|
|
116
|
-
**Ejemplo**: Para validar formato de API key:
|
|
117
|
-
```regex
|
|
118
|
-
^sk-[a-zA-Z0-9]{32}$
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
Si el valor no cumple con el patrón, se lanzará un error antes de cifrar.
|
|
122
|
-
|
|
123
|
-
## Uso
|
|
124
|
-
|
|
125
|
-
### 1. Agregar campo cifrado a una colección
|
|
74
|
+
---
|
|
126
75
|
|
|
127
|
-
|
|
128
|
-
2. Selecciona una colección o crea una nueva
|
|
129
|
-
3. Click en **"Add another field"**
|
|
130
|
-
4. Busca **"Texto Cifrado"** (con icono 🔒)
|
|
131
|
-
5. Configura el nombre del campo
|
|
132
|
-
6. Guarda y reinicia Strapi
|
|
76
|
+
## Versión en Español
|
|
133
77
|
|
|
134
|
-
|
|
78
|
+
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.
|
|
135
79
|
|
|
136
|
-
|
|
80
|
+
### Características
|
|
137
81
|
|
|
138
|
-
- **
|
|
139
|
-
- **
|
|
140
|
-
- **
|
|
141
|
-
- **
|
|
142
|
-
- **
|
|
143
|
-
- **
|
|
144
|
-
- **
|
|
145
|
-
- **
|
|
146
|
-
- **
|
|
82
|
+
- ✅ **Campo personalizado** "Texto Cifrado" en el Content-Type Builder.
|
|
83
|
+
- ✅ **Cifrado automático** AES-256-GCM al guardar.
|
|
84
|
+
- ✅ **Descifrado transparente** al leer (panel y API).
|
|
85
|
+
- ✅ **Validación backend** con soporte para regex y restricciones.
|
|
86
|
+
- ✅ **UI Nativa Strapi v5** con controles de visibilidad y copiar al portapapeles.
|
|
87
|
+
- ✅ **Soporte multi-idioma (i18n)**: Inglés y Español.
|
|
88
|
+
- ✅ **Gestión de claves robusta** con validación y mensajes de error claros.
|
|
89
|
+
- ✅ **Datos cifrados** en base de datos con IV único y Auth Tag.
|
|
90
|
+
- ✅ **Soporte para componentes anidados** a cualquier profundidad.
|
|
147
91
|
|
|
148
|
-
###
|
|
92
|
+
### Instalación
|
|
149
93
|
|
|
150
94
|
```bash
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
-d '{
|
|
155
|
-
"data": {
|
|
156
|
-
"nombre": "Juan",
|
|
157
|
-
"apiKey": "mi-clave-secreta-123"
|
|
158
|
-
}
|
|
159
|
-
}'
|
|
160
|
-
|
|
161
|
-
# Leer (devuelve descifrado)
|
|
162
|
-
curl -X GET http://localhost:1337/api/usuarios/1
|
|
163
|
-
# Response: { "nombre": "Juan", "apiKey": "mi-clave-secreta-123" }
|
|
95
|
+
npm install @growy/strapi-plugin-encrypted-field
|
|
96
|
+
# o
|
|
97
|
+
yarn add @growy/strapi-plugin-encrypted-field
|
|
164
98
|
```
|
|
165
99
|
|
|
166
|
-
|
|
100
|
+
### Configuración
|
|
167
101
|
|
|
168
|
-
|
|
102
|
+
#### 1. Habilitar el plugin
|
|
169
103
|
|
|
170
|
-
|
|
171
|
-
```json
|
|
172
|
-
{
|
|
173
|
-
"nombre": "string",
|
|
174
|
-
"email": "email",
|
|
175
|
-
"apiKey": "plugin::encrypted-field.encrypted-text"
|
|
176
|
-
}
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
**En la BD:**
|
|
180
|
-
```
|
|
181
|
-
apiKey: "a1b2c3d4e5f6....:f9e8d7c6b5a4....:9f8e7d6c5b4a3..."
|
|
182
|
-
```
|
|
104
|
+
Edita `config/plugins.js`:
|
|
183
105
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
106
|
+
```javascript
|
|
107
|
+
module.exports = {
|
|
108
|
+
'encrypted-field': {
|
|
109
|
+
enabled: true,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
187
112
|
```
|
|
188
113
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
### Especificaciones técnicas
|
|
114
|
+
#### 2. Configurar la clave (REQUERIDO)
|
|
192
115
|
|
|
193
|
-
|
|
194
|
-
- **Tamaño de clave**: 256 bits (32 bytes, 64 caracteres hexadecimales)
|
|
195
|
-
- **IV (Initialization Vector)**: 96 bits (12 bytes) generado aleatoriamente por operación
|
|
196
|
-
- **Auth Tag**: 128 bits (16 bytes) para verificación de integridad
|
|
197
|
-
- **Formato almacenado**: `iv:authTag:encryptedData` (todos en hexadecimal)
|
|
116
|
+
Agrega a tu `.env`:
|
|
198
117
|
|
|
199
|
-
|
|
118
|
+
```bash
|
|
119
|
+
ENCRYPTION_KEY=tu_clave_de_64_caracteres_hexadecimales_aqui
|
|
120
|
+
```
|
|
200
121
|
|
|
201
|
-
|
|
202
|
-
- ✅ **IV único**: Cada operación de cifrado genera un IV aleatorio
|
|
203
|
-
- ✅ **Resistencia a manipulación**: Auth Tag detecta cualquier modificación
|
|
204
|
-
- ✅ **Validación de entrada**: Soporte para regex y restricciones personalizadas
|
|
205
|
-
- ✅ **Manejo de errores seguro**: Logs controlados sin exponer datos sensibles
|
|
122
|
+
### Uso
|
|
206
123
|
|
|
207
|
-
|
|
124
|
+
1. Ve a **Content-Type Builder**.
|
|
125
|
+
2. Selecciona una colección.
|
|
126
|
+
3. Click en **"Add another field"**.
|
|
127
|
+
4. Selecciona **"Texto Cifrado"** (icono 🔒).
|
|
128
|
+
5. Los valores están ocultos por defecto en el panel pero pueden mostrarse o copiarse con los botones integrados.
|
|
208
129
|
|
|
209
|
-
|
|
210
|
-
2. **Separación de entornos**: Usa claves diferentes para dev/staging/prod
|
|
211
|
-
3. **Auditoría**: Monitorea logs de errores de cifrado/descifrado
|
|
212
|
-
4. **Backup de claves**: Mantén copias seguras de las claves en múltiples ubicaciones
|
|
213
|
-
5. **Campos privados**: Marca campos sensibles como "privados" para excluirlos de la API pública
|
|
130
|
+
### Seguridad y Especificaciones
|
|
214
131
|
|
|
215
|
-
|
|
132
|
+
- **Algoritmo**: AES-256-GCM.
|
|
133
|
+
- **IV (Initialization Vector)**: 96 bits generado aleatoriamente por operación.
|
|
134
|
+
- **Auth Tag**: 128 bits para verificación de integridad.
|
|
135
|
+
- **Formato almacenado**: `iv:authTag:encryptedData`.
|
|
216
136
|
|
|
217
|
-
|
|
218
|
-
- 🔐 Tokens de acceso
|
|
219
|
-
- 🔒 Secretos de webhooks
|
|
220
|
-
- 💳 Información sensible
|
|
221
|
-
- 📧 Credenciales SMTP
|
|
222
|
-
- 🔑 Contraseñas de aplicaciones
|
|
137
|
+
### Limitaciones
|
|
223
138
|
|
|
224
|
-
|
|
139
|
+
- ❌ **Búsqueda**: No se puede buscar por campos cifrados.
|
|
140
|
+
- ❌ **Ordenamiento**: No se puede ordenar por campos cifrados.
|
|
141
|
+
- ❌ **Filtros**: No se pueden aplicar filtros directos sobre datos cifrados.
|
|
225
142
|
|
|
226
|
-
|
|
227
|
-
- ❌ **Ordenamiento**: No se puede ordenar por campos cifrados
|
|
228
|
-
- ❌ **Filtros**: No se pueden aplicar filtros directos sobre campos cifrados
|
|
229
|
-
- ⚠️ **Rendimiento**: El cifrado/descifrado añade overhead mínimo (~1-2ms por operación)
|
|
230
|
-
- ⚠️ **Sincronización de claves**: Todos los entornos que compartan BD deben usar la misma clave
|
|
143
|
+
---
|
|
231
144
|
|
|
232
|
-
## Licencia
|
|
145
|
+
## License / Licencia
|
|
233
146
|
|
|
234
147
|
MIT © 2025 Growy AI
|
|
235
148
|
|
|
236
|
-
##
|
|
149
|
+
## Credits / Créditos
|
|
237
150
|
|
|
238
151
|
**Growy AI** - Soluciones de IA y automatización empresarial
|
|
239
152
|
|
|
240
|
-
**Autor principal**: Zahir El isaac
|
|
153
|
+
**Main Author / Autor principal**: Zahir El isaac
|
|
241
154
|
|
|
242
155
|
---
|
|
243
156
|
|
|
244
157
|
<div align="center">
|
|
245
|
-
<p>Si este plugin te resulta útil, considera darle una ⭐ en GitHub</p>
|
|
246
|
-
<p>
|
|
158
|
+
<p>If this plugin is useful to you, consider giving it a ⭐ on GitHub / Si este plugin te resulta útil, considera darle una ⭐ en GitHub</p>
|
|
159
|
+
<p>Made with ❤️ by Growy AI Team</p>
|
|
247
160
|
</div>
|
|
@@ -34,29 +34,45 @@ const Input = (props) => {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
const handleCopy = async () => {
|
|
37
|
-
if (value)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
37
|
+
if (!value) return;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await navigator.clipboard.writeText(value);
|
|
41
|
+
toggleNotification({
|
|
42
|
+
type: 'success',
|
|
43
|
+
message: formatMessage({
|
|
44
|
+
id: 'encrypted-field.notification.copied',
|
|
45
|
+
defaultMessage: 'Copiado al portapapeles',
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
} catch (err) {
|
|
49
|
+
toggleNotification({
|
|
50
|
+
type: 'danger',
|
|
51
|
+
message: formatMessage({
|
|
52
|
+
id: 'encrypted-field.notification.copyError',
|
|
53
|
+
defaultMessage: 'Error al copiar',
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
50
56
|
}
|
|
51
57
|
};
|
|
52
58
|
|
|
53
59
|
const toggleVisibility = () => {
|
|
54
|
-
setIsVisible(!
|
|
60
|
+
setIsVisible((prev) => !prev);
|
|
55
61
|
};
|
|
56
62
|
|
|
57
63
|
const fieldName = name.includes('.') ? name.split('.').pop() : name;
|
|
58
64
|
const label = intlLabel?.id ? formatMessage(intlLabel) : (intlLabel || fieldName);
|
|
59
65
|
|
|
66
|
+
const visibilityLabel = formatMessage({
|
|
67
|
+
id: isVisible ? 'encrypted-field.action.hide' : 'encrypted-field.action.show',
|
|
68
|
+
defaultMessage: isVisible ? 'Ocultar' : 'Mostrar',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const copyLabel = formatMessage({
|
|
72
|
+
id: 'encrypted-field.action.copy',
|
|
73
|
+
defaultMessage: 'Copiar',
|
|
74
|
+
});
|
|
75
|
+
|
|
60
76
|
return (
|
|
61
77
|
<Field.Root
|
|
62
78
|
name={name}
|
|
@@ -77,17 +93,17 @@ const Input = (props) => {
|
|
|
77
93
|
disabled={disabled}
|
|
78
94
|
style={{ paddingRight: '80px' }}
|
|
79
95
|
/>
|
|
80
|
-
<div style={{
|
|
81
|
-
position: 'absolute',
|
|
82
|
-
right: '8px',
|
|
83
|
-
top: '50%',
|
|
96
|
+
<div style={{
|
|
97
|
+
position: 'absolute',
|
|
98
|
+
right: '8px',
|
|
99
|
+
top: '50%',
|
|
84
100
|
transform: 'translateY(-50%)',
|
|
85
101
|
display: 'flex',
|
|
86
|
-
gap: '4px'
|
|
102
|
+
gap: '4px',
|
|
87
103
|
}}>
|
|
88
104
|
<IconButton
|
|
89
105
|
onClick={toggleVisibility}
|
|
90
|
-
label={
|
|
106
|
+
label={visibilityLabel}
|
|
91
107
|
disabled={disabled}
|
|
92
108
|
variant="ghost"
|
|
93
109
|
>
|
|
@@ -95,7 +111,7 @@ const Input = (props) => {
|
|
|
95
111
|
</IconButton>
|
|
96
112
|
<IconButton
|
|
97
113
|
onClick={handleCopy}
|
|
98
|
-
label=
|
|
114
|
+
label={copyLabel}
|
|
99
115
|
disabled={disabled || !value}
|
|
100
116
|
variant="ghost"
|
|
101
117
|
>
|
package/admin/src/index.js
CHANGED
|
@@ -103,24 +103,15 @@ export default {
|
|
|
103
103
|
},
|
|
104
104
|
|
|
105
105
|
async registerTrads({ locales }) {
|
|
106
|
-
|
|
107
|
-
locales.map((locale) => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
})
|
|
115
|
-
.catch(() => {
|
|
116
|
-
return {
|
|
117
|
-
data: {},
|
|
118
|
-
locale,
|
|
119
|
-
};
|
|
120
|
-
});
|
|
106
|
+
return Promise.all(
|
|
107
|
+
locales.map(async (locale) => {
|
|
108
|
+
try {
|
|
109
|
+
const { default: data } = await import(`./translations/${locale}.json`);
|
|
110
|
+
return { data, locale };
|
|
111
|
+
} catch {
|
|
112
|
+
return { data: {}, locale };
|
|
113
|
+
}
|
|
121
114
|
})
|
|
122
115
|
);
|
|
123
|
-
|
|
124
|
-
return Promise.resolve(importedTrads);
|
|
125
116
|
},
|
|
126
117
|
};
|
|
@@ -13,5 +13,10 @@
|
|
|
13
13
|
"encrypted-field.options.minLength.description": "Minimum number of characters required",
|
|
14
14
|
"encrypted-field.options.advanced.regex": "Validation",
|
|
15
15
|
"encrypted-field.options.regex.label": "RegEx pattern",
|
|
16
|
-
"encrypted-field.options.regex.description": "Validation pattern before encryption"
|
|
17
|
-
|
|
16
|
+
"encrypted-field.options.regex.description": "Validation pattern before encryption",
|
|
17
|
+
"encrypted-field.notification.copied": "Copied to clipboard",
|
|
18
|
+
"encrypted-field.notification.copyError": "Error copying to clipboard",
|
|
19
|
+
"encrypted-field.action.show": "Show",
|
|
20
|
+
"encrypted-field.action.hide": "Hide",
|
|
21
|
+
"encrypted-field.action.copy": "Copy"
|
|
22
|
+
}
|
|
@@ -13,5 +13,10 @@
|
|
|
13
13
|
"encrypted-field.options.minLength.description": "Número mínimo de caracteres requeridos",
|
|
14
14
|
"encrypted-field.options.advanced.regex": "Validación",
|
|
15
15
|
"encrypted-field.options.regex.label": "RegEx pattern",
|
|
16
|
-
"encrypted-field.options.regex.description": "Patrón de validación antes de cifrar"
|
|
17
|
-
|
|
16
|
+
"encrypted-field.options.regex.description": "Patrón de validación antes de cifrar",
|
|
17
|
+
"encrypted-field.notification.copied": "Copiado al portapapeles",
|
|
18
|
+
"encrypted-field.notification.copyError": "Error al copiar al portapapeles",
|
|
19
|
+
"encrypted-field.action.show": "Mostrar",
|
|
20
|
+
"encrypted-field.action.hide": "Ocultar",
|
|
21
|
+
"encrypted-field.action.copy": "Copiar"
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growy/strapi-plugin-encrypted-field",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Campo personalizado de texto cifrado para Strapi",
|
|
5
5
|
"strapi": {
|
|
6
6
|
"name": "encrypted-field",
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {},
|
|
12
12
|
"peerDependencies": {
|
|
13
|
-
"@strapi/strapi": "^5.0.0"
|
|
13
|
+
"@strapi/strapi": "^5.0.0",
|
|
14
|
+
"@strapi/design-system": "^2.0.0",
|
|
15
|
+
"react": "^18.0.0"
|
|
14
16
|
},
|
|
15
17
|
"main": "server/index.js",
|
|
16
18
|
"exports": {
|
|
@@ -53,4 +55,4 @@
|
|
|
53
55
|
"publishConfig": {
|
|
54
56
|
"access": "public"
|
|
55
57
|
}
|
|
56
|
-
}
|
|
58
|
+
}
|
package/server/bootstrap.js
CHANGED
|
@@ -1,117 +1,79 @@
|
|
|
1
1
|
const { encrypt, decrypt, validateValue, isEncryptedField } = require('./utils/crypto');
|
|
2
2
|
|
|
3
|
+
function processEncryption(event, strapi) {
|
|
4
|
+
if (!event.model?.uid) return;
|
|
5
|
+
|
|
6
|
+
const { data } = event.params;
|
|
7
|
+
const currentModel = strapi.getModel(event.model.uid);
|
|
8
|
+
|
|
9
|
+
if (!currentModel?.attributes) return;
|
|
10
|
+
|
|
11
|
+
for (const [key, attribute] of Object.entries(currentModel.attributes)) {
|
|
12
|
+
if (!isEncryptedField(attribute)) continue;
|
|
13
|
+
if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
|
|
14
|
+
|
|
15
|
+
const value = data[key];
|
|
16
|
+
if (value === null || value === undefined || value === '') continue;
|
|
17
|
+
|
|
18
|
+
const validation = validateValue(value, attribute);
|
|
19
|
+
if (!validation.valid) {
|
|
20
|
+
throw new Error(`Validación fallida para el campo "${key}": ${validation.error}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
data[key] = encrypt(value, strapi);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function processDecryption(result, event, strapi) {
|
|
28
|
+
if (!result) return;
|
|
29
|
+
if (!event.model?.uid) return;
|
|
30
|
+
|
|
31
|
+
const currentModel = strapi.getModel(event.model.uid);
|
|
32
|
+
if (!currentModel?.attributes) return;
|
|
33
|
+
|
|
34
|
+
for (const [key, attribute] of Object.entries(currentModel.attributes)) {
|
|
35
|
+
if (!isEncryptedField(attribute)) continue;
|
|
36
|
+
if (!Object.prototype.hasOwnProperty.call(result, key)) continue;
|
|
37
|
+
|
|
38
|
+
const value = result[key];
|
|
39
|
+
if (typeof value === 'string' && value) {
|
|
40
|
+
result[key] = decrypt(value, strapi);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
3
45
|
module.exports = ({ strapi }) => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
46
|
+
const allModels = [
|
|
47
|
+
...Object.values(strapi.contentTypes),
|
|
48
|
+
...Object.values(strapi.components),
|
|
49
|
+
];
|
|
50
|
+
|
|
10
51
|
allModels.forEach((model) => {
|
|
11
52
|
const attributes = model.attributes || {};
|
|
12
|
-
|
|
13
|
-
const encryptedFields = Object.entries(attributes)
|
|
14
|
-
.filter(([key, attr]) => isEncryptedField(attr))
|
|
15
|
-
.map(([key]) => key);
|
|
16
|
-
|
|
17
|
-
if (encryptedFields.length === 0) return;
|
|
53
|
+
const hasEncryptedFields = Object.values(attributes).some(isEncryptedField);
|
|
18
54
|
|
|
19
|
-
|
|
55
|
+
if (!hasEncryptedFields) return;
|
|
20
56
|
|
|
21
|
-
|
|
22
57
|
strapi.db.lifecycles.subscribe({
|
|
23
|
-
models: [uid],
|
|
24
|
-
|
|
58
|
+
models: [model.uid],
|
|
59
|
+
|
|
25
60
|
async beforeCreate(event) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (!event.model?.uid) return;
|
|
29
|
-
|
|
30
|
-
const currentModel = strapi.getModel(event.model.uid);
|
|
31
|
-
|
|
32
|
-
if (!currentModel?.attributes) return;
|
|
33
|
-
|
|
34
|
-
for (const [key, attribute] of Object.entries(currentModel.attributes)) {
|
|
35
|
-
if (!isEncryptedField(attribute)) continue;
|
|
36
|
-
|
|
37
|
-
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
38
|
-
const value = data[key];
|
|
39
|
-
|
|
40
|
-
if (value === null || value === undefined || value === '') continue;
|
|
41
|
-
|
|
42
|
-
const validation = validateValue(value, attribute);
|
|
43
|
-
if (!validation.valid) {
|
|
44
|
-
throw new Error(`Validación fallida para el campo "${key}": ${validation.error}`);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
data[key] = encrypt(value, strapi);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
61
|
+
processEncryption(event, strapi);
|
|
50
62
|
},
|
|
51
63
|
|
|
52
64
|
async beforeUpdate(event) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const { data } = event.params;
|
|
56
|
-
const currentModel = strapi.getModel(event.model.uid);
|
|
57
|
-
|
|
58
|
-
if (!currentModel?.attributes) return;
|
|
59
|
-
|
|
60
|
-
for (const [key, attribute] of Object.entries(currentModel.attributes)) {
|
|
61
|
-
if (!isEncryptedField(attribute)) continue;
|
|
62
|
-
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
63
|
-
const value = data[key];
|
|
64
|
-
if (value === null || value === undefined || value === '') continue;
|
|
65
|
-
|
|
66
|
-
const validation = validateValue(value, attribute);
|
|
67
|
-
if (!validation.valid) {
|
|
68
|
-
throw new Error(`Validación fallida para el campo "${key}": ${validation.error}`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
data[key] = encrypt(value, strapi);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
65
|
+
processEncryption(event, strapi);
|
|
74
66
|
},
|
|
75
67
|
|
|
76
68
|
async afterFindOne(event) {
|
|
77
|
-
|
|
78
|
-
if (!result) return;
|
|
79
|
-
if (!event.model?.uid) return;
|
|
80
|
-
|
|
81
|
-
const currentModel = strapi.getModel(event.model.uid);
|
|
82
|
-
|
|
83
|
-
if (!currentModel?.attributes) return;
|
|
84
|
-
|
|
85
|
-
for (const [key, attribute] of Object.entries(currentModel.attributes)) {
|
|
86
|
-
if (!isEncryptedField(attribute)) continue;
|
|
87
|
-
if (Object.prototype.hasOwnProperty.call(result, key)) {
|
|
88
|
-
const value = result[key];
|
|
89
|
-
if (typeof value === 'string' && value) {
|
|
90
|
-
result[key] = decrypt(value, strapi);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
69
|
+
processDecryption(event.result, event, strapi);
|
|
94
70
|
},
|
|
95
71
|
|
|
96
72
|
async afterFindMany(event) {
|
|
97
73
|
const { result } = event;
|
|
98
74
|
if (!result || !Array.isArray(result)) return;
|
|
99
|
-
if (!event.model?.uid) return;
|
|
100
|
-
|
|
101
|
-
const currentModel = strapi.getModel(event.model.uid);
|
|
102
|
-
|
|
103
|
-
if (!currentModel?.attributes) return;
|
|
104
|
-
|
|
105
75
|
for (const item of result) {
|
|
106
|
-
|
|
107
|
-
if (!isEncryptedField(attribute)) continue;
|
|
108
|
-
if (Object.prototype.hasOwnProperty.call(item, key)) {
|
|
109
|
-
const value = item[key];
|
|
110
|
-
if (typeof value === 'string' && value) {
|
|
111
|
-
item[key] = decrypt(value, strapi);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
76
|
+
processDecryption(item, event, strapi);
|
|
115
77
|
}
|
|
116
78
|
},
|
|
117
79
|
});
|
package/server/index.js
CHANGED
|
@@ -1,38 +1,49 @@
|
|
|
1
1
|
const { decrypt, isEncryptedField } = require('../utils/crypto');
|
|
2
2
|
|
|
3
|
+
function resolveModelUidFromPath(ctx, strapi) {
|
|
4
|
+
const path = ctx.request?.path || '';
|
|
5
|
+
const match = path.match(/^\/api\/([^/]+)/);
|
|
6
|
+
if (!match) return null;
|
|
7
|
+
|
|
8
|
+
const slug = match[1];
|
|
9
|
+
const contentTypes = Object.values(strapi.contentTypes);
|
|
10
|
+
|
|
11
|
+
const ct = contentTypes.find((ct) => {
|
|
12
|
+
const info = ct.info || {};
|
|
13
|
+
return info.pluralName === slug || info.singularName === slug;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return ct?.uid || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
3
19
|
module.exports = (config, { strapi }) => {
|
|
4
20
|
return async (ctx, next) => {
|
|
5
21
|
await next();
|
|
6
22
|
|
|
7
23
|
if (!ctx.body) return;
|
|
8
24
|
|
|
9
|
-
|
|
25
|
+
const rootModelUid = resolveModelUidFromPath(ctx, strapi);
|
|
26
|
+
|
|
10
27
|
const decryptRecursive = (obj, modelUid = null) => {
|
|
11
28
|
if (!obj || typeof obj !== 'object') return;
|
|
12
29
|
|
|
13
|
-
|
|
14
30
|
if (Array.isArray(obj)) {
|
|
15
|
-
obj.forEach(item => decryptRecursive(item, modelUid));
|
|
31
|
+
obj.forEach((item) => decryptRecursive(item, modelUid));
|
|
16
32
|
return;
|
|
17
33
|
}
|
|
18
34
|
|
|
19
|
-
|
|
20
35
|
let currentModelUid = modelUid;
|
|
21
36
|
if (obj.__component) {
|
|
22
37
|
currentModelUid = obj.__component;
|
|
23
38
|
}
|
|
24
39
|
|
|
25
|
-
|
|
26
40
|
let model = null;
|
|
27
41
|
if (currentModelUid) {
|
|
28
42
|
try {
|
|
29
43
|
model = strapi.getModel(currentModelUid) || strapi.components[currentModelUid];
|
|
30
|
-
} catch (e) {
|
|
31
|
-
|
|
32
|
-
}
|
|
44
|
+
} catch (e) { }
|
|
33
45
|
}
|
|
34
46
|
|
|
35
|
-
|
|
36
47
|
if (model?.attributes) {
|
|
37
48
|
for (const [key, attribute] of Object.entries(model.attributes)) {
|
|
38
49
|
if (isEncryptedField(attribute) && obj[key] && typeof obj[key] === 'string') {
|
|
@@ -45,7 +56,6 @@ module.exports = (config, { strapi }) => {
|
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
|
|
49
59
|
for (const value of Object.values(obj)) {
|
|
50
60
|
if (value && typeof value === 'object') {
|
|
51
61
|
decryptRecursive(value, currentModelUid);
|
|
@@ -53,12 +63,10 @@ module.exports = (config, { strapi }) => {
|
|
|
53
63
|
}
|
|
54
64
|
};
|
|
55
65
|
|
|
56
|
-
|
|
57
66
|
if (ctx.body.data) {
|
|
58
|
-
decryptRecursive(ctx.body.data);
|
|
67
|
+
decryptRecursive(ctx.body.data, rootModelUid);
|
|
59
68
|
} else {
|
|
60
|
-
|
|
61
|
-
decryptRecursive(ctx.body);
|
|
69
|
+
decryptRecursive(ctx.body, rootModelUid);
|
|
62
70
|
}
|
|
63
71
|
};
|
|
64
72
|
};
|
package/server/register.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
module.exports = ({ strapi }) => {
|
|
2
2
|
const decryptMiddleware = require('./middlewares/decrypt');
|
|
3
|
-
|
|
3
|
+
|
|
4
4
|
strapi.server.use(decryptMiddleware({}, { strapi }));
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
strapi.customFields.register({
|
|
7
7
|
name: 'encrypted-text',
|
|
8
8
|
plugin: 'encrypted-field',
|
|
9
9
|
type: 'string',
|
|
10
|
+
inputSize: {
|
|
11
|
+
default: 6,
|
|
12
|
+
isResizable: true,
|
|
13
|
+
},
|
|
10
14
|
});
|
|
11
15
|
};
|
package/server/utils/crypto.js
CHANGED
|
@@ -5,9 +5,12 @@ const IV_LENGTH = 12;
|
|
|
5
5
|
const AUTH_TAG_LENGTH = 16;
|
|
6
6
|
const KEY_LENGTH = 32;
|
|
7
7
|
|
|
8
|
+
let _cachedKey = null;
|
|
9
|
+
let _cachedKeySource = null;
|
|
10
|
+
|
|
8
11
|
function getEncryptionKey(strapi) {
|
|
9
12
|
const key = process.env.ENCRYPTION_KEY || strapi?.config?.get('plugin.encrypted-field.encryptionKey');
|
|
10
|
-
|
|
13
|
+
|
|
11
14
|
if (!key) {
|
|
12
15
|
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
16
|
if (strapi?.log?.error) {
|
|
@@ -15,33 +18,39 @@ function getEncryptionKey(strapi) {
|
|
|
15
18
|
}
|
|
16
19
|
throw new Error(errorMsg);
|
|
17
20
|
}
|
|
18
|
-
|
|
21
|
+
|
|
22
|
+
if (_cachedKey && _cachedKeySource === key) {
|
|
23
|
+
return _cachedKey;
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
if (typeof key !== 'string' || key.length !== 64) {
|
|
20
27
|
throw new Error(`ENCRYPTION_KEY debe tener exactamente 64 caracteres hexadecimales (32 bytes). Actual: ${key?.length || 0}`);
|
|
21
28
|
}
|
|
22
|
-
|
|
29
|
+
|
|
23
30
|
if (!/^[0-9a-fA-F]{64}$/.test(key)) {
|
|
24
31
|
throw new Error('ENCRYPTION_KEY debe contener solo caracteres hexadecimales (0-9, a-f, A-F)');
|
|
25
32
|
}
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
|
|
34
|
+
_cachedKey = Buffer.from(key, 'hex');
|
|
35
|
+
_cachedKeySource = key;
|
|
36
|
+
return _cachedKey;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
function encrypt(text, strapi) {
|
|
31
40
|
if (typeof text !== 'string') return text;
|
|
32
|
-
|
|
41
|
+
|
|
33
42
|
if (text === '') return text;
|
|
34
43
|
|
|
35
44
|
try {
|
|
36
45
|
const ENCRYPTION_KEY = getEncryptionKey(strapi);
|
|
37
46
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
38
47
|
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
39
|
-
|
|
48
|
+
|
|
40
49
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
41
50
|
encrypted += cipher.final('hex');
|
|
42
|
-
|
|
51
|
+
|
|
43
52
|
const authTag = cipher.getAuthTag();
|
|
44
|
-
|
|
53
|
+
|
|
45
54
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
46
55
|
} catch (error) {
|
|
47
56
|
if (strapi?.log?.error) {
|
|
@@ -53,29 +62,29 @@ function encrypt(text, strapi) {
|
|
|
53
62
|
|
|
54
63
|
function decrypt(encryptedText, strapi) {
|
|
55
64
|
if (!encryptedText || typeof encryptedText !== 'string') return encryptedText;
|
|
56
|
-
|
|
65
|
+
|
|
57
66
|
const parts = encryptedText.split(':');
|
|
58
67
|
if (parts.length !== 3) {
|
|
59
68
|
return encryptedText;
|
|
60
69
|
}
|
|
61
|
-
|
|
70
|
+
|
|
62
71
|
try {
|
|
63
72
|
const [ivHex, authTagHex, encrypted] = parts;
|
|
64
|
-
|
|
73
|
+
|
|
65
74
|
if (ivHex.length !== IV_LENGTH * 2 || authTagHex.length !== AUTH_TAG_LENGTH * 2) {
|
|
66
75
|
return encryptedText;
|
|
67
76
|
}
|
|
68
|
-
|
|
77
|
+
|
|
69
78
|
const ENCRYPTION_KEY = getEncryptionKey(strapi);
|
|
70
79
|
const iv = Buffer.from(ivHex, 'hex');
|
|
71
80
|
const authTag = Buffer.from(authTagHex, 'hex');
|
|
72
|
-
|
|
81
|
+
|
|
73
82
|
const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
74
83
|
decipher.setAuthTag(authTag);
|
|
75
|
-
|
|
84
|
+
|
|
76
85
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
77
86
|
decrypted += decipher.final('utf8');
|
|
78
|
-
|
|
87
|
+
|
|
79
88
|
return decrypted;
|
|
80
89
|
} catch (error) {
|
|
81
90
|
if (strapi?.log?.debug) {
|
|
@@ -91,9 +100,9 @@ function validateValue(value, attribute) {
|
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
if (typeof value !== 'string') {
|
|
94
|
-
return {
|
|
95
|
-
valid: false,
|
|
96
|
-
error: 'El valor debe ser una cadena de texto'
|
|
103
|
+
return {
|
|
104
|
+
valid: false,
|
|
105
|
+
error: 'El valor debe ser una cadena de texto'
|
|
97
106
|
};
|
|
98
107
|
}
|
|
99
108
|
|
|
@@ -101,30 +110,30 @@ function validateValue(value, attribute) {
|
|
|
101
110
|
try {
|
|
102
111
|
const regex = new RegExp(attribute.regex);
|
|
103
112
|
if (!regex.test(value)) {
|
|
104
|
-
return {
|
|
105
|
-
valid: false,
|
|
106
|
-
error: `El valor no cumple con el patrón de validación: ${attribute.regex}`
|
|
113
|
+
return {
|
|
114
|
+
valid: false,
|
|
115
|
+
error: `El valor no cumple con el patrón de validación: ${attribute.regex}`
|
|
107
116
|
};
|
|
108
117
|
}
|
|
109
118
|
} catch (error) {
|
|
110
|
-
return {
|
|
111
|
-
valid: false,
|
|
112
|
-
error: `Patrón regex inválido: ${error.message}`
|
|
119
|
+
return {
|
|
120
|
+
valid: false,
|
|
121
|
+
error: `Patrón regex inválido: ${error.message}`
|
|
113
122
|
};
|
|
114
123
|
}
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
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`
|
|
127
|
+
return {
|
|
128
|
+
valid: false,
|
|
129
|
+
error: `El valor excede la longitud máxima de ${attribute.maxLength} caracteres`
|
|
121
130
|
};
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
if (attribute.minLength && value.length < attribute.minLength) {
|
|
125
|
-
return {
|
|
126
|
-
valid: false,
|
|
127
|
-
error: `El valor debe tener al menos ${attribute.minLength} caracteres`
|
|
134
|
+
return {
|
|
135
|
+
valid: false,
|
|
136
|
+
error: `El valor debe tener al menos ${attribute.minLength} caracteres`
|
|
128
137
|
};
|
|
129
138
|
}
|
|
130
139
|
|