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
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import Configre from "../index.js";
|
|
10
|
+
import { generatePrivateKey, parsePrivateKey, encrypt } from "../secrets/crypto.js";
|
|
11
|
+
|
|
12
|
+
const sentinel = "synthetic-secret-for-configre-tests";
|
|
13
|
+
let privateKeys;
|
|
14
|
+
|
|
15
|
+
test.before(() => {
|
|
16
|
+
privateKeys = [generatePrivateKey(), generatePrivateKey(), generatePrivateKey()];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function fixture(t, { seed = true } = {}) {
|
|
20
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "configre-secrets-"));
|
|
21
|
+
const homes = [0, 1, 2].map(index => path.join(root, `home-${index}`));
|
|
22
|
+
let home = homes[0];
|
|
23
|
+
t.mock.method(os, "homedir", () => home);
|
|
24
|
+
t.mock.method(os, "hostname", () => "testhost");
|
|
25
|
+
const config = path.join(root, "config");
|
|
26
|
+
fs.mkdirSync(config);
|
|
27
|
+
fs.writeFileSync(path.join(config, "index.cjs"), 'module.exports = { api: { key: "", host: "default" }, list: [1, 2] };');
|
|
28
|
+
fs.writeFileSync(path.join(config, "testhost.cjs"), 'module.exports = { api: { host: "profile" } };');
|
|
29
|
+
if (seed) {
|
|
30
|
+
homes.forEach((directory, index) => {
|
|
31
|
+
const identityDir = path.join(directory, ".config", "configre");
|
|
32
|
+
fs.mkdirSync(identityDir, { recursive: true, mode: 0o700 });
|
|
33
|
+
fs.writeFileSync(path.join(identityDir, "identity.pem"), privateKeys[index], { mode: 0o600 });
|
|
34
|
+
fs.writeFileSync(path.join(identityDir, "identity.pub"), parsePrivateKey(privateKeys[index]).publicKey);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
38
|
+
return {
|
|
39
|
+
root, config, homes,
|
|
40
|
+
local: path.join(config, "index.secret.cjs"),
|
|
41
|
+
encrypted: path.join(config, "secrets.enc.json"),
|
|
42
|
+
recipients: path.join(config, "recipients"),
|
|
43
|
+
useHome(index) { home = homes[index]; },
|
|
44
|
+
publicPath(index) { return path.join(homes[index], ".config", "configre", "identity.pub"); },
|
|
45
|
+
privatePath(index) { return path.join(homes[index], ".config", "configre", "identity.pem"); }
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeSettings(f, settings = { api: { key: sentinel } }) {
|
|
50
|
+
fs.writeFileSync(f.local, `module.exports = ${JSON.stringify(settings)};\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function load(f) {
|
|
54
|
+
return Configre(f.config, { secrets: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function envelope(f) {
|
|
58
|
+
return JSON.parse(fs.readFileSync(f.encrypted, "utf8"));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function consumerCopy(f, name) {
|
|
62
|
+
const config = path.join(f.root, name);
|
|
63
|
+
fs.mkdirSync(config);
|
|
64
|
+
for (const filename of ["index.cjs", "testhost.cjs", "secrets.enc.json"]) {
|
|
65
|
+
fs.copyFileSync(path.join(f.config, filename), path.join(config, filename));
|
|
66
|
+
}
|
|
67
|
+
return { config };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function git(directory, args) {
|
|
71
|
+
const result = spawnSync("git", ["-C", directory, ...args], { encoding: "utf8" });
|
|
72
|
+
assert.equal(result.status, 0, result.stderr);
|
|
73
|
+
return result.stdout;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function gitFixture(t) {
|
|
77
|
+
const f = fixture(t);
|
|
78
|
+
git(f.root, ["init", "--quiet", "--initial-branch=main"]);
|
|
79
|
+
git(f.root, ["config", "user.name", "Configre Test"]);
|
|
80
|
+
git(f.root, ["config", "user.email", "configre-test@example.invalid"]);
|
|
81
|
+
writeSettings(f);
|
|
82
|
+
load(f);
|
|
83
|
+
fs.writeFileSync(path.join(f.root, "tracked.txt"), "initial\n");
|
|
84
|
+
git(f.root, ["add", "config/index.cjs", "config/testhost.cjs", "config/.gitignore", "config/secrets.enc.json", "tracked.txt"]);
|
|
85
|
+
git(f.root, ["commit", "--quiet", "-m", "Initial configuration"]);
|
|
86
|
+
const remote = path.join(f.root, "remote.git");
|
|
87
|
+
git(f.root, ["init", "--bare", "--quiet", "--initial-branch=main", remote]);
|
|
88
|
+
git(f.root, ["remote", "add", "origin", remote]);
|
|
89
|
+
git(f.root, ["push", "--quiet", "--set-upstream", "origin", "main"]);
|
|
90
|
+
const checkout = path.join(f.root, "consumer");
|
|
91
|
+
git(f.root, ["clone", "--quiet", remote, checkout]);
|
|
92
|
+
git(checkout, ["config", "user.name", "Configre Test"]);
|
|
93
|
+
git(checkout, ["config", "user.email", "configre-test@example.invalid"]);
|
|
94
|
+
return {
|
|
95
|
+
...f, remote, checkout,
|
|
96
|
+
consumer: { config: path.join(checkout, "config") },
|
|
97
|
+
registration: path.join(checkout, "config", "recipients", "testhost.pub")
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test("secrets stay opt-in and invalid options fail before creating an identity", t => {
|
|
102
|
+
const f = fixture(t, { seed: false });
|
|
103
|
+
fs.writeFileSync(f.local, "invalid-json");
|
|
104
|
+
t.mock.method(os, "homedir", () => { throw new Error("must not access the identity"); });
|
|
105
|
+
assert.equal(Configre(f.config).api.key, "");
|
|
106
|
+
assert.equal(Configre(f.config, { secrets: false }).api.key, "");
|
|
107
|
+
assert.equal(new Configre(f.config).get().api.key, "");
|
|
108
|
+
for (const options of [null, true, [], { secrets: "true" }]) {
|
|
109
|
+
assert.throws(() => Configre(f.config, options), /options must be an object/);
|
|
110
|
+
}
|
|
111
|
+
assert.equal(fs.existsSync(f.encrypted), false);
|
|
112
|
+
assert.equal(fs.existsSync(path.join(f.config, ".gitignore")), false);
|
|
113
|
+
assert.equal(fs.existsSync(f.homes[0]), false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("first use initializes empty secrets and reuses all generated files", t => {
|
|
117
|
+
const f = fixture(t, { seed: false });
|
|
118
|
+
const ignore = path.join(f.config, ".gitignore");
|
|
119
|
+
fs.writeFileSync(ignore, "# existing rules\n*.log");
|
|
120
|
+
const config = load(f);
|
|
121
|
+
assert.deepEqual(config, Configre(f.config));
|
|
122
|
+
const original = fs.readFileSync(f.privatePath(0));
|
|
123
|
+
const published = fs.readFileSync(f.publicPath(0), "utf8");
|
|
124
|
+
assert.equal(parsePrivateKey(original).publicKey, published);
|
|
125
|
+
if (process.platform !== "win32") {
|
|
126
|
+
assert.equal(fs.statSync(f.privatePath(0)).mode & 0o777, 0o600);
|
|
127
|
+
assert.equal(fs.statSync(path.dirname(f.privatePath(0))).mode & 0o777, 0o700);
|
|
128
|
+
assert.equal(fs.statSync(f.local).mode & 0o777, 0o600);
|
|
129
|
+
assert.equal(fs.statSync(f.recipients).mode & 0o777, 0o700);
|
|
130
|
+
}
|
|
131
|
+
const rules = fs.readFileSync(ignore, "utf8");
|
|
132
|
+
assert.ok(rules.startsWith("# existing rules\n*.log\n"));
|
|
133
|
+
assert.equal(fs.readFileSync(f.local, "utf8"), "module.exports = {};\n");
|
|
134
|
+
const encrypted = fs.readFileSync(f.encrypted);
|
|
135
|
+
assert.deepEqual(load(f), config);
|
|
136
|
+
assert.deepEqual(fs.readFileSync(f.privatePath(0)), original);
|
|
137
|
+
assert.equal(fs.readFileSync(ignore, "utf8"), rules);
|
|
138
|
+
assert.deepEqual(fs.readFileSync(f.encrypted), encrypted);
|
|
139
|
+
assert.equal(fs.existsSync(f.encrypted + ".lock"), false);
|
|
140
|
+
writeSettings(f);
|
|
141
|
+
assert.equal(load(f).api.key, sentinel);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("missing administrator files are recreated without losing values or authorized recipients", t => {
|
|
145
|
+
const f = fixture(t);
|
|
146
|
+
writeSettings(f);
|
|
147
|
+
load(f);
|
|
148
|
+
fs.copyFileSync(f.publicPath(1), path.join(f.recipients, "developer.pub"));
|
|
149
|
+
load(f);
|
|
150
|
+
const local = fs.readFileSync(f.local);
|
|
151
|
+
const encrypted = fs.readFileSync(f.encrypted);
|
|
152
|
+
const originalRecipients = envelope(f).recipients.map(entry => entry.fingerprint);
|
|
153
|
+
const ignore = path.join(f.config, ".gitignore");
|
|
154
|
+
fs.unlinkSync(ignore);
|
|
155
|
+
assert.equal(load(f).api.key, sentinel);
|
|
156
|
+
assert.equal(fs.existsSync(ignore), true);
|
|
157
|
+
assert.equal(fs.readdirSync(f.recipients).length, 1);
|
|
158
|
+
assert.deepEqual(fs.readFileSync(f.local), local);
|
|
159
|
+
assert.deepEqual(fs.readFileSync(f.encrypted), encrypted);
|
|
160
|
+
fs.unlinkSync(f.encrypted);
|
|
161
|
+
assert.equal(load(f).api.key, sentinel);
|
|
162
|
+
assert.deepEqual(envelope(f).recipients.map(entry => entry.fingerprint), originalRecipients);
|
|
163
|
+
assert.deepEqual(fs.readFileSync(f.local), local);
|
|
164
|
+
const consumer = consumerCopy(f, "consumer");
|
|
165
|
+
f.useHome(1);
|
|
166
|
+
assert.equal(load(consumer).api.key, sentinel);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("removing every recipient revokes access without restoring keys from old ciphertext", t => {
|
|
170
|
+
const f = fixture(t);
|
|
171
|
+
writeSettings(f);
|
|
172
|
+
load(f);
|
|
173
|
+
fs.copyFileSync(f.publicPath(1), path.join(f.recipients, "developer.pub"));
|
|
174
|
+
fs.copyFileSync(f.publicPath(2), path.join(f.recipients, "server.pub"));
|
|
175
|
+
load(f);
|
|
176
|
+
const encrypted = fs.readFileSync(f.encrypted);
|
|
177
|
+
fs.rmSync(f.recipients, { recursive: true });
|
|
178
|
+
assert.equal(load(f).api.key, sentinel);
|
|
179
|
+
assert.deepEqual(fs.readdirSync(f.recipients), []);
|
|
180
|
+
assert.equal(envelope(f).recipients.length, 1);
|
|
181
|
+
assert.notDeepEqual(fs.readFileSync(f.encrypted), encrypted);
|
|
182
|
+
const consumer = consumerCopy(f, "revoked-consumer");
|
|
183
|
+
f.useHome(1);
|
|
184
|
+
assert.throws(() => load(consumer), /not authorized/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("secrets merge last with profiles, arrays, JSON values and the constructor API", t => {
|
|
188
|
+
const f = fixture(t);
|
|
189
|
+
const settings = {
|
|
190
|
+
api: { key: sentinel, host: "secret-host" },
|
|
191
|
+
list: [9],
|
|
192
|
+
json: { empty: "", multiline: "line 1\nline 2", unicode: "á🙂", enabled: false, port: 123, value: null }
|
|
193
|
+
};
|
|
194
|
+
writeSettings(f, settings);
|
|
195
|
+
fs.writeFileSync(path.join(f.config, "testhost.dev.cjs"), 'module.exports = { dev: true, api: { host: "dev" } };');
|
|
196
|
+
const config = load(f);
|
|
197
|
+
assert.equal(config.dev, true);
|
|
198
|
+
assert.deepEqual(config.api, settings.api);
|
|
199
|
+
assert.deepEqual(config.list, [9, 2]);
|
|
200
|
+
assert.deepEqual(config.json, settings.json);
|
|
201
|
+
assert.deepEqual(new Configre(f.config, { secrets: true }).get(), config);
|
|
202
|
+
assert.equal(fs.existsSync(f.recipients), true);
|
|
203
|
+
assert.equal(fs.readFileSync(f.encrypted, "utf8").includes(sentinel), false);
|
|
204
|
+
assert.equal(envelope(f).recipients.length, 1);
|
|
205
|
+
const previousArgs = process.argv;
|
|
206
|
+
process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=forced"];
|
|
207
|
+
t.after(() => { process.argv = previousArgs; });
|
|
208
|
+
fs.writeFileSync(path.join(f.config, "forced.cjs"), 'module.exports = { forced: true, api: { key: "profile-key" } };');
|
|
209
|
+
assert.equal(load(f).forced, true);
|
|
210
|
+
assert.equal(load(f).api.key, sentinel);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("unchanged settings and duplicate public keys do not rewrite the encrypted file", t => {
|
|
214
|
+
const f = fixture(t);
|
|
215
|
+
writeSettings(f, { api: { key: sentinel }, other: true });
|
|
216
|
+
load(f);
|
|
217
|
+
const original = fs.readFileSync(f.encrypted);
|
|
218
|
+
fs.utimesSync(f.encrypted, 100, 100);
|
|
219
|
+
const originalTime = fs.statSync(f.encrypted).mtimeMs;
|
|
220
|
+
writeSettings(f, { other: true, api: { key: sentinel } });
|
|
221
|
+
fs.copyFileSync(f.publicPath(0), path.join(f.recipients, "self.pub"));
|
|
222
|
+
fs.copyFileSync(f.publicPath(0), path.join(f.recipients, "self-copy.pub"));
|
|
223
|
+
fs.writeFileSync(path.join(f.recipients, ".DS_Store"), "ignored");
|
|
224
|
+
load(f);
|
|
225
|
+
assert.deepEqual(fs.readFileSync(f.encrypted), original);
|
|
226
|
+
assert.equal(fs.statSync(f.encrypted).mtimeMs, originalTime);
|
|
227
|
+
fs.unlinkSync(path.join(f.recipients, "self.pub"));
|
|
228
|
+
fs.unlinkSync(path.join(f.recipients, "self-copy.pub"));
|
|
229
|
+
load(f);
|
|
230
|
+
assert.deepEqual(fs.readFileSync(f.encrypted), original);
|
|
231
|
+
writeSettings(f, { api: { key: sentinel + "-changed" }, other: true });
|
|
232
|
+
assert.equal(load(f).api.key, sentinel + "-changed");
|
|
233
|
+
const updated = envelope(f);
|
|
234
|
+
const previous = JSON.parse(original);
|
|
235
|
+
assert.notEqual(updated.iv, previous.iv);
|
|
236
|
+
assert.notEqual(updated.ciphertext, previous.ciphertext);
|
|
237
|
+
assert.notEqual(updated.recipients[0].wrappedKey, previous.recipients[0].wrappedKey);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("base and host secrets select dev and forced profiles identically on consumers", t => {
|
|
241
|
+
const f = fixture(t);
|
|
242
|
+
writeSettings(f, { api: { key: "base-secret", shared: true } });
|
|
243
|
+
const host = { local: path.join(f.config, "testhost.secret.cjs") };
|
|
244
|
+
const dev = { local: path.join(f.config, "testhost.dev.secret.cjs") };
|
|
245
|
+
const forced = { local: path.join(f.config, "forced.secret.cjs") };
|
|
246
|
+
writeSettings(host, { api: { key: "host-secret" }, productionOnly: true });
|
|
247
|
+
writeSettings(forced, { api: { key: "forced-secret" } });
|
|
248
|
+
assert.deepEqual(load(f).api, { key: "host-secret", host: "profile", shared: true });
|
|
249
|
+
writeSettings(dev, { api: { key: "dev-secret" } });
|
|
250
|
+
const expected = load(f);
|
|
251
|
+
assert.equal(expected.api.key, "dev-secret");
|
|
252
|
+
assert.equal(expected.productionOnly, undefined);
|
|
253
|
+
fs.copyFileSync(f.publicPath(1), path.join(f.recipients, "developer.pub"));
|
|
254
|
+
load(f);
|
|
255
|
+
const consumer = consumerCopy(f, "profile-consumer");
|
|
256
|
+
const consumerFiles = fs.readdirSync(consumer.config);
|
|
257
|
+
f.useHome(1);
|
|
258
|
+
assert.deepEqual(load(consumer), expected);
|
|
259
|
+
const previousArgs = process.argv;
|
|
260
|
+
process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=forced"];
|
|
261
|
+
t.after(() => { process.argv = previousArgs; });
|
|
262
|
+
assert.equal(load(consumer).api.key, "forced-secret");
|
|
263
|
+
f.useHome(0);
|
|
264
|
+
assert.equal(load(f).api.key, "forced-secret");
|
|
265
|
+
writeSettings(forced, { api: { key: "updated-forced-secret" } });
|
|
266
|
+
load(f);
|
|
267
|
+
fs.copyFileSync(f.encrypted, path.join(consumer.config, "secrets.enc.json"));
|
|
268
|
+
f.useHome(1);
|
|
269
|
+
assert.equal(load(consumer).api.key, "updated-forced-secret");
|
|
270
|
+
process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=missing"];
|
|
271
|
+
assert.equal(load(consumer).api.key, "base-secret");
|
|
272
|
+
process.argv = previousArgs;
|
|
273
|
+
f.useHome(0);
|
|
274
|
+
fs.unlinkSync(dev.local);
|
|
275
|
+
assert.equal(load(f).api.key, "host-secret");
|
|
276
|
+
assert.equal(load(f).productionOnly, true);
|
|
277
|
+
assert.deepEqual(fs.readdirSync(consumer.config), consumerFiles);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("administrator scaffolds empty counterparts without overwriting existing host secrets", t => {
|
|
281
|
+
const f = fixture(t);
|
|
282
|
+
writeSettings(f);
|
|
283
|
+
load(f);
|
|
284
|
+
const host = { local: path.join(f.config, "testhost.secret.cjs") };
|
|
285
|
+
assert.equal(fs.readFileSync(host.local, "utf8"), "module.exports = {};\n");
|
|
286
|
+
writeSettings(host, { api: { key: "host-secret" } });
|
|
287
|
+
const original = fs.readFileSync(host.local);
|
|
288
|
+
fs.writeFileSync(path.join(f.config, "newhost.cjs"), "module.exports = { public: true };\n");
|
|
289
|
+
load(f);
|
|
290
|
+
assert.deepEqual(fs.readFileSync(host.local), original);
|
|
291
|
+
assert.equal(fs.readFileSync(path.join(f.config, "newhost.secret.cjs"), "utf8"), "module.exports = {};\n");
|
|
292
|
+
const consumer = consumerCopy(f, "scaffold-consumer");
|
|
293
|
+
fs.writeFileSync(path.join(consumer.config, "newhost.cjs"), "module.exports = {};\n");
|
|
294
|
+
assert.equal(load(consumer).api.key, "host-secret");
|
|
295
|
+
assert.equal(fs.existsSync(path.join(consumer.config, "newhost.secret.cjs")), false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("secret layers preserve sequential merge behavior when a field changes type", t => {
|
|
299
|
+
const f = fixture(t);
|
|
300
|
+
writeSettings(f, { api: null, list: null });
|
|
301
|
+
writeSettings({ local: path.join(f.config, "testhost.secret.cjs") }, {
|
|
302
|
+
api: { key: sentinel }, list: [9]
|
|
303
|
+
});
|
|
304
|
+
const expected = { api: { key: sentinel }, list: [9] };
|
|
305
|
+
assert.deepEqual(load(f), expected);
|
|
306
|
+
assert.deepEqual(load(consumerCopy(f, "type-change-consumer")), expected);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("grant and revoke work across isolated machines without consumer project writes", t => {
|
|
310
|
+
const f = fixture(t);
|
|
311
|
+
writeSettings(f);
|
|
312
|
+
load(f);
|
|
313
|
+
fs.copyFileSync(f.publicPath(1), path.join(f.recipients, "developer.pub"));
|
|
314
|
+
load(f);
|
|
315
|
+
const shared = envelope(f);
|
|
316
|
+
const consumer = consumerCopy(f, "consumer");
|
|
317
|
+
const before = fs.readdirSync(consumer.config).map(name => [name, fs.readFileSync(path.join(consumer.config, name))]);
|
|
318
|
+
f.useHome(1);
|
|
319
|
+
assert.equal(load(consumer).api.key, sentinel);
|
|
320
|
+
const after = fs.readdirSync(consumer.config).map(name => [name, fs.readFileSync(path.join(consumer.config, name))]);
|
|
321
|
+
assert.deepEqual(after, before);
|
|
322
|
+
f.useHome(2);
|
|
323
|
+
assert.throws(() => load(consumer), /not authorized/);
|
|
324
|
+
f.useHome(0);
|
|
325
|
+
fs.unlinkSync(path.join(f.recipients, "developer.pub"));
|
|
326
|
+
load(f);
|
|
327
|
+
const revoked = envelope(f);
|
|
328
|
+
assert.equal(revoked.recipients.length, 1);
|
|
329
|
+
assert.notEqual(revoked.iv, shared.iv);
|
|
330
|
+
fs.copyFileSync(f.encrypted, path.join(consumer.config, "secrets.enc.json"));
|
|
331
|
+
f.useHome(1);
|
|
332
|
+
assert.throws(() => load(consumer), /not authorized/);
|
|
333
|
+
writeSettings({ local: path.join(consumer.config, "index.secret.cjs") }, { api: { key: "replacement" } });
|
|
334
|
+
assert.throws(() => load(consumer), /not authorized/);
|
|
335
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(consumer.config, "secrets.enc.json"))), revoked);
|
|
336
|
+
fs.writeFileSync(path.join(consumer.config, "secrets.enc.json"), JSON.stringify(shared));
|
|
337
|
+
fs.unlinkSync(path.join(consumer.config, "index.secret.cjs"));
|
|
338
|
+
assert.equal(load(consumer).api.key, sentinel);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("ciphertext, key wraps and recipient metadata are authenticated before returning settings", t => {
|
|
342
|
+
const f = fixture(t);
|
|
343
|
+
writeSettings(f);
|
|
344
|
+
load(f);
|
|
345
|
+
const original = envelope(f);
|
|
346
|
+
const flip = value => {
|
|
347
|
+
const bytes = Buffer.from(value, "base64");
|
|
348
|
+
bytes[0] ^= 1;
|
|
349
|
+
return bytes.toString("base64");
|
|
350
|
+
};
|
|
351
|
+
const modifications = [
|
|
352
|
+
value => { value.iv = flip(value.iv); },
|
|
353
|
+
value => { value.tag = flip(value.tag); },
|
|
354
|
+
value => { value.ciphertext = flip(value.ciphertext); },
|
|
355
|
+
value => { value.recipients[0].wrappedKey = flip(value.recipients[0].wrappedKey); },
|
|
356
|
+
value => {
|
|
357
|
+
const other = parsePrivateKey(privateKeys[1]);
|
|
358
|
+
value.recipients.push({ fingerprint: other.fingerprint, publicKey: other.publicKey, wrappedKey: value.recipients[0].wrappedKey });
|
|
359
|
+
value.recipients.sort((a, b) => a.fingerprint < b.fingerprint ? -1 : 1);
|
|
360
|
+
},
|
|
361
|
+
value => { value.version = 999; },
|
|
362
|
+
value => { value.algorithm = "other"; },
|
|
363
|
+
value => { value.tag = ""; },
|
|
364
|
+
value => { value.ciphertext += "!"; },
|
|
365
|
+
value => { value.recipients.push(value.recipients[0]); },
|
|
366
|
+
value => { delete value.tag; },
|
|
367
|
+
value => { value.extra = sentinel; }
|
|
368
|
+
];
|
|
369
|
+
for (const modify of modifications) {
|
|
370
|
+
const damaged = structuredClone(original);
|
|
371
|
+
modify(damaged);
|
|
372
|
+
const bytes = JSON.stringify(damaged);
|
|
373
|
+
fs.writeFileSync(f.encrypted, bytes);
|
|
374
|
+
assert.throws(() => load(f), error => {
|
|
375
|
+
assert.match(error.message, /Configre secrets:/);
|
|
376
|
+
assert.equal(error.message.includes(sentinel), false);
|
|
377
|
+
return true;
|
|
378
|
+
});
|
|
379
|
+
assert.equal(fs.readFileSync(f.encrypted, "utf8"), bytes);
|
|
380
|
+
assert.equal(fs.existsSync(f.encrypted + ".lock"), false);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("invalid local modules, unsafe keys and invalid public keys leave ciphertext untouched", t => {
|
|
385
|
+
const f = fixture(t);
|
|
386
|
+
writeSettings(f);
|
|
387
|
+
load(f);
|
|
388
|
+
const original = fs.readFileSync(f.encrypted);
|
|
389
|
+
const invalid = [
|
|
390
|
+
`{"api":"${sentinel}", bad}`, "null", "[]", "1",
|
|
391
|
+
'{"nested":{"__proto__":{"polluted":true}}}',
|
|
392
|
+
'{"list":[{"constructor":{}}]}', '{"prototype":{}}', '{"number":1e400}',
|
|
393
|
+
'{ value: undefined }', '{ value: () => "secret" }', '{ value: 1n }',
|
|
394
|
+
'{ value: new Date() }', '{ value: Symbol("secret") }', '{ value: [, 1] }',
|
|
395
|
+
'(() => { const value = {}; value.self = value; return value; })()',
|
|
396
|
+
`(() => { throw new Error("${sentinel}"); })()`,
|
|
397
|
+
`({ get value() { throw new Error("${sentinel}"); } })`,
|
|
398
|
+
'{ value: Object.assign(new Array(1), { 4294967295: true }) }'
|
|
399
|
+
];
|
|
400
|
+
for (const input of invalid) {
|
|
401
|
+
fs.writeFileSync(f.local, `module.exports = ${input};`);
|
|
402
|
+
assert.throws(() => load(f), error => {
|
|
403
|
+
assert.equal(error.message.includes(sentinel), false);
|
|
404
|
+
return true;
|
|
405
|
+
});
|
|
406
|
+
assert.deepEqual(fs.readFileSync(f.encrypted), original);
|
|
407
|
+
}
|
|
408
|
+
assert.equal({}.polluted, undefined);
|
|
409
|
+
writeSettings(f);
|
|
410
|
+
const invalidExponent = crypto.createPublicKey(privateKeys[1]).export({ format: "jwk" });
|
|
411
|
+
invalidExponent.e = "AQ";
|
|
412
|
+
const invalidRSA = crypto.createPublicKey({ key: invalidExponent, format: "jwk" }).export({ type: "spki", format: "pem" });
|
|
413
|
+
for (const key of [sentinel, privateKeys[1], parsePrivateKey(privateKeys[1]).publicKey + privateKeys[1], invalidRSA]) {
|
|
414
|
+
fs.writeFileSync(path.join(f.recipients, "invalid.pub"), key);
|
|
415
|
+
assert.throws(() => load(f), /invalid RSA-3072 public key/);
|
|
416
|
+
assert.deepEqual(fs.readFileSync(f.encrypted), original);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("authenticated payloads still reject unsafe JSON before merging", t => {
|
|
421
|
+
const f = fixture(t);
|
|
422
|
+
const identity = parsePrivateKey(privateKeys[0]);
|
|
423
|
+
const encrypted = encrypt(JSON.parse('{"nested":{"__proto__":{"polluted":true}}}'), [
|
|
424
|
+
{ fingerprint: identity.fingerprint, publicKey: identity.publicKey }
|
|
425
|
+
]);
|
|
426
|
+
fs.writeFileSync(f.encrypted, JSON.stringify(encrypted));
|
|
427
|
+
assert.throws(() => load(f), /unsafe property/);
|
|
428
|
+
assert.equal({}.polluted, undefined);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("a corrupt or missing private key is never silently replaced", t => {
|
|
432
|
+
const f = fixture(t);
|
|
433
|
+
writeSettings(f);
|
|
434
|
+
load(f);
|
|
435
|
+
const original = fs.readFileSync(f.encrypted);
|
|
436
|
+
fs.writeFileSync(f.privatePath(0), "invalid-private-key");
|
|
437
|
+
assert.throws(() => load(f), /invalid private identity/);
|
|
438
|
+
assert.equal(fs.readFileSync(f.privatePath(0), "utf8"), "invalid-private-key");
|
|
439
|
+
fs.unlinkSync(f.privatePath(0));
|
|
440
|
+
assert.throws(() => load(f), /private identity is missing/);
|
|
441
|
+
assert.equal(fs.existsSync(f.privatePath(0)), false);
|
|
442
|
+
fs.writeFileSync(f.privatePath(0), privateKeys[0], { mode: 0o600 });
|
|
443
|
+
fs.writeFileSync(f.publicPath(0), parsePrivateKey(privateKeys[1]).publicKey);
|
|
444
|
+
assert.throws(() => load(f), /does not match/);
|
|
445
|
+
assert.deepEqual(fs.readFileSync(f.encrypted), original);
|
|
446
|
+
fs.unlinkSync(f.publicPath(0));
|
|
447
|
+
assert.equal(load(f).api.key, sentinel);
|
|
448
|
+
assert.equal(fs.readFileSync(f.publicPath(0), "utf8"), parsePrivateKey(privateKeys[0]).publicKey);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("private permissions and symlinks are rejected", { skip: process.platform === "win32" }, t => {
|
|
452
|
+
const f = fixture(t);
|
|
453
|
+
writeSettings(f);
|
|
454
|
+
fs.chmodSync(f.privatePath(0), 0o644);
|
|
455
|
+
assert.throws(() => load(f), /owner-only permissions/);
|
|
456
|
+
fs.chmodSync(f.privatePath(0), 0o600);
|
|
457
|
+
load(f);
|
|
458
|
+
const external = path.join(f.root, "external.json");
|
|
459
|
+
fs.renameSync(f.local, external);
|
|
460
|
+
fs.symlinkSync(external, f.local);
|
|
461
|
+
assert.throws(() => load(f), /regular file/);
|
|
462
|
+
fs.unlinkSync(f.local);
|
|
463
|
+
fs.renameSync(external, f.local);
|
|
464
|
+
const privateCopy = path.join(f.root, "private-copy.pem");
|
|
465
|
+
fs.renameSync(f.privatePath(0), privateCopy);
|
|
466
|
+
fs.symlinkSync(privateCopy, f.privatePath(0));
|
|
467
|
+
assert.throws(() => load(f), /regular file/);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("explicit config files and extensionless paths use sidecars beside the resolved file", t => {
|
|
471
|
+
const f = fixture(t);
|
|
472
|
+
const filename = path.join(f.root, "settings.cjs");
|
|
473
|
+
fs.writeFileSync(filename, 'module.exports = { api: { key: "", host: "file" } };');
|
|
474
|
+
const local = filename.slice(0, -4) + ".secret.cjs";
|
|
475
|
+
writeSettings({ local });
|
|
476
|
+
assert.equal(Configre(filename, { secrets: true }).api.key, sentinel);
|
|
477
|
+
assert.equal(Configre(filename.slice(0, -4), { secrets: true }).api.key, sentinel);
|
|
478
|
+
assert.equal(fs.existsSync(filename + ".secrets.enc.json"), true);
|
|
479
|
+
assert.equal(fs.existsSync(filename + ".recipients"), true);
|
|
480
|
+
assert.equal(fs.existsSync(path.join(f.root, "secrets.enc.json")), false);
|
|
481
|
+
const relative = path.relative(process.cwd(), f.config);
|
|
482
|
+
writeSettings(f);
|
|
483
|
+
assert.equal(Configre(relative, { secrets: true }).api.key, sentinel);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("ESM callers can use the same synchronous API", t => {
|
|
487
|
+
const f = fixture(t);
|
|
488
|
+
writeSettings(f);
|
|
489
|
+
load(f);
|
|
490
|
+
const script = path.join(f.root, "check.mjs");
|
|
491
|
+
const moduleURL = pathToFileURL(path.join(import.meta.dirname, "..", "index.js")).href;
|
|
492
|
+
fs.writeFileSync(script, `
|
|
493
|
+
import assert from 'node:assert/strict';
|
|
494
|
+
import os from 'node:os';
|
|
495
|
+
import Configre from ${JSON.stringify(moduleURL)};
|
|
496
|
+
os.homedir = () => process.env.CONFIGRE_TEST_HOME;
|
|
497
|
+
const cfg = Configre(process.env.CONFIGRE_TEST_PATH, { secrets: true });
|
|
498
|
+
assert.equal(typeof cfg.then, 'undefined');
|
|
499
|
+
assert.equal(cfg.api.key, ${JSON.stringify(sentinel)});
|
|
500
|
+
`);
|
|
501
|
+
const result = spawnSync(process.execPath, [script], {
|
|
502
|
+
encoding: "utf8",
|
|
503
|
+
env: { ...process.env, CONFIGRE_TEST_HOME: f.homes[0], CONFIGRE_TEST_PATH: f.config }
|
|
504
|
+
});
|
|
505
|
+
assert.equal(result.status, 0, result.stderr);
|
|
506
|
+
assert.equal((result.stdout + result.stderr).includes(sentinel), false);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("symlinked configuration directories keep directory sidecar names", { skip: process.platform === "win32" }, t => {
|
|
510
|
+
const f = fixture(t);
|
|
511
|
+
writeSettings(f);
|
|
512
|
+
const linked = path.join(f.root, "linked-config");
|
|
513
|
+
fs.symlinkSync(f.config, linked);
|
|
514
|
+
assert.equal(Configre(linked, { secrets: true }).api.key, sentinel);
|
|
515
|
+
assert.equal(fs.existsSync(f.encrypted), true);
|
|
516
|
+
assert.equal(fs.existsSync(path.join(f.config, "index.cjs.secrets.enc.json")), false);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("error output never includes local secret values or private key contents", t => {
|
|
520
|
+
const f = fixture(t);
|
|
521
|
+
fs.writeFileSync(f.local, `module.exports = {secret: "${sentinel}", bad`);
|
|
522
|
+
assert.throws(() => load(f), /invalid secret module/);
|
|
523
|
+
assert.equal(new Configre(f.config)._isNested, false);
|
|
524
|
+
const script = `
|
|
525
|
+
const os = require('node:os');
|
|
526
|
+
os.homedir = () => process.env.CONFIGRE_TEST_HOME;
|
|
527
|
+
const Configre = require(process.env.CONFIGRE_TEST_MODULE);
|
|
528
|
+
try {
|
|
529
|
+
Configre(process.env.CONFIGRE_TEST_PATH, { secrets: true });
|
|
530
|
+
} catch (error) {
|
|
531
|
+
console.error(error.stack);
|
|
532
|
+
process.exitCode = 1;
|
|
533
|
+
}
|
|
534
|
+
`;
|
|
535
|
+
const result = spawnSync(process.execPath, ["-e", script], {
|
|
536
|
+
encoding: "utf8",
|
|
537
|
+
env: {
|
|
538
|
+
...process.env,
|
|
539
|
+
CONFIGRE_TEST_HOME: f.homes[0],
|
|
540
|
+
CONFIGRE_TEST_MODULE: path.join(import.meta.dirname, "..", "index.js"),
|
|
541
|
+
CONFIGRE_TEST_PATH: f.config
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
assert.equal(result.status, 1);
|
|
545
|
+
assert.match(result.stderr, /invalid secret module/);
|
|
546
|
+
assert.equal((result.stdout + result.stderr).includes(sentinel), false);
|
|
547
|
+
assert.equal((result.stdout + result.stderr).includes("BEGIN PRIVATE KEY"), false);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("Git exclusions preserve existing rules and never hide already tracked local files", t => {
|
|
551
|
+
const f = fixture(t);
|
|
552
|
+
git(f.root, ["init", "--quiet"]);
|
|
553
|
+
const ignore = path.join(f.config, ".gitignore");
|
|
554
|
+
const existing = "# keep these rules\n/index.secret.cjs\n!/index.secret.cjs\n";
|
|
555
|
+
fs.writeFileSync(ignore, existing);
|
|
556
|
+
writeSettings(f);
|
|
557
|
+
load(f);
|
|
558
|
+
assert.ok(fs.readFileSync(ignore, "utf8").startsWith(existing));
|
|
559
|
+
fs.copyFileSync(f.publicPath(1), path.join(f.recipients, "developer.pub"));
|
|
560
|
+
const ignored = git(f.root, ["check-ignore", "config/index.secret.cjs", "config/recipients/developer.pub"]);
|
|
561
|
+
assert.ok(ignored.includes("config/index.secret.cjs"));
|
|
562
|
+
assert.equal(ignored.includes("config/recipients/developer.pub"), false);
|
|
563
|
+
const original = fs.readFileSync(f.encrypted);
|
|
564
|
+
const originalIgnore = fs.readFileSync(ignore);
|
|
565
|
+
git(f.root, ["add", "-f", "config/index.secret.cjs"]);
|
|
566
|
+
assert.throws(() => load(f), /tracked by Git/);
|
|
567
|
+
assert.deepEqual(fs.readFileSync(ignore), originalIgnore);
|
|
568
|
+
assert.deepEqual(fs.readFileSync(f.encrypted), original);
|
|
569
|
+
assert.ok(git(f.root, ["ls-files"]).includes("config/index.secret.cjs"));
|
|
570
|
+
git(f.root, ["rm", "--cached", "config/index.secret.cjs"]);
|
|
571
|
+
git(f.root, ["add", "-f", "config/testhost.secret.cjs"]);
|
|
572
|
+
assert.throws(() => load(f), /tracked by Git/);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("recipient files can be tracked and sidecar names are escaped literally in Git rules", t => {
|
|
576
|
+
const f = fixture(t);
|
|
577
|
+
git(f.root, ["init", "--quiet"]);
|
|
578
|
+
const filename = path.join(f.root, "settings[one] #.cjs");
|
|
579
|
+
fs.writeFileSync(filename, "module.exports = {};");
|
|
580
|
+
const local = filename.slice(0, -4) + ".secret.cjs";
|
|
581
|
+
writeSettings({ local });
|
|
582
|
+
Configre(filename, { secrets: true });
|
|
583
|
+
assert.ok(git(f.root, ["check-ignore", path.basename(local)]).includes(path.basename(local)));
|
|
584
|
+
const recipient = filename + ".recipients/developer.pub";
|
|
585
|
+
fs.copyFileSync(f.publicPath(1), recipient);
|
|
586
|
+
git(f.root, ["add", "-f", path.relative(f.root, recipient)]);
|
|
587
|
+
const original = fs.readFileSync(filename + ".secrets.enc.json");
|
|
588
|
+
assert.equal(Configre(filename, { secrets: true }).api.key, sentinel);
|
|
589
|
+
assert.notDeepEqual(fs.readFileSync(filename + ".secrets.enc.json"), original);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("a server publishes only its public key once and receives automatic authorization through Git", t => {
|
|
593
|
+
const f = gitFixture(t);
|
|
594
|
+
git(f.checkout, ["config", "push.followTags", "true"]);
|
|
595
|
+
git(f.checkout, ["tag", "-a", "local-only", "-m", "Local tag"]);
|
|
596
|
+
const tracked = path.join(f.checkout, "tracked.txt");
|
|
597
|
+
const untracked = path.join(f.checkout, "untracked.txt");
|
|
598
|
+
fs.writeFileSync(tracked, "staged change\n");
|
|
599
|
+
git(f.checkout, ["add", "tracked.txt"]);
|
|
600
|
+
fs.writeFileSync(tracked, "unstaged change\n");
|
|
601
|
+
fs.writeFileSync(untracked, sentinel);
|
|
602
|
+
const index = git(f.checkout, ["ls-files", "--stage", "-z"]);
|
|
603
|
+
f.useHome(1);
|
|
604
|
+
assert.throws(() => load(f.consumer), /public key is published in Git/);
|
|
605
|
+
const published = git(f.remote, ["rev-parse", "main"]).trim();
|
|
606
|
+
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]).trim(), published);
|
|
607
|
+
assert.equal(git(f.remote, ["diff-tree", "--no-commit-id", "--name-only", "-r", "main"]).trim(), "config/recipients/testhost.pub");
|
|
608
|
+
assert.equal(fs.readFileSync(f.registration, "utf8"), fs.readFileSync(f.publicPath(1), "utf8"));
|
|
609
|
+
const otherEntries = git(f.checkout, ["ls-files", "--stage", "-z"]).split("\0")
|
|
610
|
+
.filter(entry => !entry.endsWith("\tconfig/recipients/testhost.pub")).join("\0");
|
|
611
|
+
assert.equal(otherEntries, index);
|
|
612
|
+
assert.equal(fs.readFileSync(tracked, "utf8"), "unstaged change\n");
|
|
613
|
+
assert.equal(fs.readFileSync(untracked, "utf8"), sentinel);
|
|
614
|
+
assert.equal(git(f.remote, ["show", "main:tracked.txt"]), "initial\n");
|
|
615
|
+
assert.equal(git(f.remote, ["tag", "--list"]), "");
|
|
616
|
+
assert.throws(() => load(f.consumer), /public key is published in Git/);
|
|
617
|
+
assert.equal(git(f.remote, ["rev-parse", "main"]).trim(), published);
|
|
618
|
+
|
|
619
|
+
f.useHome(0);
|
|
620
|
+
git(f.root, ["pull", "--quiet", "--ff-only"]);
|
|
621
|
+
load(f);
|
|
622
|
+
assert.equal(envelope(f).recipients.length, 2);
|
|
623
|
+
git(f.root, ["add", "config/secrets.enc.json"]);
|
|
624
|
+
git(f.root, ["commit", "--quiet", "-m", "Authorize registered server"]);
|
|
625
|
+
git(f.root, ["push", "--quiet"]);
|
|
626
|
+
git(f.checkout, ["pull", "--quiet", "--ff-only"]);
|
|
627
|
+
f.useHome(1);
|
|
628
|
+
assert.equal(load(f.consumer).api.key, sentinel);
|
|
629
|
+
f.useHome(0);
|
|
630
|
+
writeSettings(f, { api: { key: sentinel + "-updated" } });
|
|
631
|
+
load(f);
|
|
632
|
+
git(f.root, ["add", "config/secrets.enc.json"]);
|
|
633
|
+
git(f.root, ["commit", "--quiet", "-m", "Update secret values"]);
|
|
634
|
+
git(f.root, ["push", "--quiet"]);
|
|
635
|
+
git(f.checkout, ["pull", "--quiet", "--ff-only"]);
|
|
636
|
+
f.useHome(1);
|
|
637
|
+
assert.equal(load(f.consumer).api.key, sentinel + "-updated");
|
|
638
|
+
assert.equal(git(f.remote, ["rev-list", "--count", "main", "--", "config/recipients/testhost.pub"]).trim(), "1");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("a rejected registration push preserves the branch and staging area and can be retried", t => {
|
|
642
|
+
const f = gitFixture(t);
|
|
643
|
+
const hook = path.join(f.remote, "hooks", "pre-receive");
|
|
644
|
+
fs.writeFileSync(hook, `#!/bin/sh\necho '${sentinel}' >&2\nexit 1\n`, { mode: 0o755 });
|
|
645
|
+
fs.writeFileSync(path.join(f.checkout, "tracked.txt"), "staged change\n");
|
|
646
|
+
git(f.checkout, ["add", "tracked.txt"]);
|
|
647
|
+
const head = git(f.checkout, ["rev-parse", "HEAD"]);
|
|
648
|
+
const index = fs.readFileSync(path.join(f.checkout, ".git", "index"));
|
|
649
|
+
f.useHome(1);
|
|
650
|
+
assert.throws(() => load(f.consumer), error => {
|
|
651
|
+
assert.match(error.message, /failed while pushing the public-key commit/);
|
|
652
|
+
assert.equal(error.stack.includes(sentinel), false);
|
|
653
|
+
return true;
|
|
654
|
+
});
|
|
655
|
+
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), head);
|
|
656
|
+
assert.equal(git(f.remote, ["rev-parse", "main"]), head);
|
|
657
|
+
assert.deepEqual(fs.readFileSync(path.join(f.checkout, ".git", "index")), index);
|
|
658
|
+
assert.equal(fs.existsSync(path.join(f.checkout, ".git", "configre-registration.lock")), false);
|
|
659
|
+
assert.equal(fs.readdirSync(path.join(f.checkout, ".git")).some(name => name.startsWith("configre-registration-")), false);
|
|
660
|
+
fs.unlinkSync(hook);
|
|
661
|
+
assert.throws(() => load(f.consumer), /public key is published in Git/);
|
|
662
|
+
assert.equal(git(f.remote, ["rev-list", "--count", "main"]).trim(), "2");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("registration recovers when publication succeeded before the local branch and index were updated", t => {
|
|
666
|
+
const f = gitFixture(t);
|
|
667
|
+
const interrupted = path.join(f.root, "interrupted-consumer");
|
|
668
|
+
git(f.root, ["clone", "--quiet", f.remote, interrupted]);
|
|
669
|
+
const publicPath = path.join(interrupted, "config", "recipients", "testhost.pub");
|
|
670
|
+
fs.mkdirSync(path.dirname(publicPath));
|
|
671
|
+
fs.copyFileSync(f.publicPath(1), publicPath);
|
|
672
|
+
const tracked = path.join(interrupted, "tracked.txt");
|
|
673
|
+
fs.writeFileSync(tracked, "staged work\n");
|
|
674
|
+
git(interrupted, ["add", "tracked.txt"]);
|
|
675
|
+
fs.writeFileSync(tracked, "unstaged work\n");
|
|
676
|
+
f.useHome(1);
|
|
677
|
+
assert.throws(() => load(f.consumer), /public key is published in Git/);
|
|
678
|
+
const published = git(f.remote, ["rev-parse", "main"]);
|
|
679
|
+
assert.throws(() => load({ config: path.join(interrupted, "config") }), /public key is published in Git/);
|
|
680
|
+
git(interrupted, ["pull", "--quiet", "--ff-only"]);
|
|
681
|
+
assert.equal(git(interrupted, ["rev-parse", "HEAD"]), published);
|
|
682
|
+
assert.equal(git(f.remote, ["rev-parse", "main"]), published);
|
|
683
|
+
assert.equal(git(interrupted, ["show", ":tracked.txt"]), "staged work\n");
|
|
684
|
+
assert.equal(fs.readFileSync(tracked, "utf8"), "unstaged work\n");
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("registration refuses to publish unrelated local commits or guess a branch", t => {
|
|
688
|
+
const f = gitFixture(t);
|
|
689
|
+
const remoteHead = git(f.remote, ["rev-parse", "main"]);
|
|
690
|
+
git(f.checkout, ["checkout", "--quiet", "--detach"]);
|
|
691
|
+
f.useHome(1);
|
|
692
|
+
assert.throws(() => load(f.consumer), /detached HEAD/);
|
|
693
|
+
git(f.checkout, ["checkout", "--quiet", "main"]);
|
|
694
|
+
git(f.checkout, ["branch", "--unset-upstream"]);
|
|
695
|
+
assert.throws(() => load(f.consumer), /upstream remote/);
|
|
696
|
+
git(f.checkout, ["branch", "--set-upstream-to=origin/main"]);
|
|
697
|
+
fs.writeFileSync(path.join(f.checkout, "tracked.txt"), "unpublished work\n");
|
|
698
|
+
git(f.checkout, ["add", "tracked.txt"]);
|
|
699
|
+
git(f.checkout, ["commit", "--quiet", "-m", "Unpublished work"]);
|
|
700
|
+
const localHead = git(f.checkout, ["rev-parse", "HEAD"]);
|
|
701
|
+
assert.throws(() => load(f.consumer), /local branch to match its upstream/);
|
|
702
|
+
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), localHead);
|
|
703
|
+
assert.equal(git(f.remote, ["rev-parse", "main"]), remoteHead);
|
|
704
|
+
assert.equal(fs.existsSync(f.registration), false);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("registration rejects profile collisions, unsafe names and damaged encrypted files", t => {
|
|
708
|
+
const f = gitFixture(t);
|
|
709
|
+
f.useHome(1);
|
|
710
|
+
const previousArgs = process.argv;
|
|
711
|
+
process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=../escape"];
|
|
712
|
+
t.after(() => { process.argv = previousArgs; });
|
|
713
|
+
assert.throws(() => load(f.consumer), /requires a profile/);
|
|
714
|
+
process.argv = previousArgs;
|
|
715
|
+
fs.mkdirSync(path.dirname(f.registration));
|
|
716
|
+
fs.copyFileSync(f.publicPath(2), f.registration);
|
|
717
|
+
assert.throws(() => load(f.consumer), /different key for this profile/);
|
|
718
|
+
assert.equal(fs.readFileSync(f.registration, "utf8"), fs.readFileSync(f.publicPath(2), "utf8"));
|
|
719
|
+
fs.unlinkSync(f.registration);
|
|
720
|
+
fs.copyFileSync(f.publicPath(2), path.join(f.recipients, "testhost.pub"));
|
|
721
|
+
git(f.root, ["add", "config/recipients/testhost.pub"]);
|
|
722
|
+
git(f.root, ["commit", "--quiet", "-m", "Existing server identity"]);
|
|
723
|
+
git(f.root, ["push", "--quiet"]);
|
|
724
|
+
const remoteHead = git(f.remote, ["rev-parse", "main"]);
|
|
725
|
+
assert.throws(() => load(f.consumer), /different published key/);
|
|
726
|
+
const damaged = envelope(f);
|
|
727
|
+
damaged.extra = sentinel;
|
|
728
|
+
fs.writeFileSync(path.join(f.consumer.config, "secrets.enc.json"), JSON.stringify(damaged));
|
|
729
|
+
assert.throws(() => load(f.consumer), /invalid encrypted file structure/);
|
|
730
|
+
assert.equal(fs.existsSync(f.registration), false);
|
|
731
|
+
assert.equal(git(f.remote, ["rev-parse", "main"]), remoteHead);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test("writer locks and failed atomic replacement preserve the last encrypted version", t => {
|
|
735
|
+
const f = fixture(t);
|
|
736
|
+
writeSettings(f);
|
|
737
|
+
load(f);
|
|
738
|
+
const original = fs.readFileSync(f.encrypted);
|
|
739
|
+
writeSettings(f, { api: { key: sentinel + "-new" } });
|
|
740
|
+
fs.writeFileSync(f.encrypted + ".lock", "");
|
|
741
|
+
assert.throws(() => load(f), /another writer/);
|
|
742
|
+
assert.equal(fs.existsSync(f.encrypted + ".lock"), true);
|
|
743
|
+
assert.deepEqual(fs.readFileSync(f.encrypted), original);
|
|
744
|
+
fs.unlinkSync(f.encrypted + ".lock");
|
|
745
|
+
const rename = fs.renameSync;
|
|
746
|
+
t.mock.method(fs, "renameSync", (from, to) => {
|
|
747
|
+
if (to === f.encrypted) {
|
|
748
|
+
const temporary = fs.readFileSync(from, "utf8");
|
|
749
|
+
assert.equal(temporary.includes(sentinel), false);
|
|
750
|
+
assert.ok(JSON.parse(temporary).ciphertext);
|
|
751
|
+
throw new Error("simulated atomic rename failure");
|
|
752
|
+
}
|
|
753
|
+
return rename(from, to);
|
|
754
|
+
});
|
|
755
|
+
assert.throws(() => load(f), /simulated atomic rename failure/);
|
|
756
|
+
assert.deepEqual(fs.readFileSync(f.encrypted), original);
|
|
757
|
+
assert.equal(fs.existsSync(f.encrypted + ".lock"), false);
|
|
758
|
+
assert.equal(fs.readdirSync(f.config).some(name => name.endsWith(".tmp")), false);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("a second process cannot write while an encrypted replacement is pending", t => {
|
|
762
|
+
const f = fixture(t);
|
|
763
|
+
writeSettings(f);
|
|
764
|
+
load(f);
|
|
765
|
+
const original = fs.readFileSync(f.encrypted);
|
|
766
|
+
writeSettings(f, { api: { key: sentinel + "-new" } });
|
|
767
|
+
const rename = fs.renameSync;
|
|
768
|
+
let contested = false;
|
|
769
|
+
t.mock.method(fs, "renameSync", (from, to) => {
|
|
770
|
+
if (to === f.encrypted) {
|
|
771
|
+
const result = spawnSync(process.execPath, ["-e", `
|
|
772
|
+
const assert = require('node:assert/strict');
|
|
773
|
+
const os = require('node:os');
|
|
774
|
+
os.homedir = () => process.env.CONFIGRE_TEST_HOME;
|
|
775
|
+
const Configre = require(process.env.CONFIGRE_TEST_MODULE);
|
|
776
|
+
assert.throws(() => Configre(process.env.CONFIGRE_TEST_PATH, { secrets: true }), /another writer/);
|
|
777
|
+
`], {
|
|
778
|
+
encoding: "utf8",
|
|
779
|
+
env: {
|
|
780
|
+
...process.env,
|
|
781
|
+
CONFIGRE_TEST_HOME: f.homes[0],
|
|
782
|
+
CONFIGRE_TEST_MODULE: path.join(import.meta.dirname, "..", "index.js"),
|
|
783
|
+
CONFIGRE_TEST_PATH: f.config
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
assert.equal(result.status, 0, result.stderr);
|
|
787
|
+
assert.deepEqual(fs.readFileSync(f.encrypted), original);
|
|
788
|
+
contested = true;
|
|
789
|
+
}
|
|
790
|
+
return rename(from, to);
|
|
791
|
+
});
|
|
792
|
+
assert.equal(load(f).api.key, sentinel + "-new");
|
|
793
|
+
assert.equal(contested, true);
|
|
794
|
+
fs.unlinkSync(f.local);
|
|
795
|
+
fs.unlinkSync(path.join(f.config, "testhost.secret.cjs"));
|
|
796
|
+
assert.equal(load(f).api.key, sentinel + "-new");
|
|
797
|
+
});
|