sliftutils 1.7.126 → 1.7.128

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.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // Sends one message to a webhook, to prove notifications work.
4
+ require("typenode");
5
+
6
+ require("../security/notifications/testNotify").main().catch(e => {
7
+ console.error(`${e}`);
8
+ process.exitCode = 1;
9
+ }).finally(() => process.exit());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.7.126",
3
+ "version": "1.7.128",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -33,7 +33,8 @@
33
33
  "securessh": "node ./bin/securessh.js",
34
34
  "signfiles": "node ./bin/signfiles.js",
35
35
  "derivekey": "node ./bin/derivekey.js",
36
- "unrevoke": "node ./bin/unrevoke.js"
36
+ "unrevoke": "node ./bin/unrevoke.js",
37
+ "testnotify": "node ./bin/testnotify.js"
37
38
  },
38
39
  "bin": {
39
40
  "filehoster": "./bin/filehoster.js",
@@ -56,7 +57,8 @@
56
57
  "signfiles": "./bin/signfiles.js",
57
58
  "derivekey": "./bin/derivekey.js",
58
59
  "portsecuredaemon": "./bin/portsecuredaemon.js",
59
- "unrevoke": "./bin/unrevoke.js"
60
+ "unrevoke": "./bin/unrevoke.js",
61
+ "testnotify": "./bin/testnotify.js"
60
62
  },
61
63
  "dependencies": {
62
64
  "@types/chrome": "^0.0.237",
@@ -2,11 +2,6 @@ import crypto from "crypto";
2
2
  import fs from "fs/promises";
3
3
  import path from "path";
4
4
 
5
- // PORTED CODE: security/authorizedKeys/daemon/portsecureDaemon.js contains a plain JS port of normalizeKeys,
6
- // summarizeKey and readRepoKeys, so it can resolve the same keys with no dependencies. The two
7
- // must agree on which keys a repo produces - if you change one, make the matching change in the
8
- // other.
9
-
10
5
  export function normalizeKeys(contents: string) {
11
6
  return contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
12
7
  }
@@ -23,14 +18,26 @@ export function keyFingerprint(keyLine: string) {
23
18
  return "SHA256:" + crypto.createHash("sha256").update(Buffer.from(blob, "base64")).digest("base64").replace(/=+$/, "");
24
19
  }
25
20
 
21
+ export const NO_RESTRICTION = "ANY ADDRESS (no from= restriction)";
22
+
23
+ /** The addresses a key may be used from, one by one, or undefined when the key carries no from=
24
+ at all. Undefined and an empty list are very different things, so they stay distinguishable. */
25
+ export function keyRestrictionList(keyLine: string) {
26
+ let match = keyLine.match(/from="([^"]*)"/);
27
+ if (!match) {
28
+ return undefined;
29
+ }
30
+ return match[1].split(",").map(entry => entry.trim()).filter(entry => entry);
31
+ }
32
+
26
33
  /** The addresses a key may be used from, which is the part of an authorized_keys line that
27
34
  decides how much a stolen key is worth. A key with no restriction says so loudly. */
28
35
  export function keyRestriction(keyLine: string) {
29
- let match = keyLine.match(/from="([^"]*)"/);
30
- if (!match) {
31
- return "ANY ADDRESS (no from= restriction)";
36
+ let list = keyRestrictionList(keyLine);
37
+ if (!list) {
38
+ return NO_RESTRICTION;
32
39
  }
33
- return match[1];
40
+ return list.join(",");
34
41
  }
35
42
 
36
43
  /** Enough to recognise whose key this is without printing the whole blob. */
@@ -46,6 +53,39 @@ export function summarizeKey(keyLine: string) {
46
53
  return `${type} ...${blob.slice(-12)}${comment && ` ${comment}` || ""}`;
47
54
  }
48
55
 
