@webda/core 4.0.0-beta.6 → 4.0.0-beta.7

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 (138) hide show
  1. package/README.md +6 -5
  2. package/lib/application/application.d.ts.map +1 -1
  3. package/lib/application/application.js +6 -5
  4. package/lib/application/application.js.map +1 -1
  5. package/lib/application/iconfiguration.d.ts +31 -0
  6. package/lib/application/iconfiguration.d.ts.map +1 -1
  7. package/lib/application/iconfiguration.js.map +1 -1
  8. package/lib/application/unpackedapplication.d.ts.map +1 -1
  9. package/lib/application/unpackedapplication.js +2 -1
  10. package/lib/application/unpackedapplication.js.map +1 -1
  11. package/lib/bin/cli-phase.d.ts +18 -2
  12. package/lib/bin/cli-phase.d.ts.map +1 -1
  13. package/lib/bin/cli-phase.js +25 -0
  14. package/lib/bin/cli-phase.js.map +1 -1
  15. package/lib/bin/cli.d.ts +8 -0
  16. package/lib/bin/cli.d.ts.map +1 -1
  17. package/lib/bin/cli.js +94 -35
  18. package/lib/bin/cli.js.map +1 -1
  19. package/lib/configurations/configuration.service.d.ts +7 -0
  20. package/lib/configurations/configuration.service.d.ts.map +1 -1
  21. package/lib/configurations/configuration.service.js +18 -5
  22. package/lib/configurations/configuration.service.js.map +1 -1
  23. package/lib/configurations/kubernetesconfiguration.service.d.ts.map +1 -1
  24. package/lib/configurations/kubernetesconfiguration.service.js +1 -4
  25. package/lib/configurations/kubernetesconfiguration.service.js.map +1 -1
  26. package/lib/contexts/httpcontext.d.ts +15 -1
  27. package/lib/contexts/httpcontext.d.ts.map +1 -1
  28. package/lib/contexts/httpcontext.js +61 -19
  29. package/lib/contexts/httpcontext.js.map +1 -1
  30. package/lib/contexts/operationcontext.d.ts +6 -0
  31. package/lib/contexts/operationcontext.d.ts.map +1 -1
  32. package/lib/contexts/operationcontext.js +16 -2
  33. package/lib/contexts/operationcontext.js.map +1 -1
  34. package/lib/contexts/restcontext.d.ts +110 -0
  35. package/lib/contexts/restcontext.d.ts.map +1 -0
  36. package/lib/contexts/restcontext.js +247 -0
  37. package/lib/contexts/restcontext.js.map +1 -0
  38. package/lib/contexts/streamingcontext.d.ts +71 -0
  39. package/lib/contexts/streamingcontext.d.ts.map +1 -0
  40. package/lib/contexts/streamingcontext.js +93 -0
  41. package/lib/contexts/streamingcontext.js.map +1 -0
  42. package/lib/contexts/webcontext.d.ts +20 -0
  43. package/lib/contexts/webcontext.d.ts.map +1 -1
  44. package/lib/contexts/webcontext.js +43 -10
  45. package/lib/contexts/webcontext.js.map +1 -1
  46. package/lib/contexts/websocketcontext.d.ts +39 -0
  47. package/lib/contexts/websocketcontext.d.ts.map +1 -0
  48. package/lib/contexts/websocketcontext.js +67 -0
  49. package/lib/contexts/websocketcontext.js.map +1 -0
  50. package/lib/core/asyncqueue.d.ts +50 -0
  51. package/lib/core/asyncqueue.d.ts.map +1 -0
  52. package/lib/core/asyncqueue.js +96 -0
  53. package/lib/core/asyncqueue.js.map +1 -0
  54. package/lib/core/core.d.ts +18 -0
  55. package/lib/core/core.d.ts.map +1 -1
  56. package/lib/core/core.js +72 -7
  57. package/lib/core/core.js.map +1 -1
  58. package/lib/core/icore.d.ts +14 -1
  59. package/lib/core/icore.d.ts.map +1 -1
  60. package/lib/core/icore.js.map +1 -1
  61. package/lib/core/instancestorage.d.ts +1 -0
  62. package/lib/core/instancestorage.d.ts.map +1 -1
  63. package/lib/core/instancestorage.js +1 -0
  64. package/lib/core/instancestorage.js.map +1 -1
  65. package/lib/core/operations.d.ts +49 -6
  66. package/lib/core/operations.d.ts.map +1 -1
  67. package/lib/core/operations.js +187 -24
  68. package/lib/core/operations.js.map +1 -1
  69. package/lib/deployers/git.d.ts +4 -2
  70. package/lib/deployers/git.d.ts.map +1 -1
  71. package/lib/deployers/git.js +44 -5
  72. package/lib/deployers/git.js.map +1 -1
  73. package/lib/errors/errors.d.ts +15 -0
  74. package/lib/errors/errors.d.ts.map +1 -1
  75. package/lib/errors/errors.js +20 -0
  76. package/lib/errors/errors.js.map +1 -1
  77. package/lib/index.d.ts +5 -0
  78. package/lib/index.d.ts.map +1 -1
  79. package/lib/index.js +5 -0
  80. package/lib/index.js.map +1 -1
  81. package/lib/rest/restoperationstransport.service.d.ts +108 -0
  82. package/lib/rest/restoperationstransport.service.d.ts.map +1 -1
  83. package/lib/rest/restoperationstransport.service.js +348 -9
  84. package/lib/rest/restoperationstransport.service.js.map +1 -1
  85. package/lib/rest/router.service.d.ts +1 -1
  86. package/lib/rest/router.service.d.ts.map +1 -1
  87. package/lib/rest/router.service.js.map +1 -1
  88. package/lib/services/authenticatedencryption.d.ts +25 -0
  89. package/lib/services/authenticatedencryption.d.ts.map +1 -0
  90. package/lib/services/authenticatedencryption.js +56 -0
  91. package/lib/services/authenticatedencryption.js.map +1 -0
  92. package/lib/services/cloudbinary.d.ts.map +1 -1
  93. package/lib/services/cloudbinary.js +3 -2
  94. package/lib/services/cloudbinary.js.map +1 -1
  95. package/lib/services/command.d.ts +7 -1
  96. package/lib/services/command.d.ts.map +1 -1
  97. package/lib/services/command.js +4 -0
  98. package/lib/services/command.js.map +1 -1
  99. package/lib/services/cryptoservice.service.d.ts +405 -25
  100. package/lib/services/cryptoservice.service.d.ts.map +1 -1
  101. package/lib/services/cryptoservice.service.js +779 -72
  102. package/lib/services/cryptoservice.service.js.map +1 -1
  103. package/lib/services/httpserver.service.d.ts.map +1 -1
  104. package/lib/services/httpserver.service.js +15 -9
  105. package/lib/services/httpserver.service.js.map +1 -1
  106. package/lib/services/icryptoservice.d.ts +11 -0
  107. package/lib/services/icryptoservice.d.ts.map +1 -1
  108. package/lib/services/icryptoservice.js +2 -0
  109. package/lib/services/icryptoservice.js.map +1 -1
  110. package/lib/services/mailer.service.d.ts +4 -0
  111. package/lib/services/mailer.service.d.ts.map +1 -1
  112. package/lib/services/mailer.service.js +7 -1
  113. package/lib/services/mailer.service.js.map +1 -1
  114. package/lib/services/operationstransport.d.ts +13 -0
  115. package/lib/services/operationstransport.d.ts.map +1 -1
  116. package/lib/services/operationstransport.js +28 -6
  117. package/lib/services/operationstransport.js.map +1 -1
  118. package/lib/services/pki.encrypter.d.ts +214 -0
  119. package/lib/services/pki.encrypter.d.ts.map +1 -0
  120. package/lib/services/pki.encrypter.js +350 -0
  121. package/lib/services/pki.encrypter.js.map +1 -0
  122. package/lib/services/secretscheck.d.ts +120 -0
  123. package/lib/services/secretscheck.d.ts.map +1 -0
  124. package/lib/services/secretscheck.js +322 -0
  125. package/lib/services/secretscheck.js.map +1 -0
  126. package/lib/services/servicecommands.d.ts +8 -4
  127. package/lib/services/servicecommands.d.ts.map +1 -1
  128. package/lib/services/servicecommands.js +14 -5
  129. package/lib/services/servicecommands.js.map +1 -1
  130. package/lib/session/manager.model.d.ts +3 -1
  131. package/lib/session/manager.model.d.ts.map +1 -1
  132. package/lib/session/manager.model.js +6 -3
  133. package/lib/session/manager.model.js.map +1 -1
  134. package/lib/test/objects.d.ts.map +1 -1
  135. package/lib/test/objects.js +3 -1
  136. package/lib/test/objects.js.map +1 -1
  137. package/package.json +12 -11
  138. package/webda.module.json +135 -41
