sliftutils 1.7.124 → 1.7.125
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/CLAUDE.md +3 -1
- package/bin/securessh.js +10 -0
- package/bin/setupnotify.js +9 -0
- package/bin/signfiles.js +10 -0
- package/package.json +9 -3
- package/security/README.md +77 -0
- package/security/authorizedKeys/authorizedKeys.ts +43 -0
- package/security/authorizedKeys/daemon/portsecure.service +18 -0
- package/security/authorizedKeys/daemon/portsecureDaemon.js +1032 -0
- package/security/authorizedKeys/secureSSH.ts +427 -0
- package/security/authorizedKeys/sources.ts +20 -0
- package/security/helpers/paths.ts +20 -0
- package/security/helpers/remoteSSH.ts +94 -0
- package/security/helpers/spawn.ts +32 -0
- package/security/notifications/discord.ts +190 -0
- package/security/notifications/remoteWebhook.ts +85 -0
- package/security/notifications/setupNotify.ts +29 -0
- package/security/signedFiles/manifest.ts +56 -0
- package/security/signedFiles/signFiles.ts +116 -0
- package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +17 -20
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// portsecure authorized-keys daemon.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately plain JavaScript with no dependencies beyond the Node built-ins, so it can be
|
|
7
|
+
// dropped onto any machine that has node and run with nothing installed alongside it.
|
|
8
|
+
//
|
|
9
|
+
// It owns root's authorized_keys: the contents come from a git repo, anything else is reverted,
|
|
10
|
+
// and password authentication is turned off so the repo is the only way in.
|
|
11
|
+
//
|
|
12
|
+
// The complete list of things that send a Discord message. Nothing else may be added to it
|
|
13
|
+
// without the user asking for that specific case - everything else goes to the log.
|
|
14
|
+
// 1. root's authorized_keys was changed outside portsecure, and was reverted.
|
|
15
|
+
// 2. root's authorized_keys was updated because the keys repo changed.
|
|
16
|
+
// 3. Another user's authorized_keys changed.
|
|
17
|
+
// 4. The keys repo history was rewritten.
|
|
18
|
+
// 4b. A source started being signed by a different key, so its new keys are being held.
|
|
19
|
+
// 5. The webhook file itself changed, reported to the webhook being replaced.
|
|
20
|
+
|
|
21
|
+
const fs = require("fs/promises");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const os = require("os");
|
|
24
|
+
const crypto = require("crypto");
|
|
25
|
+
const { spawn } = require("child_process");
|
|
26
|
+
|
|
27
|
+
const CONFIG_PATH = "/etc/portsecure/daemon.json";
|
|
28
|
+
const STATE_PATH = "/var/lib/portsecure/state.json";
|
|
29
|
+
// Locations the daemon owns. They are fixed rather than configurable, so every machine looks the
|
|
30
|
+
// same and the config file only carries what genuinely differs between them.
|
|
31
|
+
// One key and one checkout per source, at paths derived from the repo url rather than configured.
|
|
32
|
+
const REPO_KEYS_DIR = "/etc/portsecure/repo-keys";
|
|
33
|
+
const REPOS_DIR = "/var/lib/portsecure/authorized-keys-repos";
|
|
34
|
+
const KEYS_HISTORY_PATH = "/var/lib/portsecure/authorized-keys-history";
|
|
35
|
+
const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
|
|
36
|
+
const SSHD_CONFIG_PATH = "/etc/ssh/sshd_config";
|
|
37
|
+
const SSHD_DROPIN_DIR = "/etc/ssh/sshd_config.d";
|
|
38
|
+
const SSHD_DROPIN_PATH = "/etc/ssh/sshd_config.d/00-portsecure.conf";
|
|
39
|
+
const PASSWD_PATH = "/etc/passwd";
|
|
40
|
+
|
|
41
|
+
const KEYS_CHECK_INTERVAL = 60 * 1000;
|
|
42
|
+
const REPO_POLL_INTERVAL = 5 * 60 * 1000;
|
|
43
|
+
const WEBHOOK_CHECK_INTERVAL = 5 * 60 * 1000;
|
|
44
|
+
// After this many consecutive failures the repo is thrown away and cloned from scratch, which
|
|
45
|
+
// recovers from corruption, half finished clones and interrupted fetches.
|
|
46
|
+
const MAX_REPO_FAILURES_BEFORE_RECLONE = 3;
|
|
47
|
+
const GIT_TIMEOUT = 120 * 1000;
|
|
48
|
+
// A source that starts being signed by someone new is held at arm's length for this long, so a
|
|
49
|
+
// stolen signing key cannot push keys onto a machine before anyone notices the warning.
|
|
50
|
+
const SIGNER_CHANGE_DELAY = 24 * 60 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
// PORTED CODE: security/signedFiles/manifest.ts is the TypeScript twin of these. Both sides must
|
|
53
|
+
// agree on the manifest shape and on which files it covers.
|
|
54
|
+
const MANIFEST_NAME = "signedfiles.json";
|
|
55
|
+
const SIGNATURE_NAME = "signedfiles.json.sig";
|
|
56
|
+
const SIGN_NAMESPACE = "signfiles";
|
|
57
|
+
// A source that has never been signed reads as this, so losing a signature counts as a change of
|
|
58
|
+
// signer rather than as something to wave through.
|
|
59
|
+
const UNSIGNED = "";
|
|
60
|
+
const KEY_FILE_HEADER = "# Managed by portsecure. Manual changes are reverted and reported.";
|
|
61
|
+
|
|
62
|
+
const SSHD_DROPIN_CONTENTS = `# Managed by portsecure. Manual changes are reverted and reported.
|
|
63
|
+
# Keys come from the portsecure repo, so no other authentication method may be used.
|
|
64
|
+
PasswordAuthentication no
|
|
65
|
+
PermitEmptyPasswords no
|
|
66
|
+
KbdInteractiveAuthentication no
|
|
67
|
+
ChallengeResponseAuthentication no
|
|
68
|
+
PubkeyAuthentication yes
|
|
69
|
+
PermitRootLogin prohibit-password
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
// PORTED CODE: security/authorizedKeys/sources.ts is the TypeScript twin of these three, used by securessh.
|
|
74
|
+
// Both sides must derive identical paths from a repo url.
|
|
75
|
+
function sourceName(repoURL) {
|
|
76
|
+
return repoURL.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sourceKeyPath(repoURL) {
|
|
80
|
+
return `${REPO_KEYS_DIR}/${sourceName(repoURL)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sourceRepoPath(repoURL) {
|
|
84
|
+
return `${REPOS_DIR}/${sourceName(repoURL)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Everything here is async on purpose: a daemon that blocks its event loop on disk or on a git
|
|
88
|
+
fetch stops answering everything else while it waits. */
|
|
89
|
+
async function pathExists(filePath) {
|
|
90
|
+
try {
|
|
91
|
+
await fs.access(filePath);
|
|
92
|
+
return true;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Argument list rather than a shell string, so hostnames and paths can never be parsed as shell
|
|
99
|
+
syntax. Resolves with the exit code instead of throwing, callers decide what a failure means. */
|
|
100
|
+
function spawnPromise(config) {
|
|
101
|
+
let { command, args, cwd, input, timeoutTime } = config;
|
|
102
|
+
return new Promise(resolve => {
|
|
103
|
+
let child = spawn(command, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
104
|
+
let stdout = "";
|
|
105
|
+
let stderr = "";
|
|
106
|
+
let timer = undefined;
|
|
107
|
+
let finish = result => {
|
|
108
|
+
if (timer) {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
timer = undefined;
|
|
111
|
+
}
|
|
112
|
+
resolve(result);
|
|
113
|
+
};
|
|
114
|
+
if (timeoutTime) {
|
|
115
|
+
timer = setTimeout(() => child.kill("SIGKILL"), timeoutTime);
|
|
116
|
+
}
|
|
117
|
+
child.stdout.on("data", chunk => stdout += chunk);
|
|
118
|
+
child.stderr.on("data", chunk => stderr += chunk);
|
|
119
|
+
child.on("error", e => finish({ stdout, stderr, status: undefined, error: e }));
|
|
120
|
+
child.on("close", code => finish({ stdout, stderr, status: code, error: undefined }));
|
|
121
|
+
child.stdin.on("error", () => undefined);
|
|
122
|
+
child.stdin.end(input || "");
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------------------------
|
|
127
|
+
// Discord notifications
|
|
128
|
+
//
|
|
129
|
+
// PORTED CODE: this section is a hand port of security/notifications/discord.ts, kept plain JS so the
|
|
130
|
+
// daemon stays dependency free. The two are expected to behave identically - if you change one,
|
|
131
|
+
// make the matching change in the other.
|
|
132
|
+
// ---------------------------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
const DEFAULT_WEBHOOK_FILE_PATH = "/etc/portsecure/discord-webhook";
|
|
135
|
+
const VALID_WEBHOOK_PREFIXES = [
|
|
136
|
+
"https://discord.com/api/webhooks/",
|
|
137
|
+
"https://discordapp.com/api/webhooks/",
|
|
138
|
+
"https://canary.discord.com/api/webhooks/",
|
|
139
|
+
];
|
|
140
|
+
const DISCORD_MESSAGE_LIMIT = 2000;
|
|
141
|
+
const MAX_ERROR_BODY_LENGTH = 500;
|
|
142
|
+
const MAX_SEND_ATTEMPTS = 3;
|
|
143
|
+
const DEFAULT_RATE_LIMIT_WAIT = 2 * 1000;
|
|
144
|
+
const REDACTED_TOKEN_VISIBLE = 8;
|
|
145
|
+
|
|
146
|
+
let notificationState = undefined;
|
|
147
|
+
let sendChain = Promise.resolve();
|
|
148
|
+
|
|
149
|
+
function parseWebhookFile(config) {
|
|
150
|
+
let { contents, sourceName } = config;
|
|
151
|
+
let lines = contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
|
|
152
|
+
let webhookURL = lines[0];
|
|
153
|
+
if (!webhookURL) {
|
|
154
|
+
throw new Error(`Expected a Discord webhook URL in ${sourceName}, the file has no usable lines`);
|
|
155
|
+
}
|
|
156
|
+
if (!VALID_WEBHOOK_PREFIXES.some(prefix => webhookURL.startsWith(prefix))) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Expected a Discord webhook URL starting with one of ${VALID_WEBHOOK_PREFIXES.join(", ")}, `
|
|
159
|
+
+ `was ${webhookURL.slice(0, MAX_ERROR_BODY_LENGTH)} (in ${sourceName})`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return webhookURL;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function redactWebhookURL(webhookURL) {
|
|
166
|
+
let separatorIndex = webhookURL.lastIndexOf("/");
|
|
167
|
+
let base = webhookURL.slice(0, separatorIndex + 1);
|
|
168
|
+
let token = webhookURL.slice(separatorIndex + 1);
|
|
169
|
+
if (token.length <= REDACTED_TOKEN_VISIBLE * 2) {
|
|
170
|
+
return `${base}${token.slice(0, REDACTED_TOKEN_VISIBLE)}...`;
|
|
171
|
+
}
|
|
172
|
+
return `${base}${token.slice(0, REDACTED_TOKEN_VISIBLE)}...${token.slice(-REDACTED_TOKEN_VISIBLE)}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function readWebhookFile(filePath) {
|
|
176
|
+
if (!await pathExists(filePath)) {
|
|
177
|
+
throw new Error(`Expected a Discord webhook file at ${filePath}, no such file exists`);
|
|
178
|
+
}
|
|
179
|
+
return parseWebhookFile({ contents: await fs.readFile(filePath, "utf8"), sourceName: filePath });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function postToWebhook(webhookURL, message) {
|
|
183
|
+
let content = message;
|
|
184
|
+
if (content.length > DISCORD_MESSAGE_LIMIT) {
|
|
185
|
+
content = content.slice(0, DISCORD_MESSAGE_LIMIT - 3) + "...";
|
|
186
|
+
}
|
|
187
|
+
for (let attempt = 1; attempt <= MAX_SEND_ATTEMPTS; attempt++) {
|
|
188
|
+
let response = await fetch(webhookURL, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify({ content }),
|
|
192
|
+
});
|
|
193
|
+
if (response.ok) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
let body = (await response.text()).slice(0, MAX_ERROR_BODY_LENGTH);
|
|
197
|
+
if (response.status === 429 && attempt < MAX_SEND_ATTEMPTS) {
|
|
198
|
+
let retryAfter = Number(response.headers.get("retry-after"));
|
|
199
|
+
let waitTime = retryAfter && retryAfter * 1000 || DEFAULT_RATE_LIMIT_WAIT;
|
|
200
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Expected a 2xx response from the Discord webhook, was ${response.status} ${response.statusText}, body ${body}`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function queueSend(webhookURL, message) {
|
|
210
|
+
let send = () => postToWebhook(webhookURL, message);
|
|
211
|
+
let result = sendChain.then(send, send);
|
|
212
|
+
sendChain = result.catch(() => undefined);
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function configureDiscordNotifications(config) {
|
|
217
|
+
let filePath = config && config.filePath || DEFAULT_WEBHOOK_FILE_PATH;
|
|
218
|
+
let webhookURL;
|
|
219
|
+
try {
|
|
220
|
+
webhookURL = await readWebhookFile(filePath);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
console.error(`portsecure: refusing to start without a valid Discord webhook file.\n${e}`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
notificationState = { filePath, webhookURL };
|
|
226
|
+
return { filePath };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function sendDiscordNotification(message) {
|
|
230
|
+
if (!notificationState) {
|
|
231
|
+
throw new Error(`Expected configureDiscordNotifications to be called before sending, was called with message ${message.slice(0, MAX_ERROR_BODY_LENGTH)}`);
|
|
232
|
+
}
|
|
233
|
+
await queueSend(notificationState.webhookURL, message);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function checkWebhookFileChanged() {
|
|
237
|
+
if (!notificationState) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
let state = notificationState;
|
|
241
|
+
let newWebhookURL;
|
|
242
|
+
try {
|
|
243
|
+
newWebhookURL = await readWebhookFile(state.filePath);
|
|
244
|
+
} catch (e) {
|
|
245
|
+
log(`Discord webhook file is no longer readable, still using the loaded webhook. ${e}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (newWebhookURL === state.webhookURL) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
await queueSend(
|
|
253
|
+
state.webhookURL,
|
|
254
|
+
`${hostLabel()} the Discord webhook in \`${state.filePath}\` changed to`
|
|
255
|
+
+ ` \`${redactWebhookURL(newWebhookURL)}\`.`
|
|
256
|
+
+ ` Notifications are moving to the new webhook and this channel will stop receiving them.`
|
|
257
|
+
);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
log(`Failed to warn the old Discord webhook about the change. ${e}`);
|
|
260
|
+
}
|
|
261
|
+
state.webhookURL = newWebhookURL;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------------------------
|
|
265
|
+
// End of ported Discord code.
|
|
266
|
+
// ---------------------------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
let config = undefined;
|
|
269
|
+
let state = { sources: {}, userKeyHashes: {} };
|
|
270
|
+
let repoFailureCounts = {};
|
|
271
|
+
|
|
272
|
+
function log(message) {
|
|
273
|
+
console.log(`${new Date().toISOString()} portsecure: ${message}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function hostLabel() {
|
|
277
|
+
return `**portsecure [${config && config.hostLabel || os.hostname()}]**:`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// DO NOT add new calls to this. Every message goes to a real Discord server someone reads, so a
|
|
281
|
+
// notification is only ever added when the user explicitly asks for that specific case. Startup,
|
|
282
|
+
// success, errors, retries and recoveries all belong in log() instead. The complete list of cases
|
|
283
|
+
// that are allowed to notify is at the top of this file.
|
|
284
|
+
async function notify(message) {
|
|
285
|
+
try {
|
|
286
|
+
await sendDiscordNotification(`${hostLabel()} ${message}`);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
// A failed notification must never take the daemon down, the local log is the fallback.
|
|
289
|
+
log(`Failed to send Discord notification. ${e}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function loadConfig() {
|
|
294
|
+
if (!await pathExists(CONFIG_PATH)) {
|
|
295
|
+
console.error(`portsecure: expected a config file at ${CONFIG_PATH}, no such file exists`);
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
let parsed = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8"));
|
|
299
|
+
if (!Array.isArray(parsed.repoSources)) {
|
|
300
|
+
console.error(`portsecure: expected a repoSources array in ${CONFIG_PATH}, was ${JSON.stringify(parsed.repoSources)}`);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
for (let repoURL of parsed.repoSources) {
|
|
304
|
+
if (!await pathExists(sourceKeyPath(repoURL))) {
|
|
305
|
+
console.error(`portsecure: expected the private key for ${repoURL} at ${sourceKeyPath(repoURL)}, no such file exists`);
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
repoSources: parsed.repoSources,
|
|
311
|
+
// The machine knows its own name, the config only overrides it when a nicer label helps.
|
|
312
|
+
hostLabel: parsed.hostLabel || os.hostname(),
|
|
313
|
+
webhookPath: DEFAULT_WEBHOOK_FILE_PATH,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Per source progress, created on first use so a newly added source starts clean. */
|
|
318
|
+
function sourceState(repoURL) {
|
|
319
|
+
let existing = state.sources[repoURL];
|
|
320
|
+
if (existing) {
|
|
321
|
+
return existing;
|
|
322
|
+
}
|
|
323
|
+
let created = {
|
|
324
|
+
lastSha: "",
|
|
325
|
+
branch: "",
|
|
326
|
+
// What we last decided to trust, kept on disk so nobody can tell us a different story
|
|
327
|
+
// about what we saw last time.
|
|
328
|
+
accepted: false,
|
|
329
|
+
acceptedSigner: UNSIGNED,
|
|
330
|
+
acceptedKeys: [],
|
|
331
|
+
// A signer we have seen but not accepted yet, and when we first saw it.
|
|
332
|
+
pendingSigner: UNSIGNED,
|
|
333
|
+
pendingSince: 0,
|
|
334
|
+
};
|
|
335
|
+
state.sources[repoURL] = created;
|
|
336
|
+
return created;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function loadState() {
|
|
340
|
+
if (!await pathExists(STATE_PATH)) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
let loaded = JSON.parse(await fs.readFile(STATE_PATH, "utf8"));
|
|
345
|
+
state = Object.assign(state, loaded);
|
|
346
|
+
} catch (e) {
|
|
347
|
+
// Corrupt state only costs us one duplicate notification, so it is not worth failing over.
|
|
348
|
+
log(`Ignoring unreadable state file ${STATE_PATH}. ${e}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function saveState() {
|
|
353
|
+
await fs.mkdir(path.dirname(STATE_PATH), { recursive: true });
|
|
354
|
+
await fs.writeFile(STATE_PATH, JSON.stringify(state, undefined, 4), { mode: 0o600 });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function runGit(args, options) {
|
|
358
|
+
let cwd = options && options.cwd;
|
|
359
|
+
let keyPath = options && options.keyPath;
|
|
360
|
+
// core.sshCommand keeps the key selection with the command instead of in the environment.
|
|
361
|
+
let sshCommand = `ssh -i ${keyPath} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
|
|
362
|
+
let result = await spawnPromise({
|
|
363
|
+
command: "git",
|
|
364
|
+
args: ["-c", `core.sshCommand=${sshCommand}`, ...args],
|
|
365
|
+
cwd,
|
|
366
|
+
timeoutTime: GIT_TIMEOUT,
|
|
367
|
+
});
|
|
368
|
+
if (result.error) {
|
|
369
|
+
throw new Error(`Expected git ${args.join(" ")} to run, failed with ${result.error.message}`);
|
|
370
|
+
}
|
|
371
|
+
if (result.status !== 0) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`Expected git ${args.join(" ")} to exit 0, was ${result.status}. `
|
|
374
|
+
+ `${(result.stderr || "").slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
return result.stdout.trim();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function repoIsUsable(repoURL) {
|
|
381
|
+
let repoPath = sourceRepoPath(repoURL);
|
|
382
|
+
if (!await pathExists(path.join(repoPath, ".git"))) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
await runGit(["rev-parse", "--git-dir"], { cwd: repoPath, keyPath: sourceKeyPath(repoURL) });
|
|
387
|
+
return true;
|
|
388
|
+
} catch (e) {
|
|
389
|
+
log(`Repo at ${repoPath} is not usable. ${e}`);
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function cloneRepo(repoURL) {
|
|
395
|
+
let repoPath = sourceRepoPath(repoURL);
|
|
396
|
+
let keyPath = sourceKeyPath(repoURL);
|
|
397
|
+
await fs.rm(repoPath, { recursive: true, force: true });
|
|
398
|
+
await fs.mkdir(path.dirname(repoPath), { recursive: true });
|
|
399
|
+
await runGit(["clone", repoURL, repoPath], { keyPath });
|
|
400
|
+
sourceState(repoURL).branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoPath, keyPath });
|
|
401
|
+
log(`Cloned ${repoURL} into ${repoPath} on branch ${sourceState(repoURL).branch}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function ensureRepo(repoURL) {
|
|
405
|
+
if (await repoIsUsable(repoURL)) {
|
|
406
|
+
if (!sourceState(repoURL).branch) {
|
|
407
|
+
sourceState(repoURL).branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
408
|
+
cwd: sourceRepoPath(repoURL),
|
|
409
|
+
keyPath: sourceKeyPath(repoURL),
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
await cloneRepo(repoURL);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Returns what changed, so the caller can report it. A rewritten history is called out
|
|
418
|
+
separately - it means the remote no longer contains the commits we already had. */
|
|
419
|
+
async function syncRepo(repoURL) {
|
|
420
|
+
await ensureRepo(repoURL);
|
|
421
|
+
let repoPath = sourceRepoPath(repoURL);
|
|
422
|
+
let keyPath = sourceKeyPath(repoURL);
|
|
423
|
+
let branch = sourceState(repoURL).branch;
|
|
424
|
+
await runGit(["fetch", "--prune", "origin", branch], { cwd: repoPath, keyPath });
|
|
425
|
+
let remoteSha = await runGit(["rev-parse", `origin/${branch}`], { cwd: repoPath, keyPath });
|
|
426
|
+
let localSha = await runGit(["rev-parse", "HEAD"], { cwd: repoPath, keyPath });
|
|
427
|
+
if (remoteSha === localSha && remoteSha === sourceState(repoURL).lastSha) {
|
|
428
|
+
return { changed: false, historyRewritten: false, remoteSha, previousSha: localSha };
|
|
429
|
+
}
|
|
430
|
+
let previousSha = sourceState(repoURL).lastSha || localSha;
|
|
431
|
+
let historyRewritten = false;
|
|
432
|
+
if (previousSha && previousSha !== remoteSha) {
|
|
433
|
+
// If what we already had is no longer an ancestor of the remote tip, commits were removed
|
|
434
|
+
// or rewritten rather than added.
|
|
435
|
+
let ancestry = await spawnPromise({
|
|
436
|
+
command: "git",
|
|
437
|
+
args: ["merge-base", "--is-ancestor", previousSha, remoteSha],
|
|
438
|
+
cwd: repoPath,
|
|
439
|
+
timeoutTime: GIT_TIMEOUT,
|
|
440
|
+
});
|
|
441
|
+
historyRewritten = ancestry.status !== 0;
|
|
442
|
+
}
|
|
443
|
+
await runGit(["reset", "--hard", `origin/${branch}`], { cwd: repoPath, keyPath });
|
|
444
|
+
await runGit(["clean", "-fdx"], { cwd: repoPath, keyPath });
|
|
445
|
+
return { changed: true, historyRewritten, remoteSha, previousSha };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function normalizeKeys(contents) {
|
|
449
|
+
return contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Enough to identify a key in a notification without printing the whole blob. */
|
|
453
|
+
function summarizeKey(keyLine) {
|
|
454
|
+
let parts = keyLine.trim().split(/\s+/);
|
|
455
|
+
let typeIndex = parts.findIndex(part => /^(ssh-|ecdsa-|sk-)/.test(part));
|
|
456
|
+
if (typeIndex < 0) {
|
|
457
|
+
return keyLine.slice(0, 60);
|
|
458
|
+
}
|
|
459
|
+
let type = parts[typeIndex];
|
|
460
|
+
let blob = parts[typeIndex + 1] || "";
|
|
461
|
+
let comment = parts.slice(typeIndex + 2).join(" ");
|
|
462
|
+
return `${type} ...${blob.slice(-12)}${comment && ` ${comment}` || ""}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function describeKeyDifference(config) {
|
|
466
|
+
let { before, after } = config;
|
|
467
|
+
let added = after.filter(key => !before.includes(key));
|
|
468
|
+
let removed = before.filter(key => !after.includes(key));
|
|
469
|
+
let lines = [];
|
|
470
|
+
for (let key of added) {
|
|
471
|
+
lines.push(`+ ${summarizeKey(key)}`);
|
|
472
|
+
}
|
|
473
|
+
for (let key of removed) {
|
|
474
|
+
lines.push(`- ${summarizeKey(key)}`);
|
|
475
|
+
}
|
|
476
|
+
if (!lines.length) {
|
|
477
|
+
return "(no key lines differ)";
|
|
478
|
+
}
|
|
479
|
+
return lines.join("\n");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Reads one checkout's keys. Prefers a top level authorized_keys file and otherwise concatenates
|
|
483
|
+
every .pub at the top level. */
|
|
484
|
+
async function readCheckoutKeys(repoPath) {
|
|
485
|
+
let combinedPath = path.join(repoPath, "authorized_keys");
|
|
486
|
+
if (await pathExists(combinedPath)) {
|
|
487
|
+
return normalizeKeys(await fs.readFile(combinedPath, "utf8"));
|
|
488
|
+
}
|
|
489
|
+
let pubFiles = (await fs.readdir(repoPath)).filter(name => name.endsWith(".pub")).sort();
|
|
490
|
+
if (!pubFiles.length) {
|
|
491
|
+
throw new Error(`Expected authorized_keys or at least one .pub file in ${repoPath}, found neither`);
|
|
492
|
+
}
|
|
493
|
+
let keys = [];
|
|
494
|
+
for (let name of pubFiles) {
|
|
495
|
+
keys.push(...normalizeKeys(await fs.readFile(path.join(repoPath, name), "utf8")));
|
|
496
|
+
}
|
|
497
|
+
return keys;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
/** Every file in a checkout, other than git's own directory and the signature files, which cannot
|
|
502
|
+
describe themselves. */
|
|
503
|
+
async function listCheckoutFiles(repoPath, prefix) {
|
|
504
|
+
let files = [];
|
|
505
|
+
for (let entry of await fs.readdir(path.join(repoPath, prefix || ""), { withFileTypes: true })) {
|
|
506
|
+
let relativePath = prefix && `${prefix}/${entry.name}` || entry.name;
|
|
507
|
+
if (entry.name === ".git") {
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (!prefix && (entry.name === MANIFEST_NAME || entry.name === SIGNATURE_NAME)) {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (entry.isDirectory()) {
|
|
514
|
+
files.push(...await listCheckoutFiles(repoPath, relativePath));
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
files.push(relativePath);
|
|
518
|
+
}
|
|
519
|
+
return files.sort();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The signature only covers the manifest, so the manifest has to be checked against what is
|
|
523
|
+
actually on disk. Both directions matter: a missing file changes what the keys mean, and an
|
|
524
|
+
extra unlisted file could add keys nobody signed for. */
|
|
525
|
+
async function verifyManifestMatchesFiles(repoPath) {
|
|
526
|
+
let manifest = JSON.parse(await fs.readFile(path.join(repoPath, MANIFEST_NAME), "utf8"));
|
|
527
|
+
let listed = new Map((manifest.files || []).map(file => [file.path, file]));
|
|
528
|
+
let actual = await listCheckoutFiles(repoPath);
|
|
529
|
+
|
|
530
|
+
let missing = [...listed.keys()].filter(filePath => !actual.includes(filePath));
|
|
531
|
+
if (missing.length) {
|
|
532
|
+
throw new Error(`Expected the signed files to be present, ${missing.length} missing, first ${missing[0]}`);
|
|
533
|
+
}
|
|
534
|
+
let extra = actual.filter(filePath => !listed.has(filePath));
|
|
535
|
+
if (extra.length) {
|
|
536
|
+
throw new Error(`Expected only signed files to be present, ${extra.length} extra, first ${extra[0]}`);
|
|
537
|
+
}
|
|
538
|
+
for (let filePath of actual) {
|
|
539
|
+
let expected = listed.get(filePath);
|
|
540
|
+
let fullPath = path.join(repoPath, filePath);
|
|
541
|
+
let stats = await fs.stat(fullPath);
|
|
542
|
+
if (stats.size !== expected.size) {
|
|
543
|
+
throw new Error(`Expected ${filePath} to be ${expected.size} bytes, was ${stats.size}`);
|
|
544
|
+
}
|
|
545
|
+
let hash = crypto.createHash("sha256").update(await fs.readFile(fullPath)).digest("hex");
|
|
546
|
+
if (hash !== expected.sha256) {
|
|
547
|
+
throw new Error(`Expected ${filePath} to hash to ${expected.sha256}, was ${hash}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Who signed this checkout. Returns UNSIGNED when there is no signature at all, and throws when
|
|
553
|
+
there is one that does not hold up - an unverifiable signature is never treated as an identity,
|
|
554
|
+
so it can never become something we accept. */
|
|
555
|
+
async function readCheckoutSigner(repoPath) {
|
|
556
|
+
let manifestPath = path.join(repoPath, MANIFEST_NAME);
|
|
557
|
+
let signaturePath = path.join(repoPath, SIGNATURE_NAME);
|
|
558
|
+
let hasManifest = await pathExists(manifestPath);
|
|
559
|
+
let hasSignature = await pathExists(signaturePath);
|
|
560
|
+
if (!hasManifest && !hasSignature) {
|
|
561
|
+
return UNSIGNED;
|
|
562
|
+
}
|
|
563
|
+
if (!hasManifest || !hasSignature) {
|
|
564
|
+
throw new Error(`Expected both ${MANIFEST_NAME} and ${SIGNATURE_NAME}, only one is present`);
|
|
565
|
+
}
|
|
566
|
+
let result = await spawnPromise({
|
|
567
|
+
command: "ssh-keygen",
|
|
568
|
+
args: ["-Y", "check-novalidate", "-n", SIGN_NAMESPACE, "-s", signaturePath],
|
|
569
|
+
input: await fs.readFile(manifestPath, "utf8"),
|
|
570
|
+
});
|
|
571
|
+
if (result.status !== 0) {
|
|
572
|
+
throw new Error(`Expected a valid signature over ${MANIFEST_NAME}, ssh-keygen said ${(result.stderr || "").trim().slice(0, MAX_ERROR_BODY_LENGTH)}`);
|
|
573
|
+
}
|
|
574
|
+
let match = `${result.stdout} ${result.stderr}`.match(/(SHA256:[A-Za-z0-9+/=]+)/);
|
|
575
|
+
if (!match) {
|
|
576
|
+
throw new Error(`Expected a signer fingerprint from ssh-keygen, was ${result.stdout.slice(0, MAX_ERROR_BODY_LENGTH)}`);
|
|
577
|
+
}
|
|
578
|
+
await verifyManifestMatchesFiles(repoPath);
|
|
579
|
+
return match[1];
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function describeSigner(signer) {
|
|
583
|
+
return signer === UNSIGNED && "unsigned" || signer;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The keys a source is allowed to contribute right now. A source signed by someone we have not
|
|
587
|
+
accepted keeps contributing the keys we last accepted, until the delay has passed. */
|
|
588
|
+
async function resolveSourceKeys(repoURL) {
|
|
589
|
+
let sourceStateValue = sourceState(repoURL);
|
|
590
|
+
let repoPath = sourceRepoPath(repoURL);
|
|
591
|
+
|
|
592
|
+
let signer;
|
|
593
|
+
try {
|
|
594
|
+
signer = await readCheckoutSigner(repoPath);
|
|
595
|
+
} catch (e) {
|
|
596
|
+
// Nothing here is trustworthy, so nothing here is used.
|
|
597
|
+
log(`Ignoring the contents of ${repoURL}, its signature does not hold up. ${e}`);
|
|
598
|
+
return sourceStateValue.acceptedKeys;
|
|
599
|
+
}
|
|
600
|
+
let checkoutKeys = await readCheckoutKeys(repoPath);
|
|
601
|
+
|
|
602
|
+
// Nothing has ever been accepted from this source, so this is what we start trusting.
|
|
603
|
+
if (!sourceStateValue.accepted) {
|
|
604
|
+
sourceStateValue.accepted = true;
|
|
605
|
+
sourceStateValue.acceptedSigner = signer;
|
|
606
|
+
sourceStateValue.acceptedKeys = checkoutKeys;
|
|
607
|
+
sourceStateValue.pendingSigner = UNSIGNED;
|
|
608
|
+
sourceStateValue.pendingSince = 0;
|
|
609
|
+
log(`Trusting ${repoURL} as signed by ${describeSigner(signer)}`);
|
|
610
|
+
await saveState();
|
|
611
|
+
return checkoutKeys;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (signer === sourceStateValue.acceptedSigner) {
|
|
615
|
+
// Back to the signer we already trust, so anything we were waiting on is moot.
|
|
616
|
+
if (sourceStateValue.pendingSince) {
|
|
617
|
+
log(`${repoURL} is signed by ${describeSigner(signer)} again, dropping the pending change`);
|
|
618
|
+
sourceStateValue.pendingSigner = UNSIGNED;
|
|
619
|
+
sourceStateValue.pendingSince = 0;
|
|
620
|
+
}
|
|
621
|
+
sourceStateValue.acceptedKeys = checkoutKeys;
|
|
622
|
+
await saveState();
|
|
623
|
+
return checkoutKeys;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// A signer we have not accepted. Anything new restarts the wait, so publishing twice in a row
|
|
627
|
+
// gains an attacker nothing. pendingSince is what marks a wait as running, because an unsigned
|
|
628
|
+
// checkout is itself a signer value and cannot double as "nothing pending".
|
|
629
|
+
if (!sourceStateValue.pendingSince || signer !== sourceStateValue.pendingSigner) {
|
|
630
|
+
sourceStateValue.pendingSigner = signer;
|
|
631
|
+
sourceStateValue.pendingSince = Date.now();
|
|
632
|
+
await saveState();
|
|
633
|
+
await notify(
|
|
634
|
+
`\`${repoURL}\` is now signed by ${describeSigner(signer)}, which last signed as`
|
|
635
|
+
+ ` ${describeSigner(sourceStateValue.acceptedSigner)}. Its keys are NOT being applied.`
|
|
636
|
+
+ ` If nothing changes they will be applied in 24 hours.`
|
|
637
|
+
);
|
|
638
|
+
return sourceStateValue.acceptedKeys;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
let waited = Date.now() - sourceStateValue.pendingSince;
|
|
642
|
+
if (waited < SIGNER_CHANGE_DELAY) {
|
|
643
|
+
return sourceStateValue.acceptedKeys;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Same new signer, 24 hours later, and nobody stopped it.
|
|
647
|
+
log(`Accepting ${describeSigner(signer)} for ${repoURL} after the ${SIGNER_CHANGE_DELAY}ms wait`);
|
|
648
|
+
sourceStateValue.accepted = true;
|
|
649
|
+
sourceStateValue.acceptedSigner = signer;
|
|
650
|
+
sourceStateValue.acceptedKeys = checkoutKeys;
|
|
651
|
+
sourceStateValue.pendingSigner = UNSIGNED;
|
|
652
|
+
sourceStateValue.pendingSince = 0;
|
|
653
|
+
await saveState();
|
|
654
|
+
return checkoutKeys;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** The union of every source, in source order, with duplicates dropped. A source that cannot be
|
|
658
|
+
read is skipped rather than emptying the merged set, so one broken repo cannot revoke the keys
|
|
659
|
+
that came from the others. */
|
|
660
|
+
async function readRepoKeys() {
|
|
661
|
+
let keys = [];
|
|
662
|
+
let seen = new Set();
|
|
663
|
+
for (let repoURL of config.repoSources) {
|
|
664
|
+
let sourceKeys;
|
|
665
|
+
try {
|
|
666
|
+
sourceKeys = await resolveSourceKeys(repoURL);
|
|
667
|
+
} catch (e) {
|
|
668
|
+
log(`Skipping ${repoURL}, its checkout could not be read. ${e}`);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
for (let key of sourceKeys) {
|
|
672
|
+
if (seen.has(key)) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
seen.add(key);
|
|
676
|
+
keys.push(key);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return keys;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function readAuthorizedKeysFile(filePath) {
|
|
683
|
+
if (!await pathExists(filePath)) {
|
|
684
|
+
return [];
|
|
685
|
+
}
|
|
686
|
+
return normalizeKeys(await fs.readFile(filePath, "utf8"));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function writeAuthorizedKeysFile(config) {
|
|
690
|
+
let { filePath, keys } = config;
|
|
691
|
+
let directory = path.dirname(filePath);
|
|
692
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
693
|
+
await fs.chmod(directory, 0o700);
|
|
694
|
+
// Written to a temporary file first, so an interrupted write can never leave root with a
|
|
695
|
+
// truncated authorized_keys and no way back in.
|
|
696
|
+
let temporaryPath = `${filePath}.portsecure-tmp`;
|
|
697
|
+
await fs.writeFile(temporaryPath, `${KEY_FILE_HEADER}\n${keys.join("\n")}\n`, { mode: 0o600 });
|
|
698
|
+
await fs.rename(temporaryPath, filePath);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Keeps a copy of whatever is about to be overwritten, named for the moment it was replaced.
|
|
702
|
+
Recovers a key that was clobbered by mistake, and doubles as the history of who had access.
|
|
703
|
+
The very first archive is the most valuable one, since it holds the keys from before portsecure
|
|
704
|
+
took the file over. */
|
|
705
|
+
async function archiveAuthorizedKeys(config) {
|
|
706
|
+
let { filePath, reason } = config;
|
|
707
|
+
if (!await pathExists(filePath)) {
|
|
708
|
+
return "";
|
|
709
|
+
}
|
|
710
|
+
let contents = await fs.readFile(filePath, "utf8");
|
|
711
|
+
await fs.mkdir(KEYS_HISTORY_PATH, { recursive: true, mode: 0o700 });
|
|
712
|
+
await fs.chmod(KEYS_HISTORY_PATH, 0o700);
|
|
713
|
+
let stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
714
|
+
let archivePath = path.join(KEYS_HISTORY_PATH, `${stamp}-${reason}.authorized_keys`);
|
|
715
|
+
let attempt = 1;
|
|
716
|
+
while (await pathExists(archivePath)) {
|
|
717
|
+
attempt++;
|
|
718
|
+
archivePath = path.join(KEYS_HISTORY_PATH, `${stamp}-${reason}-${attempt}.authorized_keys`);
|
|
719
|
+
}
|
|
720
|
+
await fs.writeFile(archivePath, contents, { mode: 0o600 });
|
|
721
|
+
return archivePath;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Puts the repo's keys back in place if anything else changed them. */
|
|
725
|
+
async function enforceRootKeys(options) {
|
|
726
|
+
let repoKeys = await readRepoKeys();
|
|
727
|
+
if (!repoKeys.length) {
|
|
728
|
+
// No sources, or none of them readable. Writing an empty file would lock everyone out, so
|
|
729
|
+
// whatever access is already in place stays exactly as it is.
|
|
730
|
+
log(`No keys came from any source, leaving ${ROOT_AUTHORIZED_KEYS} as it is`);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
let currentKeys = await readAuthorizedKeysFile(ROOT_AUTHORIZED_KEYS);
|
|
735
|
+
let matches = currentKeys.length === repoKeys.length && currentKeys.every((key, index) => key === repoKeys[index]);
|
|
736
|
+
if (matches) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
let reason = options && options.reason || "manual";
|
|
740
|
+
let archivePath = await archiveAuthorizedKeys({ filePath: ROOT_AUTHORIZED_KEYS, reason });
|
|
741
|
+
await writeAuthorizedKeysFile({ filePath: ROOT_AUTHORIZED_KEYS, keys: repoKeys });
|
|
742
|
+
let difference = describeKeyDifference({ before: currentKeys, after: repoKeys });
|
|
743
|
+
let archiveNote = archivePath && `\nThe previous file is kept at \`${archivePath}\`.` || "";
|
|
744
|
+
if (reason === "repo") {
|
|
745
|
+
await notify(`root's authorized_keys was updated from the keys repo.\n\`\`\`\n${difference}\n\`\`\`${archiveNote}`);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
await notify(
|
|
749
|
+
`root's authorized_keys was changed outside portsecure and has been reverted to the keys repo.`
|
|
750
|
+
+ `\n\`\`\`\n${difference}\n\`\`\`${archiveNote}`
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function listUserAuthorizedKeyFiles() {
|
|
755
|
+
let entries = [];
|
|
756
|
+
let passwd = await fs.readFile(PASSWD_PATH, "utf8");
|
|
757
|
+
for (let line of passwd.split("\n")) {
|
|
758
|
+
let fields = line.split(":");
|
|
759
|
+
if (fields.length < 7) {
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
let [name, , , , , home] = fields;
|
|
763
|
+
if (!home || !await pathExists(home)) {
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
entries.push({ name, filePath: path.join(home, ".ssh", "authorized_keys") });
|
|
767
|
+
}
|
|
768
|
+
return entries;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Root is enforced elsewhere, every other account is watched and reported on. */
|
|
772
|
+
async function checkOtherUserKeys() {
|
|
773
|
+
let hashes = {};
|
|
774
|
+
for (let entry of await listUserAuthorizedKeyFiles()) {
|
|
775
|
+
if (entry.filePath === ROOT_AUTHORIZED_KEYS) {
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
let keys = await readAuthorizedKeysFile(entry.filePath);
|
|
779
|
+
let hash = crypto.createHash("sha256").update(keys.join("\n")).digest("hex");
|
|
780
|
+
hashes[entry.name] = hash;
|
|
781
|
+
let previousHash = state.userKeyHashes[entry.name];
|
|
782
|
+
if (previousHash === undefined) {
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (previousHash === hash) {
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
await notify(
|
|
789
|
+
`authorized_keys for user \`${entry.name}\` changed (\`${entry.filePath}\`).`
|
|
790
|
+
+ ` portsecure does not manage this account, so the change was left in place.`
|
|
791
|
+
+ `\n\`\`\`\n${keys.map(summarizeKey).join("\n") || "(now empty)"}\n\`\`\``
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
state.userKeyHashes = hashes;
|
|
795
|
+
await saveState();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function sshdConfigIncludesDropinDir() {
|
|
799
|
+
let contents = await fs.readFile(SSHD_CONFIG_PATH, "utf8");
|
|
800
|
+
return contents.split("\n").some(line => {
|
|
801
|
+
let trimmed = line.trim();
|
|
802
|
+
return trimmed.startsWith("Include") && trimmed.includes("sshd_config.d");
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function restartSSHD() {
|
|
807
|
+
for (let unit of ["ssh", "sshd"]) {
|
|
808
|
+
let result = await spawnPromise({ command: "systemctl", args: ["reload-or-restart", unit] });
|
|
809
|
+
if (result.status === 0) {
|
|
810
|
+
log(`Reloaded ${unit}`);
|
|
811
|
+
return unit;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
throw new Error(`Expected to reload ssh or sshd, neither unit could be reloaded`);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Turns off every non key based way in. Validates before reloading, because a bad sshd config
|
|
818
|
+
that gets applied is exactly how a machine becomes unreachable. */
|
|
819
|
+
async function enforceSSHDConfig() {
|
|
820
|
+
let existing = "";
|
|
821
|
+
if (await pathExists(SSHD_DROPIN_PATH)) {
|
|
822
|
+
existing = await fs.readFile(SSHD_DROPIN_PATH, "utf8");
|
|
823
|
+
}
|
|
824
|
+
let includeMissing = !await sshdConfigIncludesDropinDir();
|
|
825
|
+
if (existing === SSHD_DROPIN_CONTENTS && !includeMissing) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
let originalConfig = await fs.readFile(SSHD_CONFIG_PATH, "utf8");
|
|
830
|
+
await fs.mkdir(SSHD_DROPIN_DIR, { recursive: true });
|
|
831
|
+
await fs.writeFile(SSHD_DROPIN_PATH, SSHD_DROPIN_CONTENTS, { mode: 0o644 });
|
|
832
|
+
if (includeMissing) {
|
|
833
|
+
// sshd takes the first value it sees for most keywords, so the include has to come before
|
|
834
|
+
// any setting it is meant to override.
|
|
835
|
+
await fs.writeFile(SSHD_CONFIG_PATH, `Include ${SSHD_DROPIN_DIR}/*.conf\n${originalConfig}`);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
let validation = await spawnPromise({ command: "sshd", args: ["-t"] });
|
|
839
|
+
if (validation.error) {
|
|
840
|
+
validation = await spawnPromise({ command: "/usr/sbin/sshd", args: ["-t"] });
|
|
841
|
+
}
|
|
842
|
+
if (validation.status !== 0) {
|
|
843
|
+
// Roll back rather than leave a config that sshd would refuse on its next start.
|
|
844
|
+
await fs.rm(SSHD_DROPIN_PATH, { force: true });
|
|
845
|
+
if (includeMissing) {
|
|
846
|
+
await fs.writeFile(SSHD_CONFIG_PATH, originalConfig);
|
|
847
|
+
}
|
|
848
|
+
log(
|
|
849
|
+
`sshd rejected the portsecure config, rolled it back. `
|
|
850
|
+
+ `${(validation.stderr || "").slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
851
|
+
);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
let unit = await restartSSHD();
|
|
856
|
+
log(`Password authentication disabled, reloaded ${unit}`);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Syncs one source. Returns whether the merged keys need reapplying. */
|
|
860
|
+
async function pollSource(repoURL) {
|
|
861
|
+
let result;
|
|
862
|
+
try {
|
|
863
|
+
result = await syncRepo(repoURL);
|
|
864
|
+
repoFailureCounts[repoURL] = 0;
|
|
865
|
+
} catch (e) {
|
|
866
|
+
let failures = (repoFailureCounts[repoURL] || 0) + 1;
|
|
867
|
+
repoFailureCounts[repoURL] = failures;
|
|
868
|
+
log(`Sync of ${repoURL} failed (${failures} in a row). ${e}`);
|
|
869
|
+
if (failures < MAX_REPO_FAILURES_BEFORE_RECLONE) {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
// Availability over tidiness: throw the working copy away and start again.
|
|
873
|
+
log(`Discarding the checkout of ${repoURL} and cloning from scratch`);
|
|
874
|
+
try {
|
|
875
|
+
await cloneRepo(repoURL);
|
|
876
|
+
repoFailureCounts[repoURL] = 0;
|
|
877
|
+
result = {
|
|
878
|
+
changed: true,
|
|
879
|
+
historyRewritten: false,
|
|
880
|
+
remoteSha: await runGit(["rev-parse", "HEAD"], {
|
|
881
|
+
cwd: sourceRepoPath(repoURL),
|
|
882
|
+
keyPath: sourceKeyPath(repoURL),
|
|
883
|
+
}),
|
|
884
|
+
previousSha: sourceState(repoURL).lastSha,
|
|
885
|
+
};
|
|
886
|
+
} catch (cloneError) {
|
|
887
|
+
log(`${repoURL} cannot be reached or cloned, its last known keys stay in place. ${cloneError}`);
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (result.historyRewritten) {
|
|
893
|
+
await notify(
|
|
894
|
+
`the history of \`${repoURL}\` was rewritten. Commit \`${result.previousSha.slice(0, 12)}\` is no`
|
|
895
|
+
+ ` longer an ancestor of \`${result.remoteSha.slice(0, 12)}\`, so history was force pushed or`
|
|
896
|
+
+ ` tampered with. The new state has been applied.`
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
if (!result.changed) {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
sourceState(repoURL).lastSha = result.remoteSha;
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function pollRepo() {
|
|
907
|
+
let anyChanged = false;
|
|
908
|
+
for (let repoURL of config.repoSources) {
|
|
909
|
+
// One unreachable source must not stop the others from being checked.
|
|
910
|
+
try {
|
|
911
|
+
anyChanged = await pollSource(repoURL) || anyChanged;
|
|
912
|
+
} catch (e) {
|
|
913
|
+
log(`Polling ${repoURL} failed. ${e && e.stack || e}`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (!anyChanged) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
await saveState();
|
|
920
|
+
await enforceRootKeys({ reason: "repo" });
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function everyMinute() {
|
|
924
|
+
await enforceRootKeys({ reason: "manual" });
|
|
925
|
+
await checkOtherUserKeys();
|
|
926
|
+
await enforceSSHDConfig();
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function startInterval(config) {
|
|
930
|
+
let { intervalTime, run, name } = config;
|
|
931
|
+
let running = false;
|
|
932
|
+
let tick = async () => {
|
|
933
|
+
if (running) {
|
|
934
|
+
log(`Skipping ${name}, the previous run has not finished`);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
running = true;
|
|
938
|
+
try {
|
|
939
|
+
await run();
|
|
940
|
+
} catch (e) {
|
|
941
|
+
// Every scheduled job swallows its own errors, the daemon must outlive any single one.
|
|
942
|
+
log(`${name} failed. ${e && e.stack || e}`);
|
|
943
|
+
}
|
|
944
|
+
running = false;
|
|
945
|
+
};
|
|
946
|
+
setInterval(tick, intervalTime);
|
|
947
|
+
return tick;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function main() {
|
|
951
|
+
config = await loadConfig();
|
|
952
|
+
await loadState();
|
|
953
|
+
await configureDiscordNotifications({ filePath: config.webhookPath });
|
|
954
|
+
|
|
955
|
+
log(`Starting, ${config.repoSources.length} source(s), keys applied to ${ROOT_AUTHORIZED_KEYS}`);
|
|
956
|
+
|
|
957
|
+
// A first pass has to happen before the intervals, so a machine is correct immediately after
|
|
958
|
+
// boot rather than a minute later.
|
|
959
|
+
for (let repoURL of config.repoSources) {
|
|
960
|
+
try {
|
|
961
|
+
let result = await syncRepo(repoURL);
|
|
962
|
+
sourceState(repoURL).lastSha = result.remoteSha;
|
|
963
|
+
} catch (e) {
|
|
964
|
+
log(`Initial sync of ${repoURL} failed, continuing with whatever is on disk. ${e}`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
await saveState();
|
|
968
|
+
|
|
969
|
+
// Seeds the per user hashes without reporting every existing file as a change.
|
|
970
|
+
if (!Object.keys(state.userKeyHashes).length) {
|
|
971
|
+
for (let entry of await listUserAuthorizedKeyFiles()) {
|
|
972
|
+
if (entry.filePath === ROOT_AUTHORIZED_KEYS) {
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
let keys = await readAuthorizedKeysFile(entry.filePath);
|
|
976
|
+
state.userKeyHashes[entry.name] = crypto.createHash("sha256").update(keys.join("\n")).digest("hex");
|
|
977
|
+
}
|
|
978
|
+
await saveState();
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
await enforceRootKeys({ reason: "repo" });
|
|
982
|
+
await enforceSSHDConfig();
|
|
983
|
+
|
|
984
|
+
startInterval({ name: "key check", intervalTime: KEYS_CHECK_INTERVAL, run: everyMinute });
|
|
985
|
+
startInterval({ name: "repo poll", intervalTime: REPO_POLL_INTERVAL, run: pollRepo });
|
|
986
|
+
startInterval({ name: "webhook check", intervalTime: WEBHOOK_CHECK_INTERVAL, run: checkWebhookFileChanged });
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
process.on("uncaughtException", e => {
|
|
990
|
+
log(`Uncaught exception, staying up. ${e && e.stack || e}`);
|
|
991
|
+
});
|
|
992
|
+
process.on("unhandledRejection", e => {
|
|
993
|
+
log(`Unhandled rejection, staying up. ${e && e.stack || e}`);
|
|
994
|
+
});
|
|
995
|
+
process.on("SIGTERM", () => {
|
|
996
|
+
log("Received SIGTERM, exiting");
|
|
997
|
+
process.exit(0);
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
// Exported so the pieces can be exercised on their own. Running this file is what starts the
|
|
1001
|
+
// daemon, requiring it does nothing.
|
|
1002
|
+
module.exports = {
|
|
1003
|
+
normalizeKeys,
|
|
1004
|
+
summarizeKey,
|
|
1005
|
+
describeKeyDifference,
|
|
1006
|
+
redactWebhookURL,
|
|
1007
|
+
parseWebhookFile,
|
|
1008
|
+
readRepoKeys,
|
|
1009
|
+
readCheckoutKeys,
|
|
1010
|
+
readCheckoutSigner,
|
|
1011
|
+
verifyManifestMatchesFiles,
|
|
1012
|
+
resolveSourceKeys,
|
|
1013
|
+
sourceName,
|
|
1014
|
+
sourceKeyPath,
|
|
1015
|
+
sourceRepoPath,
|
|
1016
|
+
readAuthorizedKeysFile,
|
|
1017
|
+
writeAuthorizedKeysFile,
|
|
1018
|
+
archiveAuthorizedKeys,
|
|
1019
|
+
syncRepo,
|
|
1020
|
+
cloneRepo,
|
|
1021
|
+
ensureRepo,
|
|
1022
|
+
repoIsUsable,
|
|
1023
|
+
setConfig: value => { config = value; },
|
|
1024
|
+
getState: () => state,
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
if (require.main === module) {
|
|
1028
|
+
main().catch(e => {
|
|
1029
|
+
console.error(`portsecure: failed to start. ${e && e.stack || e}`);
|
|
1030
|
+
process.exit(1);
|
|
1031
|
+
});
|
|
1032
|
+
}
|