configre 2.1.0 → 2.1.4
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 +23 -4
- package/demo/secrets/demo.js +1 -0
- package/index.js +34 -1
- package/package.json +1 -1
- package/secrets/identity.js +6 -1
- package/secrets/index.js +39 -25
- package/secrets/log.js +3 -0
- package/secrets/register.js +12 -3
- package/test/secrets.test.js +185 -21
package/README.md
CHANGED
|
@@ -124,7 +124,24 @@ const cfg = Configre(configPath);
|
|
|
124
124
|
|
|
125
125
|
Secrets activate automatically when the base configuration or selected profile has a corresponding `.secret.cjs` file, or when `secrets.enc.json` already exists. No options are needed. Loading remains synchronous, and your application reads the result through ordinary properties such as `cfg.api.key`.
|
|
126
126
|
|
|
127
|
-
> The demo reports `API key configured:` without printing the key. Avoid logging `cfg` in your application: it contains the decrypted secrets.
|
|
127
|
+
> The demo reports `API key configured:` without printing the key. Avoid logging `cfg` in your application: it contains the decrypted secrets. Use `cfg.print()` to log configuration with secret fields omitted.
|
|
128
|
+
|
|
129
|
+
```javascript
|
|
130
|
+
const cfg = Configre(configPath);
|
|
131
|
+
cfg.print();
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`print()` calls Configre's `log.debug`; enable its output with `DEBUG=Configre:*`.
|
|
135
|
+
It omits fields supplied by the base and selected profile's `.secret.cjs` files,
|
|
136
|
+
including when loaded from the encrypted file. Public siblings in nested objects
|
|
137
|
+
remain visible; arrays supplied by secrets are omitted entirely. It does not
|
|
138
|
+
modify the configuration or log automatically on load. `cfg.print()` logs its
|
|
139
|
+
current values, including changes made after loading. The method is non-enumerable
|
|
140
|
+
and does not appear in `Object.keys(cfg)`, object spreads or JSON output.
|
|
141
|
+
`new Configre(configPath).print()` is also supported. If your configuration already
|
|
142
|
+
has a field named `print`, that value is preserved; use the constructor API to print it.
|
|
143
|
+
Sensitivity is determined by secret-file fields, not by names such as `password`
|
|
144
|
+
or `token`: keep sensitive values in `.secret.cjs` files, not in public settings.
|
|
128
145
|
|
|
129
146
|
### 1. Start the demo on the administrator machine
|
|
130
147
|
|
|
@@ -224,9 +241,11 @@ If this server is not authorized yet, Configre automatically:
|
|
|
224
241
|
1. Creates its local identity, if needed.
|
|
225
242
|
2. Writes `demo/secrets/config/recipients/truco.pub`.
|
|
226
243
|
3. Creates and pushes a commit containing only that public-key file.
|
|
227
|
-
4.
|
|
244
|
+
4. Logs a warning and continues with public configuration only until the administrator publishes an updated encrypted file.
|
|
245
|
+
|
|
246
|
+
Publishing a public key does not let the server decrypt the existing ciphertext. While authorization is pending, `cfg` preserves the public defaults and selected public profile; fields that exist only in the encrypted file are absent. Repeating the command does not publish another registration commit for the same key. If Git registration fails, Configre logs a warning and still returns public settings; the next Configre load retries registration.
|
|
228
247
|
|
|
229
|
-
|
|
248
|
+
Configre uses `log.info` when it creates identity files, writes Git exclusions, creates the recipients directory or a public-key file, updates the encrypted file, and successfully pushes the public key. Messages contain operation names and file paths, never secret values or key contents. These messages use the existing `Configre` LemonLog namespace; use `DEBUG=Configre:*` to display them. Unchanged files and previously published public keys do not generate another creation or publication log.
|
|
230
249
|
|
|
231
250
|
### 4. Include the server in the encrypted file
|
|
232
251
|
|
|
@@ -281,7 +300,7 @@ To revoke this server, remove `demo/secrets/config/recipients/truco.pub`, run th
|
|
|
281
300
|
- 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.
|
|
282
301
|
- 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.
|
|
283
302
|
- 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.
|
|
284
|
-
- 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`,
|
|
303
|
+
- 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`, registration stops without overwriting it, and configuration loading continues with a warning and public settings only; use a distinct profile or resolve the key replacement explicitly.
|
|
285
304
|
- 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.
|
|
286
305
|
- 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.
|
|
287
306
|
- 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.
|
package/demo/secrets/demo.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
const configPath = join(import.meta.dirname, "config");
|
|
5
5
|
const cfg = Configre(configPath);
|
|
6
|
+
cfg.print();
|
|
6
7
|
console.info("API key configured:", Boolean(cfg.api.key));
|
|
7
8
|
console.info(`Set api.key in ${join(configPath, "index.secret.cjs")} and run this demo again.`);
|
|
8
9
|
console.info(`New servers automatically publish their public key in ${join(configPath, "recipients")}. Pull and rerun here to authorize them.`);
|
package/index.js
CHANGED
|
@@ -9,6 +9,28 @@ import loadSecrets from "./secrets/index.js";
|
|
|
9
9
|
const requireConfig = createRequire(import.meta.url);
|
|
10
10
|
const log = lemonlog("Configre");
|
|
11
11
|
|
|
12
|
+
function omitSecrets(settings, secrets) {
|
|
13
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
14
|
+
if (!Object.hasOwn(settings, key)) continue;
|
|
15
|
+
const current = settings[key];
|
|
16
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value) &&
|
|
17
|
+
current !== null && typeof current === "object" && !Array.isArray(current)) {
|
|
18
|
+
omitSecrets(current, value);
|
|
19
|
+
if (Object.keys(current).length === 0) delete settings[key];
|
|
20
|
+
} else {
|
|
21
|
+
delete settings[key];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function printSettings(settings, secretSettings) {
|
|
27
|
+
const output = merge({}, settings);
|
|
28
|
+
for (const secrets of secretSettings) {
|
|
29
|
+
omitSecrets(output, secrets);
|
|
30
|
+
}
|
|
31
|
+
log.debug(output);
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
class ConfigreClass {
|
|
13
35
|
constructor(pathOrDir) {
|
|
14
36
|
if (typeof pathOrDir !== "string" || pathOrDir.length === 0) {
|
|
@@ -83,6 +105,10 @@ class ConfigreClass {
|
|
|
83
105
|
get() {
|
|
84
106
|
return merge({}, this.defaultSettings, this.profileSettings, ...this.secretSettings);
|
|
85
107
|
}
|
|
108
|
+
|
|
109
|
+
print() {
|
|
110
|
+
printSettings(this.get(), this.secretSettings);
|
|
111
|
+
}
|
|
86
112
|
}
|
|
87
113
|
|
|
88
114
|
// Wrapper function to support both constructor and function usage
|
|
@@ -90,7 +116,14 @@ function Configre(path) {
|
|
|
90
116
|
if (this instanceof Configre) {
|
|
91
117
|
return new ConfigreClass(path);
|
|
92
118
|
} else {
|
|
93
|
-
|
|
119
|
+
const config = new ConfigreClass(path);
|
|
120
|
+
const settings = config.get();
|
|
121
|
+
if (!Object.hasOwn(settings, "print")) {
|
|
122
|
+
Object.defineProperty(settings, "print", {
|
|
123
|
+
value: () => printSettings(settings, config.secretSettings)
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return settings;
|
|
94
127
|
}
|
|
95
128
|
}
|
|
96
129
|
|
package/package.json
CHANGED
package/secrets/identity.js
CHANGED
|
@@ -3,12 +3,15 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { generatePrivateKey, parsePrivateKey, parsePublicKey } from "./crypto.js";
|
|
5
5
|
import { stat, readText, withLock } from "./files.js";
|
|
6
|
+
import log from "./log.js";
|
|
6
7
|
|
|
7
8
|
function loadIdentity() {
|
|
8
9
|
const directory = path.join(os.homedir(), ".config", "configre");
|
|
9
10
|
const privatePath = path.join(directory, "identity.pem");
|
|
10
11
|
const publicPath = path.join(directory, "identity.pub");
|
|
11
|
-
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
12
|
+
if (fs.mkdirSync(directory, { recursive: true, mode: 0o700 })) {
|
|
13
|
+
log.info("Created identity directory", directory);
|
|
14
|
+
}
|
|
12
15
|
const info = stat(directory);
|
|
13
16
|
if (!info.isDirectory() || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) {
|
|
14
17
|
throw new Error(`Configre secrets: identity directory must be private and not a symlink at ${directory}`);
|
|
@@ -32,10 +35,12 @@ function loadIdentity() {
|
|
|
32
35
|
throw new Error("Configre secrets: private identity is missing; restore it instead of replacing it");
|
|
33
36
|
}
|
|
34
37
|
fs.writeFileSync(privatePath, generatePrivateKey(), { flag: "wx", mode: 0o600 });
|
|
38
|
+
log.info("Created private identity file", privatePath);
|
|
35
39
|
}
|
|
36
40
|
const identity = parsePrivateKey(readText(privatePath, true));
|
|
37
41
|
if (!stat(publicPath)) {
|
|
38
42
|
fs.writeFileSync(publicPath, identity.publicKey, { flag: "wx", mode: 0o644 });
|
|
43
|
+
log.info("Created public identity file", publicPath);
|
|
39
44
|
}
|
|
40
45
|
return readIdentity();
|
|
41
46
|
});
|
package/secrets/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import loadIdentity from "./identity.js";
|
|
7
7
|
import registerRecipient from "./register.js";
|
|
8
|
+
import log from "./log.js";
|
|
8
9
|
import { parsePublicKey, validateSecrets, encrypt, decrypt } from "./crypto.js";
|
|
9
10
|
import { stat, readText, readJSON, withLock, writeEncrypted } from "./files.js";
|
|
10
11
|
|
|
@@ -63,11 +64,15 @@ function prepareIgnore(paths) {
|
|
|
63
64
|
const lines = existing.split(/\r?\n/).filter(line => line && !line.startsWith("#"));
|
|
64
65
|
if (!isDeepStrictEqual(lines.slice(-rules.length), rules)) {
|
|
65
66
|
fs.appendFileSync(ignorePath, (existing && !existing.endsWith("\n") ? "\n" : "") + rules.join("\n") + "\n");
|
|
67
|
+
log.info("Wrote Git exclusions for secret files", ignorePath);
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
function loadRecipients(directory, identity) {
|
|
70
|
-
if (!stat(directory))
|
|
72
|
+
if (!stat(directory)) {
|
|
73
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
74
|
+
log.info("Created recipients directory", directory);
|
|
75
|
+
}
|
|
71
76
|
if (!stat(directory).isDirectory()) {
|
|
72
77
|
throw new Error("Configre secrets: recipients must be a directory, not a symlink");
|
|
73
78
|
}
|
|
@@ -129,36 +134,45 @@ function loadSecrets(configPath, configFile, profile) {
|
|
|
129
134
|
if (!hasLocal && !hasEncrypted) return [];
|
|
130
135
|
|
|
131
136
|
const identity = loadIdentity();
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
try {
|
|
138
|
+
if (names.length === 0 && hasEncrypted) {
|
|
134
139
|
return selectSecrets(validateBundle(decrypt(readJSON(paths.encrypted), identity)), paths, profile);
|
|
135
|
-
} catch (error) {
|
|
136
|
-
if (error.code !== "CONFIGRE_NOT_AUTHORIZED") throw error;
|
|
137
|
-
registerRecipient(paths, identity, profile);
|
|
138
|
-
throw new Error("Configre secrets: not authorized yet; public key is published in Git. The administrator must pull, reload Configre and publish secrets.enc.json; then pull the updated encrypted file and restart this machine");
|
|
139
140
|
}
|
|
140
|
-
}
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
142
|
+
const bundle = withLock(paths.encrypted, () => {
|
|
143
|
+
const names = localFiles(paths);
|
|
144
|
+
const hasEncrypted = !!stat(paths.encrypted);
|
|
145
|
+
const envelope = hasEncrypted ? readJSON(paths.encrypted) : null;
|
|
146
|
+
const previous = hasEncrypted ? validateBundle(decrypt(envelope, identity)) : null;
|
|
147
|
+
if (names.length === 0 && hasEncrypted) return previous;
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
149
|
+
prepareIgnore(paths);
|
|
150
|
+
const settings = { files: Object.fromEntries(names.map(name => [name, readSecret(path.join(paths.parent, name))])) };
|
|
151
|
+
const recipients = loadRecipients(paths.recipients, identity);
|
|
152
|
+
if (hasEncrypted) {
|
|
153
|
+
const previousRecipients = envelope.recipients.map(({ fingerprint, publicKey }) => ({ fingerprint, publicKey }));
|
|
154
|
+
if (isDeepStrictEqual(settings, previous) && isDeepStrictEqual(recipients, previousRecipients)) {
|
|
155
|
+
return settings;
|
|
156
|
+
}
|
|
156
157
|
}
|
|
158
|
+
writeEncrypted(paths.encrypted, encrypt(settings, recipients));
|
|
159
|
+
log.info(hasEncrypted ? "Updated encrypted secrets file" : "Created encrypted secrets file", paths.encrypted);
|
|
160
|
+
return settings;
|
|
161
|
+
});
|
|
162
|
+
return selectSecrets(bundle, paths, profile);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error.code !== "CONFIGRE_NOT_AUTHORIZED") throw error;
|
|
165
|
+
let registration = "Public key is published in Git. The administrator must pull, reload Configre and publish secrets.enc.json; then pull the updated encrypted file and reload Configre.";
|
|
166
|
+
try {
|
|
167
|
+
registerRecipient(paths, identity, profile);
|
|
168
|
+
} catch (registrationError) {
|
|
169
|
+
registration = registrationError.code === "CONFIGRE_REGISTRATION_FAILED"
|
|
170
|
+
? registrationError.message
|
|
171
|
+
: "Automatic public-key registration failed; check Git and file permissions, then reload Configre to retry.";
|
|
157
172
|
}
|
|
158
|
-
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
return selectSecrets(bundle, paths, profile);
|
|
173
|
+
log.warn("Secrets not authorized; continuing with public settings only.", registration, "Public key:", identity.publicPath);
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
162
176
|
}
|
|
163
177
|
|
|
164
178
|
export default loadSecrets;
|
package/secrets/log.js
ADDED
package/secrets/register.js
CHANGED
|
@@ -2,11 +2,14 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import config from "./config.js";
|
|
5
|
+
import log from "./log.js";
|
|
5
6
|
import { parsePublicKey } from "./crypto.js";
|
|
6
7
|
import { stat, readText, withLock } from "./files.js";
|
|
7
8
|
|
|
8
9
|
function registrationError(message) {
|
|
9
|
-
|
|
10
|
+
const error = new Error(`Configre secrets: not authorized; automatic registration ${message}`);
|
|
11
|
+
error.code = "CONFIGRE_REGISTRATION_FAILED";
|
|
12
|
+
return error;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
function git(directory, args, operation, options = {}) {
|
|
@@ -84,9 +87,15 @@ function registerRecipient(paths, identity, profile) {
|
|
|
84
87
|
git(root, ["update-index", "--add", "--cacheinfo", "100644", blob, relative], "adding the public key to the isolated index", { env });
|
|
85
88
|
const tree = git(root, ["write-tree"], "building the registration tree", { env }).trim();
|
|
86
89
|
const commit = git(root, ["commit-tree", tree, "-p", head, "-m", `Configre: register ${profile}`], "creating the public-key commit (Git author identity must be configured)").trim();
|
|
87
|
-
fs.mkdirSync(directory, { recursive: true })
|
|
88
|
-
|
|
90
|
+
if (fs.mkdirSync(directory, { recursive: true })) {
|
|
91
|
+
log.info("Created recipients directory", directory);
|
|
92
|
+
}
|
|
93
|
+
if (!stat(filename)) {
|
|
94
|
+
fs.writeFileSync(filename, publicKey, { flag: "wx", mode: 0o644 });
|
|
95
|
+
log.info("Created public-key registration file", filename);
|
|
96
|
+
}
|
|
89
97
|
git(root, ["push", "--no-verify", "--no-follow-tags", "--", remote, `${commit}:${remoteBranch}`], "pushing the public-key commit");
|
|
98
|
+
log.info("Published public key to Git", filename);
|
|
90
99
|
if (git(root, ["symbolic-ref", "--quiet", "HEAD"], "checking the current branch").trim() !== branch) {
|
|
91
100
|
throw registrationError("published the public key, but the local branch changed concurrently; synchronize the checkout");
|
|
92
101
|
}
|
package/test/secrets.test.js
CHANGED
|
@@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process";
|
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import test from "node:test";
|
|
9
9
|
import Configre from "../index.js";
|
|
10
|
+
import log from "../secrets/log.js";
|
|
10
11
|
import { generatePrivateKey, parsePrivateKey, encrypt } from "../secrets/crypto.js";
|
|
11
12
|
|
|
12
13
|
const sentinel = "synthetic-secret-for-configre-tests";
|
|
@@ -17,6 +18,9 @@ test.before(() => {
|
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
function fixture(t, { seed = true } = {}) {
|
|
21
|
+
const logs = { info: [], warn: [] };
|
|
22
|
+
t.mock.method(log, "info", (...args) => logs.info.push(args));
|
|
23
|
+
t.mock.method(log, "warn", (...args) => logs.warn.push(args));
|
|
20
24
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "configre-secrets-"));
|
|
21
25
|
const homes = [0, 1, 2].map(index => path.join(root, `home-${index}`));
|
|
22
26
|
let home = homes[0];
|
|
@@ -36,7 +40,7 @@ function fixture(t, { seed = true } = {}) {
|
|
|
36
40
|
}
|
|
37
41
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
38
42
|
return {
|
|
39
|
-
root, config, homes,
|
|
43
|
+
root, config, homes, logs,
|
|
40
44
|
local: path.join(config, "index.secret.cjs"),
|
|
41
45
|
encrypted: path.join(config, "secrets.enc.json"),
|
|
42
46
|
recipients: path.join(config, "recipients"),
|
|
@@ -54,6 +58,15 @@ function load(f) {
|
|
|
54
58
|
return Configre(f.config);
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
function assertPublicOnly(f, logs, warning) {
|
|
62
|
+
const count = logs.warn.length;
|
|
63
|
+
const config = load(f);
|
|
64
|
+
assert.equal(config.api.key, "");
|
|
65
|
+
assert.equal(logs.warn.length, count + 1);
|
|
66
|
+
assert.match(logs.warn.at(-1).join(" "), warning);
|
|
67
|
+
return config;
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
function envelope(f) {
|
|
58
71
|
return JSON.parse(fs.readFileSync(f.encrypted, "utf8"));
|
|
59
72
|
}
|
|
@@ -67,6 +80,105 @@ function consumerCopy(f, name) {
|
|
|
67
80
|
return { config };
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
test("print logs public configuration without creating secret artifacts", t => {
|
|
84
|
+
const f = fixture(t, { seed: false });
|
|
85
|
+
const calls = [];
|
|
86
|
+
t.mock.method(Object.getPrototypeOf(log), "debug", (...args) => calls.push(args));
|
|
87
|
+
const config = new Configre(f.config);
|
|
88
|
+
|
|
89
|
+
assert.equal(calls.length, 0);
|
|
90
|
+
config.print();
|
|
91
|
+
assert.deepEqual(calls, [[config.get()]]);
|
|
92
|
+
assert.equal(fs.existsSync(f.homes[0]), false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("function results expose print without changing enumerable data and log current settings", t => {
|
|
96
|
+
const f = fixture(t, { seed: false });
|
|
97
|
+
const calls = [];
|
|
98
|
+
t.mock.method(Object.getPrototypeOf(log), "debug", (...args) => calls.push(args));
|
|
99
|
+
const cfg = Configre(f.config);
|
|
100
|
+
const expected = new Configre(f.config).get();
|
|
101
|
+
|
|
102
|
+
assert.equal(typeof cfg.print, "function");
|
|
103
|
+
assert.deepEqual(Object.keys(cfg), Object.keys(expected));
|
|
104
|
+
assert.deepEqual({ ...cfg }, expected);
|
|
105
|
+
assert.equal(JSON.stringify(cfg), JSON.stringify(expected));
|
|
106
|
+
cfg.api.host = "updated";
|
|
107
|
+
cfg.print();
|
|
108
|
+
assert.deepEqual(calls, [[{ ...expected, api: { key: "", host: "updated" } }]]);
|
|
109
|
+
assert.equal(cfg.api.host, "updated");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a public print field remains configuration data", t => {
|
|
113
|
+
const f = fixture(t, { seed: false });
|
|
114
|
+
fs.writeFileSync(path.join(f.config, "index.cjs"), 'module.exports = { print: false };');
|
|
115
|
+
assert.equal(Configre(f.config).print, false);
|
|
116
|
+
const calls = [];
|
|
117
|
+
t.mock.method(Object.getPrototypeOf(log), "debug", (...args) => calls.push(args));
|
|
118
|
+
new Configre(f.config).print();
|
|
119
|
+
assert.equal(calls[0][0].print, false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("print omits local and encrypted secret fields without changing effective settings", t => {
|
|
123
|
+
const f = fixture(t);
|
|
124
|
+
const calls = [];
|
|
125
|
+
t.mock.method(Object.getPrototypeOf(log), "debug", (...args) => calls.push(args));
|
|
126
|
+
writeSettings(f, {
|
|
127
|
+
api: { key: sentinel },
|
|
128
|
+
list: [sentinel],
|
|
129
|
+
privateGroup: { value: sentinel },
|
|
130
|
+
optional: null,
|
|
131
|
+
empty: "",
|
|
132
|
+
enabled: false,
|
|
133
|
+
count: 0
|
|
134
|
+
});
|
|
135
|
+
fs.writeFileSync(path.join(f.config, "testhost.secret.cjs"),
|
|
136
|
+
`module.exports = { api: { other: ${JSON.stringify(sentinel)} }, privateGroup: "public-looking" };`);
|
|
137
|
+
|
|
138
|
+
load(f);
|
|
139
|
+
for (const source of [f, consumerCopy(f, "debug-consumer")]) {
|
|
140
|
+
const config = new Configre(source.config);
|
|
141
|
+
const before = config.get();
|
|
142
|
+
const secretsBefore = structuredClone(config.secretSettings);
|
|
143
|
+
config.print();
|
|
144
|
+
|
|
145
|
+
assert.deepEqual(calls.at(-1), [{ api: { host: "profile" } }]);
|
|
146
|
+
assert.equal(JSON.stringify(calls).includes(sentinel), false);
|
|
147
|
+
assert.deepEqual(config.get(), before);
|
|
148
|
+
assert.deepEqual(config.secretSettings, secretsBefore);
|
|
149
|
+
assert.equal(before.api.key, sentinel);
|
|
150
|
+
assert.equal(before.list[0], sentinel);
|
|
151
|
+
assert.equal(before.list[1], 2);
|
|
152
|
+
const cfg = Configre(source.config);
|
|
153
|
+
cfg.api.key = sentinel + "-updated";
|
|
154
|
+
cfg.api.host = "updated";
|
|
155
|
+
cfg.print();
|
|
156
|
+
assert.deepEqual(calls.at(-1), [{ api: { host: "updated" } }]);
|
|
157
|
+
assert.equal(JSON.stringify(calls).includes(sentinel), false);
|
|
158
|
+
assert.equal(cfg.api.key, sentinel + "-updated");
|
|
159
|
+
}
|
|
160
|
+
const result = spawnSync(process.execPath, ["-e", `
|
|
161
|
+
const os = require('node:os');
|
|
162
|
+
os.homedir = () => process.env.CONFIGRE_TEST_HOME;
|
|
163
|
+
const Configre = require(process.env.CONFIGRE_TEST_MODULE);
|
|
164
|
+
Configre(process.env.CONFIGRE_TEST_PATH).print();
|
|
165
|
+
`, "--", "--config=testhost"], {
|
|
166
|
+
encoding: "utf8",
|
|
167
|
+
env: {
|
|
168
|
+
...process.env, DEBUG: "Configre:*",
|
|
169
|
+
CONFIGRE_TEST_HOME: f.homes[0],
|
|
170
|
+
CONFIGRE_TEST_MODULE: path.join(import.meta.dirname, "..", "index.js"),
|
|
171
|
+
CONFIGRE_TEST_PATH: path.join(f.root, "debug-consumer")
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
assert.equal(result.status, 0, result.stderr);
|
|
175
|
+
const output = result.stdout + result.stderr;
|
|
176
|
+
assert.match(output, /Configre:debug/);
|
|
177
|
+
assert.match(output, /host: 'profile'/);
|
|
178
|
+
assert.equal(output.includes(sentinel), false);
|
|
179
|
+
assert.equal(output.includes("privateGroup"), false);
|
|
180
|
+
});
|
|
181
|
+
|
|
70
182
|
function git(directory, args) {
|
|
71
183
|
const result = spawnSync("git", ["-C", directory, ...args], { encoding: "utf8" });
|
|
72
184
|
assert.equal(result.status, 0, result.stderr);
|
|
@@ -110,6 +222,45 @@ test("missing secret counterparts leave configuration and identity untouched", t
|
|
|
110
222
|
assert.equal(new Configre(f.config).get().api.key, "");
|
|
111
223
|
assert.deepEqual(fs.readdirSync(f.config), files);
|
|
112
224
|
assert.equal(fs.existsSync(f.homes[0]), false);
|
|
225
|
+
assert.deepEqual(f.logs, { info: [], warn: [] });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("an unauthorized machine keeps public settings and omits every encrypted field", t => {
|
|
229
|
+
const f = fixture(t);
|
|
230
|
+
writeSettings(f, { api: { key: sentinel }, encryptedOnly: { token: sentinel } });
|
|
231
|
+
load(f);
|
|
232
|
+
const consumer = consumerCopy(f, "unauthorized-consumer");
|
|
233
|
+
f.useHome(1);
|
|
234
|
+
const config = assertPublicOnly(consumer, f.logs, /not authorized.*public settings/i);
|
|
235
|
+
assert.deepEqual(config, { api: { key: "", host: "profile" }, list: [1, 2] });
|
|
236
|
+
assert.equal(Object.hasOwn(config, "encryptedOnly"), false);
|
|
237
|
+
assert.deepEqual(new Configre(consumer.config).get(), config);
|
|
238
|
+
assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
|
|
239
|
+
const result = spawnSync(process.execPath, ["-e", `
|
|
240
|
+
const assert = require('node:assert/strict');
|
|
241
|
+
const os = require('node:os');
|
|
242
|
+
os.homedir = () => process.env.CONFIGRE_TEST_HOME;
|
|
243
|
+
const Configre = require(process.env.CONFIGRE_TEST_MODULE);
|
|
244
|
+
const cfg = Configre(process.env.CONFIGRE_TEST_PATH);
|
|
245
|
+
assert.equal(cfg.api.key, '');
|
|
246
|
+
assert.equal(Object.hasOwn(cfg, 'encryptedOnly'), false);
|
|
247
|
+
console.info('Application started');
|
|
248
|
+
`], {
|
|
249
|
+
encoding: "utf8",
|
|
250
|
+
env: {
|
|
251
|
+
...process.env, DEBUG: "Configre:*",
|
|
252
|
+
CONFIGRE_TEST_HOME: f.homes[1],
|
|
253
|
+
CONFIGRE_TEST_MODULE: path.join(import.meta.dirname, "..", "index.js"),
|
|
254
|
+
CONFIGRE_TEST_PATH: consumer.config
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
assert.equal(result.status, 0, result.stderr);
|
|
258
|
+
const output = result.stdout + result.stderr;
|
|
259
|
+
assert.match(output, /Secrets not authorized; continuing with public settings only/);
|
|
260
|
+
assert.match(output, /Application started/);
|
|
261
|
+
assert.equal(output.includes(sentinel), false);
|
|
262
|
+
assert.equal(output.includes("BEGIN PRIVATE KEY"), false);
|
|
263
|
+
assert.equal(output.includes("BEGIN PUBLIC KEY"), false);
|
|
113
264
|
});
|
|
114
265
|
|
|
115
266
|
for (const profile of ["testhost", "testhost.dev", "forced"]) {
|
|
@@ -135,7 +286,12 @@ test("an existing empty secret module initializes secrets and reuses generated f
|
|
|
135
286
|
const ignore = path.join(f.config, ".gitignore");
|
|
136
287
|
fs.writeFileSync(ignore, "# existing rules\n*.log");
|
|
137
288
|
const config = load(f);
|
|
289
|
+
const initialLogs = f.logs.info.length;
|
|
138
290
|
assert.deepEqual(config, Configre(f.config));
|
|
291
|
+
assert.equal(f.logs.info.length, initialLogs);
|
|
292
|
+
for (const filename of [path.dirname(f.privatePath(0)), f.privatePath(0), f.publicPath(0), ignore, f.recipients, f.encrypted]) {
|
|
293
|
+
assert.ok(f.logs.info.some(([message, target]) => /Created|Wrote/.test(message) && target === filename));
|
|
294
|
+
}
|
|
139
295
|
const original = fs.readFileSync(f.privatePath(0));
|
|
140
296
|
const published = fs.readFileSync(f.publicPath(0), "utf8");
|
|
141
297
|
assert.equal(parsePrivateKey(original).publicKey, published);
|
|
@@ -156,6 +312,9 @@ test("an existing empty secret module initializes secrets and reuses generated f
|
|
|
156
312
|
assert.equal(fs.existsSync(f.encrypted + ".lock"), false);
|
|
157
313
|
writeSettings(f);
|
|
158
314
|
assert.equal(load(f).api.key, sentinel);
|
|
315
|
+
assert.deepEqual(f.logs.info.at(-1), ["Updated encrypted secrets file", f.encrypted]);
|
|
316
|
+
assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
|
|
317
|
+
assert.equal(JSON.stringify(f.logs).includes("BEGIN PRIVATE KEY"), false);
|
|
159
318
|
});
|
|
160
319
|
|
|
161
320
|
test("missing administrator files are recreated without losing values or authorized recipients", t => {
|
|
@@ -198,7 +357,7 @@ test("removing every recipient revokes access without restoring keys from old ci
|
|
|
198
357
|
assert.notDeepEqual(fs.readFileSync(f.encrypted), encrypted);
|
|
199
358
|
const consumer = consumerCopy(f, "revoked-consumer");
|
|
200
359
|
f.useHome(1);
|
|
201
|
-
|
|
360
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
202
361
|
});
|
|
203
362
|
|
|
204
363
|
test("secrets merge last with profiles, arrays, JSON values and the constructor API", t => {
|
|
@@ -337,7 +496,7 @@ test("grant and revoke work across isolated machines without consumer project wr
|
|
|
337
496
|
const after = fs.readdirSync(consumer.config).map(name => [name, fs.readFileSync(path.join(consumer.config, name))]);
|
|
338
497
|
assert.deepEqual(after, before);
|
|
339
498
|
f.useHome(2);
|
|
340
|
-
|
|
499
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
341
500
|
f.useHome(0);
|
|
342
501
|
fs.unlinkSync(path.join(f.recipients, "developer.pub"));
|
|
343
502
|
load(f);
|
|
@@ -346,9 +505,9 @@ test("grant and revoke work across isolated machines without consumer project wr
|
|
|
346
505
|
assert.notEqual(revoked.iv, shared.iv);
|
|
347
506
|
fs.copyFileSync(f.encrypted, path.join(consumer.config, "secrets.enc.json"));
|
|
348
507
|
f.useHome(1);
|
|
349
|
-
|
|
508
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
350
509
|
writeSettings({ local: path.join(consumer.config, "index.secret.cjs") }, { api: { key: "replacement" } });
|
|
351
|
-
|
|
510
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
352
511
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(consumer.config, "secrets.enc.json"))), revoked);
|
|
353
512
|
fs.writeFileSync(path.join(consumer.config, "secrets.enc.json"), JSON.stringify(shared));
|
|
354
513
|
fs.unlinkSync(path.join(consumer.config, "index.secret.cjs"));
|
|
@@ -633,7 +792,11 @@ test("a server publishes only its public key once and receives automatic authori
|
|
|
633
792
|
fs.writeFileSync(untracked, sentinel);
|
|
634
793
|
const index = git(f.checkout, ["ls-files", "--stage", "-z"]);
|
|
635
794
|
f.useHome(1);
|
|
636
|
-
|
|
795
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
796
|
+
const registeredPath = fs.realpathSync(f.registration);
|
|
797
|
+
assert.ok(f.logs.info.some(([message, filename]) => message === "Created public-key registration file" && filename === registeredPath));
|
|
798
|
+
assert.deepEqual(f.logs.info.at(-1), ["Published public key to Git", registeredPath]);
|
|
799
|
+
const registrationLogs = f.logs.info.length;
|
|
637
800
|
const published = git(f.remote, ["rev-parse", "main"]).trim();
|
|
638
801
|
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]).trim(), published);
|
|
639
802
|
assert.equal(git(f.remote, ["diff-tree", "--no-commit-id", "--name-only", "-r", "main"]).trim(), "config/recipients/testhost.pub");
|
|
@@ -645,7 +808,8 @@ test("a server publishes only its public key once and receives automatic authori
|
|
|
645
808
|
assert.equal(fs.readFileSync(untracked, "utf8"), sentinel);
|
|
646
809
|
assert.equal(git(f.remote, ["show", "main:tracked.txt"]), "initial\n");
|
|
647
810
|
assert.equal(git(f.remote, ["tag", "--list"]), "");
|
|
648
|
-
|
|
811
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
812
|
+
assert.equal(f.logs.info.length, registrationLogs);
|
|
649
813
|
assert.equal(git(f.remote, ["rev-parse", "main"]).trim(), published);
|
|
650
814
|
|
|
651
815
|
f.useHome(0);
|
|
@@ -657,7 +821,9 @@ test("a server publishes only its public key once and receives automatic authori
|
|
|
657
821
|
git(f.root, ["push", "--quiet"]);
|
|
658
822
|
git(f.checkout, ["pull", "--quiet", "--ff-only"]);
|
|
659
823
|
f.useHome(1);
|
|
824
|
+
const warnings = f.logs.warn.length;
|
|
660
825
|
assert.equal(load(f.consumer).api.key, sentinel);
|
|
826
|
+
assert.equal(f.logs.warn.length, warnings);
|
|
661
827
|
f.useHome(0);
|
|
662
828
|
writeSettings(f, { api: { key: sentinel + "-updated" } });
|
|
663
829
|
load(f);
|
|
@@ -679,18 +845,16 @@ test("a rejected registration push preserves the branch and staging area and can
|
|
|
679
845
|
const head = git(f.checkout, ["rev-parse", "HEAD"]);
|
|
680
846
|
const index = fs.readFileSync(path.join(f.checkout, ".git", "index"));
|
|
681
847
|
f.useHome(1);
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
return true;
|
|
686
|
-
});
|
|
848
|
+
assertPublicOnly(f.consumer, f.logs, /failed while pushing the public-key commit/);
|
|
849
|
+
assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
|
|
850
|
+
assert.equal(f.logs.info.some(([message]) => message === "Published public key to Git"), false);
|
|
687
851
|
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), head);
|
|
688
852
|
assert.equal(git(f.remote, ["rev-parse", "main"]), head);
|
|
689
853
|
assert.deepEqual(fs.readFileSync(path.join(f.checkout, ".git", "index")), index);
|
|
690
854
|
assert.equal(fs.existsSync(path.join(f.checkout, ".git", "configre-registration.lock")), false);
|
|
691
855
|
assert.equal(fs.readdirSync(path.join(f.checkout, ".git")).some(name => name.startsWith("configre-registration-")), false);
|
|
692
856
|
fs.unlinkSync(hook);
|
|
693
|
-
|
|
857
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
694
858
|
assert.equal(git(f.remote, ["rev-list", "--count", "main"]).trim(), "2");
|
|
695
859
|
});
|
|
696
860
|
|
|
@@ -706,9 +870,9 @@ test("registration recovers when publication succeeded before the local branch a
|
|
|
706
870
|
git(interrupted, ["add", "tracked.txt"]);
|
|
707
871
|
fs.writeFileSync(tracked, "unstaged work\n");
|
|
708
872
|
f.useHome(1);
|
|
709
|
-
|
|
873
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
710
874
|
const published = git(f.remote, ["rev-parse", "main"]);
|
|
711
|
-
|
|
875
|
+
assertPublicOnly({ config: path.join(interrupted, "config") }, f.logs, /public key is published in Git/i);
|
|
712
876
|
git(interrupted, ["pull", "--quiet", "--ff-only"]);
|
|
713
877
|
assert.equal(git(interrupted, ["rev-parse", "HEAD"]), published);
|
|
714
878
|
assert.equal(git(f.remote, ["rev-parse", "main"]), published);
|
|
@@ -721,16 +885,16 @@ test("registration refuses to publish unrelated local commits or guess a branch"
|
|
|
721
885
|
const remoteHead = git(f.remote, ["rev-parse", "main"]);
|
|
722
886
|
git(f.checkout, ["checkout", "--quiet", "--detach"]);
|
|
723
887
|
f.useHome(1);
|
|
724
|
-
|
|
888
|
+
assertPublicOnly(f.consumer, f.logs, /detached HEAD/);
|
|
725
889
|
git(f.checkout, ["checkout", "--quiet", "main"]);
|
|
726
890
|
git(f.checkout, ["branch", "--unset-upstream"]);
|
|
727
|
-
|
|
891
|
+
assertPublicOnly(f.consumer, f.logs, /upstream remote/);
|
|
728
892
|
git(f.checkout, ["branch", "--set-upstream-to=origin/main"]);
|
|
729
893
|
fs.writeFileSync(path.join(f.checkout, "tracked.txt"), "unpublished work\n");
|
|
730
894
|
git(f.checkout, ["add", "tracked.txt"]);
|
|
731
895
|
git(f.checkout, ["commit", "--quiet", "-m", "Unpublished work"]);
|
|
732
896
|
const localHead = git(f.checkout, ["rev-parse", "HEAD"]);
|
|
733
|
-
|
|
897
|
+
assertPublicOnly(f.consumer, f.logs, /local branch to match its upstream/);
|
|
734
898
|
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), localHead);
|
|
735
899
|
assert.equal(git(f.remote, ["rev-parse", "main"]), remoteHead);
|
|
736
900
|
assert.equal(fs.existsSync(f.registration), false);
|
|
@@ -742,11 +906,11 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
|
|
|
742
906
|
const previousArgs = process.argv;
|
|
743
907
|
process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=../escape"];
|
|
744
908
|
t.after(() => { process.argv = previousArgs; });
|
|
745
|
-
|
|
909
|
+
assertPublicOnly(f.consumer, f.logs, /requires a profile/);
|
|
746
910
|
process.argv = previousArgs;
|
|
747
911
|
fs.mkdirSync(path.dirname(f.registration));
|
|
748
912
|
fs.copyFileSync(f.publicPath(2), f.registration);
|
|
749
|
-
|
|
913
|
+
assertPublicOnly(f.consumer, f.logs, /different key for this profile/);
|
|
750
914
|
assert.equal(fs.readFileSync(f.registration, "utf8"), fs.readFileSync(f.publicPath(2), "utf8"));
|
|
751
915
|
fs.unlinkSync(f.registration);
|
|
752
916
|
fs.copyFileSync(f.publicPath(2), path.join(f.recipients, "testhost.pub"));
|
|
@@ -754,7 +918,7 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
|
|
|
754
918
|
git(f.root, ["commit", "--quiet", "-m", "Existing server identity"]);
|
|
755
919
|
git(f.root, ["push", "--quiet"]);
|
|
756
920
|
const remoteHead = git(f.remote, ["rev-parse", "main"]);
|
|
757
|
-
|
|
921
|
+
assertPublicOnly(f.consumer, f.logs, /different published key/);
|
|
758
922
|
const damaged = envelope(f);
|
|
759
923
|
damaged.extra = sentinel;
|
|
760
924
|
fs.writeFileSync(path.join(f.consumer.config, "secrets.enc.json"), JSON.stringify(damaged));
|