@@ -36,35 +36,99 @@ import { createCipheriv, createDecipheriv, createHash, createHmac, generateKeyPa
36
36
  import jwt from "jsonwebtoken";
37
37
  import { pem2jwk } from "pem-jwk";
38
38
  import * as util from "util";
39
- import { JSONUtils } from "@webda/utils";
39
+ import { FileUtils, JSONUtils } from "@webda/utils";
40
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
41
+ import { isAbsolute, relative, resolve } from "node:path";
42
+ import { deepmerge } from "deepmerge-ts";
43
+ import { AuthenticatedEncryption } from "./authenticatedencryption.js";
44
+ import { checkKid, parsePkiValue, PKI_MIN_BITS, PkiKeyring, pkiEncrypter } from "./pki.encrypter.js";
45
+ import { CodeError } from "../errors/errors.js";
40
46
  import { getMachineId, useCore, useService } from "../core/hooks.js";
41
- import { useLog } from "../loggers/hooks.js";
47
+ import { useLog, useOutput } from "../loggers/hooks.js";
42
48
  import { Service } from "./service.js";
49
+ import { Command } from "./command.js";
43
50
  import { Route } from "../rest/irest.js";
44
51
  import { useRegistry } from "../models/registry.model.js";
45
- /** Wraps a secret string value, masking it in logs and inspect output */
52
+ import { useApplication } from "../application/hooks.js";
53
+ import { findConfigurationSecrets, OPT_OUT_ENCRYPTER, secretPathExcluder } from "./secretscheck.js";
54
+ /**
55
+ * Masked representation of a secret
56
+ */
57
+ const SECRET_MASK = "********";
58
+ /**
59
+ * A secret value decrypted from a `crypt:<encrypter>:<data>` configuration string
60
+ *
61
+ * The plain value is only available through {@link SecretString.getValue}: string
62
+ * conversion, `JSON.stringify` and `util.inspect` all return a mask, so the secret
63
+ * does not leak into logs, configuration dumps or serialized services.
64
+ *
65
+ * In a service parameters class, type a secret field as `string | SecretString`
66
+ * and read it with {@link SecretString.from}.
67
+ *
68
+ * `@secret` marks every property typed with this class as a secret in the generated
69
+ * schema (`x-webda-secret`), so `webda config encrypt` encrypts it and `--check` reports it when it is not encrypted.
70
+ *
71
+ * @type string
72
+ * @secret
73
+ */
46
74
  export class SecretString {
47
- str;
75
+ /**
76
+ * Plain value, kept out of the enumerable properties
77
+ */
78
+ #value;
79
+ /**
80
+ * Name of the encrypter that decrypted the value
81
+ */
48
82
  encrypter;
49
83
  /** Create a new SecretString
50
84
  * @param str - the secret value
51
- * @param encrypter - the encrypter service name
85
+ * @param encrypter - the encrypter name
52
86
  */
53
87
  constructor(str, encrypter) {
54
- this.str = str;
88
+ this.#value = str;
55
89
  this.encrypter = encrypter;
56
90
  }
57
91
  /**
58
92
  * Extract the plain string value, warning if the string is not encrypted
59
- * @param value - the value to set
60
- * @param path - the path
61
- * @returns the result string
93
+ *
94
+ * The warning never contains the value itself, only the optional path.
95
+ *
96
+ * @param value - the secret, or a plain string
97
+ * @param path - the configuration path of the value, used in the warning
98
+ * @returns the plain value
62
99
  */
63
100
  static from(value, path) {
64
101
  if (value instanceof SecretString) {
65
102
  return value.getValue();
66
103
  }
67
- useLog("WARN", "A secret string is not encrypted", value);
104
+ if (value !== undefined && value !== null && value !== "") {
105
+ useLog("WARN", `A secret is not encrypted${path ? ` (${path})` : ""}, use 'webda config encrypt'`);
106
+ }
107
+ return value;
108
+ }
109
+ /**
110
+ * Replace every SecretString in a structure by its plain value
111
+ *
112
+ * Use it to pass a configuration object holding secrets to a third-party client
113
+ * that expects plain strings. The structure is copied, not modified.
114
+ *
115
+ * @param value - the structure to unwrap
116
+ * @returns a copy with plain values
117
+ */
118
+ static unwrap(value) {
119
+ if (value instanceof SecretString) {
120
+ return value.getValue();
121
+ }
122
+ if (Array.isArray(value)) {
123
+ return value.map(v => SecretString.unwrap(v));
124
+ }
125
+ if (isPlainObject(value)) {
126
+ const res = {};
127
+ for (const [key, v] of Object.entries(value)) {
128
+ res[key] = SecretString.unwrap(v);
129
+ }
130
+ return res;
131
+ }
68
132
  return value;
69
133
  }
70
134
  /**
@@ -72,14 +136,21 @@ export class SecretString {
72
136
  * @returns the result string
73
137
  */
74
138
  getValue() {
75
- return this.str;
139
+ return this.#value;
76
140
  }
77
141
  /**
78
142
  * Return a masked representation to prevent secret leakage
79
143
  * @returns the result string
80
144
  */
81
145
  toString() {
82
- return "********";
146
+ return SECRET_MASK;
147
+ }
148
+ /**
149
+ * Return a masked representation to prevent secret leakage through JSON
150
+ * @returns the result string
151
+ */
152
+ toJSON() {
153
+ return SECRET_MASK;
83
154
  }
84
155
  /**
85
156
  * Custom inspect handler to mask the secret in Node.js util.inspect
@@ -89,15 +160,118 @@ export class SecretString {
89
160
  * @returns the result
90
161
  */
91
162
  [util.inspect.custom](depth, options, inspect) {
92
- return "********";
163
+ return SECRET_MASK;
164
+ }
165
+ }
166
+ /**
167
+ * Check if a value is a plain object or an object without prototype
168
+ *
169
+ * Class instances (SecretString, Duration...) are not plain objects.
170
+ *
171
+ * @param value - the value to test
172
+ * @returns true for a plain object
173
+ */
174
+ function isPlainObject(value) {
175
+ if (value === null || typeof value !== "object") {
176
+ return false;
177
+ }
178
+ const proto = Object.getPrototypeOf(value);
179
+ return proto === Object.prototype || proto === null;
180
+ }
181
+ /**
182
+ * An encrypted configuration value
183
+ *
184
+ * - `encrypt:[<encrypter>:]<plain>` and `sencrypt:[<encrypter>:]<plain>` mark a value to encrypt
185
+ * - `crypt:<encrypter>:<data>` decrypts to a {@link SecretString}
186
+ * - `scrypt:<encrypter>:<data>` decrypts to a plain string
187
+ */
188
+ const CONFIGURATION_VALUE = /^(s?)(encrypt|crypt):([\s\S]+)$/;
189
+ /**
190
+ * The `<encrypter>:<data>` part of a configuration value
191
+ */
192
+ const ENCRYPTER_SEGMENT = /^([A-Za-z0-9_.-]+):([\s\S]*)$/;
193
+ /**
194
+ * Parse an encrypted or to-encrypt configuration value
195
+ *
196
+ * `crypt:` and `scrypt:` values always name their encrypter. For `encrypt:` and `sencrypt:`, the
197
+ * first segment is the encrypter when it is a registered encrypter name: otherwise the whole
198
+ * remainder is the plain value, encrypted by the default encrypter.
199
+ *
200
+ * @param value - the configuration value
201
+ * @param defaultEncrypter - the encrypter of `encrypt:<value>`
202
+ * @returns undefined if the value is not an encryption string
203
+ */
204
+ export function parseEncryptedValue(value, defaultEncrypter) {
205
+ if (typeof value !== "string") {
206
+ return undefined;
207
+ }
208
+ const match = CONFIGURATION_VALUE.exec(value);
209
+ if (!match) {
210
+ return undefined;
211
+ }
212
+ const plain = match[1] === "s";
213
+ const mode = match[2];
214
+ const segment = ENCRYPTER_SEGMENT.exec(match[3]);
215
+ if (mode === "crypt") {
216
+ return segment ? { plain, mode, encrypter: segment[1], data: segment[2], explicit: true } : undefined;
217
+ }
218
+ if (segment && (CryptoService.getEncrypter(segment[1]) || !defaultEncrypter)) {
219
+ // A registered encrypter, or an unknown one reported as such when there is no default
220
+ return { plain, mode, encrypter: segment[1], data: segment[2], explicit: true };
221
+ }
222
+ return { plain, mode, encrypter: defaultEncrypter, data: match[3], explicit: false };
223
+ }
224
+ /**
225
+ * Path of the encryption settings in a configuration
226
+ */
227
+ const SETTINGS_PATH = "parameters.configurationEncryption";
228
+ /**
229
+ * Check that the encryption settings hold no encrypted value
230
+ * @param settings - the settings
231
+ * @param path - the path, for messages
232
+ * @returns the settings
233
+ */
234
+ function checkEncryptionSettings(settings, path = SETTINGS_PATH) {
235
+ if (settings instanceof SecretString || /^s?crypt:/.test(typeof settings === "string" ? settings : "")) {
236
+ throw new CodeError("CONFIGURATION_ENCRYPTION_INVALID", `${path} cannot be encrypted`);
237
+ }
238
+ if (settings !== null && typeof settings === "object") {
239
+ for (const [key, value] of Object.entries(settings)) {
240
+ checkEncryptionSettings(value, `${path}.${key}`);
241
+ }
93
242
  }
243
+ return settings;
244
+ }
245
+ /**
246
+ * Get the application path, the current directory without application
247
+ * @returns the path
248
+ */
249
+ function getAppPath() {
250
+ try {
251
+ return useApplication().getPath();
252
+ }
253
+ catch {
254
+ return process.cwd();
255
+ }
256
+ }
257
+ /**
258
+ * Format a configuration path for messages
259
+ * @param path - the path segments
260
+ * @returns the dotted path
261
+ */
262
+ function formatPath(path) {
263
+ return path.length ? path.join(".") : "<root>";
94
264
  }
95
265
  /**
96
266
  * @WebdaModda
97
267
  */
98
268
  let CryptoService = (() => {
99
269
  let _classSuper = Service;
270
+ let _staticExtraInitializers = [];
100
271
  let _instanceExtraInitializers = [];
272
+ let _static_encryptConfigurationFile_decorators;
273
+ let _static_pkiGenerate_decorators;
274
+ let _static_pkiList_decorators;
101
275
  let _serveJWKS_decorators;
102
276
  return class CryptoService extends _classSuper {
103
277
  static {
@@ -108,23 +282,73 @@ let CryptoService = (() => {
108
282
  operationId: "getJWKS"
109
283
  }
110
284
  })];
285
+ _static_encryptConfigurationFile_decorators = [Command("config encrypt", {
286
+ description: "Encrypt the secrets of the configuration files, or check them with --check",
287
+ // No Core: runs without decrypting the configuration, its sources or the private keys
288
+ phase: "application"
289
+ })];
290
+ _static_pkiGenerate_decorators = [Command("config pki generate", {
291
+ description: "Generate a key pair for the pki configuration encrypter",
292
+ // No Core: runs without decrypting the configuration, its sources or the private keys
293
+ phase: "application"
294
+ })];
295
+ _static_pkiList_decorators = [Command("config pki list", {
296
+ description: "List the pki keys and the values encrypted with each of them",
297
+ // No Core: runs without decrypting the configuration, its sources or the private keys
298
+ phase: "application"
299
+ })];
300
+ __esDecorate(this, null, _static_encryptConfigurationFile_decorators, { kind: "method", name: "encryptConfigurationFile", static: true, private: false, access: { has: obj => "encryptConfigurationFile" in obj, get: obj => obj.encryptConfigurationFile }, metadata: _metadata }, null, _staticExtraInitializers);
301
+ __esDecorate(this, null, _static_pkiGenerate_decorators, { kind: "method", name: "pkiGenerate", static: true, private: false, access: { has: obj => "pkiGenerate" in obj, get: obj => obj.pkiGenerate }, metadata: _metadata }, null, _staticExtraInitializers);
302
+ __esDecorate(this, null, _static_pkiList_decorators, { kind: "method", name: "pkiList", static: true, private: false, access: { has: obj => "pkiList" in obj, get: obj => obj.pkiList }, metadata: _metadata }, null, _staticExtraInitializers);
111
303
  __esDecorate(this, null, _serveJWKS_decorators, { kind: "method", name: "serveJWKS", static: false, private: false, access: { has: obj => "serveJWKS" in obj, get: obj => obj.serveJWKS }, metadata: _metadata }, null, _instanceExtraInitializers);
112
304
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
113
305
  }
114
- static encrypters = {};
306
+ static encrypters = (__runInitializers(this, _staticExtraInitializers), {});
115
307
  /**
116
308
  * Register an encrypter for configuration
117
- * @param name - the name to use
309
+ *
310
+ * Values written as `encrypt:<name>:<plain>` are then encrypted by it with `webda config encrypt`,
311
+ * and `crypt:<name>:<data>` values are decrypted by it when the configuration is loaded.
312
+ *
313
+ * Register it as a side effect of a module that the application loads (a Modda file), so it is
314
+ * available before the services are created.
315
+ *
316
+ * @param name - the name to use, letters, digits, `_`, `-` and `.` only
118
317
  * @param encrypter - the encrypter implementation
119
- * @param encrypter.encrypt - the encrypter.encrypt
120
- * @param encrypter.decrypt - the encrypter.decrypt
121
318
  */
122
319
  static registerEncrypter(name, encrypter) {
123
- if (CryptoService.encrypters[name]) {
124
- console.error("Encrypter", name, "already registered");
320
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
321
+ throw new Error(`Invalid encrypter name '${name}'`);
322
+ }
323
+ if (CryptoService.encrypters[name] && CryptoService.encrypters[name] !== encrypter) {
324
+ useLog("WARN", `Encrypter '${name}' already registered, replacing it`);
125
325
  }
126
326
  CryptoService.encrypters[name] = encrypter;
127
327
  }
328
+ /**
329
+ * Get a registered encrypter
330
+ * @param name - the encrypter name
331
+ * @returns the encrypter or undefined
332
+ */
333
+ static getEncrypter(name) {
334
+ return CryptoService.encrypters[name];
335
+ }
336
+ /**
337
+ * Get the encrypter for a value, with a helpful error if missing
338
+ * @param name - the encrypter name
339
+ * @param path - the configuration path, for the error message
340
+ * @returns the encrypter
341
+ */
342
+ static requireEncrypter(name, path) {
343
+ const encrypter = CryptoService.encrypters[name];
344
+ if (!encrypter) {
345
+ const hint = name === "self"
346
+ ? " ('self' is only available once a CryptoService is initialized, it cannot be used in the configuration)"
347
+ : ` (registered: ${Object.keys(CryptoService.encrypters).join(", ")})`;
348
+ throw new Error(`Unknown encrypter '${name}' at ${formatPath(path)}${hint}`);
349
+ }
350
+ return encrypter;
351
+ }
128
352
  currentSymetricKey = __runInitializers(this, _instanceExtraInitializers);
129
353
  currentAsymetricKey;
130
354
  current;
@@ -200,7 +424,9 @@ let CryptoService = (() => {
200
424
  generateAsymetricKeys() {
201
425
  const { publicKey, privateKey } = generateKeyPairSync(
202
426
  // @ts-ignore
203
- this.parameters.asymetricType, this.parameters.asymetricOptions);
427
+ this.parameters.asymetricType,
428
+ // The private key passphrase can be an encrypted configuration value
429
+ SecretString.unwrap(this.parameters.asymetricOptions));
204
430
  return { publicKey: publicKey, privateKey: privateKey };
205
431
  }
206
432
  /**
@@ -267,7 +493,8 @@ let CryptoService = (() => {
267
493
  * @returns the result string
268
494
  */
269
495
  async jwtSign(data, options) {
270
- const res = { ...this.parameters.jwt, ...options };
496
+ // Encrypted configuration values (secretOrPublicKey) are unwrapped to plain strings
497
+ const res = SecretString.unwrap({ ...this.parameters.jwt, ...options });
271
498
  let key = res.secretOrPublicKey;
272
499
  // Default to our current private key
273
500
  if (!res.secretOrPublicKey) {
@@ -336,6 +563,8 @@ let CryptoService = (() => {
336
563
  * @returns the result
337
564
  */
338
565
  async jwtVerify(token, options) {
566
+ // Encrypted configuration values (secretOrPublicKey) are unwrapped to plain strings
567
+ options = SecretString.unwrap(options);
339
568
  return new Promise((resolve, reject) => {
340
569
  jwt.verify(token, options?.secretOrPublicKey || this.getJWTKey.bind(this), {
341
570
  ...options,
@@ -378,63 +607,507 @@ let CryptoService = (() => {
378
607
  return JSON.parse(Buffer.from(token.split(".")[0], "base64").toString());
379
608
  }
380
609
  /**
381
- * Encrypt configuration
382
- * @param data - the data to process
383
- * @returns the result
610
+ * Start an encryption or decryption pass
611
+ * @param data - the configuration
612
+ * @param options - the options
613
+ * @param path - the path of data
614
+ * @param mode - encrypt or decrypt
615
+ * @returns the pass
384
616
  */
385
- static async encryptConfiguration(data) {
386
- if (data instanceof Object) {
387
- for (const i in data) {
388
- data[i] = await CryptoService.encryptConfiguration(data[i]);
617
+ static startPass(data, options, path, mode) {
618
+ // Read the settings from the configuration itself when given the whole configuration
619
+ const settings = options.settings ?? (path.length === 0 ? data?.parameters?.configurationEncryption : undefined);
620
+ return {
621
+ settings: checkEncryptionSettings(settings ?? {}),
622
+ context: { appPath: options.appPath ?? getAppPath(), mode },
623
+ configured: new Set()
624
+ };
625
+ }
626
+ /**
627
+ * Get an encrypter, configured for this pass
628
+ * @param name - the encrypter name
629
+ * @param pass - the current pass
630
+ * @param path - the configuration path, for messages
631
+ * @returns the encrypter
632
+ */
633
+ static async useEncrypter(name, pass, path) {
634
+ const encrypter = CryptoService.requireEncrypter(name, path);
635
+ if (encrypter.configure && !pass.configured.has(name)) {
636
+ pass.configured.add(name);
637
+ await encrypter.configure(pass.settings.encrypters?.[name] ?? {}, pass.context);
638
+ }
639
+ return encrypter;
640
+ }
641
+ /**
642
+ * Encrypt the `encrypt:[<encrypter>:]<plain>` and `sencrypt:[<encrypter>:]<plain>` values of a configuration
643
+ *
644
+ * The structure is modified in place, so a comment-preserving document loaded with
645
+ * `FileUtils.load` can be saved back with its comments.
646
+ *
647
+ * `encrypt:<plain>` uses the default encrypter, `parameters.configurationEncryption.default`. The output
648
+ * always names its encrypter.
649
+ *
650
+ * @param data - the configuration, or a single value
651
+ * @param options - options
652
+ * @param options.migrate - also re-encrypt the already encrypted values with this encrypter, with the
653
+ * default encrypter when `""`, or with their own encrypter when `true` (same as `upgrade`)
654
+ * @param options.upgrade - also re-encrypt the already encrypted values with their own encrypter, to its
655
+ * current format or key
656
+ * @param path - the path of data, for error messages
657
+ * @returns the encrypted data
658
+ */
659
+ static async encryptConfiguration(data, options = {}, path = []) {
660
+ const pass = CryptoService.startPass(data, options, path, "encrypt");
661
+ pass.migrate = typeof options.migrate === "string" ? options.migrate : undefined;
662
+ pass.upgrade = options.upgrade || options.migrate === true;
663
+ if (pass.migrate === "") {
664
+ pass.migrate = pass.settings.default;
665
+ if (!pass.migrate) {
666
+ throw new Error(`No default encrypter to migrate to, set ${SETTINGS_PATH}.default`);
389
667
  }
390
668
  }
391
- else if (typeof data === "string") {
392
- if (data.startsWith("encrypt:") || data.startsWith("sencrypt:")) {
393
- let str = data.substring(data.indexOf(":") + 1);
394
- const type = str.substring(0, str.indexOf(":"));
395
- str = str.substring(str.indexOf(":") + 1);
396
- if (!CryptoService.encrypters[type]) {
397
- throw new Error("Unknown encrypter " + type);
398
- }
399
- if (data.startsWith("s")) {
400
- data = `scrypt:${type}:` + (await CryptoService.encrypters[type].encrypt(str));
669
+ return CryptoService.encryptValues(data, pass, path);
670
+ }
671
+ /**
672
+ * Encrypt the values of a configuration, see {@link encryptConfiguration}
673
+ * @param data - the configuration, or a single value
674
+ * @param pass - the current pass
675
+ * @param path - the path of data
676
+ * @returns the encrypted data
677
+ */
678
+ static async encryptValues(data, pass, path) {
679
+ if (data !== null && typeof data === "object" && !(data instanceof SecretString)) {
680
+ for (const i in data) {
681
+ // The encryption settings are never encrypted
682
+ if (path.length === 1 && path[0] === "parameters" && i === "configurationEncryption") {
683
+ continue;
401
684
  }
402
- else {
403
- data = `crypt:${type}:` + (await CryptoService.encrypters[type].encrypt(str));
685
+ data[i] = await CryptoService.encryptValues(data[i], pass, [...path, i]);
686
+ }
687
+ return data;
688
+ }
689
+ const parsed = parseEncryptedValue(data, pass.settings.default);
690
+ if (!parsed) {
691
+ return data;
692
+ }
693
+ let { encrypter, data: plain } = parsed;
694
+ if (parsed.mode === "crypt") {
695
+ // An opted-out value stays in clear text, it is not migrated to another encrypter
696
+ if ((!pass.migrate && !pass.upgrade) || encrypter === OPT_OUT_ENCRYPTER) {
697
+ return data;
698
+ }
699
+ const target = pass.migrate || encrypter;
700
+ if (target === encrypter) {
701
+ const current = await CryptoService.useEncrypter(encrypter, pass, path);
702
+ if (current.needsUpgrade && !current.needsUpgrade(parsed.data)) {
703
+ return data;
404
704
  }
405
705
  }
706
+ plain = await CryptoService.decryptValue(encrypter, parsed.data, path, pass);
707
+ encrypter = target;
708
+ }
709
+ else if (!encrypter) {
710
+ throw new Error(`No default encrypter for the value at ${formatPath(path)}, set ${SETTINGS_PATH}.default`);
711
+ }
712
+ else if (!CryptoService.getEncrypter(encrypter) && !pass.settings.default) {
713
+ throw new Error(`Unknown encrypter '${encrypter}' at ${formatPath(path)} (registered: ${Object.keys(CryptoService.encrypters).join(", ")}), ` +
714
+ `or set ${SETTINGS_PATH}.default to encrypt the whole value with the default encrypter`);
715
+ }
716
+ const encrypted = await (await CryptoService.useEncrypter(encrypter, pass, path)).encrypt(plain);
717
+ return `${parsed.plain ? "s" : ""}crypt:${encrypter}:${encrypted}`;
718
+ }
719
+ /**
720
+ * Decrypt one value with an encrypter, with a message that never contains the value
721
+ * @param encrypter - the encrypter name
722
+ * @param data - the encrypted data
723
+ * @param path - the configuration path
724
+ * @param pass - the current pass, to configure the encrypter
725
+ * @returns the plain value
726
+ */
727
+ static async decryptValue(encrypter, data, path, pass) {
728
+ const impl = pass
729
+ ? await CryptoService.useEncrypter(encrypter, pass, path)
730
+ : CryptoService.requireEncrypter(encrypter, path);
731
+ try {
732
+ return await impl.decrypt(data);
733
+ }
734
+ catch (err) {
735
+ throw new Error(`Cannot decrypt the value at ${formatPath(path)} with encrypter '${encrypter}': ${err.message}`);
736
+ }
737
+ }
738
+ /**
739
+ * Decrypt the `crypt:` and `scrypt:` values of a configuration
740
+ *
741
+ * - `crypt:<encrypter>:<data>` becomes a {@link SecretString}
742
+ * - `scrypt:<encrypter>:<data>` becomes a plain string, for a module that cannot handle SecretString
743
+ *
744
+ * The input is not modified: a decrypted copy is returned. Only plain objects and
745
+ * arrays are traversed.
746
+ *
747
+ * A value still marked `encrypt:` is kept as is, with a warning.
748
+ *
749
+ * @param data - the configuration, or a single value
750
+ * @param path - the path of data, for messages
751
+ * @param options - the encryption settings and the application path
752
+ * @returns the decrypted copy
753
+ */
754
+ static async decryptConfiguration(data, path = [], options = {}) {
755
+ return CryptoService.decryptValues(data, CryptoService.startPass(data, options, path, "decrypt"), path);
756
+ }
757
+ /**
758
+ * Decrypt the values of a configuration, see {@link decryptConfiguration}
759
+ * @param data - the configuration, or a single value
760
+ * @param pass - the current pass
761
+ * @param path - the path of data
762
+ * @returns the decrypted copy
763
+ */
764
+ static async decryptValues(data, pass, path) {
765
+ if (Array.isArray(data)) {
766
+ const res = [];
767
+ for (let i = 0; i < data.length; i++) {
768
+ res.push(await CryptoService.decryptValues(data[i], pass, [...path, i]));
769
+ }
770
+ return res;
406
771
  }
407
- return data;
772
+ if (isPlainObject(data)) {
773
+ const res = {};
774
+ for (const i of Object.keys(data)) {
775
+ res[i] = await CryptoService.decryptValues(data[i], pass, [...path, i]);
776
+ }
777
+ return res;
778
+ }
779
+ const parsed = parseEncryptedValue(data);
780
+ if (!parsed) {
781
+ return data;
782
+ }
783
+ if (parsed.mode === "encrypt") {
784
+ useLog("WARN", `The value at ${formatPath(path)} is marked for encryption but not encrypted, run 'webda config encrypt'`);
785
+ return data;
786
+ }
787
+ const plain = await CryptoService.decryptValue(parsed.encrypter, parsed.data, path, pass);
788
+ return parsed.plain ? plain : new SecretString(plain, parsed.encrypter);
789
+ }
790
+ /**
791
+ * Get the encryption settings of the application, merged with the ones of a configuration file
792
+ * @param data - the configuration file content
793
+ * @returns the settings
794
+ */
795
+ static getEncryptionSettings(data) {
796
+ return checkEncryptionSettings(deepmerge(useApplication().getConfiguration()?.parameters?.configurationEncryption ?? {}, data?.parameters?.configurationEncryption ?? {}));
408
797
  }
409
798
  /**
799
+ * Encrypt the marked values of a configuration file, keeping its comments
410
800
  *
411
- * @param data - the data to process
412
- * @returns the result
801
+ * Prefix a value with `encrypt:[<encrypter>:]` (SecretString once decrypted) or
802
+ * `sencrypt:[<encrypter>:]` (plain string once decrypted) then run this command.
803
+ *
804
+ * @param file - the configuration file (json, jsonc or yaml) to update
805
+ * @param migrate - re-encrypt the already encrypted values with this encrypter, the default one when empty
806
+ * @param upgrade - re-encrypt the already encrypted values with their own encrypter, to its current format or key
807
+ * @returns the number of values changed
413
808
  */
414
- static async decryptConfiguration(data) {
415
- if (data instanceof Object) {
416
- for (const i in data) {
417
- data[i] = await CryptoService.decryptConfiguration(data[i]);
809
+ async encryptConfigurationFile(file, migrate, upgrade) {
810
+ return CryptoService.encryptFile(file, migrate, upgrade);
811
+ }
812
+ /**
813
+ * Encrypt the secrets of the configuration files, keeping their comments, or check them
814
+ *
815
+ * Encrypts the values marked `encrypt:` or `sencrypt:`, and the plain values of the secret fields: the fields
816
+ * typed `SecretString` or tagged `@secret` in their service parameters, marked `x-webda-secret` in the compiled
817
+ * schemas. The plain secrets are encrypted with the default encrypter.
818
+ *
819
+ * Without `file`, it updates the application configuration and the file of the selected deployment.
820
+ *
821
+ * With `check`, nothing is written: it fails when a secret is not encrypted. It needs no key, so it runs in CI.
822
+ * Only the paths are reported, never the values.
823
+ *
824
+ * It only needs the loaded application: the configuration is not decrypted and no service is created.
825
+ *
826
+ * @param file - the configuration file (json, jsonc or yaml) to update
827
+ * @param migrate - re-encrypt the already encrypted values with this encrypter, the default one when empty
828
+ * @param upgrade - re-encrypt the already encrypted values with their own encrypter, to its current format or key
829
+ * @param check - only check that the secrets are encrypted, write nothing
830
+ * @param strict - with check, report the values still marked `encrypt:` as failures
831
+ * @param format - with check, output format: text or json
832
+ * @param none - with check, accept the values kept in clear text with `crypt:none:`, false to fail on them
833
+ * @param exclude - secret paths to leave alone, comma-separated: not checked, not auto-encrypted
834
+ * @returns with check, 1 when a secret is not encrypted, otherwise 0
835
+ */
836
+ static async encryptConfigurationFile(
837
+ /** @alias f @description Configuration file to update (json, jsonc or yaml), the application and deployment files by default */
838
+ file,
839
+ /** @description Re-encrypt the already encrypted values with this encrypter, the default one when empty */
840
+ migrate,
841
+ /** @description Re-encrypt the already encrypted values with their own encrypter, to its current format or key */
842
+ upgrade,
843
+ /** @description Only check that the secrets are encrypted, write nothing and fail if one is not */
844
+ check = false,
845
+ /** @description With --check, report the values marked encrypt: as failures */
846
+ strict = true,
847
+ /** @description With --check, output format: text or json */
848
+ format = "text",
849
+ /** @description With --check, accept the values kept in clear text with crypt:none: (--no-none to fail on them) */
850
+ none = true,
851
+ /** @description Secret paths to leave alone, comma-separated (services.demo.password, units[*].token, $.parameters.**) */
852
+ exclude) {
853
+ const files = CryptoService.getConfigurationFiles(file);
854
+ if (check) {
855
+ return CryptoService.checkFiles(files, { strict, format, none, exclude });
856
+ }
857
+ // The count is logged, not returned: a command result is its exit code
858
+ for (const filename of files) {
859
+ await CryptoService.encryptFile(filename, migrate, upgrade, exclude);
860
+ }
861
+ return 0;
862
+ }
863
+ /**
864
+ * The configuration files to encrypt or check
865
+ * @param file - the file given on the command line
866
+ * @returns the absolute paths: the file, or the application configuration and the selected deployment file
867
+ */
868
+ static getConfigurationFiles(file) {
869
+ const app = useApplication();
870
+ if (file) {
871
+ return [resolve(app.getPath(), file)];
872
+ }
873
+ const files = [app.configurationFile, app.getDeployment() ? app.deploymentFile : undefined].filter(f => f && existsSync(f));
874
+ if (!files.length) {
875
+ throw new Error("No configuration file found, use --file");
876
+ }
877
+ return files;
878
+ }
879
+ /**
880
+ * Check that the secrets of configuration files are encrypted
881
+ * @param files - the files to check
882
+ * @param options - check options
883
+ * @param options.strict - report the values still marked `encrypt:` as failures
884
+ * @param options.format - output format: text or json
885
+ * @param options.none - accept the values kept in clear text with `crypt:none:`
886
+ * @param options.exclude - secret paths not checked, comma-separated
887
+ * @returns 1 when a secret is not encrypted, 0 otherwise
888
+ */
889
+ static checkFiles(files, options = {}) {
890
+ const { strict = true, format = "text", none = true, exclude } = options;
891
+ const app = useApplication();
892
+ const failures = [];
893
+ const warnings = [];
894
+ for (const filename of files) {
895
+ // Same report on every OS: forward slashes
896
+ const file = (relative(app.getPath(), filename) || filename).replace(/\\/g, "/");
897
+ // Plain JSON: the comment-preserving loader returns wrapper objects
898
+ const findings = CryptoService.findFileSecrets(JSON.parse(JSON.stringify(FileUtils.load(filename))), exclude);
899
+ for (const finding of findings) {
900
+ const failure = finding.reason === "plain" ||
901
+ (strict && finding.reason === "marked") ||
902
+ (!none && finding.reason === "opted-out");
903
+ const message = {
904
+ plain: "secret is not encrypted",
905
+ marked: "secret is marked encrypt: but not encrypted, run 'webda config encrypt'",
906
+ "opted-out": "secret is kept in clear text on purpose (crypt:none:)"
907
+ }[finding.reason];
908
+ useLog(failure ? "ERROR" : "WARN", `${failure ? "✗" : "!"} ${file}: ${finding.path} (${finding.type}): ${message}`);
909
+ const { location: _location, ...reported } = finding;
910
+ (failure ? failures : warnings).push({ file, ...reported });
418
911
  }
419
912
  }
420
- else if (typeof data === "string") {
421
- if (data.startsWith("crypt:") || data.startsWith("scrypt:")) {
422
- let str = data.substring(data.indexOf(":") + 1);
423
- const type = str.substring(0, str.indexOf(":"));
424
- str = str.substring(str.indexOf(":") + 1);
425
- if (!CryptoService.encrypters[type]) {
426
- throw new Error("Unknown encrypter " + type);
427
- }
428
- // We keep the ability to map to a simple string for incompatible module
429
- if (data.startsWith("scrypt:")) {
430
- return await CryptoService.encrypters[type].decrypt(str);
431
- }
432
- else {
433
- return new SecretString(await CryptoService.encrypters[type].decrypt(str), type);
434
- }
913
+ if (format === "json") {
914
+ useOutput(JSON.stringify({ findings: failures, warnings }, undefined, 2));
915
+ }
916
+ else {
917
+ const optedOut = warnings.filter(w => w.reason === "opted-out").length;
918
+ useOutput((failures.length ? `${failures.length} unencrypted secret(s) found` : "No unencrypted secret found") +
919
+ (optedOut ? `, ${optedOut} kept in clear text on purpose` : ""));
920
+ }
921
+ return failures.length ? 1 : 0;
922
+ }
923
+ /**
924
+ * Find the unencrypted secrets of a configuration or deployment file
925
+ *
926
+ * A service the file overrides without its type takes the type of the application configuration, and the
927
+ * file `parameters` are also checked against the services of the application.
928
+ *
929
+ * @param data - the file content, as plain JSON
930
+ * @param exclude - secret paths to leave out, comma-separated patterns, see {@link secretPathExcluder}
931
+ * @returns the findings, with their location in data, without the values
932
+ */
933
+ static findFileSecrets(data, exclude) {
934
+ const app = useApplication();
935
+ const services = app.getConfiguration().services ?? {};
936
+ const { findings, unknownTypes } = findConfigurationSecrets({ services: data?.services, parameters: data?.parameters, resources: data?.resources, units: data?.units }, {
937
+ getSchema: type => app.getSchema(type),
938
+ // Same resolution as the services: the application namespace, then Webda
939
+ completeNamespace: type => {
940
+ const namespaced = app.completeNamespace(type);
941
+ return app.getSchema(namespaced) || type.includes("/") ? namespaced : `Webda/${type}`;
942
+ },
943
+ getType: name => services[name]?.type,
944
+ inheritingServices: services
945
+ });
946
+ for (const type of unknownTypes) {
947
+ useLog("WARN", `No schema for service type ${type}, its secrets are not checked`);
948
+ }
949
+ const excluded = secretPathExcluder(exclude);
950
+ return findings.filter(finding => {
951
+ if (!excluded(finding.path))
952
+ return true;
953
+ useLog("DEBUG", `Secret ${finding.path} excluded`);
954
+ return false;
955
+ });
956
+ }
957
+ /**
958
+ * Encrypt the secrets of a configuration file, keeping its comments
959
+ *
960
+ * The values marked `encrypt:` or `sencrypt:`, and the plain values of the secret fields, with the default
961
+ * encrypter.
962
+ *
963
+ * @param file - the configuration file (json, jsonc or yaml) to update
964
+ * @param migrate - re-encrypt the already encrypted values with this encrypter, the default one when empty
965
+ * @param upgrade - re-encrypt the already encrypted values with their own encrypter, to its current format or key
966
+ * @param exclude - secret paths not auto-encrypted, comma-separated patterns, see {@link secretPathExcluder}
967
+ * @returns the number of values changed
968
+ */
969
+ static async encryptFile(file, migrate, upgrade, exclude) {
970
+ const filename = resolve(useApplication().getPath(), file);
971
+ const data = FileUtils.load(filename);
972
+ const before = JSON.stringify(data);
973
+ const settings = CryptoService.getEncryptionSettings(data);
974
+ const plain = CryptoService.findFileSecrets(JSON.parse(before), exclude).filter(f => f.reason === "plain");
975
+ if (plain.length && !settings.default) {
976
+ throw new Error(`${plain.length} secret(s) are not encrypted in ${filename} (${plain.map(f => f.path).join(", ")}): ` +
977
+ `set ${SETTINGS_PATH}.default or prefix them with encrypt:<encrypter>:`);
978
+ }
979
+ for (const finding of plain) {
980
+ // The encrypter is explicit: a plain value starting with an encrypter name is not misread
981
+ const parent = finding.location.slice(0, -1).reduce((target, segment) => target[segment], data);
982
+ const key = finding.location[finding.location.length - 1];
983
+ parent[key] = `encrypt:${settings.default}:${parent[key]}`;
984
+ useLog("INFO", `Encrypting the plain secret ${finding.path}`);
985
+ }
986
+ let changes = 0;
987
+ await CryptoService.encryptConfiguration(data, {
988
+ migrate,
989
+ upgrade,
990
+ settings,
991
+ appPath: useApplication().getPath()
992
+ });
993
+ // Count the changed values without logging them
994
+ const count = (a, b) => {
995
+ if (a !== null && typeof a === "object") {
996
+ Object.keys(a).forEach(k => count(a[k], b?.[k]));
997
+ }
998
+ else if (a !== b) {
999
+ changes++;
1000
+ }
1001
+ };
1002
+ // Compare plain JSON: the comment-preserving arrays are wrapper objects
1003
+ count(JSON.parse(JSON.stringify(data)), JSON.parse(before));
1004
+ if (changes) {
1005
+ FileUtils.save(data, filename);
1006
+ }
1007
+ useLog("INFO", `${changes} value(s) encrypted in ${filename}`);
1008
+ return changes;
1009
+ }
1010
+ /**
1011
+ * Get the keyring of the `pki` encrypter, as configured for the application
1012
+ * @param data - a configuration file content, its settings override the application ones
1013
+ * @returns the keyring
1014
+ */
1015
+ static getPkiKeyring(data) {
1016
+ return new PkiKeyring(CryptoService.getEncryptionSettings(data).encrypters?.pki ?? {}, useApplication().getPath());
1017
+ }
1018
+ /**
1019
+ * Generate a key pair for the `pki` encrypter
1020
+ *
1021
+ * The public key is written in the public keys directory, to commit. The private key is written
1022
+ * outside of it, to store in the runtime secrets (a Kubernetes Secret for example), never in the repository.
1023
+ *
1024
+ * @param kid - the key id
1025
+ * @param bits - the RSA modulus length
1026
+ * @param out - the private key file
1027
+ * @returns the written files
1028
+ */
1029
+ static async pkiGenerate(
1030
+ /** @description Key id, for example 2026-10 */
1031
+ kid,
1032
+ /** @description RSA modulus length */
1033
+ bits = 4096,
1034
+ /** @description Private key file, ./<kid>.pem by default */
1035
+ out) {
1036
+ checkKid(kid);
1037
+ if (!(bits >= PKI_MIN_BITS)) {
1038
+ throw new Error(`RSA ${PKI_MIN_BITS} bits at least is required`);
1039
+ }
1040
+ const keyring = CryptoService.getPkiKeyring();
1041
+ const publicKey = resolve(keyring.publicKeysDir, `${kid}.pub.pem`);
1042
+ const privateKey = resolve(useApplication().getPath(), out || `${kid}.pem`);
1043
+ const fromPublicDir = relative(keyring.publicKeysDir, privateKey);
1044
+ if (!fromPublicDir.startsWith("..") && !isAbsolute(fromPublicDir)) {
1045
+ throw new Error(`The private key cannot be written in the public keys directory ${keyring.publicKeysDir}`);
1046
+ }
1047
+ for (const f of [publicKey, privateKey]) {
1048
+ if (existsSync(f)) {
1049
+ throw new Error(`${f} already exists`);
435
1050
  }
436
1051
  }
437
- return data;
1052
+ const pair = generateKeyPairSync("rsa", {
1053
+ modulusLength: bits,
1054
+ publicKeyEncoding: { type: "spki", format: "pem" },
1055
+ privateKeyEncoding: { type: "pkcs8", format: "pem" }
1056
+ });
1057
+ mkdirSync(keyring.publicKeysDir, { recursive: true });
1058
+ writeFileSync(privateKey, pair.privateKey, { mode: 0o600, flag: "wx" });
1059
+ writeFileSync(publicKey, pair.publicKey, { flag: "wx" });
1060
+ useLog("INFO", `Public key written to ${publicKey}, commit it`);
1061
+ useLog("INFO", `Private key written to ${privateKey}, never commit it. Ignore it in git:\n *.pem\n !*.pub.pem`);
1062
+ useLog("INFO", `Store it in the runtime, then delete the local file:\n kubectl create secret generic webda-pki --from-file=${kid}.pem=${privateKey}`);
1063
+ useLog("INFO", `Set ${SETTINGS_PATH}.encrypters.pki.activeKey to '${kid}' to encrypt the new values with it`);
1064
+ return { publicKey, privateKey };
1065
+ }
1066
+ /**
1067
+ * List the public keys of the `pki` encrypter and the number of values encrypted with each key in a file
1068
+ *
1069
+ * A key with no value left can be retired.
1070
+ *
1071
+ * @param file - the configuration file to inspect
1072
+ * @returns the active key, the public keys and the values count by kid
1073
+ */
1074
+ static async pkiList(
1075
+ /** @alias f @description Configuration file to inspect (json, jsonc or yaml) */
1076
+ file) {
1077
+ const filename = resolve(useApplication().getPath(), file);
1078
+ const data = FileUtils.load(filename);
1079
+ const keyring = CryptoService.getPkiKeyring(data);
1080
+ const values = {};
1081
+ const count = (value) => {
1082
+ if (value !== null && typeof value === "object") {
1083
+ Object.values(value).forEach(count);
1084
+ return;
1085
+ }
1086
+ const parsed = parseEncryptedValue(value);
1087
+ if (parsed?.mode !== "crypt" || parsed.encrypter !== "pki") {
1088
+ return;
1089
+ }
1090
+ let kid;
1091
+ try {
1092
+ kid = parsePkiValue(parsed.data).kid;
1093
+ }
1094
+ catch {
1095
+ kid = "<invalid>";
1096
+ }
1097
+ values[kid] = (values[kid] ?? 0) + 1;
1098
+ };
1099
+ count(data);
1100
+ const publicKeys = keyring.listKids();
1101
+ const lines = [
1102
+ `Active key: ${keyring.activeKey ?? "<none>"}`,
1103
+ `Public keys in ${keyring.publicKeysDir}: ${publicKeys.join(", ") || "<none>"}`,
1104
+ `Values in ${filename}:`
1105
+ ];
1106
+ for (const kid of [...new Set([...publicKeys, ...Object.keys(values)])].sort()) {
1107
+ lines.push(` ${kid}${kid === keyring.activeKey ? " (active)" : ""}: ${values[kid] ?? 0}`);
1108
+ }
1109
+ useOutput(lines.join("\n"));
1110
+ return { activeKey: keyring.activeKey, publicKeys, values };
438
1111
  }
439
1112
  /**
440
1113
  * Decrypt data
@@ -492,27 +1165,61 @@ let CryptoService = (() => {
492
1165
  };
493
1166
  })();
494
1167
  export { CryptoService };
1168
+ /**
1169
+ * Prefix of the current encrypted format of the built-in encrypters
1170
+ *
1171
+ * Legacy values (AES-256-CTR, unauthenticated) have no prefix: base64 never contains `:`.
1172
+ */
1173
+ export const ENCRYPTION_FORMAT_V2 = "v2:";
1174
+ /**
1175
+ * Get the key of the local encrypter
1176
+ * @returns the key derived from the machine id
1177
+ */
1178
+ function getLocalKey() {
1179
+ return new Uint8Array(createHash("sha256").update(getMachineId()).digest());
1180
+ }
495
1181
  /**
496
1182
  * Encrypt data with local machine id
1183
+ *
1184
+ * The machine id is `WEBDA_MACHINE_ID` if defined, the OS machine id otherwise: a value
1185
+ * encrypted on one machine can only be decrypted on the same machine, or with the same
1186
+ * `WEBDA_MACHINE_ID`.
497
1187
  */
498
1188
  CryptoService.registerEncrypter("local", {
499
1189
  encrypt: async (data) => {
500
- // Initialization Vector
501
- const iv = randomBytes(16);
502
- const key = createHash("sha256").update(getMachineId()).digest();
503
- const cipher = createCipheriv("aes-256-ctr", new Uint8Array(key), new Uint8Array(iv));
504
- const updated = cipher.update(new Uint8Array(Buffer.from(data)));
505
- const final = cipher.final();
506
- return Buffer.concat([new Uint8Array(iv), new Uint8Array(updated), new Uint8Array(final)]).toString("base64");
1190
+ return ENCRYPTION_FORMAT_V2 + AuthenticatedEncryption.encrypt(getLocalKey(), data);
507
1191
  },
508
1192
  decrypt: async (data) => {
1193
+ if (data.startsWith(ENCRYPTION_FORMAT_V2)) {
1194
+ return AuthenticatedEncryption.decrypt(getLocalKey(), data.substring(ENCRYPTION_FORMAT_V2.length));
1195
+ }
1196
+ // Legacy format: AES-256-CTR, re-encrypt with 'webda config encrypt --upgrade'
509
1197
  const input = Buffer.from(data, "base64");
510
1198
  const iv = input.subarray(0, 16);
511
- const key = createHash("sha256").update(getMachineId()).digest();
512
- const decipher = createDecipheriv("aes-256-ctr", new Uint8Array(key), new Uint8Array(iv));
1199
+ const decipher = createDecipheriv("aes-256-ctr", getLocalKey(), new Uint8Array(iv));
513
1200
  return decipher.update(new Uint8Array(input.subarray(16))).toString() + decipher.final().toString();
514
1201
  }
515
1202
  });
1203
+ /**
1204
+ * Asymmetric encrypter: public keys in the repository, private keys in the runtime
1205
+ */
1206
+ CryptoService.registerEncrypter("pki", pkiEncrypter);
1207
+ /**
1208
+ * Opt-out: `crypt:none:<value>` keeps a value of a secret field in clear text on purpose
1209
+ *
1210
+ * `webda config encrypt` does not encrypt it, even with `--migrate`, and `--check` reports it as a warning.
1211
+ */
1212
+ CryptoService.registerEncrypter(OPT_OUT_ENCRYPTER, {
1213
+ encrypt: async (data) => {
1214
+ if (/[\r\n]/.test(data)) {
1215
+ throw new Error("A multi-line value cannot be kept in clear text with crypt:none:");
1216
+ }
1217
+ return data;
1218
+ },
1219
+ decrypt: async (data) => data,
1220
+ needsUpgrade: () => false
1221
+ });
1222
+ export { AuthenticatedEncryption };
516
1223
  export default CryptoService;
517
1224
  /**
518
1225
  * Return the CryptoService