56
+ /** What a set of keys has to satisfy before it is worth signing, as a list of complaints.
57
+
58
+ Every key needs a from=, because an unrestricted key can never be caught being used from the
59
+ wrong place, and being caught is the only thing that triggers a revocation.
60
+
61
+ No two keys may allow the same addresses, because that is one person holding two keys: revoking
62
+ one of them leaves the other working, so the revocation achieves nothing. */
63
+ export function findKeyProblems(keys: string[]) {
64
+ let problems: string[] = [];
65
+ let byRestriction = new Map<string, string[]>();
66
+ for (let key of keys) {
67
+ let restriction = keyRestrictionList(key);
68
+ if (!restriction) {
69
+ problems.push(`no from= restriction, so it can be used from anywhere:\n ${summarizeKey(key)}`);
70
+ continue;
71
+ }
72
+ // Sorted, so the same addresses written in a different order still count as the same.
73
+ let identity = [...restriction].sort().join(",");
74
+ byRestriction.set(identity, [...(byRestriction.get(identity) || []), key]);
75
+ }
76
+ for (let [restriction, sharing] of byRestriction) {
77
+ if (sharing.length < 2) {
78
+ continue;
79
+ }
80
+ problems.push(
81
+ `${sharing.length} keys allow exactly the same addresses (${restriction}), which is one`
82
+ + ` person holding more than one key:\n`
83
+ + sharing.map(key => ` ${summarizeKey(key)}`).join("\n")
84
+ );
85
+ }
86
+ return problems;
87
+ }
88
+
49
89
  /** Reads the authorized keys a repo checkout wants applied. Prefers a top level authorized_keys
50
90
  file and otherwise concatenates every .pub at the top level. */
