@webda/core 4.0.0-beta.5 → 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.
- package/README.md +6 -5
- package/lib/application/application.d.ts.map +1 -1
- package/lib/application/application.js +18 -6
- package/lib/application/application.js.map +1 -1
- package/lib/application/iconfiguration.d.ts +31 -0
- package/lib/application/iconfiguration.d.ts.map +1 -1
- package/lib/application/iconfiguration.js.map +1 -1
- package/lib/application/unpackedapplication.d.ts.map +1 -1
- package/lib/application/unpackedapplication.js +2 -1
- package/lib/application/unpackedapplication.js.map +1 -1
- package/lib/bin/cli-phase.d.ts +18 -2
- package/lib/bin/cli-phase.d.ts.map +1 -1
- package/lib/bin/cli-phase.js +25 -0
- package/lib/bin/cli-phase.js.map +1 -1
- package/lib/bin/cli.d.ts +19 -0
- package/lib/bin/cli.d.ts.map +1 -1
- package/lib/bin/cli.js +120 -41
- package/lib/bin/cli.js.map +1 -1
- package/lib/configurations/configuration.service.d.ts +7 -0
- package/lib/configurations/configuration.service.d.ts.map +1 -1
- package/lib/configurations/configuration.service.js +18 -5
- package/lib/configurations/configuration.service.js.map +1 -1
- package/lib/configurations/kubernetesconfiguration.service.d.ts.map +1 -1
- package/lib/configurations/kubernetesconfiguration.service.js +1 -4
- package/lib/configurations/kubernetesconfiguration.service.js.map +1 -1
- package/lib/contexts/httpcontext.d.ts +15 -1
- package/lib/contexts/httpcontext.d.ts.map +1 -1
- package/lib/contexts/httpcontext.js +61 -19
- package/lib/contexts/httpcontext.js.map +1 -1
- package/lib/contexts/operationcontext.d.ts +6 -0
- package/lib/contexts/operationcontext.d.ts.map +1 -1
- package/lib/contexts/operationcontext.js +16 -2
- package/lib/contexts/operationcontext.js.map +1 -1
- package/lib/contexts/restcontext.d.ts +110 -0
- package/lib/contexts/restcontext.d.ts.map +1 -0
- package/lib/contexts/restcontext.js +247 -0
- package/lib/contexts/restcontext.js.map +1 -0
- package/lib/contexts/streamingcontext.d.ts +71 -0
- package/lib/contexts/streamingcontext.d.ts.map +1 -0
- package/lib/contexts/streamingcontext.js +93 -0
- package/lib/contexts/streamingcontext.js.map +1 -0
- package/lib/contexts/webcontext.d.ts +20 -0
- package/lib/contexts/webcontext.d.ts.map +1 -1
- package/lib/contexts/webcontext.js +43 -10
- package/lib/contexts/webcontext.js.map +1 -1
- package/lib/contexts/websocketcontext.d.ts +39 -0
- package/lib/contexts/websocketcontext.d.ts.map +1 -0
- package/lib/contexts/websocketcontext.js +67 -0
- package/lib/contexts/websocketcontext.js.map +1 -0
- package/lib/core/asyncqueue.d.ts +50 -0
- package/lib/core/asyncqueue.d.ts.map +1 -0
- package/lib/core/asyncqueue.js +96 -0
- package/lib/core/asyncqueue.js.map +1 -0
- package/lib/core/core.d.ts +21 -2
- package/lib/core/core.d.ts.map +1 -1
- package/lib/core/core.js +87 -24
- package/lib/core/core.js.map +1 -1
- package/lib/core/icore.d.ts +14 -1
- package/lib/core/icore.d.ts.map +1 -1
- package/lib/core/icore.js.map +1 -1
- package/lib/core/instancestorage.d.ts +1 -0
- package/lib/core/instancestorage.d.ts.map +1 -1
- package/lib/core/instancestorage.js +1 -0
- package/lib/core/instancestorage.js.map +1 -1
- package/lib/core/operations.d.ts +49 -6
- package/lib/core/operations.d.ts.map +1 -1
- package/lib/core/operations.js +187 -24
- package/lib/core/operations.js.map +1 -1
- package/lib/deployers/git.d.ts +4 -2
- package/lib/deployers/git.d.ts.map +1 -1
- package/lib/deployers/git.js +44 -5
- package/lib/deployers/git.js.map +1 -1
- package/lib/deployers/packager.d.ts +38 -0
- package/lib/deployers/packager.d.ts.map +1 -1
- package/lib/deployers/packager.js +70 -18
- package/lib/deployers/packager.js.map +1 -1
- package/lib/deployers/packager.service.d.ts +33 -0
- package/lib/deployers/packager.service.d.ts.map +1 -0
- package/lib/deployers/packager.service.js +112 -0
- package/lib/deployers/packager.service.js.map +1 -0
- package/lib/errors/errors.d.ts +15 -0
- package/lib/errors/errors.d.ts.map +1 -1
- package/lib/errors/errors.js +20 -0
- package/lib/errors/errors.js.map +1 -1
- package/lib/index.d.ts +7 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -0
- package/lib/index.js.map +1 -1
- package/lib/models/permissions.d.ts +12 -1
- package/lib/models/permissions.d.ts.map +1 -1
- package/lib/models/permissions.js +29 -2
- package/lib/models/permissions.js.map +1 -1
- package/lib/rest/restoperationstransport.service.d.ts +108 -0
- package/lib/rest/restoperationstransport.service.d.ts.map +1 -1
- package/lib/rest/restoperationstransport.service.js +348 -9
- package/lib/rest/restoperationstransport.service.js.map +1 -1
- package/lib/rest/router.service.d.ts +1 -1
- package/lib/rest/router.service.d.ts.map +1 -1
- package/lib/rest/router.service.js.map +1 -1
- package/lib/services/audit.model.d.ts.map +1 -1
- package/lib/services/audit.model.js +7 -3
- package/lib/services/audit.model.js.map +1 -1
- package/lib/services/authenticatedencryption.d.ts +25 -0
- package/lib/services/authenticatedencryption.d.ts.map +1 -0
- package/lib/services/authenticatedencryption.js +56 -0
- package/lib/services/authenticatedencryption.js.map +1 -0
- package/lib/services/cloudbinary.d.ts.map +1 -1
- package/lib/services/cloudbinary.js +3 -2
- package/lib/services/cloudbinary.js.map +1 -1
- package/lib/services/command.d.ts +7 -1
- package/lib/services/command.d.ts.map +1 -1
- package/lib/services/command.js +4 -0
- package/lib/services/command.js.map +1 -1
- package/lib/services/cryptoservice.service.d.ts +405 -25
- package/lib/services/cryptoservice.service.d.ts.map +1 -1
- package/lib/services/cryptoservice.service.js +779 -72
- package/lib/services/cryptoservice.service.js.map +1 -1
- package/lib/services/httpserver.service.d.ts.map +1 -1
- package/lib/services/httpserver.service.js +15 -9
- package/lib/services/httpserver.service.js.map +1 -1
- package/lib/services/icryptoservice.d.ts +11 -0
- package/lib/services/icryptoservice.d.ts.map +1 -1
- package/lib/services/icryptoservice.js +2 -0
- package/lib/services/icryptoservice.js.map +1 -1
- package/lib/services/mailer.service.d.ts +4 -0
- package/lib/services/mailer.service.d.ts.map +1 -1
- package/lib/services/mailer.service.js +8 -1
- package/lib/services/mailer.service.js.map +1 -1
- package/lib/services/operationstransport.d.ts +13 -0
- package/lib/services/operationstransport.d.ts.map +1 -1
- package/lib/services/operationstransport.js +28 -6
- package/lib/services/operationstransport.js.map +1 -1
- package/lib/services/pki.encrypter.d.ts +214 -0
- package/lib/services/pki.encrypter.d.ts.map +1 -0
- package/lib/services/pki.encrypter.js +350 -0
- package/lib/services/pki.encrypter.js.map +1 -0
- package/lib/services/secretscheck.d.ts +120 -0
- package/lib/services/secretscheck.d.ts.map +1 -0
- package/lib/services/secretscheck.js +322 -0
- package/lib/services/secretscheck.js.map +1 -0
- package/lib/services/servicecommands.d.ts +8 -4
- package/lib/services/servicecommands.d.ts.map +1 -1
- package/lib/services/servicecommands.js +14 -5
- package/lib/services/servicecommands.js.map +1 -1
- package/lib/session/manager.model.d.ts +3 -1
- package/lib/session/manager.model.d.ts.map +1 -1
- package/lib/session/manager.model.js +6 -3
- package/lib/session/manager.model.js.map +1 -1
- package/lib/stores/store.d.ts +20 -1
- package/lib/stores/store.d.ts.map +1 -1
- package/lib/stores/store.js +29 -1
- package/lib/stores/store.js.map +1 -1
- package/lib/test/objects.d.ts.map +1 -1
- package/lib/test/objects.js +3 -1
- package/lib/test/objects.js.map +1 -1
- package/package.json +15 -15
- package/webda.module.json +224 -50
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
85
|
+
* @param encrypter - the encrypter name
|
|
52
86
|
*/
|
|
53
87
|
constructor(str, encrypter) {
|
|
54
|
-
this
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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 (
|
|
124
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
382
|
-
* @param data - the
|
|
383
|
-
* @
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
412
|
-
*
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|