@growy/strapi-plugin-encrypted-field 2.3.3 → 2.4.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/CHANGELOG.md +101 -0
- package/README.md +12 -1
- package/package.json +5 -5
- package/scripts/rotate-key.js +117 -0
- package/server/bootstrap.js +1 -1
- package/server/middlewares/decrypt.js +1 -1
- package/server/utils/crypto.js +11 -11
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [2.4.1] - 2026-02-26
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Updated Node.js engine requirement to support version 23.x.x.
|
|
9
|
+
|
|
10
|
+
## [2.4.0] - 2026-02-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Key rotation script (`scripts/rotate-key.js`) for re-encrypting data when changing the encryption key.
|
|
14
|
+
- CHANGELOG.md with full version history.
|
|
15
|
+
- Documented `uniqueField` limitation in README (unique constraints don't work on encrypted fields due to random IV).
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- All server-side error messages and logs translated to English for global developer audience.
|
|
19
|
+
- `description` fields in `package.json` translated to English.
|
|
20
|
+
- `repository.url` fixed via `npm pkg fix` (normalized to `git+https://` format).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- `strapi.config.get()` path updated from deprecated `plugin.encrypted-field` to Strapi v5 convention `plugin::encrypted-field`.
|
|
24
|
+
|
|
25
|
+
## [2.3.3] - 2026-02-26
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- README rewritten in English for global npm community adoption.
|
|
29
|
+
- Removed unused `admin/src/pages/` directory.
|
|
30
|
+
|
|
31
|
+
## [2.3.2] - 2026-02-26
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- Restored detailed technical documentation in README (API examples, key management warnings, regex validation guide).
|
|
35
|
+
|
|
36
|
+
## [2.3.1] - 2026-02-26
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- README converted to bilingual format (EN/ES).
|
|
40
|
+
- Removed unused server directories (`content-types`, `controllers`, `routes`, `services`).
|
|
41
|
+
- Added `.npmignore` for cleaner npm package.
|
|
42
|
+
|
|
43
|
+
## [2.3.0] - 2026-02-26
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- Multi-language support (i18n): English and Spanish translations for admin UI.
|
|
47
|
+
- Encryption key caching in memory for improved performance.
|
|
48
|
+
- `inputSize` configuration for resizable inputs in Content-Type Builder.
|
|
49
|
+
- Plugin `config` block with `default` and `validator` (Strapi v5 standard).
|
|
50
|
+
- `@strapi/design-system` and `react` as `peerDependencies`.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
- Refactored lifecycle hooks (`bootstrap.js`): extracted shared `processEncryption` and `processDecryption` functions to eliminate code duplication.
|
|
54
|
+
- Simplified `registerTrads` to native Strapi v5 async/await pattern.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
- Decrypt middleware now resolves the root entity `modelUid` from the API request path, fixing a bug where top-level encrypted fields were not decrypted in API responses.
|
|
58
|
+
|
|
59
|
+
## [2.2.1] - 2025-10-14
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
- Visibility toggle (show/hide) and copy-to-clipboard controls with confirmation notifications in admin UI.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
- Updated README with improved documentation and package metadata.
|
|
66
|
+
|
|
67
|
+
## [2.0.4] - 2025-10-13
|
|
68
|
+
|
|
69
|
+
### Removed
|
|
70
|
+
- Placeholder field option removed after multiple iterations.
|
|
71
|
+
|
|
72
|
+
## [2.0.3] - 2025-10-13
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
- Set predefined placeholder and removed customization option.
|
|
76
|
+
|
|
77
|
+
## [2.0.1] - 2025-10-13
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
- Nested component support for encryption/decryption at any depth.
|
|
81
|
+
- Decrypt middleware for API responses.
|
|
82
|
+
- Regex and length validation before encryption.
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
- Simplified encrypted field UI.
|
|
86
|
+
- Improved props handling in Input component.
|
|
87
|
+
- Field type changed to `string`.
|
|
88
|
+
- Removed unnecessary code comments.
|
|
89
|
+
|
|
90
|
+
## [1.0.1] - 2025-10-13
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
- Package exports configuration for Strapi v5.
|
|
94
|
+
|
|
95
|
+
## [1.0.0] - 2025-10-13
|
|
96
|
+
|
|
97
|
+
### Added
|
|
98
|
+
- Initial release.
|
|
99
|
+
- Custom field "Encrypted Text" for Content-Type Builder.
|
|
100
|
+
- AES-256-GCM encryption/decryption.
|
|
101
|
+
- Basic admin panel input component.
|
package/README.md
CHANGED
|
@@ -201,12 +201,22 @@ apiKey: "sk-1234567890abcdef"
|
|
|
201
201
|
|
|
202
202
|
### Best Practices
|
|
203
203
|
|
|
204
|
-
1. **Key rotation**:
|
|
204
|
+
1. **Key rotation**: Use the included rotation script (see below)
|
|
205
205
|
2. **Environment separation**: Use different keys per dev/staging/prod
|
|
206
206
|
3. **Auditing**: Monitor encryption/decryption error logs
|
|
207
207
|
4. **Key backup**: Keep secure copies of keys in multiple locations
|
|
208
208
|
5. **Private fields**: Mark sensitive fields as "private" to exclude them from the public API
|
|
209
209
|
|
|
210
|
+
### Key Rotation
|
|
211
|
+
|
|
212
|
+
If you need to change your encryption key, use the included rotation script to re-encrypt existing data:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
node scripts/rotate-key.js --old=<CURRENT_64_CHAR_KEY> --new=<NEW_64_CHAR_KEY>
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The script reads encrypted values from stdin, decrypts with the old key, and re-encrypts with the new key. See the script output for database-specific integration examples (PostgreSQL, etc.).
|
|
219
|
+
|
|
210
220
|
## Use Cases
|
|
211
221
|
|
|
212
222
|
- 🔑 Third-party API Keys
|
|
@@ -221,6 +231,7 @@ apiKey: "sk-1234567890abcdef"
|
|
|
221
231
|
- ❌ **Search**: Cannot search by encrypted fields (data is encrypted in DB)
|
|
222
232
|
- ❌ **Sorting**: Cannot sort by encrypted fields
|
|
223
233
|
- ❌ **Filters**: Cannot apply direct filters on encrypted fields
|
|
234
|
+
- ❌ **Unique constraint**: Strapi's unique validation will not work correctly on encrypted fields because each encryption produces a different ciphertext (random IV)
|
|
224
235
|
- ⚠️ **Performance**: Encryption/decryption adds minimal overhead (~1-2ms per operation)
|
|
225
236
|
- ⚠️ **Key synchronization**: All environments sharing the same DB must use the same key
|
|
226
237
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growy/strapi-plugin-encrypted-field",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.4.1",
|
|
4
|
+
"description": "Custom encrypted text field plugin for Strapi using AES-256-GCM",
|
|
5
5
|
"strapi": {
|
|
6
6
|
"name": "encrypted-field",
|
|
7
7
|
"displayName": "Encrypted Field",
|
|
8
|
-
"description": "
|
|
8
|
+
"description": "Adds an AES-256-GCM encrypted text field",
|
|
9
9
|
"kind": "plugin"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {},
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
30
|
-
"url": "https://github.com/ZahirElIsaac/strapi-campo-encriptado.git"
|
|
30
|
+
"url": "git+https://github.com/ZahirElIsaac/strapi-campo-encriptado.git"
|
|
31
31
|
},
|
|
32
32
|
"contributors": [
|
|
33
33
|
{
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
}
|
|
36
36
|
],
|
|
37
37
|
"engines": {
|
|
38
|
-
"node": ">=18.0.0 <=
|
|
38
|
+
"node": ">=18.0.0 <=23.x.x",
|
|
39
39
|
"npm": ">=6.0.0"
|
|
40
40
|
},
|
|
41
41
|
"license": "MIT",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const readline = require('readline');
|
|
5
|
+
|
|
6
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
7
|
+
const IV_LENGTH = 12;
|
|
8
|
+
const AUTH_TAG_LENGTH = 16;
|
|
9
|
+
|
|
10
|
+
function parseKey(hexKey) {
|
|
11
|
+
if (!hexKey || typeof hexKey !== 'string' || hexKey.length !== 64) {
|
|
12
|
+
throw new Error(`Key must be exactly 64 hexadecimal characters. Got: ${hexKey?.length || 0}`);
|
|
13
|
+
}
|
|
14
|
+
if (!/^[0-9a-fA-F]{64}$/.test(hexKey)) {
|
|
15
|
+
throw new Error('Key must contain only hexadecimal characters (0-9, a-f, A-F)');
|
|
16
|
+
}
|
|
17
|
+
return Buffer.from(hexKey, 'hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function decryptWithKey(encryptedText, keyBuffer) {
|
|
21
|
+
const parts = encryptedText.split(':');
|
|
22
|
+
if (parts.length !== 3) return null;
|
|
23
|
+
|
|
24
|
+
const [ivHex, authTagHex, encrypted] = parts;
|
|
25
|
+
if (ivHex.length !== IV_LENGTH * 2 || authTagHex.length !== AUTH_TAG_LENGTH * 2) return null;
|
|
26
|
+
|
|
27
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
28
|
+
const authTag = Buffer.from(authTagHex, 'hex');
|
|
29
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
|
|
30
|
+
decipher.setAuthTag(authTag);
|
|
31
|
+
|
|
32
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
33
|
+
decrypted += decipher.final('utf8');
|
|
34
|
+
return decrypted;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function encryptWithKey(text, keyBuffer) {
|
|
38
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
39
|
+
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
|
|
40
|
+
|
|
41
|
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
42
|
+
encrypted += cipher.final('hex');
|
|
43
|
+
const authTag = cipher.getAuthTag();
|
|
44
|
+
|
|
45
|
+
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
const args = process.argv.slice(2);
|
|
50
|
+
const oldKeyArg = args.find((a) => a.startsWith('--old='));
|
|
51
|
+
const newKeyArg = args.find((a) => a.startsWith('--new='));
|
|
52
|
+
|
|
53
|
+
if (!oldKeyArg || !newKeyArg) {
|
|
54
|
+
console.error('Usage: node scripts/rotate-key.js --old=<OLD_KEY> --new=<NEW_KEY>');
|
|
55
|
+
console.error('Keys must be 64-character hexadecimal strings.');
|
|
56
|
+
console.error('\nGenerate a new key with:');
|
|
57
|
+
console.error(' node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const oldKey = oldKeyArg.split('=')[1];
|
|
62
|
+
const newKey = newKeyArg.split('=')[1];
|
|
63
|
+
|
|
64
|
+
let oldKeyBuffer, newKeyBuffer;
|
|
65
|
+
try {
|
|
66
|
+
oldKeyBuffer = parseKey(oldKey);
|
|
67
|
+
newKeyBuffer = parseKey(newKey);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error(`Key validation error: ${error.message}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (oldKey === newKey) {
|
|
74
|
+
console.error('Old and new keys are identical. Aborting.');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
79
|
+
const question = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
80
|
+
|
|
81
|
+
console.log('\n🔑 Encrypted Field - Key Rotation Tool');
|
|
82
|
+
console.log('━'.repeat(45));
|
|
83
|
+
console.log('\nThis script reads encrypted values from stdin (one per line),');
|
|
84
|
+
console.log('decrypts them with the OLD key, and re-encrypts with the NEW key.');
|
|
85
|
+
console.log('\nTo use with a database, export the encrypted column values,');
|
|
86
|
+
console.log('pipe them through this script, and update the database.\n');
|
|
87
|
+
console.log('Example with PostgreSQL:');
|
|
88
|
+
console.log(' psql -t -A -c "SELECT id, api_key FROM users WHERE api_key IS NOT NULL" |\\');
|
|
89
|
+
console.log(' while IFS="|" read id val; do');
|
|
90
|
+
console.log(' newval=$(echo "$val" | node scripts/rotate-key.js --old=X --new=Y)');
|
|
91
|
+
console.log(' psql -c "UPDATE users SET api_key=\'$newval\' WHERE id=$id"');
|
|
92
|
+
console.log(' done\n');
|
|
93
|
+
console.log('Paste encrypted values (one per line). Press Ctrl+D when done:\n');
|
|
94
|
+
|
|
95
|
+
rl.on('line', (line) => {
|
|
96
|
+
const trimmed = line.trim();
|
|
97
|
+
if (!trimmed) return;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const decrypted = decryptWithKey(trimmed, oldKeyBuffer);
|
|
101
|
+
if (decrypted === null) {
|
|
102
|
+
console.error(`SKIP (not encrypted format): ${trimmed.substring(0, 30)}...`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const reEncrypted = encryptWithKey(decrypted, newKeyBuffer);
|
|
106
|
+
console.log(reEncrypted);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error(`ERROR: ${error.message} | Input: ${trimmed.substring(0, 30)}...`);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
rl.on('close', () => {
|
|
113
|
+
console.error('\n✅ Key rotation complete.');
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
main();
|
package/server/bootstrap.js
CHANGED
|
@@ -17,7 +17,7 @@ function processEncryption(event, strapi) {
|
|
|
17
17
|
|
|
18
18
|
const validation = validateValue(value, attribute);
|
|
19
19
|
if (!validation.valid) {
|
|
20
|
-
throw new Error(`
|
|
20
|
+
throw new Error(`Validation failed for field "${key}": ${validation.error}`);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
data[key] = encrypt(value, strapi);
|
|
@@ -50,7 +50,7 @@ module.exports = (config, { strapi }) => {
|
|
|
50
50
|
try {
|
|
51
51
|
obj[key] = decrypt(obj[key], strapi);
|
|
52
52
|
} catch (error) {
|
|
53
|
-
strapi.log.error(`
|
|
53
|
+
strapi.log.error(`Decryption error on field ${key}: ${error.message}`);
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
}
|
package/server/utils/crypto.js
CHANGED
|
@@ -9,10 +9,10 @@ let _cachedKey = null;
|
|
|
9
9
|
let _cachedKeySource = null;
|
|
10
10
|
|
|
11
11
|
function getEncryptionKey(strapi) {
|
|
12
|
-
const key = process.env.ENCRYPTION_KEY || strapi?.config?.get('plugin
|
|
12
|
+
const key = process.env.ENCRYPTION_KEY || strapi?.config?.get('plugin::encrypted-field.encryptionKey');
|
|
13
13
|
|
|
14
14
|
if (!key) {
|
|
15
|
-
const errorMsg = '⚠️ ENCRYPTION_KEY
|
|
15
|
+
const errorMsg = '⚠️ ENCRYPTION_KEY not configured. You must set a 64-character hexadecimal key in environment variables or Strapi configuration.';
|
|
16
16
|
if (strapi?.log?.error) {
|
|
17
17
|
strapi.log.error(errorMsg);
|
|
18
18
|
}
|
|
@@ -24,11 +24,11 @@ function getEncryptionKey(strapi) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
if (typeof key !== 'string' || key.length !== 64) {
|
|
27
|
-
throw new Error(`ENCRYPTION_KEY
|
|
27
|
+
throw new Error(`ENCRYPTION_KEY must be exactly 64 hexadecimal characters (32 bytes). Current: ${key?.length || 0}`);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
if (!/^[0-9a-fA-F]{64}$/.test(key)) {
|
|
31
|
-
throw new Error('ENCRYPTION_KEY
|
|
31
|
+
throw new Error('ENCRYPTION_KEY must contain only hexadecimal characters (0-9, a-f, A-F)');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
_cachedKey = Buffer.from(key, 'hex');
|
|
@@ -54,7 +54,7 @@ function encrypt(text, strapi) {
|
|
|
54
54
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
55
55
|
} catch (error) {
|
|
56
56
|
if (strapi?.log?.error) {
|
|
57
|
-
strapi.log.error(`
|
|
57
|
+
strapi.log.error(`Encryption error: ${error.message}`);
|
|
58
58
|
}
|
|
59
59
|
throw error;
|
|
60
60
|
}
|
|
@@ -88,7 +88,7 @@ function decrypt(encryptedText, strapi) {
|
|
|
88
88
|
return decrypted;
|
|
89
89
|
} catch (error) {
|
|
90
90
|
if (strapi?.log?.debug) {
|
|
91
|
-
strapi.log.debug(`
|
|
91
|
+
strapi.log.debug(`Decryption error: ${error.message}. Returning original text.`);
|
|
92
92
|
}
|
|
93
93
|
return encryptedText;
|
|
94
94
|
}
|
|
@@ -102,7 +102,7 @@ function validateValue(value, attribute) {
|
|
|
102
102
|
if (typeof value !== 'string') {
|
|
103
103
|
return {
|
|
104
104
|
valid: false,
|
|
105
|
-
error: '
|
|
105
|
+
error: 'Value must be a string'
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -112,13 +112,13 @@ function validateValue(value, attribute) {
|
|
|
112
112
|
if (!regex.test(value)) {
|
|
113
113
|
return {
|
|
114
114
|
valid: false,
|
|
115
|
-
error: `
|
|
115
|
+
error: `Value does not match the validation pattern: ${attribute.regex}`
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
118
|
} catch (error) {
|
|
119
119
|
return {
|
|
120
120
|
valid: false,
|
|
121
|
-
error: `
|
|
121
|
+
error: `Invalid regex pattern: ${error.message}`
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
}
|
|
@@ -126,14 +126,14 @@ function validateValue(value, attribute) {
|
|
|
126
126
|
if (attribute.maxLength && value.length > attribute.maxLength) {
|
|
127
127
|
return {
|
|
128
128
|
valid: false,
|
|
129
|
-
error: `
|
|
129
|
+
error: `Value exceeds maximum length of ${attribute.maxLength} characters`
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
if (attribute.minLength && value.length < attribute.minLength) {
|
|
134
134
|
return {
|
|
135
135
|
valid: false,
|
|
136
|
-
error: `
|
|
136
|
+
error: `Value must be at least ${attribute.minLength} characters`
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
|