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.
Files changed (61) hide show
  1. package/.env.development +35 -3
  2. package/.env.production +40 -3
  3. package/.env.test +35 -3
  4. package/.github/workflows/release.cd.yml +3 -3
  5. package/README.md +20 -2
  6. package/bin/deploy.js +40 -0
  7. package/cli.md +3 -1
  8. package/conf.js +29 -3
  9. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  10. package/manifests/deployment/dd-test-development/deployment.yaml +6 -6
  11. package/package.json +1 -2
  12. package/src/api/document/document.controller.js +66 -0
  13. package/src/api/document/document.model.js +51 -0
  14. package/src/api/document/document.router.js +24 -0
  15. package/src/api/document/document.service.js +133 -0
  16. package/src/cli/deploy.js +1 -1
  17. package/src/cli/index.js +2 -0
  18. package/src/cli/repository.js +2 -0
  19. package/src/cli/run.js +27 -1
  20. package/src/client/Default.index.js +46 -1
  21. package/src/client/components/core/Account.js +8 -1
  22. package/src/client/components/core/AgGrid.js +18 -9
  23. package/src/client/components/core/Auth.js +258 -89
  24. package/src/client/components/core/BtnIcon.js +13 -3
  25. package/src/client/components/core/Content.js +2 -1
  26. package/src/client/components/core/CssCore.js +40 -27
  27. package/src/client/components/core/Docs.js +189 -88
  28. package/src/client/components/core/Input.js +34 -19
  29. package/src/client/components/core/LoadingAnimation.js +5 -10
  30. package/src/client/components/core/Modal.js +280 -123
  31. package/src/client/components/core/ObjectLayerEngine.js +470 -104
  32. package/src/client/components/core/ObjectLayerEngineModal.js +1 -0
  33. package/src/client/components/core/Panel.js +9 -2
  34. package/src/client/components/core/PanelForm.js +234 -76
  35. package/src/client/components/core/Router.js +15 -15
  36. package/src/client/components/core/ToolTip.js +83 -19
  37. package/src/client/components/core/Translate.js +1 -1
  38. package/src/client/components/core/VanillaJs.js +7 -3
  39. package/src/client/components/core/windowGetDimensions.js +202 -0
  40. package/src/client/components/default/MenuDefault.js +105 -41
  41. package/src/client/components/default/RoutesDefault.js +2 -0
  42. package/src/client/services/default/default.management.js +1 -0
  43. package/src/client/services/document/document.service.js +97 -0
  44. package/src/client/services/file/file.service.js +2 -0
  45. package/src/client/ssr/Render.js +1 -1
  46. package/src/client/ssr/head/DefaultScripts.js +2 -0
  47. package/src/client/ssr/head/Seo.js +1 -0
  48. package/src/index.js +1 -1
  49. package/src/mailer/EmailRender.js +1 -1
  50. package/src/server/auth.js +68 -17
  51. package/src/server/client-build.js +2 -3
  52. package/src/server/client-formatted.js +40 -12
  53. package/src/server/conf.js +5 -1
  54. package/src/server/crypto.js +195 -76
  55. package/src/server/object-layer.js +196 -0
  56. package/src/server/peer.js +47 -5
  57. package/src/server/process.js +85 -1
  58. package/src/server/runtime.js +23 -23
  59. package/src/server/ssr.js +52 -10
  60. package/src/server/valkey.js +89 -1
  61. package/test/crypto.test.js +117 -0
@@ -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: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
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: ["'self'", httpDirective, (req, res) => `'nonce-${res.locals.nonce}'`],
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, ssrFactory, JSONweb } from './client-formatted.js';
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
- 'use strict';
1
+ /**
2
+ * Module for formatting client-side code
3
+ * @module src/server/client-formatted.js
4
+ * @namespace clientFormatted
5
+ */
2
6
 
3
- import fs from 'fs-extra';
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
- const ssrFactory = async (componentPath = `./src/client/ssr/Render.js`) => {
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 };
@@ -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 = Object.keys(confClient).concat(['core', 'test', 'default', 'user']);
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;
@@ -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
- import fs from 'fs-extra';
3
-
4
- const CryptoBuilder = {
5
- symmetric: {
6
- instance: function (options = { iv: '', encryptionKey: '' }) {
7
- // Generate a random 32-byte encryption key
8
- const encryptionKey = option?.encryptionKey ? options.encryptionKey : crypto.randomBytes(32);
9
- const iv = option?.iv ? options.iv : crypto.randomBytes(16); // Generate a new Initialization Vector (IV) for each encryption
10
-
11
- // Function to encrypt data
12
- function encryptData(plaintext = '') {
13
- const cipher = crypto.createCipheriv('aes-256-cbc', encryptionKey, iv);
14
- let encrypted = cipher.update(plaintext, 'utf8', 'hex');
15
- encrypted += cipher.final('hex');
16
- return `${iv.toString('hex')}:${encrypted}`;
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
- // Function to decrypt data
20
- function decryptData(ciphertext = '') {
21
- const [ivHex, encrypted] = ciphertext.split(':');
22
- const _iv = Buffer.from(ivHex, 'hex');
23
- const decipher = crypto.createDecipheriv('aes-256-cbc', encryptionKey, _iv);
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
- return {
30
- encryptionKey,
31
- iv,
32
- encryptData,
33
- decryptData,
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
- // Function to decrypt data
67
- function decryptData(ciphertext) {
68
- const buffer = Buffer.from(ciphertext, 'hex');
69
- const decrypted = crypto.privateDecrypt(privateKey, buffer);
70
- return decrypted.toString('utf8');
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
- fs.writeFileSync('./public.pem', publicKey);
74
- fs.writeFileSync('./private.pem', privateKey);
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
- const result = {
77
- privateKey: fs.readFileSync('./public.pem', 'utf8'),
78
- publicKey: fs.readFileSync('./private.pem', 'utf8'),
79
- encryptData,
80
- decryptData,
81
- };
181
+ return encrypted.toString('hex');
182
+ }
82
183
 
83
- fs.removeSync('./public.pem');
84
- fs.removeSync('./private.pem');
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 result;
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 { CryptoBuilder };
210
+ export { SymmetricCrypto, AsymmetricCrypto };