underpost 2.8.878 → 2.8.882
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/.env.development +35 -3
- package/.env.production +40 -3
- package/.env.test +35 -3
- package/.github/workflows/release.cd.yml +3 -3
- package/README.md +20 -2
- package/bin/deploy.js +40 -0
- package/cli.md +3 -1
- package/conf.js +29 -3
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +6 -6
- package/package.json +1 -2
- package/src/api/document/document.controller.js +66 -0
- package/src/api/document/document.model.js +51 -0
- package/src/api/document/document.router.js +24 -0
- package/src/api/document/document.service.js +133 -0
- package/src/cli/deploy.js +1 -1
- package/src/cli/index.js +2 -0
- package/src/cli/repository.js +2 -0
- package/src/cli/run.js +27 -1
- package/src/client/Default.index.js +46 -1
- package/src/client/components/core/Account.js +8 -1
- package/src/client/components/core/AgGrid.js +18 -9
- package/src/client/components/core/Auth.js +258 -89
- package/src/client/components/core/BtnIcon.js +13 -3
- package/src/client/components/core/Content.js +2 -1
- package/src/client/components/core/CssCore.js +40 -27
- package/src/client/components/core/Docs.js +189 -88
- package/src/client/components/core/Input.js +34 -19
- package/src/client/components/core/LoadingAnimation.js +5 -10
- package/src/client/components/core/Modal.js +280 -123
- package/src/client/components/core/ObjectLayerEngine.js +470 -104
- package/src/client/components/core/ObjectLayerEngineModal.js +1 -0
- package/src/client/components/core/Panel.js +9 -2
- package/src/client/components/core/PanelForm.js +234 -76
- package/src/client/components/core/Router.js +15 -15
- package/src/client/components/core/ToolTip.js +83 -19
- package/src/client/components/core/Translate.js +1 -1
- package/src/client/components/core/VanillaJs.js +7 -3
- package/src/client/components/core/windowGetDimensions.js +202 -0
- package/src/client/components/default/MenuDefault.js +105 -41
- package/src/client/components/default/RoutesDefault.js +2 -0
- package/src/client/services/default/default.management.js +1 -0
- package/src/client/services/document/document.service.js +97 -0
- package/src/client/services/file/file.service.js +2 -0
- package/src/client/ssr/Render.js +1 -1
- package/src/client/ssr/head/DefaultScripts.js +2 -0
- package/src/client/ssr/head/Seo.js +1 -0
- package/src/index.js +1 -1
- package/src/mailer/EmailRender.js +1 -1
- package/src/server/auth.js +68 -17
- package/src/server/client-build.js +2 -3
- package/src/server/client-formatted.js +40 -12
- package/src/server/conf.js +5 -1
- package/src/server/crypto.js +195 -76
- package/src/server/object-layer.js +196 -0
- package/src/server/peer.js +47 -5
- package/src/server/process.js +85 -1
- package/src/server/runtime.js +23 -23
- package/src/server/ssr.js +52 -10
- package/src/server/valkey.js +89 -1
- package/test/crypto.test.js +117 -0
package/src/server/auth.js
CHANGED
|
@@ -306,6 +306,61 @@ const validatePasswordMiddleware = (req) => {
|
|
|
306
306
|
};
|
|
307
307
|
|
|
308
308
|
// ---------- Session & Refresh token management ----------
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Creates cookie options for the refresh token.
|
|
312
|
+
* @param {import('express').Request} req The Express request object.
|
|
313
|
+
* @returns {object} Cookie options.
|
|
314
|
+
* @memberof Auth
|
|
315
|
+
*/
|
|
316
|
+
const cookieOptionsFactory = (req) => {
|
|
317
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
318
|
+
|
|
319
|
+
// Determine hostname safely:
|
|
320
|
+
// Prefer origin header if present (it contains protocol + host)
|
|
321
|
+
let candidateHost = undefined;
|
|
322
|
+
try {
|
|
323
|
+
if (req.headers && req.headers.origin) {
|
|
324
|
+
candidateHost = new URL(req.headers.origin).hostname;
|
|
325
|
+
}
|
|
326
|
+
} catch (e) {
|
|
327
|
+
/* ignore parse error */
|
|
328
|
+
logger.error(e);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// fallback to req.hostname (Express sets this; ensure trust proxy if behind proxy)
|
|
332
|
+
if (!candidateHost) candidateHost = (req.hostname || '').split(':')[0];
|
|
333
|
+
|
|
334
|
+
candidateHost = (candidateHost || '').trim().replace(/^www\./i, '');
|
|
335
|
+
|
|
336
|
+
// Do not set domain for localhost, 127.x.x.x, or plain IPs
|
|
337
|
+
const isIpOrLocal = /^(localhost|127(?:\.\d+){0,2}\.\d+|\[::1\]|\d+\.\d+\.\d+\.\d+)$/i.test(candidateHost);
|
|
338
|
+
const domain = isProduction && candidateHost && !isIpOrLocal ? `.${candidateHost}` : undefined;
|
|
339
|
+
|
|
340
|
+
// Determine if request is secure: respect X-Forwarded-Proto when behind proxy
|
|
341
|
+
const forwardedProto = (req.headers && req.headers['x-forwarded-proto']) || '';
|
|
342
|
+
const reqIsSecure = Boolean(req.secure || forwardedProto.split(',')[0] === 'https');
|
|
343
|
+
|
|
344
|
+
// secure must be true for SameSite=None to work across sites
|
|
345
|
+
const secure = isProduction ? reqIsSecure : false;
|
|
346
|
+
const sameSite = secure ? 'None' : 'Lax';
|
|
347
|
+
|
|
348
|
+
// Safe parse of maxAge minutes
|
|
349
|
+
const minutes = Number.parseInt(process.env.REFRESH_EXPIRE_MINUTES, 10);
|
|
350
|
+
const maxAge = Number.isFinite(minutes) && minutes > 0 ? minutes * 60 * 1000 : undefined;
|
|
351
|
+
|
|
352
|
+
const opts = {
|
|
353
|
+
httpOnly: true,
|
|
354
|
+
secure,
|
|
355
|
+
sameSite,
|
|
356
|
+
path: '/',
|
|
357
|
+
};
|
|
358
|
+
if (typeof maxAge !== 'undefined') opts.maxAge = maxAge;
|
|
359
|
+
if (domain) opts.domain = domain;
|
|
360
|
+
|
|
361
|
+
return opts;
|
|
362
|
+
};
|
|
363
|
+
|
|
309
364
|
/**
|
|
310
365
|
* Create session and set refresh cookie. Rotating and hashed stored token.
|
|
311
366
|
* @param {object} user The user object.
|
|
@@ -339,13 +394,7 @@ async function createSessionAndUserToken(user, User, req, res, options = { host:
|
|
|
339
394
|
const jwtid = session._id.toString();
|
|
340
395
|
|
|
341
396
|
// Secure cookie settings
|
|
342
|
-
res.cookie('refreshToken', refreshToken,
|
|
343
|
-
httpOnly: true,
|
|
344
|
-
secure: process.env.NODE_ENV === 'production',
|
|
345
|
-
sameSite: 'Lax',
|
|
346
|
-
maxAge: parseInt(process.env.REFRESH_EXPIRE_MINUTES) * 60 * 1000,
|
|
347
|
-
path: '/',
|
|
348
|
-
});
|
|
397
|
+
res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req));
|
|
349
398
|
|
|
350
399
|
return { jwtid };
|
|
351
400
|
}
|
|
@@ -439,13 +488,7 @@ async function refreshSessionAndToken(req, res, User, options = { host: '', path
|
|
|
439
488
|
|
|
440
489
|
logger.warn('Refreshed session for user ' + user.email);
|
|
441
490
|
|
|
442
|
-
res.cookie('refreshToken', refreshToken,
|
|
443
|
-
httpOnly: true,
|
|
444
|
-
secure: process.env.NODE_ENV === 'production',
|
|
445
|
-
sameSite: 'Lax',
|
|
446
|
-
maxAge: parseInt(process.env.REFRESH_EXPIRE_MINUTES) * 60 * 1000,
|
|
447
|
-
path: '/',
|
|
448
|
-
});
|
|
491
|
+
res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req));
|
|
449
492
|
|
|
450
493
|
return jwtSign(
|
|
451
494
|
UserDto.auth.payload(user, session._id.toString(), req.ip, req.headers['user-agent'], options.host, options.path),
|
|
@@ -533,14 +576,22 @@ function applySecurity(app, opts = {}) {
|
|
|
533
576
|
blockAllMixedContent: [],
|
|
534
577
|
fontSrc: ["'self'", httpDirective, 'data:'],
|
|
535
578
|
frameAncestors: frameAncestors,
|
|
536
|
-
imgSrc: ["'self'", 'data:', httpDirective],
|
|
579
|
+
imgSrc: ["'self'", 'data:', httpDirective, 'https:', 'blob:'],
|
|
537
580
|
objectSrc: ["'none'"],
|
|
538
581
|
// script-src and script-src-elem include dynamic nonce
|
|
539
|
-
scriptSrc: [
|
|
582
|
+
scriptSrc: [
|
|
583
|
+
"'self'",
|
|
584
|
+
(req, res) => `'nonce-${res.locals.nonce}'`,
|
|
585
|
+
(req, res) => (res.locals.isSwagger ? "'unsafe-inline'" : ''),
|
|
586
|
+
],
|
|
540
587
|
scriptSrcElem: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
|
|
541
588
|
// style-src: avoid 'unsafe-inline' when possible; if you must inline styles,
|
|
542
589
|
// use a nonce for them too (or hash).
|
|
543
|
-
styleSrc: [
|
|
590
|
+
styleSrc: [
|
|
591
|
+
"'self'",
|
|
592
|
+
httpDirective,
|
|
593
|
+
(req, res) => (res.locals.isSwagger ? "'unsafe-inline'" : `'nonce-${res.locals.nonce}'`),
|
|
594
|
+
],
|
|
544
595
|
// deny plugins
|
|
545
596
|
objectSrc: ["'none'"],
|
|
546
597
|
},
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
-
import { srcFormatted, componentFormatted, viewFormatted,
|
|
4
|
+
import { srcFormatted, componentFormatted, viewFormatted, JSONweb } from './client-formatted.js';
|
|
5
5
|
import { loggerFactory } from './logger.js';
|
|
6
6
|
import {
|
|
7
|
-
cap,
|
|
8
7
|
getCapVariableName,
|
|
9
8
|
newInstance,
|
|
10
9
|
orderArrayFromAttrInt,
|
|
11
|
-
titleFormatted,
|
|
12
10
|
uniqueArray,
|
|
13
11
|
} from '../client/components/core/CommonJs.js';
|
|
14
12
|
import UglifyJS from 'uglify-js';
|
|
@@ -22,6 +20,7 @@ import { Readable } from 'stream';
|
|
|
22
20
|
import { buildIcons } from './client-icons.js';
|
|
23
21
|
import Underpost from '../index.js';
|
|
24
22
|
import { buildDocs } from './client-build-docs.js';
|
|
23
|
+
import { ssrFactory } from './ssr.js';
|
|
25
24
|
|
|
26
25
|
dotenv.config();
|
|
27
26
|
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Module for formatting client-side code
|
|
3
|
+
* @module src/server/client-formatted.js
|
|
4
|
+
* @namespace clientFormatted
|
|
5
|
+
*/
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
import vm from 'node:vm';
|
|
5
|
-
import Underpost from '../index.js';
|
|
7
|
+
'use strict';
|
|
6
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Formats a source code string by removing 'html`' and 'css`' tags from template literals.
|
|
11
|
+
* @param {string} src - The source code string.
|
|
12
|
+
* @returns {string} The formatted source code.
|
|
13
|
+
* @memberof clientFormatted
|
|
14
|
+
*/
|
|
7
15
|
const srcFormatted = (src) =>
|
|
8
16
|
src
|
|
9
17
|
.replaceAll(' html`', '`')
|
|
@@ -15,8 +23,26 @@ const srcFormatted = (src) =>
|
|
|
15
23
|
.replaceAll('[html`', '[`')
|
|
16
24
|
.replaceAll('[css`', '[`');
|
|
17
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Converts a JavaScript object into a string that can be embedded in client-side code
|
|
28
|
+
* and parsed back into an object (e.g., 'JSON.parse(`{...}`)').
|
|
29
|
+
* @param {*} data - The data to be stringified.
|
|
30
|
+
* @returns {string} A string representing the code to parse the JSON data.
|
|
31
|
+
* @memberof clientFormatted
|
|
32
|
+
*/
|
|
18
33
|
const JSONweb = (data) => 'JSON.parse(`' + JSON.stringify(data) + '`)';
|
|
19
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Formats a component's source code by rewriting its import paths to be absolute for browser consumption.
|
|
37
|
+
* @param {string} src - The source code of the component.
|
|
38
|
+
* @param {string} module - The name of the module/component.
|
|
39
|
+
* @param {Array<object>} dists - An array of distribution objects with import names.
|
|
40
|
+
* @param {string} proxyPath - The proxy path for the application.
|
|
41
|
+
* @param {string} [componentBasePath=''] - The base path for components.
|
|
42
|
+
* @param {string} [baseHost=''] - The base host URL.
|
|
43
|
+
* @returns {string} The formatted source code with updated import paths.
|
|
44
|
+
* @memberof clientFormatted
|
|
45
|
+
*/
|
|
20
46
|
const componentFormatted = (src, module, dists, proxyPath, componentBasePath = '', baseHost = '') => {
|
|
21
47
|
dists.map(
|
|
22
48
|
(dist) =>
|
|
@@ -40,6 +66,15 @@ const componentFormatted = (src, module, dists, proxyPath, componentBasePath = '
|
|
|
40
66
|
);
|
|
41
67
|
};
|
|
42
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Formats a view's source code by rewriting its import paths.
|
|
71
|
+
* @param {string} src - The source code of the view.
|
|
72
|
+
* @param {Array<object>} dists - An array of distribution objects with import names.
|
|
73
|
+
* @param {string} proxyPath - The proxy path for the application.
|
|
74
|
+
* @param {string} [baseHost=''] - The base host URL.
|
|
75
|
+
* @returns {string} The formatted source code with updated import paths.
|
|
76
|
+
* @memberof clientFormatted
|
|
77
|
+
*/
|
|
43
78
|
const viewFormatted = (src, dists, proxyPath, baseHost = '') => {
|
|
44
79
|
dists.map(
|
|
45
80
|
(dist) =>
|
|
@@ -49,11 +84,4 @@ const viewFormatted = (src, dists, proxyPath, baseHost = '') => {
|
|
|
49
84
|
return src.replaceAll(`from './`, componentFromFormatted).replaceAll(`from '../`, componentFromFormatted);
|
|
50
85
|
};
|
|
51
86
|
|
|
52
|
-
|
|
53
|
-
const context = { SrrComponent: () => {}, npm_package_version: Underpost.version };
|
|
54
|
-
vm.createContext(context);
|
|
55
|
-
vm.runInContext(await srcFormatted(fs.readFileSync(componentPath, 'utf8')), context);
|
|
56
|
-
return context.SrrComponent;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
export { srcFormatted, JSONweb, componentFormatted, viewFormatted, ssrFactory };
|
|
87
|
+
export { srcFormatted, JSONweb, componentFormatted, viewFormatted };
|
package/src/server/conf.js
CHANGED
|
@@ -77,6 +77,10 @@ const Config = {
|
|
|
77
77
|
'utf8',
|
|
78
78
|
);
|
|
79
79
|
shellExec(`node bin/deploy update-default-conf ${deployId}`);
|
|
80
|
+
|
|
81
|
+
if (!fs.existsSync(`./engine-private/deploy/dd.router`))
|
|
82
|
+
fs.writeFileSync(`./engine-private/deploy/dd.router`, '', 'utf8');
|
|
83
|
+
|
|
80
84
|
fs.writeFileSync(
|
|
81
85
|
`./engine-private/deploy/dd.router`,
|
|
82
86
|
fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').trim() + `,${deployId}`,
|
|
@@ -692,7 +696,7 @@ const validateTemplatePath = (absolutePath = '') => {
|
|
|
692
696
|
const confServer = DefaultConf.server[host][path];
|
|
693
697
|
const confClient = DefaultConf.client[client];
|
|
694
698
|
const confSsr = DefaultConf.ssr[ssr];
|
|
695
|
-
const clients =
|
|
699
|
+
const clients = DefaultConf.client.default.services;
|
|
696
700
|
|
|
697
701
|
if (absolutePath.match('src/api') && !confServer.apis.find((p) => absolutePath.match(`src/api/${p}/`))) {
|
|
698
702
|
return false;
|
package/src/server/crypto.js
CHANGED
|
@@ -1,91 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module for managing crypto operations
|
|
3
|
+
* @module src/server/crypto.js
|
|
4
|
+
* @namespace Crypto
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
import crypto from 'crypto';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
|
|
9
|
+
/* ----------------------------- SymmetricCrypto ----------------------------- */
|
|
10
|
+
|
|
11
|
+
class SymmetricCrypto {
|
|
12
|
+
#encryptionKey;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} [options]
|
|
16
|
+
* @param {Buffer | string} [options.encryptionKey] - 32-byte key as Buffer or hex string. If not provided, a new random key is generated.
|
|
17
|
+
*/
|
|
18
|
+
/** @memberof Crypto */
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
const { encryptionKey } = options;
|
|
21
|
+
|
|
22
|
+
if (encryptionKey) {
|
|
23
|
+
this.#encryptionKey = typeof encryptionKey === 'string' ? Buffer.from(encryptionKey, 'hex') : encryptionKey;
|
|
24
|
+
} else {
|
|
25
|
+
this.#encryptionKey = crypto.randomBytes(32);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!Buffer.isBuffer(this.#encryptionKey) || this.#encryptionKey.length !== 32) {
|
|
29
|
+
throw new Error('Encryption key must be a 32-byte Buffer or 64-length hex string.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Provide a compatibility IV property expected by some test suites / legacy code.
|
|
33
|
+
// This IV is not reused for each encryption operation (encryptData will generate its own IV).
|
|
34
|
+
// It exists so tests that expect an ivHex on the instance (16 bytes) continue to work.
|
|
35
|
+
this.ivHex = crypto.randomBytes(16).toString('hex'); // 16 bytes -> 32 hex chars
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Returns encryption key as hex. */
|
|
39
|
+
/** @memberof Crypto */
|
|
40
|
+
get encryptionKeyHex() {
|
|
41
|
+
return this.#encryptionKey.toString('hex');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Encrypts plaintext using AES-256-GCM and returns `iv_hex:ciphertext_hex:authTag_hex`.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} [plaintext='']
|
|
48
|
+
* @returns {string}
|
|
49
|
+
* @memberof Crypto
|
|
50
|
+
*/
|
|
51
|
+
encryptData(plaintext = '') {
|
|
52
|
+
// GCM recommended IV size is 12 bytes
|
|
53
|
+
const iv = crypto.randomBytes(12);
|
|
54
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', this.#encryptionKey, iv);
|
|
55
|
+
|
|
56
|
+
const encryptedPart = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
57
|
+
const authTag = cipher.getAuthTag();
|
|
58
|
+
|
|
59
|
+
return `${iv.toString('hex')}:${encryptedPart.toString('hex')}:${authTag.toString('hex')}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decrypts data. Supports two formats:
|
|
64
|
+
* - AES-256-GCM: `iv_hex:ciphertext_hex:authTag_hex` (preferred)
|
|
65
|
+
* - Legacy AES-256-CBC: `iv_hex:ciphertext_hex` (fallback for backward compatibility)
|
|
66
|
+
*
|
|
67
|
+
* @param {string} [ciphertext='']
|
|
68
|
+
* @returns {string} plaintext
|
|
69
|
+
* @throws {Error} Generic error on failure (to avoid leaking details).
|
|
70
|
+
* @memberof Crypto
|
|
71
|
+
*/
|
|
72
|
+
decryptData(ciphertext = '') {
|
|
73
|
+
try {
|
|
74
|
+
const parts = ciphertext.split(':');
|
|
75
|
+
|
|
76
|
+
if (parts.length === 3) {
|
|
77
|
+
// AES-256-GCM
|
|
78
|
+
const [ivHex, encryptedHex, tagHex] = parts;
|
|
79
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
80
|
+
const encrypted = Buffer.from(encryptedHex, 'hex');
|
|
81
|
+
const authTag = Buffer.from(tagHex, 'hex');
|
|
82
|
+
|
|
83
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', this.#encryptionKey, iv);
|
|
84
|
+
decipher.setAuthTag(authTag);
|
|
85
|
+
|
|
86
|
+
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
87
|
+
return decrypted.toString('utf8');
|
|
17
88
|
}
|
|
18
89
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const [ivHex,
|
|
22
|
-
const
|
|
23
|
-
const
|
|
90
|
+
if (parts.length === 2) {
|
|
91
|
+
// Legacy: AES-256-CBC (no authentication). Provided for compatibility only.
|
|
92
|
+
const [ivHex, encryptedHex] = parts;
|
|
93
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
94
|
+
const encrypted = encryptedHex;
|
|
95
|
+
|
|
96
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', this.#encryptionKey, iv);
|
|
24
97
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
25
98
|
decrypted += decipher.final('utf8');
|
|
26
99
|
return decrypted;
|
|
27
100
|
}
|
|
28
101
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
asymmetric: {
|
|
38
|
-
instance: function (
|
|
39
|
-
options = {
|
|
40
|
-
publicKey: '', // fs.readFileSync('./key.pem', 'utf8')
|
|
41
|
-
privateKey: '', // fs.readFileSync('./key.pem', 'utf8')
|
|
42
|
-
},
|
|
43
|
-
) {
|
|
44
|
-
// Generate a new key pair
|
|
45
|
-
const { privateKey, publicKey } = options
|
|
46
|
-
? options
|
|
47
|
-
: crypto.generateKeyPairSync('rsa', {
|
|
48
|
-
modulusLength: 2048, // Key size in bits
|
|
49
|
-
publicKeyEncoding: {
|
|
50
|
-
type: 'spki',
|
|
51
|
-
format: 'pem',
|
|
52
|
-
},
|
|
53
|
-
privateKeyEncoding: {
|
|
54
|
-
type: 'pkcs8',
|
|
55
|
-
format: 'pem',
|
|
56
|
-
},
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// Function to encrypt data
|
|
60
|
-
function encryptData(plaintext) {
|
|
61
|
-
const buffer = Buffer.from(plaintext, 'utf8');
|
|
62
|
-
const encrypted = crypto.publicEncrypt(publicKey, buffer);
|
|
63
|
-
return encrypted.toString('hex');
|
|
64
|
-
}
|
|
102
|
+
throw new Error('Invalid ciphertext format.');
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Do not leak internal error details (stack, key material, etc.).
|
|
105
|
+
// Optional: instrument monitoring/logging but avoid logging sensitive inputs.
|
|
106
|
+
throw new Error('Decryption failed. Check key, IV, or ciphertext integrity.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
65
110
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
111
|
+
/* ---------------------------- AsymmetricCrypto ---------------------------- */
|
|
112
|
+
|
|
113
|
+
class AsymmetricCrypto {
|
|
114
|
+
#publicKey;
|
|
115
|
+
#privateKey;
|
|
116
|
+
#modulusLength;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {object} [options]
|
|
120
|
+
* @param {string|Buffer} [options.publicKey] - PEM-formatted public key
|
|
121
|
+
* @param {string|Buffer} [options.privateKey] - PEM-formatted private key
|
|
122
|
+
* @param {number} [options.modulusLength=2048] - If keys are not provided, generates a new key pair of this size (bits). Consider 3072 for long-lived keys.
|
|
123
|
+
*/
|
|
124
|
+
/** @memberof Crypto */
|
|
125
|
+
constructor(options = {}) {
|
|
126
|
+
const { publicKey, privateKey } = options;
|
|
127
|
+
this.#modulusLength = options.modulusLength || 2048;
|
|
128
|
+
|
|
129
|
+
if (!publicKey || !privateKey) {
|
|
130
|
+
// Generate an in-memory key pair. No file I/O; keys remain in process memory only.
|
|
131
|
+
const { publicKey: pub, privateKey: priv } = crypto.generateKeyPairSync('rsa', {
|
|
132
|
+
modulusLength: this.#modulusLength,
|
|
133
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
134
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
135
|
+
});
|
|
136
|
+
this.#publicKey = pub;
|
|
137
|
+
this.#privateKey = priv;
|
|
138
|
+
} else {
|
|
139
|
+
// Accept provided keys (string or Buffer)
|
|
140
|
+
this.#publicKey = typeof publicKey === 'string' || Buffer.isBuffer(publicKey) ? publicKey : String(publicKey);
|
|
141
|
+
this.#privateKey =
|
|
142
|
+
typeof privateKey === 'string' || Buffer.isBuffer(privateKey) ? privateKey : String(privateKey);
|
|
143
|
+
|
|
144
|
+
// Basic validation: ensure PEM headers exist. This is intentionally lightweight.
|
|
145
|
+
const pubStr = String(this.#publicKey);
|
|
146
|
+
const privStr = String(this.#privateKey);
|
|
147
|
+
if (!pubStr.includes('BEGIN PUBLIC KEY') || !privStr.includes('BEGIN')) {
|
|
148
|
+
throw new Error('Provided keys do not appear to be valid PEM-formatted keys.');
|
|
71
149
|
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
72
152
|
|
|
73
|
-
|
|
74
|
-
|
|
153
|
+
/** @memberof Crypto */
|
|
154
|
+
get publicKey() {
|
|
155
|
+
return this.#publicKey;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @memberof Crypto */
|
|
159
|
+
get privateKey() {
|
|
160
|
+
return this.#privateKey;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Encrypts plaintext using RSA-OAEP with SHA-256. Returns hex-encoded ciphertext.
|
|
165
|
+
* Note: RSA encryption is intended for small payloads. For larger data use hybrid encryption (encrypt a symmetric key and then use AES-GCM).
|
|
166
|
+
* @param {string} plaintext
|
|
167
|
+
* @returns {string} hex ciphertext
|
|
168
|
+
* @memberof Crypto
|
|
169
|
+
*/
|
|
170
|
+
encryptData(plaintext) {
|
|
171
|
+
const buffer = Buffer.from(plaintext, 'utf8');
|
|
172
|
+
const encrypted = crypto.publicEncrypt(
|
|
173
|
+
{
|
|
174
|
+
key: this.#publicKey,
|
|
175
|
+
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
|
176
|
+
oaepHash: 'sha256',
|
|
177
|
+
},
|
|
178
|
+
buffer,
|
|
179
|
+
);
|
|
75
180
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
publicKey: fs.readFileSync('./private.pem', 'utf8'),
|
|
79
|
-
encryptData,
|
|
80
|
-
decryptData,
|
|
81
|
-
};
|
|
181
|
+
return encrypted.toString('hex');
|
|
182
|
+
}
|
|
82
183
|
|
|
83
|
-
|
|
84
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Decrypts RSA-OAEP hex ciphertext and returns utf8 plaintext.
|
|
186
|
+
* @param {string} ciphertextHex
|
|
187
|
+
* @returns {string}
|
|
188
|
+
* @memberof Crypto
|
|
189
|
+
*/
|
|
190
|
+
decryptData(ciphertextHex) {
|
|
191
|
+
try {
|
|
192
|
+
const buffer = Buffer.from(ciphertextHex, 'hex');
|
|
193
|
+
const decrypted = crypto.privateDecrypt(
|
|
194
|
+
{
|
|
195
|
+
key: this.#privateKey,
|
|
196
|
+
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
|
197
|
+
oaepHash: 'sha256',
|
|
198
|
+
},
|
|
199
|
+
buffer,
|
|
200
|
+
);
|
|
85
201
|
|
|
86
|
-
return
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
202
|
+
return decrypted.toString('utf8');
|
|
203
|
+
} catch (err) {
|
|
204
|
+
// Avoid leaking details about keys or ciphertext
|
|
205
|
+
throw new Error('Decryption failed. Check private key or ciphertext integrity.');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
90
209
|
|
|
91
|
-
export {
|
|
210
|
+
export { SymmetricCrypto, AsymmetricCrypto };
|