configre 1.2.4 → 2.0.0

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 CHANGED
@@ -15,7 +15,7 @@ To get started with Configre, follow these steps:
15
15
 
16
16
  1. **Install the Library**
17
17
 
18
- First, make sure you have `Node.js` installed. Then, add Configre to your project:
18
+ Configre requires **Node.js 22.13 or later**. Add it to your project:
19
19
 
20
20
  ```bash
21
21
  npm install configre --save
@@ -38,13 +38,29 @@ To get started with Configre, follow these steps:
38
38
 
39
39
  3. **Use Configre in Your Project**
40
40
 
41
- Import and use Configre to load your configurations:
41
+ Import and use Configre to load your configurations. The configuration path is required:
42
42
 
43
43
  ```javascript
44
- const cfg = require("Configre")(); // default dir '/config'
44
+ import Configre from "configre";
45
+ import { join } from "node:path";
46
+
47
+ const cfg = Configre(join(import.meta.dirname, "config"));
45
48
  console.log(cfg.db); // Access your db configuration
46
49
  ```
47
50
 
51
+ Prefer an absolute path anchored to the module, as above. Avoid deriving it from `process.cwd()` (for example, `path.join(process.cwd(), "config")`), because that can point to a different location depending on where the process is started.
52
+
53
+ Configre is a native ES module. CommonJS consumers keep the same synchronous API, without accessing `.default`:
54
+
55
+ ```javascript
56
+ const Configre = require("configre");
57
+ const { join } = require("node:path");
58
+
59
+ const cfg = Configre(join(__dirname, "config"));
60
+ ```
61
+
62
+ Both module systems share the same implementation. Existing configuration files stay in `.cjs` format; Configre uses Node's `createRequire` only to load these files synchronously.
63
+
48
64
  ## 📚 Example
49
65
 
50
66
  Imagine you have the following structure in your `config` directory:
@@ -92,6 +108,184 @@ node demo.js --port=3000 --config=myhost --debug
92
108
 
93
109
  This loads `config/staging.cjs` (or `config/staging.dev.cjs`) instead of the one for the actual hostname. The `--config=` flag can appear anywhere in the command and works alongside other arguments.
94
110
 
111
+ ## 🔐 Shared secrets: follow the demo
112
+
113
+ The [secrets demo](demo/secrets/) shows how to keep API keys out of your public configuration and share them with a server through Git. Use one checkout as the **administrator**, where you edit secrets, and another as the **server**, which reads the encrypted values.
114
+
115
+ Run the commands below from the repository root. In [demo/secrets/demo.js](demo/secrets/demo.js), the configuration is loaded with:
116
+
117
+ ```javascript
118
+ import Configre from "../../index.js";
119
+ import { join } from "node:path";
120
+
121
+ const configPath = join(import.meta.dirname, "config");
122
+ const cfg = Configre(configPath, { secrets: true });
123
+ ```
124
+
125
+ `{ secrets: true }` enables this workflow. Loading remains synchronous, and your application reads the result through ordinary properties such as `cfg.api.key`.
126
+
127
+ > The demo reports `API key configured:` without printing the key. Avoid logging `cfg` in your application: it contains the decrypted secrets.
128
+
129
+ ### 1. Start the demo on the administrator machine
130
+
131
+ For this walkthrough, use an empty public placeholder in [demo/secrets/config/index.cjs](demo/secrets/config/index.cjs):
132
+
133
+ ```javascript
134
+ module.exports = {
135
+ api: {
136
+ key: ""
137
+ }
138
+ };
139
+ ```
140
+
141
+ Then run:
142
+
143
+ ```bash
144
+ node demo/secrets/demo.js
145
+ ```
146
+
147
+ For a new setup, Configre creates the following files and directory:
148
+
149
+ ```text
150
+ demo/secrets/config/
151
+ index.cjs # Public configuration
152
+ index.secret.cjs # Editable secrets; starts with module.exports = {};
153
+ recipients/ # Public keys of servers that register
154
+ secrets.enc.json # Generated encrypted values
155
+ .gitignore # Keeps .secret.cjs files out of Git
156
+ ```
157
+
158
+ It also creates this OS user's identity outside the repository, at `~/.config/configre/identity.pem` and `identity.pub`.
159
+
160
+ With the public placeholder empty and no secrets added, look for:
161
+
162
+ ```text
163
+ API key configured: false
164
+ ```
165
+
166
+ **`index.secret.cjs` starts empty on purpose.** Configre does not copy values from `index.cjs` into it. It contains only the fields you choose to override with secrets; public settings continue to come from `index.cjs`.
167
+
168
+ These initialization steps describe a new setup. If `secrets.enc.json` already exists, Configre uses that encrypted file instead of resetting it. A checkout containing the encrypted file but no `.secret.cjs` files is treated as a server checkout.
169
+
170
+ ### 2. Add an API key and publish the encrypted file
171
+
172
+ On the administrator machine, edit `demo/secrets/config/index.secret.cjs`:
173
+
174
+ ```javascript
175
+ module.exports = {
176
+ api: {
177
+ key: ""
178
+ }
179
+ };
180
+ ```
181
+
182
+ Replace the empty string with your actual key **in this local file**, then run the demo again:
183
+
184
+ ```bash
185
+ node demo/secrets/demo.js
186
+ ```
187
+
188
+ Configre updates `demo/secrets/config/secrets.enc.json`, merges the secret into the configuration, and the demo reports:
189
+
190
+ ```text
191
+ API key configured: true
192
+ ```
193
+
194
+ Commit and push the demo's public files, including `config/index.cjs`, the generated `config/.gitignore`, and `config/secrets.enc.json`. Keep `config/index.secret.cjs` on the administrator machine.
195
+
196
+ | File in `demo/secrets/config/` | Purpose | Share through Git? |
197
+ | --- | --- | --- |
198
+ | `index.cjs` | Public settings and empty placeholders | Yes |
199
+ | `index.secret.cjs` | Values you edit on the administrator | No |
200
+ | `secrets.enc.json` | Encrypted values generated by Configre | Yes |
201
+ | `recipients/truco.pub` | Public identity of the server named `truco` | Yes; the server publishes it |
202
+ | `.gitignore` | Excludes editable secret files | Yes |
203
+
204
+ ### 3. Register the server as `truco`
205
+
206
+ On the server, clone or pull the repository **including `demo/secrets/config/secrets.enc.json`**. Use a branch with a configured upstream, Git author name/email, and credentials that allow a push without an interactive prompt. Before a new registration, the local branch must match its upstream.
207
+
208
+ Run:
209
+
210
+ ```bash
211
+ node demo/secrets/demo.js --config=truco
212
+ ```
213
+
214
+ `--config=truco` selects the profile and names the public-key file. Without this flag, Configre uses the hostname.
215
+
216
+ If this server is not authorized yet, Configre automatically:
217
+
218
+ 1. Creates its local identity, if needed.
219
+ 2. Writes `demo/secrets/config/recipients/truco.pub`.
220
+ 3. Creates and pushes a commit containing only that public-key file.
221
+ 4. Stops startup with a message explaining that the administrator must publish an updated encrypted file.
222
+
223
+ This first stop is expected: publishing a public key does not let the server decrypt the existing ciphertext. Repeating the command does not publish another registration commit for the same key.
224
+
225
+ ### 4. Include the server in the encrypted file
226
+
227
+ Back on the administrator machine:
228
+
229
+ ```bash
230
+ git pull --ff-only
231
+ node demo/secrets/demo.js
232
+ ```
233
+
234
+ The pull brings in `demo/secrets/config/recipients/truco.pub`. When the demo runs, Configre automatically includes that public key and regenerates `demo/secrets/config/secrets.enc.json`. There is no separate approval step.
235
+
236
+ Commit and push the updated encrypted file. Then, on the server:
237
+
238
+ ```bash
239
+ git pull --ff-only
240
+ node demo/secrets/demo.js --config=truco
241
+ ```
242
+
243
+ The server can now decrypt the key, and the demo reports `API key configured: true`. It does not need `index.secret.cjs`; the value reaches `cfg.api.key` through the encrypted file.
244
+
245
+ **Repository write access allows a machine to register for all project secrets.** Configre publishes the server's public key automatically. Pulling changes and publishing the administrator's updated encrypted file remain part of your Git/deployment workflow.
246
+
247
+ ### 5. Update keys or use profile-specific secrets
248
+
249
+ To change the shared API key, edit `demo/secrets/config/index.secret.cjs` on the administrator and run:
250
+
251
+ ```bash
252
+ node demo/secrets/demo.js
253
+ ```
254
+
255
+ Commit and push the updated `secrets.enc.json`, then pull and restart the demo on the server. **You do not need to copy or register `truco.pub` again.** Its existing authorization also covers new keys you add later.
256
+
257
+ For settings specific to `truco`, add `demo/secrets/config/truco.cjs`. The next administrator run creates an empty `truco.secret.cjs` counterpart, which you can fill with that profile's secrets. All profile secret files, including inactive ones, are encrypted together when the administrator runs the demo.
258
+
259
+ For `--config=truco`, the merge order is:
260
+
261
+ ```text
262
+ index.cjs → truco.cjs → index.secret.cjs → truco.secret.cjs
263
+ ```
264
+
265
+ Later files override matching fields while preserving other fields. If `truco.dev.cjs` exists, it is selected instead of `truco.cjs`. Secret selection independently prefers `truco.dev.secret.cjs` over `truco.secret.cjs`; those two secret variants do not merge with each other. A missing profile uses the available base configuration and base secrets.
266
+
267
+ To revoke this server, remove `demo/secrets/config/recipients/truco.pub`, run the demo on the administrator, and commit and push both the removal and the updated encrypted file. Remove the server's repository write access too if it must not register itself again. Revocation protects new encrypted versions; rotate the key at its provider to invalidate a credential the server already received.
268
+
269
+ <details>
270
+ <summary>File formats, Git behavior and recovery</summary>
271
+
272
+ - Secret `.cjs` modules execute on the administrator and are reloaded on each Configre call. They must export plain objects containing JSON-compatible objects, arrays, strings, finite numbers, booleans and `null`. Functions, `undefined`, accessors, symbols, custom objects, circular references, and properties named `__proto__`, `constructor` or `prototype` are rejected. Consumers decrypt data without executing these modules.
273
+ - Empty strings stay empty strings. Configre does not fill them from environment variables or populate `process.env`. The existing deep-merge behavior, including array merging, also applies to secrets.
274
+ - Without `{ secrets: true }`, Configre does not access identities or secret files. The option also works with `new Configre(configPath, { secrets: true }).get()` and CommonJS consumers.
275
+ - Each OS user has one identity reused across projects; authorization is per project. A service running under another OS user needs its own registration. All authorized identities can decrypt all profiles in the project's encrypted file.
276
+ - Public recipient files must contain a single RSA-3072 public key in PEM format, with exponent 65537, as generated by Configre. Other file extensions are ignored, and duplicate keys do not add recipients. The administrator is always included.
277
+ - Registration uses an isolated Git index and publishes only the public-key file. It preserves unrelated staged and unstaged changes, runs no commit or pre-push hooks, and does not push local tags, merge, rebase or force-push. Failed registrations can be retried at the next startup; each Git command has a 30-second timeout. An authorized server loads secrets without Git commands or project writes.
278
+ - Registration names must start with a letter or digit and contain only letters, digits, dots, underscores or hyphens. If another key already occupies `truco.pub`, Configre stops without overwriting it; use a distinct profile or resolve the key replacement explicitly.
279
+ - Configre appends Git exclusions without removing existing rules and refuses to proceed on the administrator if editable secret modules are already tracked. It does not untrack files or rewrite history. It also prepares `.gitignore` when no repository exists yet.
280
+ - The administrator's `.secret.cjs` files are the source of truth for values, and `recipients/` is the source of truth for authorization. Removing all recipient files revokes them on the next administrator reload; old ciphertext does not restore them. Recover accidentally deleted public-key files from Git before reloading. Missing Git exclusions and ciphertext are regenerated on the administrator, and existing secret modules are never overwritten.
281
+ - Back up the administrator's editable secrets and private identity securely. Never share or commit `identity.pem`. On POSIX, its directory must have owner-only permissions (`0700`) and the private file must have owner-only permissions (`0600`); on Windows, protection depends on the user profile's filesystem permissions. Private identities and secret input files cannot be symlinks.
282
+ - A corrupt or missing private identity is never silently replaced. Restore it from backup, or register a new identity on a consumer. A missing public file can be regenerated from the valid private key. Losing every authorized private key makes existing ciphertext unrecoverable; an administrator with surviving editable secret files can create fresh ciphertext.
283
+ - The encrypted JSON envelope uses AES-256-GCM with a fresh content key and nonce for each update, wrapping that key for each recipient with RSA-3072 / OAEP-SHA-256. Version and recipient metadata are authenticated. Invalid modules, malformed ciphertext and authentication failures stop loading without replacing the existing encrypted file.
284
+ - Ciphertext is replaced atomically under an exclusive writer lock. Temporary vault files contain only encrypted data and public metadata. After a crash, remove a reported stale `.lock` only after confirming its writer has stopped. Unchanged values and recipient keys do not cause a rewrite.
285
+ - For an individual configuration file, the same workflow uses sidecars beside that file: `settings.secret.cjs`, `settings.cjs.recipients/` and `settings.cjs.secrets.enc.json`. An extensionless path resolving to `settings.cjs` uses the same sidecars.
286
+
287
+ </details>
288
+
95
289
  ## 🤔 Why Configre?
96
290
 
97
291
  - **No More Manual Switching**: Automatically adjusts your configuration based on the environment.
@@ -114,4 +308,4 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
114
308
 
115
309
  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
116
310
 
117
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
311
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/demo/demo.js CHANGED
@@ -1,4 +1,8 @@
1
- const cfg = require("../index")(__dirname + "/config");
2
- const log = require("lemonlog")("Configre");
1
+ import Configre from "../index.js";
2
+ import lemonlog from "lemonlog";
3
+ import { join } from "node:path";
3
4
 
4
- log.info(cfg.db);
5
+ const cfg = Configre(join(import.meta.dirname, "config"));
6
+ const log = lemonlog("Configre");
7
+
8
+ log.info(cfg.db);
@@ -1,12 +1,8 @@
1
1
  import Configre from "../../index.js";
2
2
  import lemonlog from "lemonlog";
3
- import { fileURLToPath } from "url";
4
- import { dirname, join } from "path";
3
+ import { join } from "node:path";
5
4
 
6
5
  const log = lemonlog("Configre");
7
- const __filename = fileURLToPath(import.meta.url);
8
- const __dirname = dirname(__filename);
9
6
 
10
- const cfg = Configre(join(__dirname, "config"));
7
+ const cfg = Configre(join(import.meta.dirname, "config"));
11
8
  log.info(cfg.db);
12
-
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ api: {
3
+ key: ""
4
+ }
5
+ };
@@ -0,0 +1,9 @@
1
+ import Configre from "../../index.js";
2
+ import { join } from "node:path";
3
+
4
+ const configPath = join(import.meta.dirname, "config");
5
+ const cfg = Configre(configPath, { secrets: true });
6
+ console.info("API key configured:", Boolean(cfg.api.key));
7
+ console.info(`Set api.key in ${join(configPath, "index.secret.cjs")} and run this demo again.`);
8
+ console.info(`New servers automatically publish their public key in ${join(configPath, "recipients")}. Pull and rerun here to authorize them.`);
9
+ console.info(`Share ${join(configPath, "secrets.enc.json")} through Git.`);
package/index.js CHANGED
@@ -1,34 +1,55 @@
1
- const fs = require("fs");
2
- const path = require("path");
3
- const os = require("os");
4
- const obj = require("./merge");
5
- const log = require("lemonlog")("Configre");
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { createRequire } from "node:module";
5
+ import lemonlog from "lemonlog";
6
+ import { merge } from "./merge.js";
7
+ import loadSecrets from "./secrets/index.js";
8
+
9
+ const requireConfig = createRequire(import.meta.url);
10
+ const log = lemonlog("Configre");
6
11
 
7
12
  class ConfigreClass {
8
- constructor(pathOrDir = __dirname + "/../../config") {
13
+ constructor(pathOrDir, options = {}) {
14
+ if (typeof pathOrDir !== "string" || pathOrDir.length === 0) {
15
+ throw new TypeError("Configre path must be a non-empty string");
16
+ }
17
+ if (options === null || typeof options !== "object" || Array.isArray(options) ||
18
+ (options.secrets !== undefined && typeof options.secrets !== "boolean")) {
19
+ throw new TypeError("Configre options must be an object with an optional boolean secrets property");
20
+ }
21
+
9
22
  const dir = path.join(pathOrDir);
10
23
  const isNested = (ConfigreClass._nesting || 0) > 0;
11
24
  ConfigreClass._nesting = (ConfigreClass._nesting || 0) + 1;
12
25
 
13
- this.defaultSettings = this.tryRequire([
14
- dir,
15
- path.join(dir, "index.cjs"),
16
- pathOrDir + ".cjs"
17
- ]);
26
+ try {
27
+ this.defaultSettings = this.tryRequire([
28
+ dir,
29
+ path.join(dir, "index.cjs"),
30
+ pathOrDir + ".cjs"
31
+ ]);
18
32
 
19
- this.dirname = dir;
20
- this._isNested = isNested;
21
- const configArg = process.argv.find(arg => arg.startsWith('--config='));
22
- this.profile = configArg ? configArg.slice('--config='.length) : os.hostname();
23
- this.profileSettings = this.loadProfileSettings();
24
- ConfigreClass._nesting -= 1;
33
+ this.dirname = dir;
34
+ this._isNested = isNested;
35
+ const configArg = process.argv.find(arg => arg.startsWith('--config='));
36
+ this.profile = configArg ? configArg.slice('--config='.length) : os.hostname();
37
+ this.profileSettings = this.loadProfileSettings();
38
+ this.secretSettings = options.secrets === true
39
+ ? loadSecrets(pathOrDir, this.configFile, this.profile)
40
+ : [];
41
+ } finally {
42
+ ConfigreClass._nesting -= 1;
43
+ }
25
44
  }
26
45
 
27
46
  // Helper method to try requiring files with different extensions or paths
28
47
  tryRequire(paths) {
29
48
  for (const p of paths) {
30
49
  try {
31
- return require(p);
50
+ const settings = requireConfig(p);
51
+ this.configFile = requireConfig.resolve(p);
52
+ return settings;
32
53
  } catch (e) {
33
54
  continue;
34
55
  }
@@ -41,7 +62,7 @@ class ConfigreClass {
41
62
  const ext = '.cjs';
42
63
  const fullPath = path.join(basePath + ext);
43
64
  if (fs.existsSync(fullPath)) {
44
- return { path: fullPath, module: require(fullPath) };
65
+ return { path: fullPath, module: requireConfig(fullPath) };
45
66
  }
46
67
  return null;
47
68
  }
@@ -67,17 +88,17 @@ class ConfigreClass {
67
88
  }
68
89
 
69
90
  get() {
70
- return obj.merge({}, this.defaultSettings, this.profileSettings);
91
+ return merge({}, this.defaultSettings, this.profileSettings, ...this.secretSettings);
71
92
  }
72
93
  }
73
94
 
74
95
  // Wrapper function to support both constructor and function usage
75
- function Configre(path) {
96
+ function Configre(path, options) {
76
97
  if (this instanceof Configre) {
77
- return new ConfigreClass(path);
98
+ return new ConfigreClass(path, options);
78
99
  } else {
79
- return new ConfigreClass(path).get();
100
+ return new ConfigreClass(path, options).get();
80
101
  }
81
102
  }
82
103
 
83
- module.exports = Configre;
104
+ export { Configre as default, Configre as "module.exports" };
package/merge.js CHANGED
@@ -76,6 +76,4 @@ function merge(object, ...sources) {
76
76
  return output;
77
77
  }
78
78
 
79
- module.exports = {
80
- merge
81
- };
79
+ export { merge };
package/package.json CHANGED
@@ -1,7 +1,14 @@
1
1
  {
2
2
  "name": "configre",
3
- "version": "1.2.4",
3
+ "version": "2.0.0",
4
4
  "description": "🔧 Effortlessly Tailor Your Settings",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.13.0"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test"
11
+ },
5
12
  "dependencies": {
6
13
  "lemonlog": "^1.2.2"
7
14
  },
@@ -12,6 +19,16 @@
12
19
  "setup",
13
20
  "deployment",
14
21
  "configuration",
22
+ "secrets",
23
+ "secret-management",
24
+ "api-keys",
25
+ "encryption",
26
+ "encrypted-config",
27
+ "public-key",
28
+ "asymmetric-encryption",
29
+ "secret-sharing",
30
+ "sops",
31
+ "dotenv-alternative",
15
32
  "convention",
16
33
  "stage",
17
34
  "environment",
@@ -32,5 +49,22 @@
32
49
  "bugs": {
33
50
  "url": "https://github.com/clasen/Configre/issues"
34
51
  },
35
- "homepage": "https://github.com/clasen/Configre#readme"
52
+ "homepage": "https://github.com/clasen/Configre#readme",
53
+ "packageManager": "pnpm@11.9.0",
54
+ "files": [
55
+ "index.js",
56
+ "merge.js",
57
+ "secrets/*.js",
58
+ "test/*.js",
59
+ "skills/configre/SKILL.md",
60
+ "demo/demo.js",
61
+ "demo/config/index.cjs",
62
+ "demo/config/hostname.cjs",
63
+ "demo/module/demo.js",
64
+ "demo/module/package.json",
65
+ "demo/module/config/index.cjs",
66
+ "demo/module/config/hostname.cjs",
67
+ "demo/secrets/demo.js",
68
+ "demo/secrets/config/index.cjs"
69
+ ]
36
70
  }
@@ -0,0 +1,5 @@
1
+ export default {
2
+ git: {
3
+ timeoutMs: 30_000
4
+ }
5
+ };
@@ -0,0 +1,203 @@
1
+ import crypto from "node:crypto";
2
+
3
+ const version = 2;
4
+ const algorithm = "RSA-OAEP-SHA256+AES-256-GCM";
5
+
6
+ function publicIdentity(key) {
7
+ if (key.asymmetricKeyType !== "rsa" || key.asymmetricKeyDetails.modulusLength !== 3072 ||
8
+ key.asymmetricKeyDetails.publicExponent !== 65537n) {
9
+ throw new Error("Configre secrets: keys must be RSA-3072");
10
+ }
11
+ const publicKey = key.type === "private" ? crypto.createPublicKey(key) : key;
12
+ return {
13
+ fingerprint: crypto.createHash("sha256")
14
+ .update(publicKey.export({ type: "spki", format: "der" })).digest("hex"),
15
+ publicKey: publicKey.export({ type: "spki", format: "pem" })
16
+ };
17
+ }
18
+
19
+ function parsePublicKey(pem) {
20
+ try {
21
+ if (typeof pem !== "string" || !/^-----BEGIN PUBLIC KEY-----\r?\n[A-Za-z0-9+/=\r\n]+\r?\n-----END PUBLIC KEY-----$/.test(pem.trim())) {
22
+ throw new Error();
23
+ }
24
+ return publicIdentity(crypto.createPublicKey(pem));
25
+ } catch {
26
+ throw new Error("Configre secrets: invalid RSA-3072 public key");
27
+ }
28
+ }
29
+
30
+ function parsePrivateKey(pem) {
31
+ try {
32
+ const privateKey = crypto.createPrivateKey(pem);
33
+ return { ...publicIdentity(privateKey), privateKey };
34
+ } catch {
35
+ throw new Error("Configre secrets: invalid private identity; restore it instead of replacing it");
36
+ }
37
+ }
38
+
39
+ function generatePrivateKey() {
40
+ return crypto.generateKeyPairSync("rsa", {
41
+ modulusLength: 3072,
42
+ publicExponent: 65537,
43
+ publicKeyEncoding: { type: "spki", format: "pem" },
44
+ privateKeyEncoding: { type: "pkcs8", format: "pem" }
45
+ }).privateKey;
46
+ }
47
+
48
+ function validateSecrets(value) {
49
+ const ancestors = new Set();
50
+ function visit(entry) {
51
+ if (typeof entry === "number" && !Number.isFinite(entry)) {
52
+ throw new Error("Configre secrets: secret numbers must be finite");
53
+ }
54
+ if (entry !== null && typeof entry === "object") {
55
+ if (ancestors.has(entry) || (!Array.isArray(entry) && Object.getPrototypeOf(entry) !== Object.prototype)) {
56
+ throw new Error("Configre secrets: secrets must contain only JSON values without circular references");
57
+ }
58
+ ancestors.add(entry);
59
+ const keys = Reflect.ownKeys(entry);
60
+ if (Array.isArray(entry) && keys.length !== entry.length + 1) {
61
+ throw new Error("Configre secrets: secret arrays must not contain holes or extra properties");
62
+ }
63
+ for (const key of keys) {
64
+ if (Array.isArray(entry) && key === "length") continue;
65
+ if (key === "__proto__" || key === "constructor" || key === "prototype") {
66
+ throw new Error("Configre secrets: unsafe property in secrets object");
67
+ }
68
+ const descriptor = Object.getOwnPropertyDescriptor(entry, key);
69
+ if (typeof key !== "string" || !descriptor.enumerable || !Object.hasOwn(descriptor, "value") ||
70
+ (Array.isArray(entry) && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= entry.length))) {
71
+ throw new Error("Configre secrets: secrets must contain only JSON data properties");
72
+ }
73
+ visit(descriptor.value);
74
+ }
75
+ ancestors.delete(entry);
76
+ } else if (entry !== null && !["string", "number", "boolean"].includes(typeof entry)) {
77
+ throw new Error("Configre secrets: secrets must contain only JSON values");
78
+ }
79
+ }
80
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
81
+ throw new Error("Configre secrets: secrets must be a JSON object");
82
+ }
83
+ visit(value);
84
+ return value;
85
+ }
86
+
87
+ function exactFields(object, names) {
88
+ if (!object || Array.isArray(object) || typeof object !== "object" ||
89
+ Object.keys(object).length !== names.length ||
90
+ !names.every(name => Object.hasOwn(object, name))) {
91
+ throw new Error("Configre secrets: invalid encrypted file structure");
92
+ }
93
+ }
94
+
95
+ function decode(value, length) {
96
+ if (typeof value !== "string") {
97
+ throw new Error("Configre secrets: invalid encrypted data");
98
+ }
99
+ const buffer = Buffer.from(value, "base64");
100
+ if (buffer.length === 0 || buffer.toString("base64") !== value ||
101
+ (length !== undefined && buffer.length !== length)) {
102
+ throw new Error("Configre secrets: invalid encrypted data");
103
+ }
104
+ return buffer;
105
+ }
106
+
107
+ function authenticatedHeader(envelope) {
108
+ return Buffer.from(JSON.stringify({
109
+ version: envelope.version,
110
+ algorithm: envelope.algorithm,
111
+ recipients: envelope.recipients.map(({ fingerprint, publicKey, wrappedKey }) => ({
112
+ fingerprint, publicKey, wrappedKey
113
+ }))
114
+ }));
115
+ }
116
+
117
+ function validateEnvelope(envelope) {
118
+ exactFields(envelope, ["version", "algorithm", "recipients", "iv", "tag", "ciphertext"]);
119
+ if (envelope.version !== version || envelope.algorithm !== algorithm) {
120
+ throw new Error("Configre secrets: unsupported encrypted file version or algorithm");
121
+ }
122
+ if (!Array.isArray(envelope.recipients) || envelope.recipients.length === 0) {
123
+ throw new Error("Configre secrets: encrypted file has no recipients");
124
+ }
125
+ let previous = "";
126
+ for (const recipient of envelope.recipients) {
127
+ exactFields(recipient, ["fingerprint", "publicKey", "wrappedKey"]);
128
+ const parsed = parsePublicKey(recipient.publicKey);
129
+ if (recipient.fingerprint !== parsed.fingerprint || recipient.publicKey !== parsed.publicKey ||
130
+ recipient.fingerprint <= previous) {
131
+ throw new Error("Configre secrets: invalid encrypted recipients");
132
+ }
133
+ previous = recipient.fingerprint;
134
+ decode(recipient.wrappedKey, 384);
135
+ }
136
+ decode(envelope.iv, 12);
137
+ decode(envelope.tag, 16);
138
+ decode(envelope.ciphertext);
139
+ }
140
+
141
+ function encrypt(settings, recipients) {
142
+ const contentKey = crypto.randomBytes(32);
143
+ const iv = crypto.randomBytes(12);
144
+ try {
145
+ const envelope = {
146
+ version,
147
+ algorithm,
148
+ recipients: recipients.map(recipient => ({
149
+ ...recipient,
150
+ wrappedKey: crypto.publicEncrypt({
151
+ key: recipient.publicKey,
152
+ padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
153
+ oaepHash: "sha256"
154
+ }, contentKey).toString("base64")
155
+ })),
156
+ iv: iv.toString("base64")
157
+ };
158
+ const cipher = crypto.createCipheriv("aes-256-gcm", contentKey, iv, { authTagLength: 16 });
159
+ cipher.setAAD(authenticatedHeader(envelope));
160
+ envelope.ciphertext = Buffer.concat([
161
+ cipher.update(JSON.stringify(settings), "utf8"), cipher.final()
162
+ ]).toString("base64");
163
+ envelope.tag = cipher.getAuthTag().toString("base64");
164
+ return envelope;
165
+ } finally {
166
+ contentKey.fill(0);
167
+ }
168
+ }
169
+
170
+ function decrypt(envelope, identity) {
171
+ validateEnvelope(envelope);
172
+ const recipient = envelope.recipients.find(entry => entry.fingerprint === identity.fingerprint);
173
+ if (!recipient) {
174
+ const error = new Error("Configre secrets: this machine is not authorized by the current encrypted file");
175
+ error.code = "CONFIGRE_NOT_AUTHORIZED";
176
+ throw error;
177
+ }
178
+ let contentKey;
179
+ let plaintext;
180
+ let settings;
181
+ try {
182
+ contentKey = crypto.privateDecrypt({
183
+ key: identity.privateKey,
184
+ padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
185
+ oaepHash: "sha256"
186
+ }, decode(recipient.wrappedKey, 384));
187
+ const decipher = crypto.createDecipheriv("aes-256-gcm", contentKey, decode(envelope.iv, 12), {
188
+ authTagLength: 16
189
+ });
190
+ decipher.setAAD(authenticatedHeader(envelope));
191
+ decipher.setAuthTag(decode(envelope.tag, 16));
192
+ plaintext = Buffer.concat([decipher.update(decode(envelope.ciphertext)), decipher.final()]);
193
+ settings = JSON.parse(plaintext.toString("utf8"));
194
+ } catch {
195
+ throw new Error("Configre secrets: could not authenticate or decrypt secrets; encrypted file was not changed");
196
+ } finally {
197
+ if (contentKey) contentKey.fill(0);
198
+ if (plaintext) plaintext.fill(0);
199
+ }
200
+ return validateSecrets(settings);
201
+ }
202
+
203
+ export { parsePublicKey, parsePrivateKey, generatePrivateKey, validateSecrets, encrypt, decrypt };