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 +198 -4
- package/demo/demo.js +7 -3
- package/demo/module/demo.js +2 -6
- package/demo/secrets/config/index.cjs +5 -0
- package/demo/secrets/demo.js +9 -0
- package/index.js +45 -24
- package/merge.js +1 -3
- package/package.json +36 -2
- package/secrets/config.js +5 -0
- package/secrets/crypto.js +203 -0
- package/secrets/files.js +71 -0
- package/secrets/identity.js +44 -0
- package/secrets/index.js +172 -0
- package/secrets/register.js +101 -0
- package/skills/configre/SKILL.md +14 -4
- package/test/index.test.js +67 -0
- package/test/secrets.test.js +797 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
1
|
+
import Configre from "../index.js";
|
|
2
|
+
import lemonlog from "lemonlog";
|
|
3
|
+
import { join } from "node:path";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
const cfg = Configre(join(import.meta.dirname, "config"));
|
|
6
|
+
const log = lemonlog("Configre");
|
|
7
|
+
|
|
8
|
+
log.info(cfg.db);
|
package/demo/module/demo.js
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import Configre from "../../index.js";
|
|
2
2
|
import lemonlog from "lemonlog";
|
|
3
|
-
import {
|
|
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(
|
|
7
|
+
const cfg = Configre(join(import.meta.dirname, "config"));
|
|
11
8
|
log.info(cfg.db);
|
|
12
|
-
|
|
@@ -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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 =
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
try {
|
|
27
|
+
this.defaultSettings = this.tryRequire([
|
|
28
|
+
dir,
|
|
29
|
+
path.join(dir, "index.cjs"),
|
|
30
|
+
pathOrDir + ".cjs"
|
|
31
|
+
]);
|
|
18
32
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
104
|
+
export { Configre as default, Configre as "module.exports" };
|
package/merge.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "configre",
|
|
3
|
-
"version": "
|
|
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,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 };
|