@nextage/nx-frame-be 1.0.42 → 1.0.44
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/build/common/__test__/random-string.test.d.ts +1 -0
- package/build/common/__test__/random-string.test.js +30 -0
- package/build/common/constants.js +1 -1
- package/build/common/crypto/__test__/crypto.utils.test.d.ts +1 -0
- package/build/common/crypto/__test__/crypto.utils.test.js +101 -0
- package/build/common/crypto/__test__/migrate-encrypted-fields.test.d.ts +1 -0
- package/build/common/crypto/__test__/migrate-encrypted-fields.test.js +105 -0
- package/build/common/crypto/crypto-legacy.d.ts +16 -0
- package/build/common/crypto/crypto-legacy.js +83 -0
- package/build/common/crypto/crypto.constants.d.ts +11 -0
- package/build/common/crypto/crypto.constants.js +28 -1
- package/build/common/crypto/crypto.interfaces.d.ts +1 -1
- package/build/common/crypto/crypto.utils.d.ts +58 -15
- package/build/common/crypto/crypto.utils.js +106 -23
- package/build/common/crypto/index.d.ts +2 -0
- package/build/common/crypto/index.js +2 -0
- package/build/common/crypto/migrate-encrypted-fields.d.ts +55 -0
- package/build/common/crypto/migrate-encrypted-fields.js +114 -0
- package/build/common/types.d.ts +13 -0
- package/build/common/utils.d.ts +7 -1
- package/build/common/utils.js +11 -3
- package/package.json +1 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const utils_1 = require("../utils");
|
|
4
|
+
/**
|
|
5
|
+
* `randomString` must return a URL-safe token of the requested length. The
|
|
6
|
+
* base64url alphabet (`A-Za-z0-9-_`) avoids the `+`/`/`/`=` characters that the
|
|
7
|
+
* previous base64 implementation could emit, so the value is safe in URLs,
|
|
8
|
+
* tokens and filenames.
|
|
9
|
+
*/
|
|
10
|
+
describe('randomString', () => {
|
|
11
|
+
const URL_SAFE = /^[A-Za-z0-9_-]+$/;
|
|
12
|
+
it('returns a string of the requested length (default 21)', () => {
|
|
13
|
+
expect((0, utils_1.randomString)()).toHaveLength(21);
|
|
14
|
+
expect((0, utils_1.randomString)(7)).toHaveLength(7);
|
|
15
|
+
expect((0, utils_1.randomString)(32)).toHaveLength(32);
|
|
16
|
+
});
|
|
17
|
+
it('only uses URL-safe characters (no +, /, =)', () => {
|
|
18
|
+
for (const size of [7, 12, 21, 40]) {
|
|
19
|
+
const value = (0, utils_1.randomString)(size);
|
|
20
|
+
expect(value).toMatch(URL_SAFE);
|
|
21
|
+
expect(value).not.toContain('+');
|
|
22
|
+
expect(value).not.toContain('/');
|
|
23
|
+
expect(value).not.toContain('=');
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
it('is effectively unique across calls', () => {
|
|
27
|
+
const values = new Set(Array.from({ length: 500 }, () => (0, utils_1.randomString)(21)));
|
|
28
|
+
expect(values.size).toBe(500);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -18,7 +18,7 @@ exports.APP = {
|
|
|
18
18
|
dataScr: process.env.SECRET,
|
|
19
19
|
nxPubSub: new pubsub_manager_1.PubSubManager(),
|
|
20
20
|
shutdownMng: new shutdown_manager_1.ShutdownManager(),
|
|
21
|
-
config: { endpoint: {}, email: {}, jwt: {}, aws: {}, mongo: {}, logging: {}, invite: {}, msg: {} }
|
|
21
|
+
config: { endpoint: {}, email: {}, jwt: {}, aws: {}, mongo: {}, logging: {}, invite: {}, msg: {}, app: {} }
|
|
22
22
|
};
|
|
23
23
|
exports.FILE_CONTENT_TYPES = {
|
|
24
24
|
none: '',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const constants_1 = require("../../constants");
|
|
4
|
+
const crypto_legacy_1 = require("../crypto-legacy");
|
|
5
|
+
const crypto_constants_1 = require("../crypto.constants");
|
|
6
|
+
/**
|
|
7
|
+
* `CryptoUtils` migrated off `crypto-js` onto `node:crypto`.
|
|
8
|
+
*
|
|
9
|
+
* These tests pin two contracts:
|
|
10
|
+
* 1. the hash/PBKDF2 helpers stay BYTE-COMPATIBLE with the previous CryptoJS
|
|
11
|
+
* output (oracle vectors captured while crypto-js was still installed), so
|
|
12
|
+
* existing blind indexes keep matching;
|
|
13
|
+
* 2. field encryption uses authenticated AES-256-GCM (`v1:` scheme) while still
|
|
14
|
+
* decrypting the legacy OpenSSL `Salted__` format for backward compatibility.
|
|
15
|
+
*/
|
|
16
|
+
describe('CryptoUtils (node:crypto)', () => {
|
|
17
|
+
// Oracle vectors produced by crypto-js 4.2.0 before its removal.
|
|
18
|
+
const ORACLE = {
|
|
19
|
+
md5: '5d41402abc4b2a76b9719d911017c592',
|
|
20
|
+
sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
|
|
21
|
+
sha512: '9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043',
|
|
22
|
+
pbkdf2: '64747239499f1befef7f59618f443dd8f2ec330698a919978a6b4d90b14aa915',
|
|
23
|
+
// AES.encrypt('secret-data-àéì', 'passphrase-123')
|
|
24
|
+
legacy: 'U2FsdGVkX18FAy7zi7VFY7AD1ebKT3xBwKo9UavXDMgWA1DKBQ4RHgnYaWlV7gof',
|
|
25
|
+
};
|
|
26
|
+
describe('hashes stay byte-compatible with crypto-js', () => {
|
|
27
|
+
it('md5', () => expect(constants_1.cryptoUtils.md5('hello')).toBe(ORACLE.md5));
|
|
28
|
+
it('sha256', () => expect(constants_1.cryptoUtils.sha256('hello')).toBe(ORACLE.sha256));
|
|
29
|
+
it('sha512', () => expect(constants_1.cryptoUtils.sha512('hello')).toBe(ORACLE.sha512));
|
|
30
|
+
it('pbkdf2 blind index (256-bit, sha256, 10000)', () => {
|
|
31
|
+
expect(constants_1.cryptoUtils.pbkdf2('value', 'salt')).toBe(ORACLE.pbkdf2);
|
|
32
|
+
});
|
|
33
|
+
it('empty input returns empty string', () => {
|
|
34
|
+
expect(constants_1.cryptoUtils.md5('')).toBe('');
|
|
35
|
+
expect(constants_1.cryptoUtils.sha256('')).toBe('');
|
|
36
|
+
expect(constants_1.cryptoUtils.sha512('')).toBe('');
|
|
37
|
+
expect(constants_1.cryptoUtils.pbkdf2('', 'salt')).toBe('');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('field encryption (AES-256-GCM v1)', () => {
|
|
41
|
+
const secret = 'test-secret-passphrase';
|
|
42
|
+
it('round-trips a value and stamps the v1 prefix', () => {
|
|
43
|
+
const plain = 'user@example.com';
|
|
44
|
+
const enc = constants_1.cryptoUtils.encryptText(plain, secret);
|
|
45
|
+
expect(enc.startsWith(crypto_constants_1.FIELD_ENC_PREFIX)).toBe(true);
|
|
46
|
+
expect(enc).not.toContain(plain);
|
|
47
|
+
expect(constants_1.cryptoUtils.decryptText(enc, secret)).toBe(plain);
|
|
48
|
+
});
|
|
49
|
+
it('produces a different ciphertext each time (random IV)', () => {
|
|
50
|
+
const a = constants_1.cryptoUtils.encryptText('same', secret);
|
|
51
|
+
const b = constants_1.cryptoUtils.encryptText('same', secret);
|
|
52
|
+
expect(a).not.toBe(b);
|
|
53
|
+
expect(constants_1.cryptoUtils.decryptText(a, secret)).toBe('same');
|
|
54
|
+
expect(constants_1.cryptoUtils.decryptText(b, secret)).toBe('same');
|
|
55
|
+
});
|
|
56
|
+
it('handles unicode', () => {
|
|
57
|
+
const plain = 'àéìòù — 日本語 — €';
|
|
58
|
+
const enc = constants_1.cryptoUtils.encryptText(plain, secret);
|
|
59
|
+
expect(constants_1.cryptoUtils.decryptText(enc, secret)).toBe(plain);
|
|
60
|
+
});
|
|
61
|
+
it('returns empty string on empty input', () => {
|
|
62
|
+
expect(constants_1.cryptoUtils.encryptText('', secret)).toBe('');
|
|
63
|
+
expect(constants_1.cryptoUtils.decryptText('', secret)).toBe('');
|
|
64
|
+
expect(constants_1.cryptoUtils.encryptText('x', '')).toBe('');
|
|
65
|
+
});
|
|
66
|
+
it('fails closed on a tampered ciphertext (GCM auth)', () => {
|
|
67
|
+
const enc = constants_1.cryptoUtils.encryptText('sensitive', secret);
|
|
68
|
+
// Flip a character in the base64 body → auth tag no longer verifies.
|
|
69
|
+
const body = enc.slice(crypto_constants_1.FIELD_ENC_PREFIX.length);
|
|
70
|
+
const tampered = crypto_constants_1.FIELD_ENC_PREFIX + (body[0] === 'A' ? 'B' : 'A') + body.slice(1);
|
|
71
|
+
expect(constants_1.cryptoUtils.decryptText(tampered, secret)).toBe('');
|
|
72
|
+
});
|
|
73
|
+
it('wrong secret does not decrypt', () => {
|
|
74
|
+
const enc = constants_1.cryptoUtils.encryptText('sensitive', secret);
|
|
75
|
+
expect(constants_1.cryptoUtils.decryptText(enc, 'other-secret')).toBe('');
|
|
76
|
+
});
|
|
77
|
+
it('re-rotation: decrypt with old secret, re-encrypt with new secret', () => {
|
|
78
|
+
const plain = 'rotate-me';
|
|
79
|
+
const v1old = constants_1.cryptoUtils.encryptText(plain, 'old-secret');
|
|
80
|
+
// Simulate the migration engine's rotation step.
|
|
81
|
+
const roundTrip = constants_1.cryptoUtils.decryptText(v1old, 'old-secret');
|
|
82
|
+
const v1new = constants_1.cryptoUtils.encryptText(roundTrip, 'new-secret');
|
|
83
|
+
expect(constants_1.cryptoUtils.decryptText(v1new, 'new-secret')).toBe(plain);
|
|
84
|
+
expect(constants_1.cryptoUtils.decryptText(v1new, 'old-secret')).toBe('');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('legacy CryptoJS compatibility', () => {
|
|
88
|
+
it('detects a legacy Salted__ ciphertext', () => {
|
|
89
|
+
expect((0, crypto_legacy_1.isLegacyCiphertext)(ORACLE.legacy)).toBe(true);
|
|
90
|
+
expect((0, crypto_legacy_1.isLegacyCiphertext)('v1:abc')).toBe(false);
|
|
91
|
+
expect((0, crypto_legacy_1.isLegacyCiphertext)('plain text')).toBe(false);
|
|
92
|
+
expect((0, crypto_legacy_1.isLegacyCiphertext)('')).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
it('decrypts a real crypto-js ciphertext via node:crypto', () => {
|
|
95
|
+
expect((0, crypto_legacy_1.decryptLegacyAes)(ORACLE.legacy, 'passphrase-123')).toBe('secret-data-àéì');
|
|
96
|
+
});
|
|
97
|
+
it('decryptText transparently reads legacy values', () => {
|
|
98
|
+
expect(constants_1.cryptoUtils.decryptText(ORACLE.legacy, 'passphrase-123')).toBe('secret-data-àéì');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
const user_model_1 = require("../../models/user.model");
|
|
13
|
+
const constants_1 = require("../../constants");
|
|
14
|
+
const migrate_encrypted_fields_1 = require("../migrate-encrypted-fields");
|
|
15
|
+
const crypto_constants_1 = require("../crypto.constants");
|
|
16
|
+
/**
|
|
17
|
+
* `reEncryptLegacyFields` bulk-migrates legacy CryptoJS field ciphertexts to the
|
|
18
|
+
* current AES-256-GCM scheme, operating at the native driver level so it neither
|
|
19
|
+
* re-runs getters (read) nor setters (write). These tests seed a raw legacy value
|
|
20
|
+
* (produced by crypto-js 4.2.0 with secret 'aaa'), migrate it, and assert the
|
|
21
|
+
* value becomes readable `v1:` — and that a second run is a no-op (idempotent).
|
|
22
|
+
*/
|
|
23
|
+
describe('reEncryptLegacyFields', () => {
|
|
24
|
+
const SECRET = 'aaa'; // matches src/test/env.ts
|
|
25
|
+
// crypto-js AES.encrypt('mario.rossi@example.com', 'aaa') captured pre-removal.
|
|
26
|
+
const LEGACY_EMAIL = 'U2FsdGVkX18wA04OcAdQSqF5R07te9XD+nuXAMAbCaI8kSXFdK7bNBdSjKPyIvrX';
|
|
27
|
+
const LEGACY_PLAINTEXT = 'mario.rossi@example.com';
|
|
28
|
+
it('migrates a legacy value to the v1 GCM scheme and stays idempotent', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
29
|
+
const collection = user_model_1.users.mgModel.collection;
|
|
30
|
+
// Seed a raw document with a legacy-encrypted email (bypassing setters).
|
|
31
|
+
const { insertedId } = yield collection.insertOne({
|
|
32
|
+
username: 'mario',
|
|
33
|
+
email: LEGACY_EMAIL,
|
|
34
|
+
version: 0,
|
|
35
|
+
});
|
|
36
|
+
// --- first run: should migrate the field ---
|
|
37
|
+
const res1 = yield (0, migrate_encrypted_fields_1.reEncryptLegacyFields)({
|
|
38
|
+
targets: [{ model: user_model_1.users, fields: ['email'] }],
|
|
39
|
+
secret: SECRET,
|
|
40
|
+
});
|
|
41
|
+
expect(res1.migrated).toBe(1);
|
|
42
|
+
expect(res1.errors).toBe(0);
|
|
43
|
+
const migrated = yield collection.findOne({ _id: insertedId });
|
|
44
|
+
expect(migrated.email.startsWith(crypto_constants_1.FIELD_ENC_PREFIX)).toBe(true);
|
|
45
|
+
expect(constants_1.cryptoUtils.decryptText(migrated.email, SECRET)).toBe(LEGACY_PLAINTEXT);
|
|
46
|
+
// --- second run: nothing left to migrate ---
|
|
47
|
+
const res2 = yield (0, migrate_encrypted_fields_1.reEncryptLegacyFields)({
|
|
48
|
+
targets: [{ model: user_model_1.users, fields: ['email'] }],
|
|
49
|
+
secret: SECRET,
|
|
50
|
+
});
|
|
51
|
+
expect(res2.migrated).toBe(0);
|
|
52
|
+
expect(res2.skipped).toBe(1);
|
|
53
|
+
}));
|
|
54
|
+
it('dry-run reports the work without writing', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
55
|
+
const collection = user_model_1.users.mgModel.collection;
|
|
56
|
+
const { insertedId } = yield collection.insertOne({
|
|
57
|
+
username: 'anna',
|
|
58
|
+
email: LEGACY_EMAIL,
|
|
59
|
+
version: 0,
|
|
60
|
+
});
|
|
61
|
+
const res = yield (0, migrate_encrypted_fields_1.reEncryptLegacyFields)({
|
|
62
|
+
targets: [{ model: user_model_1.users, fields: ['email'] }],
|
|
63
|
+
secret: SECRET,
|
|
64
|
+
dryRun: true,
|
|
65
|
+
});
|
|
66
|
+
expect(res.migrated).toBe(1);
|
|
67
|
+
const untouched = yield collection.findOne({ _id: insertedId });
|
|
68
|
+
expect(untouched.email).toBe(LEGACY_EMAIL); // unchanged on disk
|
|
69
|
+
}));
|
|
70
|
+
it('skips empty and already-migrated values', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
71
|
+
const collection = user_model_1.users.mgModel.collection;
|
|
72
|
+
yield collection.insertOne({ username: 'empty', email: '', version: 0 });
|
|
73
|
+
yield collection.insertOne({
|
|
74
|
+
username: 'fresh',
|
|
75
|
+
email: constants_1.cryptoUtils.encryptText('fresh@example.com', SECRET),
|
|
76
|
+
version: 0,
|
|
77
|
+
});
|
|
78
|
+
const res = yield (0, migrate_encrypted_fields_1.reEncryptLegacyFields)({
|
|
79
|
+
targets: [{ model: user_model_1.users, fields: ['email'] }],
|
|
80
|
+
secret: SECRET,
|
|
81
|
+
});
|
|
82
|
+
expect(res.migrated).toBe(0);
|
|
83
|
+
expect(res.skipped).toBe(2);
|
|
84
|
+
}));
|
|
85
|
+
it('migrates a nested field without wiping its siblings', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
86
|
+
const collection = user_model_1.users.mgModel.collection;
|
|
87
|
+
// Only `profile.email` is legacy; firstname/lastname are plain siblings that
|
|
88
|
+
// MUST survive (a nested-object $set would replace the whole `profile`).
|
|
89
|
+
const { insertedId } = yield collection.insertOne({
|
|
90
|
+
username: 'nested',
|
|
91
|
+
profile: { email: LEGACY_EMAIL, firstname: 'Mario', lastname: 'Rossi' },
|
|
92
|
+
version: 0,
|
|
93
|
+
});
|
|
94
|
+
const res = yield (0, migrate_encrypted_fields_1.reEncryptLegacyFields)({
|
|
95
|
+
targets: [{ model: user_model_1.users, fields: ['profile.email'] }],
|
|
96
|
+
secret: SECRET,
|
|
97
|
+
});
|
|
98
|
+
expect(res.migrated).toBe(1);
|
|
99
|
+
const doc = yield collection.findOne({ _id: insertedId });
|
|
100
|
+
expect(doc.profile.firstname).toBe('Mario'); // sibling preserved
|
|
101
|
+
expect(doc.profile.lastname).toBe('Rossi'); // sibling preserved
|
|
102
|
+
expect(doc.profile.email.startsWith(crypto_constants_1.FIELD_ENC_PREFIX)).toBe(true);
|
|
103
|
+
expect(constants_1.cryptoUtils.decryptText(doc.profile.email, SECRET)).toBe(LEGACY_PLAINTEXT);
|
|
104
|
+
}));
|
|
105
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects whether a stored value is a legacy CryptoJS/OpenSSL ciphertext.
|
|
3
|
+
*
|
|
4
|
+
* @param text the stored field value
|
|
5
|
+
* @returns `true` if it base64-decodes to the `Salted__` header
|
|
6
|
+
*/
|
|
7
|
+
export declare function isLegacyCiphertext(text: unknown): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Decrypts a legacy CryptoJS/OpenSSL `Salted__` ciphertext.
|
|
10
|
+
*
|
|
11
|
+
* @param text the base64 legacy ciphertext
|
|
12
|
+
* @param secret the passphrase used to encrypt it
|
|
13
|
+
* @returns the decrypted UTF-8 plaintext
|
|
14
|
+
* @throws if the payload is not a valid `Salted__` block
|
|
15
|
+
*/
|
|
16
|
+
export declare function decryptLegacyAes(text: string, secret: string): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isLegacyCiphertext = isLegacyCiphertext;
|
|
7
|
+
exports.decryptLegacyAes = decryptLegacyAes;
|
|
8
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
9
|
+
const crypto_constants_1 = require("./crypto.constants");
|
|
10
|
+
/**
|
|
11
|
+
* Backward-compatibility decryptor for values produced by the previous
|
|
12
|
+
* `CryptoJS.AES.encrypt(text, passphrase)` implementation.
|
|
13
|
+
*
|
|
14
|
+
* CryptoJS emits the OpenSSL format: base64 of `"Salted__" | salt(8) |
|
|
15
|
+
* ciphertext`, where the AES-256-CBC key and IV are derived from the passphrase
|
|
16
|
+
* and salt with OpenSSL's `EVP_BytesToKey` (one round of MD5). This module
|
|
17
|
+
* re-implements that derivation with `node:crypto`, so `crypto-js` can be
|
|
18
|
+
* dropped while existing ciphertexts remain readable.
|
|
19
|
+
*/
|
|
20
|
+
const SALT_HEADER_BYTES = 8;
|
|
21
|
+
const SALT_BYTES = 8;
|
|
22
|
+
const AES_KEY_BYTES = 32; // AES-256
|
|
23
|
+
const AES_IV_BYTES = 16; // CBC IV
|
|
24
|
+
/**
|
|
25
|
+
* OpenSSL `EVP_BytesToKey` with a single MD5 digest per block.
|
|
26
|
+
*
|
|
27
|
+
* Derives `keyLen + ivLen` bytes by hashing `previousBlock | password | salt`
|
|
28
|
+
* repeatedly, exactly as OpenSSL/CryptoJS do for `AES.encrypt(text, passphrase)`.
|
|
29
|
+
*
|
|
30
|
+
* @param password the passphrase (secret) as used by CryptoJS
|
|
31
|
+
* @param salt the 8-byte salt extracted from the payload
|
|
32
|
+
* @param keyLen number of key bytes to derive (32 for AES-256)
|
|
33
|
+
* @param ivLen number of IV bytes to derive (16 for CBC)
|
|
34
|
+
*/
|
|
35
|
+
function evpBytesToKey(password, salt, keyLen, ivLen) {
|
|
36
|
+
const pwd = Buffer.from(password, 'utf8');
|
|
37
|
+
let derived = Buffer.alloc(0);
|
|
38
|
+
let block = Buffer.alloc(0);
|
|
39
|
+
while (derived.length < keyLen + ivLen) {
|
|
40
|
+
block = node_crypto_1.default.createHash('md5').update(Buffer.concat([block, pwd, salt])).digest();
|
|
41
|
+
derived = Buffer.concat([derived, block]);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
key: derived.subarray(0, keyLen),
|
|
45
|
+
iv: derived.subarray(keyLen, keyLen + ivLen),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Detects whether a stored value is a legacy CryptoJS/OpenSSL ciphertext.
|
|
50
|
+
*
|
|
51
|
+
* @param text the stored field value
|
|
52
|
+
* @returns `true` if it base64-decodes to the `Salted__` header
|
|
53
|
+
*/
|
|
54
|
+
function isLegacyCiphertext(text) {
|
|
55
|
+
if (!text || typeof text !== 'string')
|
|
56
|
+
return false;
|
|
57
|
+
try {
|
|
58
|
+
const raw = Buffer.from(text, 'base64');
|
|
59
|
+
return raw.length > SALT_HEADER_BYTES + SALT_BYTES
|
|
60
|
+
&& raw.subarray(0, SALT_HEADER_BYTES).toString('latin1') === crypto_constants_1.LEGACY_ENC_MAGIC;
|
|
61
|
+
}
|
|
62
|
+
catch (_a) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Decrypts a legacy CryptoJS/OpenSSL `Salted__` ciphertext.
|
|
68
|
+
*
|
|
69
|
+
* @param text the base64 legacy ciphertext
|
|
70
|
+
* @param secret the passphrase used to encrypt it
|
|
71
|
+
* @returns the decrypted UTF-8 plaintext
|
|
72
|
+
* @throws if the payload is not a valid `Salted__` block
|
|
73
|
+
*/
|
|
74
|
+
function decryptLegacyAes(text, secret) {
|
|
75
|
+
const raw = Buffer.from(text, 'base64');
|
|
76
|
+
if (raw.subarray(0, SALT_HEADER_BYTES).toString('latin1') !== crypto_constants_1.LEGACY_ENC_MAGIC)
|
|
77
|
+
throw new Error('Not a legacy Salted__ ciphertext');
|
|
78
|
+
const salt = raw.subarray(SALT_HEADER_BYTES, SALT_HEADER_BYTES + SALT_BYTES);
|
|
79
|
+
const ciphertext = raw.subarray(SALT_HEADER_BYTES + SALT_BYTES);
|
|
80
|
+
const { key, iv } = evpBytesToKey(secret, salt, AES_KEY_BYTES, AES_IV_BYTES);
|
|
81
|
+
const decipher = node_crypto_1.default.createDecipheriv('aes-256-cbc', key, iv);
|
|
82
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
83
|
+
}
|
|
@@ -5,6 +5,17 @@ export declare const CRYPTO_SYM_BITSIZE = 256;
|
|
|
5
5
|
export declare const CRYPTO_ASYM_BITSIZE_LIST: number[];
|
|
6
6
|
export declare const CRYPTO_PUB_KEY_ENCODING: KeyEncoding;
|
|
7
7
|
export declare const CRYPTO_PRIV_KEY_ENCODING: KeyEncoding;
|
|
8
|
+
export declare const FIELD_ENC_VERSION = "v1";
|
|
9
|
+
export declare const FIELD_ENC_PREFIX = "v1:";
|
|
10
|
+
export declare const FIELD_ENC_ALGO = "aes-256-gcm";
|
|
11
|
+
export declare const FIELD_ENC_IV_BYTES = 12;
|
|
12
|
+
export declare const FIELD_ENC_TAG_BYTES = 16;
|
|
13
|
+
export declare const FIELD_ENC_KEY_BYTES = 32;
|
|
14
|
+
export declare const FIELD_ENC_KDF_SALT = "nxfb.field.v1";
|
|
15
|
+
export declare const FIELD_ENC_KDF_N = 16384;
|
|
16
|
+
export declare const FIELD_ENC_KDF_R = 8;
|
|
17
|
+
export declare const FIELD_ENC_KDF_P = 1;
|
|
18
|
+
export declare const LEGACY_ENC_MAGIC = "Salted__";
|
|
8
19
|
export declare const CRYPTO_SYM_BITSIZE_MAP: NxTypeObject<KeySizeMap>;
|
|
9
20
|
export declare enum CRYPTO_ERR_CODES {
|
|
10
21
|
BITSIZE = "ERR_INVALID_BITSIZE",
|
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CRYPTO_ERRORS = exports.CRYPTO_ERR_CODES = exports.CRYPTO_SYM_BITSIZE_MAP = exports.CRYPTO_PRIV_KEY_ENCODING = exports.CRYPTO_PUB_KEY_ENCODING = exports.CRYPTO_ASYM_BITSIZE_LIST = exports.CRYPTO_SYM_BITSIZE = exports.CRYPTO_ASYM_BITSIZE = void 0;
|
|
3
|
+
exports.CRYPTO_ERRORS = exports.CRYPTO_ERR_CODES = exports.CRYPTO_SYM_BITSIZE_MAP = exports.LEGACY_ENC_MAGIC = exports.FIELD_ENC_KDF_P = exports.FIELD_ENC_KDF_R = exports.FIELD_ENC_KDF_N = exports.FIELD_ENC_KDF_SALT = exports.FIELD_ENC_KEY_BYTES = exports.FIELD_ENC_TAG_BYTES = exports.FIELD_ENC_IV_BYTES = exports.FIELD_ENC_ALGO = exports.FIELD_ENC_PREFIX = exports.FIELD_ENC_VERSION = exports.CRYPTO_PRIV_KEY_ENCODING = exports.CRYPTO_PUB_KEY_ENCODING = exports.CRYPTO_ASYM_BITSIZE_LIST = exports.CRYPTO_SYM_BITSIZE = exports.CRYPTO_ASYM_BITSIZE = void 0;
|
|
4
4
|
exports.CRYPTO_ASYM_BITSIZE = 4096;
|
|
5
5
|
exports.CRYPTO_SYM_BITSIZE = 256;
|
|
6
6
|
exports.CRYPTO_ASYM_BITSIZE_LIST = [1024, 2048, 4096];
|
|
7
7
|
exports.CRYPTO_PUB_KEY_ENCODING = { type: 'spki', format: 'pem' };
|
|
8
8
|
exports.CRYPTO_PRIV_KEY_ENCODING = { type: 'pkcs8', format: 'pem' };
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Field-level symmetric encryption (encryptText / decryptText).
|
|
11
|
+
//
|
|
12
|
+
// New scheme: AES-256-GCM (authenticated) with a random 12-byte IV per value.
|
|
13
|
+
// The stored value is `<version>:<base64(iv | authTag | ciphertext)>`, where the
|
|
14
|
+
// version prefix identifies the scheme and enables key rotation (a future `v2`
|
|
15
|
+
// can coexist with `v1`). The AES key is derived from the secret passphrase with
|
|
16
|
+
// scrypt (memory-hard) and cached per secret+version.
|
|
17
|
+
//
|
|
18
|
+
// Legacy scheme (produced by the previous CryptoJS.AES implementation) has NO
|
|
19
|
+
// version prefix: it is OpenSSL "Salted__" base64 (MD5 EVP_BytesToKey +
|
|
20
|
+
// AES-256-CBC). `decryptText` still reads it for backward compatibility.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
exports.FIELD_ENC_VERSION = 'v1';
|
|
23
|
+
exports.FIELD_ENC_PREFIX = `${exports.FIELD_ENC_VERSION}:`;
|
|
24
|
+
exports.FIELD_ENC_ALGO = 'aes-256-gcm';
|
|
25
|
+
exports.FIELD_ENC_IV_BYTES = 12;
|
|
26
|
+
exports.FIELD_ENC_TAG_BYTES = 16;
|
|
27
|
+
exports.FIELD_ENC_KEY_BYTES = 32;
|
|
28
|
+
// scrypt KDF parameters used to derive the AES key from the secret passphrase.
|
|
29
|
+
// `N=16384,r=8,p=1` needs ~16 MB and is derived once per secret (then cached).
|
|
30
|
+
exports.FIELD_ENC_KDF_SALT = 'nxfb.field.v1';
|
|
31
|
+
exports.FIELD_ENC_KDF_N = 16384;
|
|
32
|
+
exports.FIELD_ENC_KDF_R = 8;
|
|
33
|
+
exports.FIELD_ENC_KDF_P = 1;
|
|
34
|
+
// Legacy CryptoJS/OpenSSL "Salted__" magic header (8 bytes, then 8-byte salt).
|
|
35
|
+
exports.LEGACY_ENC_MAGIC = 'Salted__';
|
|
9
36
|
exports.CRYPTO_SYM_BITSIZE_MAP = {
|
|
10
37
|
128: {
|
|
11
38
|
keySize: 128,
|
|
@@ -4,6 +4,8 @@ import { BlindIndexOptions } from './crypto.interfaces';
|
|
|
4
4
|
import { SymmetricKey, AsymmetricKeyPair } from './crypto.classes';
|
|
5
5
|
export declare class CryptoUtils {
|
|
6
6
|
logger: any;
|
|
7
|
+
/** Cache of scrypt-derived AES keys, keyed by the secret passphrase. */
|
|
8
|
+
private readonly keyCache;
|
|
7
9
|
constructor();
|
|
8
10
|
/**
|
|
9
11
|
* Generate a random Symmetric key based on AES bit size specified
|
|
@@ -52,48 +54,83 @@ export declare class CryptoUtils {
|
|
|
52
54
|
*/
|
|
53
55
|
decypherAsym(privateKey: string, encData: string): Promise<string>;
|
|
54
56
|
/**
|
|
55
|
-
*
|
|
57
|
+
* Derives (and caches) the AES-256 key for a secret passphrase via scrypt.
|
|
56
58
|
*
|
|
57
|
-
*
|
|
59
|
+
* scrypt is memory-hard; the derivation runs once per distinct secret and the
|
|
60
|
+
* 32-byte result is cached, so per-field encrypt/decrypt stays cheap.
|
|
61
|
+
*
|
|
62
|
+
* @param secret the secret passphrase
|
|
63
|
+
* @returns the 32-byte AES key
|
|
64
|
+
*/
|
|
65
|
+
private deriveFieldKey;
|
|
66
|
+
/**
|
|
67
|
+
* Decrypts a field value.
|
|
68
|
+
*
|
|
69
|
+
* Dispatches on the stored format: a `v1:`-prefixed value is AES-256-GCM
|
|
70
|
+
* (current scheme); an un-prefixed value is decrypted with the legacy
|
|
71
|
+
* CryptoJS/OpenSSL reader for backward compatibility. Returns `''` on missing
|
|
72
|
+
* input or on any decryption failure (matching the previous behaviour).
|
|
73
|
+
*
|
|
74
|
+
* @param { string } text AES encrypted string to be decrypted
|
|
58
75
|
* @param { string } secret Secret string to decrypt value
|
|
59
76
|
* @returns { string } Decrypted value
|
|
60
77
|
*/
|
|
61
78
|
decryptText(text: string, secret: string): string;
|
|
62
79
|
/**
|
|
63
|
-
*
|
|
80
|
+
* Encrypts a field value with the current scheme (AES-256-GCM).
|
|
64
81
|
*
|
|
65
|
-
*
|
|
82
|
+
* Output is `v1:<base64(iv | authTag | ciphertext)>`. The version prefix keeps
|
|
83
|
+
* the value self-describing and enables future key rotation.
|
|
84
|
+
*
|
|
85
|
+
* @param { string } text Plain text string to be encrypted
|
|
66
86
|
* @param { string } secret Secret string to encrypt value
|
|
67
87
|
* @returns { string } Encrypted value
|
|
68
88
|
*/
|
|
69
89
|
encryptText(text: string, secret: string): string;
|
|
70
90
|
/**
|
|
71
|
-
*
|
|
91
|
+
* Decrypts a current-scheme (`v1:`) AES-256-GCM value.
|
|
92
|
+
*
|
|
93
|
+
* @param text the `v1:`-prefixed ciphertext
|
|
94
|
+
* @param secret the secret passphrase
|
|
95
|
+
* @returns the decrypted UTF-8 plaintext
|
|
96
|
+
* @throws if the authentication tag does not verify (tampered/wrong key)
|
|
97
|
+
*/
|
|
98
|
+
private decryptGcm;
|
|
99
|
+
/**
|
|
100
|
+
* Generates an md5 hash (hex) from the given string.
|
|
72
101
|
*
|
|
73
102
|
* @param {string} text
|
|
74
103
|
*/
|
|
75
104
|
md5(text: string): string;
|
|
76
105
|
/**
|
|
106
|
+
* Generates a sha256 hash (hex) from the given string.
|
|
77
107
|
*
|
|
78
|
-
* @param {
|
|
79
|
-
* @returns
|
|
108
|
+
* @param {string} text
|
|
109
|
+
* @returns {string} the hex digest
|
|
80
110
|
*/
|
|
81
111
|
sha256(text: string): string;
|
|
82
112
|
/**
|
|
113
|
+
* Generates a sha512 hash (hex) from the given string.
|
|
83
114
|
*
|
|
84
115
|
* @param {*} text
|
|
85
|
-
* @returns
|
|
116
|
+
* @returns the hex digest
|
|
86
117
|
*/
|
|
87
118
|
sha512(text: string): string;
|
|
88
119
|
/**
|
|
89
|
-
*
|
|
120
|
+
* Derives a PBKDF2 hash (hex) — used for searchable blind indexes.
|
|
121
|
+
*
|
|
122
|
+
* Byte-compatible with the previous CryptoJS implementation: `keySize` is in
|
|
123
|
+
* bits (default 256 → 32 output bytes) and `digest` selects the PRF hash.
|
|
124
|
+
* Existing `_bidx` values remain valid.
|
|
90
125
|
*
|
|
91
126
|
* @param { string } text
|
|
92
127
|
* @param { string } salt
|
|
93
|
-
* @param { number} keySize default 256
|
|
94
|
-
* @
|
|
128
|
+
* @param { number } keySize output size in bits (default 256)
|
|
129
|
+
* @param { string } digest PRF hash name (default `'sha256'`)
|
|
130
|
+
* @param { number } iterations iteration count (default 10000)
|
|
131
|
+
* @return { string } the hex digest
|
|
95
132
|
*/
|
|
96
|
-
pbkdf2(text: string, salt: string, keySize?: number,
|
|
133
|
+
pbkdf2(text: string, salt: string, keySize?: number, digest?: string, iterations?: number): string;
|
|
97
134
|
/**
|
|
98
135
|
* Encrypt a text to PBKDF2 derivation function using given salt and keysize
|
|
99
136
|
*
|
|
@@ -124,11 +161,17 @@ export declare class CryptoUtils {
|
|
|
124
161
|
*/
|
|
125
162
|
createBlindIndexes(item: NxObject | undefined, keys: string[] | undefined, secret: string, opts?: BlindIndexOptions): void;
|
|
126
163
|
/**
|
|
164
|
+
* Computes a searchable blind index (`<key>_bidx`) for a field.
|
|
127
165
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
166
|
+
* Hashes the (optionally upper-cased) value with PBKDF2 so equality searches
|
|
167
|
+
* are possible over the encrypted field without exposing the plaintext.
|
|
168
|
+
*
|
|
169
|
+
* @param item the document, mutated in place with `<key>_bidx`
|
|
170
|
+
* @param key the field to index
|
|
171
|
+
* @param salt per-field salt (falls back to the field name)
|
|
172
|
+
* @param opts blind-index options (`digest`, `iterations`, `keySize`, `normalize`)
|
|
130
173
|
*/
|
|
131
|
-
createBlindIndex(item: NxObject, key: string, salt: string, {
|
|
174
|
+
createBlindIndex(item: NxObject, key: string, salt: string, { digest, iterations, keySize, normalize }?: BlindIndexOptions): void;
|
|
132
175
|
/**
|
|
133
176
|
*
|
|
134
177
|
* @param {*} item
|
|
@@ -5,7 +5,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.CryptoUtils = void 0;
|
|
7
7
|
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
-
const crypto_js_1 = __importDefault(require("crypto-js"));
|
|
9
8
|
const stream_1 = require("stream");
|
|
10
9
|
const constants_1 = require("../constants");
|
|
11
10
|
const utils_1 = require("../utils");
|
|
@@ -15,9 +14,21 @@ const crypto_constants_2 = require("./crypto.constants");
|
|
|
15
14
|
const crypto_constants_3 = require("./crypto.constants");
|
|
16
15
|
const crypto_constants_4 = require("./crypto.constants");
|
|
17
16
|
const crypto_constants_5 = require("./crypto.constants");
|
|
17
|
+
const crypto_constants_6 = require("./crypto.constants");
|
|
18
|
+
const crypto_constants_7 = require("./crypto.constants");
|
|
19
|
+
const crypto_constants_8 = require("./crypto.constants");
|
|
20
|
+
const crypto_constants_9 = require("./crypto.constants");
|
|
21
|
+
const crypto_constants_10 = require("./crypto.constants");
|
|
22
|
+
const crypto_constants_11 = require("./crypto.constants");
|
|
23
|
+
const crypto_constants_12 = require("./crypto.constants");
|
|
24
|
+
const crypto_constants_13 = require("./crypto.constants");
|
|
25
|
+
const crypto_legacy_1 = require("./crypto-legacy");
|
|
26
|
+
const crypto_legacy_2 = require("./crypto-legacy");
|
|
18
27
|
const crypto_classes_1 = require("./crypto.classes");
|
|
19
28
|
class CryptoUtils {
|
|
20
29
|
constructor() {
|
|
30
|
+
/** Cache of scrypt-derived AES keys, keyed by the secret passphrase. */
|
|
31
|
+
this.keyCache = new Map();
|
|
21
32
|
this.logger = constants_1.APP.logger;
|
|
22
33
|
}
|
|
23
34
|
/**
|
|
@@ -202,10 +213,37 @@ class CryptoUtils {
|
|
|
202
213
|
});
|
|
203
214
|
}
|
|
204
215
|
// --------------------------------------------------------- CRYPTO-JS METHODS ---------------------------------------------------------------------------
|
|
216
|
+
// --------------------------------------------------------- SYMMETRIC FIELD ENCRYPTION ---------------------------------------------------------------
|
|
205
217
|
/**
|
|
206
|
-
*
|
|
218
|
+
* Derives (and caches) the AES-256 key for a secret passphrase via scrypt.
|
|
207
219
|
*
|
|
208
|
-
*
|
|
220
|
+
* scrypt is memory-hard; the derivation runs once per distinct secret and the
|
|
221
|
+
* 32-byte result is cached, so per-field encrypt/decrypt stays cheap.
|
|
222
|
+
*
|
|
223
|
+
* @param secret the secret passphrase
|
|
224
|
+
* @returns the 32-byte AES key
|
|
225
|
+
*/
|
|
226
|
+
deriveFieldKey(secret) {
|
|
227
|
+
const cached = this.keyCache.get(secret);
|
|
228
|
+
if (cached)
|
|
229
|
+
return cached;
|
|
230
|
+
const key = crypto_1.default.scryptSync(secret, crypto_constants_10.FIELD_ENC_KDF_SALT, crypto_constants_9.FIELD_ENC_KEY_BYTES, {
|
|
231
|
+
N: crypto_constants_11.FIELD_ENC_KDF_N,
|
|
232
|
+
r: crypto_constants_12.FIELD_ENC_KDF_R,
|
|
233
|
+
p: crypto_constants_13.FIELD_ENC_KDF_P,
|
|
234
|
+
});
|
|
235
|
+
this.keyCache.set(secret, key);
|
|
236
|
+
return key;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Decrypts a field value.
|
|
240
|
+
*
|
|
241
|
+
* Dispatches on the stored format: a `v1:`-prefixed value is AES-256-GCM
|
|
242
|
+
* (current scheme); an un-prefixed value is decrypted with the legacy
|
|
243
|
+
* CryptoJS/OpenSSL reader for backward compatibility. Returns `''` on missing
|
|
244
|
+
* input or on any decryption failure (matching the previous behaviour).
|
|
245
|
+
*
|
|
246
|
+
* @param { string } text AES encrypted string to be decrypted
|
|
209
247
|
* @param { string } secret Secret string to decrypt value
|
|
210
248
|
* @returns { string } Decrypted value
|
|
211
249
|
*/
|
|
@@ -213,7 +251,12 @@ class CryptoUtils {
|
|
|
213
251
|
if (!text || !secret)
|
|
214
252
|
return '';
|
|
215
253
|
try {
|
|
216
|
-
|
|
254
|
+
if (text.startsWith(crypto_constants_6.FIELD_ENC_PREFIX))
|
|
255
|
+
return this.decryptGcm(text, secret);
|
|
256
|
+
if ((0, crypto_legacy_1.isLegacyCiphertext)(text))
|
|
257
|
+
return (0, crypto_legacy_2.decryptLegacyAes)(text, secret);
|
|
258
|
+
// Neither current nor legacy ciphertext: nothing safe to decrypt.
|
|
259
|
+
return '';
|
|
217
260
|
}
|
|
218
261
|
catch (e) {
|
|
219
262
|
this.logger.error(e, (0, utils_1.logClassCtx)(this, 'decryptText'));
|
|
@@ -221,9 +264,12 @@ class CryptoUtils {
|
|
|
221
264
|
}
|
|
222
265
|
}
|
|
223
266
|
/**
|
|
224
|
-
*
|
|
267
|
+
* Encrypts a field value with the current scheme (AES-256-GCM).
|
|
268
|
+
*
|
|
269
|
+
* Output is `v1:<base64(iv | authTag | ciphertext)>`. The version prefix keeps
|
|
270
|
+
* the value self-describing and enables future key rotation.
|
|
225
271
|
*
|
|
226
|
-
* @param { string } text
|
|
272
|
+
* @param { string } text Plain text string to be encrypted
|
|
227
273
|
* @param { string } secret Secret string to encrypt value
|
|
228
274
|
* @returns { string } Encrypted value
|
|
229
275
|
*/
|
|
@@ -231,7 +277,12 @@ class CryptoUtils {
|
|
|
231
277
|
if (!text || !secret)
|
|
232
278
|
return '';
|
|
233
279
|
try {
|
|
234
|
-
|
|
280
|
+
const key = this.deriveFieldKey(secret);
|
|
281
|
+
const iv = crypto_1.default.randomBytes(crypto_constants_7.FIELD_ENC_IV_BYTES);
|
|
282
|
+
const cipher = crypto_1.default.createCipheriv(crypto_constants_6.FIELD_ENC_ALGO, key, iv);
|
|
283
|
+
const ciphertext = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
|
284
|
+
const authTag = cipher.getAuthTag();
|
|
285
|
+
return crypto_constants_6.FIELD_ENC_PREFIX + Buffer.concat([iv, authTag, ciphertext]).toString('base64');
|
|
235
286
|
}
|
|
236
287
|
catch (e) {
|
|
237
288
|
this.logger.error(e, (0, utils_1.logClassCtx)(this, 'encryptText'));
|
|
@@ -239,47 +290,73 @@ class CryptoUtils {
|
|
|
239
290
|
}
|
|
240
291
|
}
|
|
241
292
|
/**
|
|
242
|
-
*
|
|
293
|
+
* Decrypts a current-scheme (`v1:`) AES-256-GCM value.
|
|
294
|
+
*
|
|
295
|
+
* @param text the `v1:`-prefixed ciphertext
|
|
296
|
+
* @param secret the secret passphrase
|
|
297
|
+
* @returns the decrypted UTF-8 plaintext
|
|
298
|
+
* @throws if the authentication tag does not verify (tampered/wrong key)
|
|
299
|
+
*/
|
|
300
|
+
decryptGcm(text, secret) {
|
|
301
|
+
const raw = Buffer.from(text.slice(crypto_constants_6.FIELD_ENC_PREFIX.length), 'base64');
|
|
302
|
+
const iv = raw.subarray(0, crypto_constants_7.FIELD_ENC_IV_BYTES);
|
|
303
|
+
const authTag = raw.subarray(crypto_constants_7.FIELD_ENC_IV_BYTES, crypto_constants_7.FIELD_ENC_IV_BYTES + crypto_constants_8.FIELD_ENC_TAG_BYTES);
|
|
304
|
+
const ciphertext = raw.subarray(crypto_constants_7.FIELD_ENC_IV_BYTES + crypto_constants_8.FIELD_ENC_TAG_BYTES);
|
|
305
|
+
const key = this.deriveFieldKey(secret);
|
|
306
|
+
const decipher = crypto_1.default.createDecipheriv(crypto_constants_6.FIELD_ENC_ALGO, key, iv);
|
|
307
|
+
decipher.setAuthTag(authTag);
|
|
308
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Generates an md5 hash (hex) from the given string.
|
|
243
312
|
*
|
|
244
313
|
* @param {string} text
|
|
245
314
|
*/
|
|
246
315
|
md5(text) {
|
|
247
316
|
if (!text)
|
|
248
317
|
return '';
|
|
249
|
-
return
|
|
318
|
+
return crypto_1.default.createHash('md5').update(text).digest('hex');
|
|
250
319
|
}
|
|
251
320
|
/**
|
|
321
|
+
* Generates a sha256 hash (hex) from the given string.
|
|
252
322
|
*
|
|
253
|
-
* @param {
|
|
254
|
-
* @returns
|
|
323
|
+
* @param {string} text
|
|
324
|
+
* @returns {string} the hex digest
|
|
255
325
|
*/
|
|
256
326
|
sha256(text) {
|
|
257
327
|
if (!text)
|
|
258
328
|
return '';
|
|
259
|
-
return
|
|
329
|
+
return crypto_1.default.createHash('sha256').update(text).digest('hex');
|
|
260
330
|
}
|
|
261
331
|
/**
|
|
332
|
+
* Generates a sha512 hash (hex) from the given string.
|
|
262
333
|
*
|
|
263
334
|
* @param {*} text
|
|
264
|
-
* @returns
|
|
335
|
+
* @returns the hex digest
|
|
265
336
|
*/
|
|
266
337
|
sha512(text) {
|
|
267
338
|
if (!text)
|
|
268
339
|
return '';
|
|
269
|
-
return
|
|
340
|
+
return crypto_1.default.createHash('sha512').update(text).digest('hex');
|
|
270
341
|
}
|
|
271
342
|
/**
|
|
272
|
-
*
|
|
343
|
+
* Derives a PBKDF2 hash (hex) — used for searchable blind indexes.
|
|
344
|
+
*
|
|
345
|
+
* Byte-compatible with the previous CryptoJS implementation: `keySize` is in
|
|
346
|
+
* bits (default 256 → 32 output bytes) and `digest` selects the PRF hash.
|
|
347
|
+
* Existing `_bidx` values remain valid.
|
|
273
348
|
*
|
|
274
349
|
* @param { string } text
|
|
275
350
|
* @param { string } salt
|
|
276
|
-
* @param { number} keySize default 256
|
|
277
|
-
* @
|
|
351
|
+
* @param { number } keySize output size in bits (default 256)
|
|
352
|
+
* @param { string } digest PRF hash name (default `'sha256'`)
|
|
353
|
+
* @param { number } iterations iteration count (default 10000)
|
|
354
|
+
* @return { string } the hex digest
|
|
278
355
|
*/
|
|
279
|
-
pbkdf2(text, salt, keySize = 256,
|
|
356
|
+
pbkdf2(text, salt, keySize = 256, digest = 'sha256', iterations = 10000) {
|
|
280
357
|
if (!text || !salt)
|
|
281
358
|
return '';
|
|
282
|
-
return
|
|
359
|
+
return crypto_1.default.pbkdf2Sync(text, salt, iterations, keySize / 8, digest).toString('hex');
|
|
283
360
|
}
|
|
284
361
|
/**
|
|
285
362
|
* Encrypt a text to PBKDF2 derivation function using given salt and keysize
|
|
@@ -327,17 +404,23 @@ class CryptoUtils {
|
|
|
327
404
|
keys.forEach(k => this.createBlindIndex(item, k, secret, opts));
|
|
328
405
|
}
|
|
329
406
|
/**
|
|
407
|
+
* Computes a searchable blind index (`<key>_bidx`) for a field.
|
|
330
408
|
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
409
|
+
* Hashes the (optionally upper-cased) value with PBKDF2 so equality searches
|
|
410
|
+
* are possible over the encrypted field without exposing the plaintext.
|
|
411
|
+
*
|
|
412
|
+
* @param item the document, mutated in place with `<key>_bidx`
|
|
413
|
+
* @param key the field to index
|
|
414
|
+
* @param salt per-field salt (falls back to the field name)
|
|
415
|
+
* @param opts blind-index options (`digest`, `iterations`, `keySize`, `normalize`)
|
|
333
416
|
*/
|
|
334
|
-
createBlindIndex(item, key, salt, {
|
|
417
|
+
createBlindIndex(item, key, salt, { digest, iterations, keySize, normalize } = {}) {
|
|
335
418
|
let value = (0, utils_1.getAttr)(item, key);
|
|
336
419
|
if (!value)
|
|
337
420
|
return;
|
|
338
421
|
if (normalize)
|
|
339
422
|
value = `${value}`.toUpperCase();
|
|
340
|
-
(0, utils_1.setAttr)(item, `${key}_bidx`, this.pbkdf2(value, salt || key, keySize,
|
|
423
|
+
(0, utils_1.setAttr)(item, `${key}_bidx`, this.pbkdf2(value, salt || key, keySize, digest, iterations));
|
|
341
424
|
}
|
|
342
425
|
/**
|
|
343
426
|
*
|
|
@@ -17,4 +17,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
17
17
|
__exportStar(require("./crypto.interfaces"), exports);
|
|
18
18
|
__exportStar(require("./crypto.classes"), exports);
|
|
19
19
|
__exportStar(require("./crypto.utils"), exports);
|
|
20
|
+
__exportStar(require("./crypto-legacy"), exports);
|
|
21
|
+
__exportStar(require("./migrate-encrypted-fields"), exports);
|
|
20
22
|
__exportStar(require("./encryption.plugin"), exports);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { BaseModel } from '../base/base.model';
|
|
2
|
+
/**
|
|
3
|
+
* Reusable one-shot migration for legacy field-level encryption.
|
|
4
|
+
*
|
|
5
|
+
* The previous `CryptoJS.AES` implementation stored un-versioned OpenSSL
|
|
6
|
+
* `Salted__` ciphertexts. The current scheme is AES-256-GCM with a `v1:`
|
|
7
|
+
* version prefix. New writes migrate lazily (every `save` re-encrypts through the
|
|
8
|
+
* model setters), but records that are never re-saved stay in the legacy format.
|
|
9
|
+
*
|
|
10
|
+
* This engine bulk-migrates them: it reads documents at the **native driver
|
|
11
|
+
* level** (bypassing Mongoose getters), and for each configured field decrypts
|
|
12
|
+
* the legacy value and re-encrypts it with the current scheme, writing back at
|
|
13
|
+
* the **native driver level** too (bypassing setters, so the value is not
|
|
14
|
+
* double-encrypted). It is idempotent: already-`v1:` values are skipped, so it
|
|
15
|
+
* can be re-run safely.
|
|
16
|
+
*
|
|
17
|
+
* Because it operates through the standard nx-frame-be `BaseModel`, the same
|
|
18
|
+
* engine works for ANY project built on the framework: the caller only supplies
|
|
19
|
+
* the list of `{ model, fields }` targets (the fields that use the encrypting
|
|
20
|
+
* getter/setter in that project's schema).
|
|
21
|
+
*/
|
|
22
|
+
/** A model plus the dotted paths of its encrypted string fields. */
|
|
23
|
+
export interface EncryptedFieldTarget {
|
|
24
|
+
model: BaseModel<any, any, any>;
|
|
25
|
+
fields: string[];
|
|
26
|
+
}
|
|
27
|
+
/** Options for {@link reEncryptLegacyFields}. */
|
|
28
|
+
export interface MigrateEncryptedFieldsOptions {
|
|
29
|
+
targets: EncryptedFieldTarget[];
|
|
30
|
+
secret?: string;
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
batchSize?: number;
|
|
33
|
+
logger?: any;
|
|
34
|
+
}
|
|
35
|
+
/** Per-field counters for a migration run. */
|
|
36
|
+
export interface FieldMigrationStats {
|
|
37
|
+
migrated: number;
|
|
38
|
+
skipped: number;
|
|
39
|
+
errors: number;
|
|
40
|
+
}
|
|
41
|
+
/** Aggregated result of a migration run. */
|
|
42
|
+
export interface MigrateEncryptedFieldsResult {
|
|
43
|
+
scanned: number;
|
|
44
|
+
migrated: number;
|
|
45
|
+
skipped: number;
|
|
46
|
+
errors: number;
|
|
47
|
+
perModel: Record<string, Record<string, FieldMigrationStats>>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Re-encrypts legacy-encrypted fields to the current AES-256-GCM scheme.
|
|
51
|
+
*
|
|
52
|
+
* @param opts the targets to migrate and run options
|
|
53
|
+
* @returns aggregated counters (also broken down per model and field)
|
|
54
|
+
*/
|
|
55
|
+
export declare function reEncryptLegacyFields(opts: MigrateEncryptedFieldsOptions): Promise<MigrateEncryptedFieldsResult>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __asyncValues = (this && this.__asyncValues) || function (o) {
|
|
12
|
+
if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
|
|
13
|
+
var m = o[Symbol.asyncIterator], i;
|
|
14
|
+
return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
|
|
15
|
+
function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
|
|
16
|
+
function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
|
|
17
|
+
};
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.reEncryptLegacyFields = reEncryptLegacyFields;
|
|
20
|
+
const constants_1 = require("../constants");
|
|
21
|
+
const constants_2 = require("../constants");
|
|
22
|
+
const utils_1 = require("../utils");
|
|
23
|
+
const crypto_constants_1 = require("./crypto.constants");
|
|
24
|
+
const crypto_legacy_1 = require("./crypto-legacy");
|
|
25
|
+
const EMPTY_STATS = () => ({ migrated: 0, skipped: 0, errors: 0 });
|
|
26
|
+
/**
|
|
27
|
+
* Re-encrypts legacy-encrypted fields to the current AES-256-GCM scheme.
|
|
28
|
+
*
|
|
29
|
+
* @param opts the targets to migrate and run options
|
|
30
|
+
* @returns aggregated counters (also broken down per model and field)
|
|
31
|
+
*/
|
|
32
|
+
function reEncryptLegacyFields(opts) {
|
|
33
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
34
|
+
var _a, e_1, _b, _c;
|
|
35
|
+
var _d, _e, _f, _g, _h;
|
|
36
|
+
const secret = (_f = (_e = (_d = opts.secret) !== null && _d !== void 0 ? _d : constants_1.APP.dataScr) !== null && _e !== void 0 ? _e : process.env.SECRET) !== null && _f !== void 0 ? _f : '';
|
|
37
|
+
const logger = (_g = opts.logger) !== null && _g !== void 0 ? _g : constants_1.APP.logger;
|
|
38
|
+
const dryRun = !!opts.dryRun;
|
|
39
|
+
const batchSize = (_h = opts.batchSize) !== null && _h !== void 0 ? _h : 500;
|
|
40
|
+
if (!secret)
|
|
41
|
+
throw new Error('reEncryptLegacyFields: no secret available (pass opts.secret or set APP.dataScr/SECRET)');
|
|
42
|
+
const result = {
|
|
43
|
+
scanned: 0,
|
|
44
|
+
migrated: 0,
|
|
45
|
+
skipped: 0,
|
|
46
|
+
errors: 0,
|
|
47
|
+
perModel: {},
|
|
48
|
+
};
|
|
49
|
+
for (const target of opts.targets) {
|
|
50
|
+
const modelName = target.model.name;
|
|
51
|
+
const collection = target.model.mgModel.collection;
|
|
52
|
+
result.perModel[modelName] = {};
|
|
53
|
+
for (const field of target.fields)
|
|
54
|
+
result.perModel[modelName][field] = EMPTY_STATS();
|
|
55
|
+
// Only fetch documents that carry at least one of the target fields.
|
|
56
|
+
const projection = target.fields.reduce((acc, f) => (acc[f] = 1, acc), { _id: 1 });
|
|
57
|
+
const cursor = collection.find({}, { projection, batchSize });
|
|
58
|
+
try {
|
|
59
|
+
for (var _j = true, cursor_1 = (e_1 = void 0, __asyncValues(cursor)), cursor_1_1; cursor_1_1 = yield cursor_1.next(), _a = cursor_1_1.done, !_a; _j = true) {
|
|
60
|
+
_c = cursor_1_1.value;
|
|
61
|
+
_j = false;
|
|
62
|
+
const doc = _c;
|
|
63
|
+
result.scanned++;
|
|
64
|
+
const update = {};
|
|
65
|
+
for (const field of target.fields) {
|
|
66
|
+
const stats = result.perModel[modelName][field];
|
|
67
|
+
const value = (0, utils_1.getAttr)(doc, field);
|
|
68
|
+
// Nothing to do: empty, or already migrated to the current scheme.
|
|
69
|
+
if (!value || typeof value !== 'string' || value.startsWith(crypto_constants_1.FIELD_ENC_PREFIX)) {
|
|
70
|
+
stats.skipped++;
|
|
71
|
+
result.skipped++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// Only touch values we can confidently identify as legacy ciphertext.
|
|
75
|
+
if (!(0, crypto_legacy_1.isLegacyCiphertext)(value)) {
|
|
76
|
+
stats.skipped++;
|
|
77
|
+
result.skipped++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const plain = constants_2.cryptoUtils.decryptText(value, secret);
|
|
82
|
+
const reencoded = constants_2.cryptoUtils.encryptText(plain, secret);
|
|
83
|
+
if (!reencoded)
|
|
84
|
+
throw new Error('re-encryption produced an empty value');
|
|
85
|
+
// Use the DOTTED path as a literal `$set` key (e.g. `profile.email`) so
|
|
86
|
+
// MongoDB updates only that nested field and leaves its siblings intact.
|
|
87
|
+
// A nested object here would replace the whole sub-document.
|
|
88
|
+
update[field] = reencoded;
|
|
89
|
+
stats.migrated++;
|
|
90
|
+
result.migrated++;
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
stats.errors++;
|
|
94
|
+
result.errors++;
|
|
95
|
+
logger.error(`reEncryptLegacyFields: ${modelName}.${field} on _id=${doc._id}: %o`, e);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!dryRun && Object.keys(update).length)
|
|
99
|
+
yield collection.updateOne({ _id: doc._id }, { $set: update });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (e_1_1) { e_1 = { error: e_1_1 }; }
|
|
103
|
+
finally {
|
|
104
|
+
try {
|
|
105
|
+
if (!_j && !_a && (_b = cursor_1.return)) yield _b.call(cursor_1);
|
|
106
|
+
}
|
|
107
|
+
finally { if (e_1) throw e_1.error; }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
logger.info(`reEncryptLegacyFields: scanned=${result.scanned} migrated=${result.migrated} ` +
|
|
111
|
+
`skipped=${result.skipped} errors=${result.errors}${dryRun ? ' (dry-run)' : ''}`);
|
|
112
|
+
return result;
|
|
113
|
+
});
|
|
114
|
+
}
|
package/build/common/types.d.ts
CHANGED
|
@@ -42,7 +42,20 @@ export type AppConfig = {
|
|
|
42
42
|
mongo: AppMongoDB;
|
|
43
43
|
invite: AppInvitation;
|
|
44
44
|
logging: AppLogging;
|
|
45
|
+
app: AppCustomConfig;
|
|
45
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Application-specific configuration slot.
|
|
49
|
+
*
|
|
50
|
+
* Intentionally EMPTY in the framework: each consuming application augments this
|
|
51
|
+
* interface (declaration merging: `declare module '@nextage/nx-frame-be'`) to
|
|
52
|
+
* add its own strongly-typed settings, and populates `APP.config.app` at bootstrap
|
|
53
|
+
* (after env is read). This keeps CORE config (`endpoint`, `jwt`, `mongo`, …) in
|
|
54
|
+
* the framework while APP-ONLY settings live here — typed by the app, not by env
|
|
55
|
+
* reads scattered across the codebase.
|
|
56
|
+
*/
|
|
57
|
+
export interface AppCustomConfig {
|
|
58
|
+
}
|
|
46
59
|
export type AppMongoDB = {
|
|
47
60
|
auth?: {
|
|
48
61
|
us: string;
|
package/build/common/utils.d.ts
CHANGED
|
@@ -19,8 +19,14 @@ export declare function generateJwt(sub: string, jti: string, iss: string, data?
|
|
|
19
19
|
*/
|
|
20
20
|
export declare function validateJwt(token: string, options?: jwt.VerifyOptions, secret?: jwt.Secret): string | jwt.Jwt | jwt.JwtPayload;
|
|
21
21
|
/**
|
|
22
|
+
* Generates a cryptographically-strong random string.
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
+
* Uses a URL-safe base64 alphabet (`A-Za-z0-9-_`, no `+`/`/`/`=` padding) so the
|
|
25
|
+
* value is safe to drop into URLs, tokens and filenames. Enough random bytes are
|
|
26
|
+
* generated to fill `size` characters without truncating away entropy.
|
|
27
|
+
*
|
|
28
|
+
* @param size number of characters to return (default 21)
|
|
29
|
+
* @returns a random URL-safe string of length `size`
|
|
24
30
|
*/
|
|
25
31
|
export declare function randomString(size?: number): string;
|
|
26
32
|
/**
|
package/build/common/utils.js
CHANGED
|
@@ -146,12 +146,20 @@ function validateJwt(token, options = {}, secret) {
|
|
|
146
146
|
return jsonwebtoken_1.default.verify(token, secret, options);
|
|
147
147
|
}
|
|
148
148
|
/**
|
|
149
|
+
* Generates a cryptographically-strong random string.
|
|
149
150
|
*
|
|
150
|
-
*
|
|
151
|
+
* Uses a URL-safe base64 alphabet (`A-Za-z0-9-_`, no `+`/`/`/`=` padding) so the
|
|
152
|
+
* value is safe to drop into URLs, tokens and filenames. Enough random bytes are
|
|
153
|
+
* generated to fill `size` characters without truncating away entropy.
|
|
154
|
+
*
|
|
155
|
+
* @param size number of characters to return (default 21)
|
|
156
|
+
* @returns a random URL-safe string of length `size`
|
|
151
157
|
*/
|
|
152
158
|
function randomString(size = 21) {
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
// base64url yields 4 chars per 3 bytes → ceil(3/4 * size) bytes cover `size` chars.
|
|
160
|
+
const bytes = Math.ceil((size * 3) / 4);
|
|
161
|
+
return crypto_1.default.randomBytes(bytes)
|
|
162
|
+
.toString('base64url')
|
|
155
163
|
.slice(0, size);
|
|
156
164
|
}
|
|
157
165
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextage/nx-frame-be",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.44",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"types": "./build/index.d.ts",
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
"@aws-sdk/s3-request-presigner": "^3.1017.0",
|
|
36
36
|
"@types/async": "^3.2.24",
|
|
37
37
|
"@types/cookie-session": "^2.0.49",
|
|
38
|
-
"@types/crypto-js": "^4.2.2",
|
|
39
38
|
"@types/express": "^5.0.6",
|
|
40
39
|
"@types/express-validator": "^3.0.0",
|
|
41
40
|
"@types/graphql": "^14.5.0",
|
|
@@ -54,7 +53,6 @@
|
|
|
54
53
|
"@types/uuid": "^9.0.8",
|
|
55
54
|
"async": "^3.2.5",
|
|
56
55
|
"cookie": "^1.1.1",
|
|
57
|
-
"crypto-js": "^4.2.0",
|
|
58
56
|
"express": "^5.2.1",
|
|
59
57
|
"express-validator": "^7.1.0",
|
|
60
58
|
"graphql": "^16.11.0",
|