51
91
  export async function readRepoKeys(repoPath: string) {
@@ -1,5 +1,7 @@
1
1
  import crypto from "crypto";
2
+ import { watch } from "fs";
2
3
  import fs from "fs/promises";
4
+ import path from "path";
3
5
  import { spawnPromise } from "../../helpers/spawn";
4
6
  import { AUTH_LOG_PATH } from "./paths";
5
7
  import { getState, saveState } from "./state";
@@ -15,11 +17,29 @@ const SIGNATURE_LENGTH = 512;
15
17
 
16
18
  export type RefusedAttempt = { fingerprint: string; attempt: Attempt };
17
19
 
20
+ // The two halves of one refusal are separate log lines, written milliseconds apart, and a watcher
21
+ // woken by the first of them reads only as far as that. So the halves are kept between reads and
22
+ // paired when both have arrived, keyed by the connection's process id.
23
+ let refusals = new Map<string, { user: string; ip: string; required: string; line: string }[]>();
24
+ let fingerprints = new Map<string, { fingerprint: string; port: string }>();
25
+ // A half that never finds its other half would otherwise sit there forever. Maps keep insertion
26
+ // order, so the oldest are the ones to drop.
27
+ const MAX_HALVES = 500;
28
+
29
+ function forget(half: Map<string, unknown>) {
30
+ while (half.size > MAX_HALVES) {
31
+ let oldest = half.keys().next().value;
32
+ if (oldest === undefined) {
33
+ return;
34
+ }
35
+ half.delete(oldest);
36
+ }
37
+ }
38
+
18
39
  /** Pairs each refusal with the fingerprint sshd logged for the same connection. A refusal we
19
- cannot tie to a key is dropped: revoking the wrong key would lock out the wrong person. */
40
+ cannot tie to a key is held rather than acted on: revoking the wrong key would lock out the
41
+ wrong person, and the line naming the key usually arrives a moment later. */
20
42
  export function parseAuthLog(contents: string) {
21
- let refusals = new Map<string, { user: string; ip: string; required: string; line: string }[]>();
22
- let fingerprints = new Map<string, { fingerprint: string; port: string }>();
23
43
  for (let line of contents.split("\n")) {
24
44
  let processMatch = line.match(PROCESS_ID);
25
45
  if (!processMatch) {
@@ -40,10 +60,11 @@ export function parseAuthLog(contents: string) {
40
60
  }
41
61
 
42
62
  let attempts: RefusedAttempt[] = [];
43
- for (let [processId, entries] of refusals) {
63
+ for (let [processId, entries] of [...refusals]) {
44
64
  let key = fingerprints.get(processId);
45
65
  if (!key) {
46
- console.log(`A refused attempt named no key, so nothing is being revoked for it: ${entries[0].line}`);
66
+ // The line naming the key has not been written yet, or not been read yet. Kept for the
67
+ // next read rather than thrown away.
47
68
  continue;
48
69
  }
49
70
  for (let entry of entries) {
@@ -58,7 +79,12 @@ export function parseAuthLog(contents: string) {
58
79
  },
59
80
  });
60
81
  }
82
+ // Paired, so neither half is needed again.
83
+ refusals.delete(processId);
84
+ fingerprints.delete(processId);
61
85
  }
86
+ forget(refusals);
87
+ forget(fingerprints);
62
88
  return attempts;
63
89
  }
64
90
 
@@ -115,3 +141,46 @@ export async function readNewAuthLog() {
115
141
  await handle.close();
116
142
  }
117
143
  }
144
+
145
+ /** Reacts when sshd writes, rather than on a timer. A refused login is worth acting on at once,
146
+ and nothing is gained by hearing about it up to a minute later.
147
+
148
+ The directory is watched rather than the file. A watch on the file follows the inode, so it
149
+ goes silent the moment the log is rotated out from under it, while the directory keeps
150
+ reporting both the writes and the rotation. */
151
+ export function watchAuthLog(onChange: () => Promise<void>) {
152
+ let running = false;
153
+ let againWhenDone = false;
154
+ let run = async () => {
155
+ // One at a time. Whatever arrives mid run is covered by a single further pass, rather than
156
+ // by however many events happened to fire.
157
+ if (running) {
158
+ againWhenDone = true;
159
+ return;
160
+ }
161
+ running = true;
162
+ try {
163
+ await onChange();
164
+ } catch (e) {
165
+ console.log(`Reading ${AUTH_LOG_PATH} failed. ${e}`);
166
+ }
167
+ running = false;
168
+ if (againWhenDone) {
169
+ againWhenDone = false;
170
+ void run();
171
+ }
172
+ };
173
+
174
+ let name = path.basename(AUTH_LOG_PATH);
175
+ try {
176
+ watch(path.dirname(AUTH_LOG_PATH), (type, changed) => {
177
+ if (changed === name) {
178
+ void run();
179
+ }
180
+ });
181
+ console.log(`Watching ${AUTH_LOG_PATH} for refused logins`);
182
+ } catch (e) {
183
+ console.log(`Cannot watch ${AUTH_LOG_PATH}, so refused logins will not be noticed. ${e}`);
184
+ }
185
+ return run;
186
+ }
@@ -0,0 +1,26 @@
1
+ // Why root's authorized_keys is about to change, collected as it happens.
2
+ //
3
+ // Nothing announces itself at the moment it decides something. A revocation arriving, a key being
4
+ // revoked here, an unrevoke taking effect, a repo moving on: each of those says what it is and
5
+ // leaves it here. The file is then written, and only if it actually came out different does any of
6
+ // it get reported, in one message.
7
+ //
8
+ // Doing it the other way round is what produced several messages for one event, and messages about
9
+ // removing a key that had already gone.
10
+
11
+ let reasons: string[] = [];
12
+
13
+ export function addChangeReason(reason: string) {
14
+ // The same reason twice in one pass says nothing the once did not.
15
+ if (!reasons.includes(reason)) {
16
+ reasons.push(reason);
17
+ }
18
+ }
19
+
20
+ /** The reasons gathered since the last write, and clears them. Called whether or not anything
21
+ changed, so reasons that came to nothing cannot show up against some later change. */
22
+ export function takeChangeReasons() {
23
+ let taken = reasons;
24
+ reasons = [];
25
+ return taken;
26
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from "fs/promises";
2
2
  import os from "os";
3
3
  import { configureDiscordNotifications, DEFAULT_WEBHOOK_FILE_PATH } from "../../notifications/discord";
4
- import { sourceKeyPath, sourceRepoPath } from "../sources";
4
+ import { findSourceKey, legacySourceKeyPath, sourceKeyPath, sourceRepoPath } from "../sources";
5
5
  import { cloneRepo, syncRepo } from "./git";
6
6
  import {
7
7
  CHECK_INTERVAL,
@@ -14,9 +14,10 @@ import { enforceRootKeys } from "./rootKeys";
14
14
  import { enforceSSHDConfig } from "./sshdConfig";
15
15
  import { getState, loadState, saveState, sourceState } from "./state";
16
16
  import { resolveSourceKeys } from "./trust";
17
- import { parseAuthLog, readNewAuthLog } from "./authLog";
17
+ import { parseAuthLog, readNewAuthLog, watchAuthLog } from "./authLog";
18
18
  import { absorbRevocations, applyUnrevokes, recordRevocation, removeRevokedKeys, syncRevokeRepo } from "./revocation";
19
19
  import { keyFingerprint } from "../authorizedKeys";
20
+ import { addChangeReason } from "./changes";
20
21
  import { checkOtherUserKeys, seedUserKeys } from "./userKeys";
21
22
 
22
23
  // portsecure authorized-keys daemon.
@@ -26,19 +27,21 @@ import { checkOtherUserKeys, seedUserKeys } from "./userKeys";
26
27
  //
27
28
  // The complete list of things that send a Discord message. Nothing else may be added to it
28
29
  // without the user asking for that specific case - everything else goes to the log.
29
- // 1. root's authorized_keys was changed outside portsecure, and was reverted.
30
- // 2. root's authorized_keys was updated because a source changed.
31
- // 3. Another user's authorized_keys changed.
30
+ //
31
+ // Anything that changes which keys root may use says nothing at the time. It leaves its reason
32
+ // with addChangeReason, the file is written, and if it came out different that one message carries
33
+ // the difference and every reason behind it. Announcing at the point of deciding is what produced
34
+ // several messages for one event, and messages about removing a key that was already gone.
35
+ // 1. root's authorized_keys changed, with what changed and why: a key revoked here, a revocation
36
+ // published elsewhere, an unrevoke taking effect, a source moving on.
37
+ // 2. root's authorized_keys was edited by something else, and was put back.
38
+ // 3. Another user's authorized_keys changed. A different file, and not one we manage.
32
39
  // 4. A source's history was rewritten.
33
40
  // 5. A source started being signed by a different key, so its new keys are being held.
34
41
  // 6. A source is now signed when it was not before, applied right away.
35
42
  // 7. A source changed without its signature being updated, so the change is ignored.
36
43
  // 8. A source has a corrupted signature, so its contents are ignored.
37
44
  // 9. The webhook file itself changed, reported to the webhook being replaced.
38
- // 10. A key was revoked here, after being used from an address it is not allowed from.
39
- // 11. A revoked key was removed from root's authorized_keys, said once per key.
40
- // 12. An unrevoke was published, and is being held for an hour.
41
- // 13. An unrevoke was applied once that hour passed.
42
45
 
43
46
  export type DaemonConfig = {
44
47
  repoSources: string[];
@@ -71,10 +74,11 @@ async function loadConfig(): Promise<DaemonConfig> {
71
74
  process.exit(1);
72
75
  }
73
76
  for (let repoURL of parsed.repoSources) {
74
- try {
75
- await fs.access(sourceKeyPath(repoURL));
76
- } catch (e) {
77
- console.error(`portsecure: expected the private key for ${repoURL} at ${sourceKeyPath(repoURL)}, ${e}`);
77
+ if (!await findSourceKey(repoURL)) {
78
+ console.error(
79
+ `portsecure: expected the private key for ${repoURL} at ${sourceKeyPath(repoURL)}`
80
+ + ` or ${legacySourceKeyPath(repoURL)}, neither exists`
81
+ );
78
82
  process.exit(1);
79
83
  }
80
84
  }
@@ -157,24 +161,41 @@ async function queueRefusedKeys(allowedKeys: string[]) {
157
161
  await saveState();
158
162
  }
159
163
 
164
+ // The log watcher and the periodic check both reach this, and two of them pushing to the same
165
+ // revoke repo at once would only fight each other.
166
+ let writingQueued = false;
167
+
160
168
  /** Writes down everything queued that we have not managed to write down yet. */
161
169
  async function writeQueuedRevocations() {
162
170
  let state = getState();
163
- if (!state.pendingRevocations.length) {
171
+ if (!state.pendingRevocations.length || writingQueued) {
164
172
  return;
165
173
  }
166
- let remaining = [];
167
- for (let pending of state.pendingRevocations) {
168
- if (state.revocations[pending.fingerprint]) {
169
- continue;
170
- }
171
- let recorded = await recordRevocation({ ...pending, hostLabel: config.hostLabel });
172
- if (!recorded && !state.revocations[pending.fingerprint]) {
173
- remaining.push(pending);
174
+ writingQueued = true;
175
+ try {
176
+ let remaining = [];
177
+ for (let pending of state.pendingRevocations) {
178
+ if (state.revocations[pending.fingerprint]) {
179
+ continue;
180
+ }
181
+ let recorded = await recordRevocation({ ...pending, hostLabel: config.hostLabel });
182
+ if (!recorded && !state.revocations[pending.fingerprint]) {
183
+ remaining.push(pending);
184
+ }
174
185
  }
186
+ state.pendingRevocations = remaining;
187
+ await saveState();
188
+ } finally {
189
+ writingQueued = false;
175
190
  }
176
- state.pendingRevocations = remaining;
177
- await saveState();
191
+ }
192
+
193
+ /** Everything the arrival of a refused login needs: read what is new, queue it, write it down.
194
+ Kept small, and off the merged key set the periodic check rebuilds, so reacting to a log line
195
+ cannot race that check. */
196
+ async function onAuthLogChanged() {
197
+ await queueRefusedKeys(getState().appliedKeys);
198
+ await writeQueuedRevocations();
178
199
  }
179
200
 
180
201
  /** Syncs one source. Returns whether the merged keys need reapplying. */
@@ -193,7 +214,7 @@ async function pollSource(repoURL: string) {
193
214
  // Availability over tidiness: throw the working copy away and start again.
194
215
  console.log(`Discarding the checkout of ${repoURL} and cloning from scratch`);
195
216
  try {
196
- await cloneRepo({ repoURL, repoPath: sourceRepoPath(repoURL), keyPath: sourceKeyPath(repoURL) });
217
+ await cloneRepo({ repoURL, repoPath: sourceRepoPath(repoURL), keyPath: await findSourceKey(repoURL) || sourceKeyPath(repoURL) });
197
218
  repoFailureCounts[repoURL] = 0;
198
219
  return true;
199
220
  } catch (cloneError) {
@@ -212,6 +233,9 @@ async function pollSource(repoURL: string) {
212
233
  if (!result.changed) {
213
234
  return false;
214
235
  }
236
+ // Said when the file is written, and only if it came out different. A commit that does not
237
+ // touch the keys is not worth telling anyone about.
238
+ addChangeReason(`\`${repoURL}\` moved to \`${result.remoteSha.slice(0, 12)}\`.`);
215
239
  sourceState(repoURL).lastSha = result.remoteSha;
216
240
  return true;
217
241
  }
@@ -238,12 +262,11 @@ async function everyCheck() {
238
262
  await applyUnrevokes(config.repoSources);
239
263
 
240
264
  let mergedKeys = await readAllowedKeys();
241
- await queueRefusedKeys(mergedKeys);
265
+ // The log is watched, not polled. This is only the retry for anything that could not be
266
+ // written down when it happened, because the revoke repo was unreachable.
242
267
  await writeQueuedRevocations();
243
268
 
244
- // The repo is checked first, so a change that came from it is reported as an update rather
245
- // than as somebody having edited the file locally.
246
- await enforceRootKeys({ keys: await removeRevokedKeys(mergedKeys), reason: anyChanged && "repo" || "manual" });
269
+ await enforceRootKeys(await removeRevokedKeys(mergedKeys));
247
270
  await checkOtherUserKeys();
248
271
  await enforceSSHDConfig();
249
272
  }
@@ -292,12 +315,17 @@ export async function main() {
292
315
  await syncRevokeRepo(repoURL);
293
316
  }
294
317
  await absorbRevocations(config.repoSources);
295
- await enforceRootKeys({ keys: await removeRevokedKeys(await readAllowedKeys()), reason: "repo" });
318
+ await enforceRootKeys(await removeRevokedKeys(await readAllowedKeys()));
296
319
  await enforceSSHDConfig();
297
320
 
298
321
  // configureDiscordNotifications watches the webhook file on its own, so there is nothing to
299
322
  // schedule for it here.
300
323
  startInterval({ name: "check", intervalTime: CHECK_INTERVAL, run: everyCheck });
324
+
325
+ // A refused login is acted on when sshd writes it. The one pass here covers anything written
326
+ // while the daemon was not running.
327
+ let readAuthLogNow = watchAuthLog(onAuthLogChanged);
328
+ await readAuthLogNow();
301
329
  }
302
330
 
303
331
  process.on("uncaughtException", e => console.log(`Uncaught exception, staying up. ${e && e.stack || e}`));
@@ -1,7 +1,7 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import { spawnPromise } from "../../helpers/spawn";
4
- import { sourceKeyPath, sourceRepoPath } from "../sources";
4
+ import { findSourceKey, sourceKeyPath, sourceRepoPath } from "../sources";
5
5
  import { GIT_TIMEOUT, MAX_ERROR_BODY_LENGTH } from "./paths";
6
6
  import { sourceState } from "./state";
7
7
 
@@ -68,7 +68,7 @@ export async function currentBranch(config: { repoPath: string; keyPath: string
68
68
 
69
69
  async function ensureSourceRepo(repoURL: string) {
70
70
  let repoPath = sourceRepoPath(repoURL);
71
- let keyPath = sourceKeyPath(repoURL);
71
+ let keyPath = await findSourceKey(repoURL) || sourceKeyPath(repoURL);
72
72
  if (!await repoIsUsable({ repoPath, keyPath })) {
73
73
  await cloneRepo({ repoURL, repoPath, keyPath });
74
74
  }
@@ -82,7 +82,7 @@ async function ensureSourceRepo(repoURL: string) {
82
82
  export async function syncRepo(repoURL: string) {
83
83
  await ensureSourceRepo(repoURL);
84
84
  let repoPath = sourceRepoPath(repoURL);
85
- let keyPath = sourceKeyPath(repoURL);
85
+ let keyPath = await findSourceKey(repoURL) || sourceKeyPath(repoURL);
86
86
  let branch = sourceState(repoURL).branch;
87
87
  let localSha = (await runGit({ args: ["rev-parse", "HEAD"], cwd: repoPath, keyPath })).stdout.trim();
88
88
 
@@ -1,11 +1,14 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import { keyFingerprint, summarizeKey } from "../authorizedKeys";
4
- import { deriveRevokeKey, revokeKeyPath, revokeRepoPath, revokeRepoURL } from "../revokeSource";
5
- import { sourceKeyPath, sourceRepoPath } from "../sources";
4
+ import { deriveRevokeKey, findRevokeKey, REVOKE_KEY_LABEL, revokeKeyPath, revokeRepoPath, revokeRepoURL } from "../revokeSource";
5
+ import { findSourceKey, sourceKeyPath, sourceRepoPath } from "../sources";
6
6
  import { cloneRepo, repoIsUsable, runGit } from "./git";
7
+ import { messageTimestamp } from "../../notifications/discord";
7
8
  import { UNREVOKE_DELAY } from "./paths";
8
9
  import { notify } from "./notify";
10
+ import { addChangeReason } from "./changes";
11
+ import { describeAllEnded, endAllSSHSessions } from "./sessions";
9
12
  import { getState, saveState } from "./state";
10
13
 
11
14
  // One revocation per key, ever. Naming the file after the fingerprint is what makes that true:
@@ -37,14 +40,20 @@ async function pathExists(filePath: string) {
37
40
  /** The revoke repo's key is worked out from the source's, so nothing extra had to be uploaded and
38
41
  nothing extra is stored anywhere it could be taken from. */
39
42
  async function ensureRevokeKey(sourceURL: string) {
40
- let keyPath = revokeKeyPath(sourceURL);
41
- if (await pathExists(keyPath)) {
42
- return keyPath;
43
+ let existing = await findRevokeKey(sourceURL);
44
+ if (existing) {
45
+ return existing;
46
+ }
47
+ let sourceKey = await findSourceKey(sourceURL);
48
+ if (!sourceKey) {
49
+ throw new Error(`Expected a key for ${sourceURL} at ${sourceKeyPath(sourceURL)}, no such file exists`);
43
50
  }
44
- let derived = deriveRevokeKey(await fs.readFile(sourceKeyPath(sourceURL), "utf8"));
51
+ let derived = deriveRevokeKey(await fs.readFile(sourceKey, "utf8"));
52
+ let keyPath = revokeKeyPath(sourceURL);
45
53
  await fs.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
46
54
  await fs.writeFile(keyPath, derived.privateKeyFile, { mode: 0o600 });
47
- console.log(`Derived the revoke key for ${sourceURL}`);
55
+ await fs.writeFile(`${keyPath}.pub`, `${derived.publicKey} ${REVOKE_KEY_LABEL}\n`, { mode: 0o644 });
56
+ console.log(`Derived the revoke key for ${sourceURL} into ${keyPath}`);
48
57
  return keyPath;
49
58
  }
50
59
 
@@ -162,7 +171,7 @@ export async function recordRevocation(config: {
162
171
  }
163
172
 
164
173
  let repoPath = revokeRepoPath(sourceURL);
165
- let keyPath = revokeKeyPath(sourceURL);
174
+ let keyPath = await ensureRevokeKey(sourceURL);
166
175
  let directory = path.join(repoPath, REVOCATIONS_DIR);
167
176
  await fs.mkdir(directory, { recursive: true });
168
177
  await fs.writeFile(path.join(directory, `${revocationId}.json`), JSON.stringify({
@@ -186,16 +195,21 @@ export async function recordRevocation(config: {
186
195
  }
187
196
  state.revocations[fingerprint] = {
188
197
  fingerprint, revocationId, unrevokeSeenAt: 0, unrevokeId: "", unrevoked: false,
189
- reportedRemoved: false,
198
+ // The message below already says this machine has stopped accepting the key, so the one
199
+ // about noticing a revocation would only repeat it.
200
+ reportedRemoved: true,
190
201
  };
191
202
  await saveState();
192
- await notify(
193
- `revoked \`${keyLine && summarizeKey(keyLine) || fingerprint}\` (\`${fingerprint}\`) after it was`
194
- + ` used from \`${attempt.ip}\`, which its from= restriction does not allow (user`
195
- + ` \`${attempt.user}\`, allowed \`${attempt.required}\`). Every machine will stop accepting it.`
196
- + `\n\nIf this really was an attack, IMMEDIATELY remove that key from \`${sourceURL}\`.`
197
- + `\nIf it was legitimate use, run this in \`${sourceURL}\` and deploy it as normal:`
198
- + `\n\`\`\`\nyarn unrevoke\nyarn signfiles git\n\`\`\``
203
+ let ended = describeAllEnded(await endAllSSHSessions());
204
+ // Said when the file is written, not here, so one event produces one message.
205
+ addChangeReason(
206
+ `**AUTHENTICATED ACCESS FROM AN UNAPPROVED IP: \`${attempt.ip}\`** The key was correct, so`
207
+ + ` either someone else has this key, or a developer's IP has changed.`
208
+ + `\nkey \`${keyLine && summarizeKey(keyLine) || fingerprint}\` (\`${fingerprint}\`)`
209
+ + `\ntried as user \`${attempt.user}\`, and is only allowed from \`${attempt.required}\``
210
+ + `\nThat key is now revoked everywhere.${ended}`
211
+ + `\nIf this really was an attack, IMMEDIATELY remove that key from \`${sourceURL}\`.`
212
+ + `\nIf it was legitimate use, run this in \`${sourceURL}\`: \`yarn unrevoke git\``
199
213
  );
200
214
  return true;
201
215
  }
@@ -249,9 +263,11 @@ export async function applyUnrevokes(sourceURLs: string[]) {
249
263
  revocation.unrevokeSeenAt = Date.now();
250
264
  revocation.unrevokeId = unrevokeId;
251
265
  await saveState();
252
- await notify(
253
- `an unrevoke for \`${revocation.fingerprint}\` was published as \`${unrevokeId}\`.`
254
- + ` It will be applied in ${Math.round(UNREVOKE_DELAY / 60000)} minutes, not now.`
266
+ // Nothing has changed yet, so nobody is told. Whoever published it was already told it
267
+ // takes an hour, and every machine seeing the same unrevoke would say so separately.
268
+ console.log(
269
+ `Holding the unrevoke ${unrevokeId} for ${revocation.fingerprint} until`
270
+ + ` ${messageTimestamp(new Date(revocation.unrevokeSeenAt + UNREVOKE_DELAY))}`
255
271
  );
256
272
  continue;
257
273
  }
@@ -261,10 +277,8 @@ export async function applyUnrevokes(sourceURLs: string[]) {
261
277
  revocation.unrevoked = true;
262
278
  revocation.reportedRemoved = false;
263
279
  await saveState();
264
- await notify(
265
- `the unrevoke of \`${revocation.fingerprint}\` has been applied. The key is accepted again`
266
- + ` if it is still in a keys repo.`
267
- );
280
+ // Only means anything if the key comes back into the file, so it is said there.
281
+ addChangeReason(`the unrevoke of \`${revocation.fingerprint}\` has taken effect, ${unrevokeId}.`);
268
282
  }
269
283
  }
270
284
 
@@ -285,22 +299,37 @@ export async function removeRevokedKeys(keys: string[]) {
285
299
  }
286
300
  let state = getState();
287
301
  let allowed: string[] = [];
302
+ let dropped: string[] = [];
288
303
  for (let key of keys) {
289
304
  let fingerprint = keyFingerprint(key);
290
305
  if (!fingerprint || !revoked.has(fingerprint)) {
291
306
  allowed.push(key);
292
307
  continue;
293
308
  }
294
- // Said once, when the key actually goes, rather than every check for as long as it is gone.
309
+ dropped.push(fingerprint);
295
310
  let revocation = state.revocations[fingerprint];
296
- if (revocation && !revocation.reportedRemoved) {
297
- revocation.reportedRemoved = true;
298
- await saveState();
299
- await notify(
300
- `\`${fingerprint}\` is revoked, so it has been removed from root's authorized_keys`
301
- + ` and this machine no longer accepts it.`
302
- );
311
+ if (!revocation || revocation.reportedRemoved) {
312
+ continue;
303
313
  }
314
+ revocation.reportedRemoved = true;
315
+ await saveState();
316
+ // Nothing to do if the key is not in the file. It left long ago, and this is a machine that
317
+ // restarted and read the revocation back out of the repo.
318
+ if (!state.appliedKeys.includes(key)) {
319
+ continue;
320
+ }
321
+ // Whatever that key is holding open goes with it.
322
+ let ended = describeAllEnded(await endAllSSHSessions());
323
+ addChangeReason(`a revocation for \`${fingerprint}\` was published elsewhere.${ended}`);
324
+ }
325
+ // Said on every check, not once. A key being held out of authorized_keys is the current state
326
+ // of the machine, and someone reading the log to work out why a key does not work should find
327
+ // the answer there rather than having to know what to search the history for.
328
+ if (dropped.length) {
329
+ console.log(
330
+ `Dropped ${dropped.length} revoked key(s) from the merged set, ${allowed.length} left.`
331
+ + ` Revoked: ${dropped.join(", ")}`
332
+ );
304
333
  }
305
334
  return allowed;
306
335
  }