configre 2.1.0 → 2.1.2
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 +5 -3
- 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 +86 -21
package/README.md
CHANGED
|
@@ -224,9 +224,11 @@ If this server is not authorized yet, Configre automatically:
|
|
|
224
224
|
1. Creates its local identity, if needed.
|
|
225
225
|
2. Writes `demo/secrets/config/recipients/truco.pub`.
|
|
226
226
|
3. Creates and pushes a commit containing only that public-key file.
|
|
227
|
-
4.
|
|
227
|
+
4. Logs a warning and continues with public configuration only until the administrator publishes an updated encrypted file.
|
|
228
228
|
|
|
229
|
-
|
|
229
|
+
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.
|
|
230
|
+
|
|
231
|
+
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
232
|
|
|
231
233
|
### 4. Include the server in the encrypted file
|
|
232
234
|
|
|
@@ -281,7 +283,7 @@ To revoke this server, remove `demo/secrets/config/recipients/truco.pub`, run th
|
|
|
281
283
|
- 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
284
|
- 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
285
|
- 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`,
|
|
286
|
+
- 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
287
|
- 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
288
|
- 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
289
|
- 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/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
|
}
|
|
@@ -110,6 +123,45 @@ test("missing secret counterparts leave configuration and identity untouched", t
|
|
|
110
123
|
assert.equal(new Configre(f.config).get().api.key, "");
|
|
111
124
|
assert.deepEqual(fs.readdirSync(f.config), files);
|
|
112
125
|
assert.equal(fs.existsSync(f.homes[0]), false);
|
|
126
|
+
assert.deepEqual(f.logs, { info: [], warn: [] });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("an unauthorized machine keeps public settings and omits every encrypted field", t => {
|
|
130
|
+
const f = fixture(t);
|
|
131
|
+
writeSettings(f, { api: { key: sentinel }, encryptedOnly: { token: sentinel } });
|
|
132
|
+
load(f);
|
|
133
|
+
const consumer = consumerCopy(f, "unauthorized-consumer");
|
|
134
|
+
f.useHome(1);
|
|
135
|
+
const config = assertPublicOnly(consumer, f.logs, /not authorized.*public settings/i);
|
|
136
|
+
assert.deepEqual(config, { api: { key: "", host: "profile" }, list: [1, 2] });
|
|
137
|
+
assert.equal(Object.hasOwn(config, "encryptedOnly"), false);
|
|
138
|
+
assert.deepEqual(new Configre(consumer.config).get(), config);
|
|
139
|
+
assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
|
|
140
|
+
const result = spawnSync(process.execPath, ["-e", `
|
|
141
|
+
const assert = require('node:assert/strict');
|
|
142
|
+
const os = require('node:os');
|
|
143
|
+
os.homedir = () => process.env.CONFIGRE_TEST_HOME;
|
|
144
|
+
const Configre = require(process.env.CONFIGRE_TEST_MODULE);
|
|
145
|
+
const cfg = Configre(process.env.CONFIGRE_TEST_PATH);
|
|
146
|
+
assert.equal(cfg.api.key, '');
|
|
147
|
+
assert.equal(Object.hasOwn(cfg, 'encryptedOnly'), false);
|
|
148
|
+
console.info('Application started');
|
|
149
|
+
`], {
|
|
150
|
+
encoding: "utf8",
|
|
151
|
+
env: {
|
|
152
|
+
...process.env, DEBUG: "Configre:*",
|
|
153
|
+
CONFIGRE_TEST_HOME: f.homes[1],
|
|
154
|
+
CONFIGRE_TEST_MODULE: path.join(import.meta.dirname, "..", "index.js"),
|
|
155
|
+
CONFIGRE_TEST_PATH: consumer.config
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
assert.equal(result.status, 0, result.stderr);
|
|
159
|
+
const output = result.stdout + result.stderr;
|
|
160
|
+
assert.match(output, /Secrets not authorized; continuing with public settings only/);
|
|
161
|
+
assert.match(output, /Application started/);
|
|
162
|
+
assert.equal(output.includes(sentinel), false);
|
|
163
|
+
assert.equal(output.includes("BEGIN PRIVATE KEY"), false);
|
|
164
|
+
assert.equal(output.includes("BEGIN PUBLIC KEY"), false);
|
|
113
165
|
});
|
|
114
166
|
|
|
115
167
|
for (const profile of ["testhost", "testhost.dev", "forced"]) {
|
|
@@ -135,7 +187,12 @@ test("an existing empty secret module initializes secrets and reuses generated f
|
|
|
135
187
|
const ignore = path.join(f.config, ".gitignore");
|
|
136
188
|
fs.writeFileSync(ignore, "# existing rules\n*.log");
|
|
137
189
|
const config = load(f);
|
|
190
|
+
const initialLogs = f.logs.info.length;
|
|
138
191
|
assert.deepEqual(config, Configre(f.config));
|
|
192
|
+
assert.equal(f.logs.info.length, initialLogs);
|
|
193
|
+
for (const filename of [path.dirname(f.privatePath(0)), f.privatePath(0), f.publicPath(0), ignore, f.recipients, f.encrypted]) {
|
|
194
|
+
assert.ok(f.logs.info.some(([message, target]) => /Created|Wrote/.test(message) && target === filename));
|
|
195
|
+
}
|
|
139
196
|
const original = fs.readFileSync(f.privatePath(0));
|
|
140
197
|
const published = fs.readFileSync(f.publicPath(0), "utf8");
|
|
141
198
|
assert.equal(parsePrivateKey(original).publicKey, published);
|
|
@@ -156,6 +213,9 @@ test("an existing empty secret module initializes secrets and reuses generated f
|
|
|
156
213
|
assert.equal(fs.existsSync(f.encrypted + ".lock"), false);
|
|
157
214
|
writeSettings(f);
|
|
158
215
|
assert.equal(load(f).api.key, sentinel);
|
|
216
|
+
assert.deepEqual(f.logs.info.at(-1), ["Updated encrypted secrets file", f.encrypted]);
|
|
217
|
+
assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
|
|
218
|
+
assert.equal(JSON.stringify(f.logs).includes("BEGIN PRIVATE KEY"), false);
|
|
159
219
|
});
|
|
160
220
|
|
|
161
221
|
test("missing administrator files are recreated without losing values or authorized recipients", t => {
|
|
@@ -198,7 +258,7 @@ test("removing every recipient revokes access without restoring keys from old ci
|
|
|
198
258
|
assert.notDeepEqual(fs.readFileSync(f.encrypted), encrypted);
|
|
199
259
|
const consumer = consumerCopy(f, "revoked-consumer");
|
|
200
260
|
f.useHome(1);
|
|
201
|
-
|
|
261
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
202
262
|
});
|
|
203
263
|
|
|
204
264
|
test("secrets merge last with profiles, arrays, JSON values and the constructor API", t => {
|
|
@@ -337,7 +397,7 @@ test("grant and revoke work across isolated machines without consumer project wr
|
|
|
337
397
|
const after = fs.readdirSync(consumer.config).map(name => [name, fs.readFileSync(path.join(consumer.config, name))]);
|
|
338
398
|
assert.deepEqual(after, before);
|
|
339
399
|
f.useHome(2);
|
|
340
|
-
|
|
400
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
341
401
|
f.useHome(0);
|
|
342
402
|
fs.unlinkSync(path.join(f.recipients, "developer.pub"));
|
|
343
403
|
load(f);
|
|
@@ -346,9 +406,9 @@ test("grant and revoke work across isolated machines without consumer project wr
|
|
|
346
406
|
assert.notEqual(revoked.iv, shared.iv);
|
|
347
407
|
fs.copyFileSync(f.encrypted, path.join(consumer.config, "secrets.enc.json"));
|
|
348
408
|
f.useHome(1);
|
|
349
|
-
|
|
409
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
350
410
|
writeSettings({ local: path.join(consumer.config, "index.secret.cjs") }, { api: { key: "replacement" } });
|
|
351
|
-
|
|
411
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
352
412
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(consumer.config, "secrets.enc.json"))), revoked);
|
|
353
413
|
fs.writeFileSync(path.join(consumer.config, "secrets.enc.json"), JSON.stringify(shared));
|
|
354
414
|
fs.unlinkSync(path.join(consumer.config, "index.secret.cjs"));
|
|
@@ -633,7 +693,11 @@ test("a server publishes only its public key once and receives automatic authori
|
|
|
633
693
|
fs.writeFileSync(untracked, sentinel);
|
|
634
694
|
const index = git(f.checkout, ["ls-files", "--stage", "-z"]);
|
|
635
695
|
f.useHome(1);
|
|
636
|
-
|
|
696
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
697
|
+
const registeredPath = fs.realpathSync(f.registration);
|
|
698
|
+
assert.ok(f.logs.info.some(([message, filename]) => message === "Created public-key registration file" && filename === registeredPath));
|
|
699
|
+
assert.deepEqual(f.logs.info.at(-1), ["Published public key to Git", registeredPath]);
|
|
700
|
+
const registrationLogs = f.logs.info.length;
|
|
637
701
|
const published = git(f.remote, ["rev-parse", "main"]).trim();
|
|
638
702
|
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]).trim(), published);
|
|
639
703
|
assert.equal(git(f.remote, ["diff-tree", "--no-commit-id", "--name-only", "-r", "main"]).trim(), "config/recipients/testhost.pub");
|
|
@@ -645,7 +709,8 @@ test("a server publishes only its public key once and receives automatic authori
|
|
|
645
709
|
assert.equal(fs.readFileSync(untracked, "utf8"), sentinel);
|
|
646
710
|
assert.equal(git(f.remote, ["show", "main:tracked.txt"]), "initial\n");
|
|
647
711
|
assert.equal(git(f.remote, ["tag", "--list"]), "");
|
|
648
|
-
|
|
712
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
713
|
+
assert.equal(f.logs.info.length, registrationLogs);
|
|
649
714
|
assert.equal(git(f.remote, ["rev-parse", "main"]).trim(), published);
|
|
650
715
|
|
|
651
716
|
f.useHome(0);
|
|
@@ -657,7 +722,9 @@ test("a server publishes only its public key once and receives automatic authori
|
|
|
657
722
|
git(f.root, ["push", "--quiet"]);
|
|
658
723
|
git(f.checkout, ["pull", "--quiet", "--ff-only"]);
|
|
659
724
|
f.useHome(1);
|
|
725
|
+
const warnings = f.logs.warn.length;
|
|
660
726
|
assert.equal(load(f.consumer).api.key, sentinel);
|
|
727
|
+
assert.equal(f.logs.warn.length, warnings);
|
|
661
728
|
f.useHome(0);
|
|
662
729
|
writeSettings(f, { api: { key: sentinel + "-updated" } });
|
|
663
730
|
load(f);
|
|
@@ -679,18 +746,16 @@ test("a rejected registration push preserves the branch and staging area and can
|
|
|
679
746
|
const head = git(f.checkout, ["rev-parse", "HEAD"]);
|
|
680
747
|
const index = fs.readFileSync(path.join(f.checkout, ".git", "index"));
|
|
681
748
|
f.useHome(1);
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
return true;
|
|
686
|
-
});
|
|
749
|
+
assertPublicOnly(f.consumer, f.logs, /failed while pushing the public-key commit/);
|
|
750
|
+
assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
|
|
751
|
+
assert.equal(f.logs.info.some(([message]) => message === "Published public key to Git"), false);
|
|
687
752
|
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), head);
|
|
688
753
|
assert.equal(git(f.remote, ["rev-parse", "main"]), head);
|
|
689
754
|
assert.deepEqual(fs.readFileSync(path.join(f.checkout, ".git", "index")), index);
|
|
690
755
|
assert.equal(fs.existsSync(path.join(f.checkout, ".git", "configre-registration.lock")), false);
|
|
691
756
|
assert.equal(fs.readdirSync(path.join(f.checkout, ".git")).some(name => name.startsWith("configre-registration-")), false);
|
|
692
757
|
fs.unlinkSync(hook);
|
|
693
|
-
|
|
758
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
694
759
|
assert.equal(git(f.remote, ["rev-list", "--count", "main"]).trim(), "2");
|
|
695
760
|
});
|
|
696
761
|
|
|
@@ -706,9 +771,9 @@ test("registration recovers when publication succeeded before the local branch a
|
|
|
706
771
|
git(interrupted, ["add", "tracked.txt"]);
|
|
707
772
|
fs.writeFileSync(tracked, "unstaged work\n");
|
|
708
773
|
f.useHome(1);
|
|
709
|
-
|
|
774
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
710
775
|
const published = git(f.remote, ["rev-parse", "main"]);
|
|
711
|
-
|
|
776
|
+
assertPublicOnly({ config: path.join(interrupted, "config") }, f.logs, /public key is published in Git/i);
|
|
712
777
|
git(interrupted, ["pull", "--quiet", "--ff-only"]);
|
|
713
778
|
assert.equal(git(interrupted, ["rev-parse", "HEAD"]), published);
|
|
714
779
|
assert.equal(git(f.remote, ["rev-parse", "main"]), published);
|
|
@@ -721,16 +786,16 @@ test("registration refuses to publish unrelated local commits or guess a branch"
|
|
|
721
786
|
const remoteHead = git(f.remote, ["rev-parse", "main"]);
|
|
722
787
|
git(f.checkout, ["checkout", "--quiet", "--detach"]);
|
|
723
788
|
f.useHome(1);
|
|
724
|
-
|
|
789
|
+
assertPublicOnly(f.consumer, f.logs, /detached HEAD/);
|
|
725
790
|
git(f.checkout, ["checkout", "--quiet", "main"]);
|
|
726
791
|
git(f.checkout, ["branch", "--unset-upstream"]);
|
|
727
|
-
|
|
792
|
+
assertPublicOnly(f.consumer, f.logs, /upstream remote/);
|
|
728
793
|
git(f.checkout, ["branch", "--set-upstream-to=origin/main"]);
|
|
729
794
|
fs.writeFileSync(path.join(f.checkout, "tracked.txt"), "unpublished work\n");
|
|
730
795
|
git(f.checkout, ["add", "tracked.txt"]);
|
|
731
796
|
git(f.checkout, ["commit", "--quiet", "-m", "Unpublished work"]);
|
|
732
797
|
const localHead = git(f.checkout, ["rev-parse", "HEAD"]);
|
|
733
|
-
|
|
798
|
+
assertPublicOnly(f.consumer, f.logs, /local branch to match its upstream/);
|
|
734
799
|
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), localHead);
|
|
735
800
|
assert.equal(git(f.remote, ["rev-parse", "main"]), remoteHead);
|
|
736
801
|
assert.equal(fs.existsSync(f.registration), false);
|
|
@@ -742,11 +807,11 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
|
|
|
742
807
|
const previousArgs = process.argv;
|
|
743
808
|
process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=../escape"];
|
|
744
809
|
t.after(() => { process.argv = previousArgs; });
|
|
745
|
-
|
|
810
|
+
assertPublicOnly(f.consumer, f.logs, /requires a profile/);
|
|
746
811
|
process.argv = previousArgs;
|
|
747
812
|
fs.mkdirSync(path.dirname(f.registration));
|
|
748
813
|
fs.copyFileSync(f.publicPath(2), f.registration);
|
|
749
|
-
|
|
814
|
+
assertPublicOnly(f.consumer, f.logs, /different key for this profile/);
|
|
750
815
|
assert.equal(fs.readFileSync(f.registration, "utf8"), fs.readFileSync(f.publicPath(2), "utf8"));
|
|
751
816
|
fs.unlinkSync(f.registration);
|
|
752
817
|
fs.copyFileSync(f.publicPath(2), path.join(f.recipients, "testhost.pub"));
|
|
@@ -754,7 +819,7 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
|
|
|
754
819
|
git(f.root, ["commit", "--quiet", "-m", "Existing server identity"]);
|
|
755
820
|
git(f.root, ["push", "--quiet"]);
|
|
756
821
|
const remoteHead = git(f.remote, ["rev-parse", "main"]);
|
|
757
|
-
|
|
822
|
+
assertPublicOnly(f.consumer, f.logs, /different published key/);
|
|
758
823
|
const damaged = envelope(f);
|
|
759
824
|
damaged.extra = sentinel;
|
|
760
825
|
fs.writeFileSync(path.join(f.consumer.config, "secrets.enc.json"), JSON.stringify(damaged));
|