sliftutils 1.7.126 → 1.7.127

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.127",
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
+ }
@@ -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[];
@@ -157,24 +160,41 @@ async function queueRefusedKeys(allowedKeys: string[]) {
157
160
  await saveState();
158
161
  }
159
162
 
163
+ // The log watcher and the periodic check both reach this, and two of them pushing to the same
164
+ // revoke repo at once would only fight each other.
165
+ let writingQueued = false;
166
+
160
167
  /** Writes down everything queued that we have not managed to write down yet. */
161
168
  async function writeQueuedRevocations() {
162
169
  let state = getState();
163
- if (!state.pendingRevocations.length) {
170
+ if (!state.pendingRevocations.length || writingQueued) {
164
171
  return;
165
172
  }
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);
173
+ writingQueued = true;
174
+ try {
175
+ let remaining = [];
176
+ for (let pending of state.pendingRevocations) {
177
+ if (state.revocations[pending.fingerprint]) {
178
+ continue;
179
+ }
180
+ let recorded = await recordRevocation({ ...pending, hostLabel: config.hostLabel });
181
+ if (!recorded && !state.revocations[pending.fingerprint]) {
182
+ remaining.push(pending);
183
+ }
174
184
  }
185
+ state.pendingRevocations = remaining;
186
+ await saveState();
187
+ } finally {
188
+ writingQueued = false;
175
189
  }
176
- state.pendingRevocations = remaining;
177
- await saveState();
190
+ }
191
+
192
+ /** Everything the arrival of a refused login needs: read what is new, queue it, write it down.
193
+ Kept small, and off the merged key set the periodic check rebuilds, so reacting to a log line
194
+ cannot race that check. */
195
+ async function onAuthLogChanged() {
196
+ await queueRefusedKeys(getState().appliedKeys);
197
+ await writeQueuedRevocations();
178
198
  }
179
199
 
180
200
  /** Syncs one source. Returns whether the merged keys need reapplying. */
@@ -212,6 +232,9 @@ async function pollSource(repoURL: string) {
212
232
  if (!result.changed) {
213
233
  return false;
214
234
  }
235
+ // Said when the file is written, and only if it came out different. A commit that does not
236
+ // touch the keys is not worth telling anyone about.
237
+ addChangeReason(`\`${repoURL}\` moved to \`${result.remoteSha.slice(0, 12)}\`.`);
215
238
  sourceState(repoURL).lastSha = result.remoteSha;
216
239
  return true;
217
240
  }
@@ -238,12 +261,11 @@ async function everyCheck() {
238
261
  await applyUnrevokes(config.repoSources);
239
262
 
240
263
  let mergedKeys = await readAllowedKeys();
241
- await queueRefusedKeys(mergedKeys);
264
+ // The log is watched, not polled. This is only the retry for anything that could not be
265
+ // written down when it happened, because the revoke repo was unreachable.
242
266
  await writeQueuedRevocations();
243
267
 
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" });
268
+ await enforceRootKeys(await removeRevokedKeys(mergedKeys));
247
269
  await checkOtherUserKeys();
248
270
  await enforceSSHDConfig();
249
271
  }
@@ -292,12 +314,17 @@ export async function main() {
292
314
  await syncRevokeRepo(repoURL);
293
315
  }
294
316
  await absorbRevocations(config.repoSources);
295
- await enforceRootKeys({ keys: await removeRevokedKeys(await readAllowedKeys()), reason: "repo" });
317
+ await enforceRootKeys(await removeRevokedKeys(await readAllowedKeys()));
296
318
  await enforceSSHDConfig();
297
319
 
298
320
  // configureDiscordNotifications watches the webhook file on its own, so there is nothing to
299
321
  // schedule for it here.
300
322
  startInterval({ name: "check", intervalTime: CHECK_INTERVAL, run: everyCheck });
323
+
324
+ // A refused login is acted on when sshd writes it. The one pass here covers anything written
325
+ // while the daemon was not running.
326
+ let readAuthLogNow = watchAuthLog(onAuthLogChanged);
327
+ await readAuthLogNow();
301
328
  }
302
329
 
303
330
  process.on("uncaughtException", e => console.log(`Uncaught exception, staying up. ${e && e.stack || e}`));
@@ -4,8 +4,11 @@ import { keyFingerprint, summarizeKey } from "../authorizedKeys";
4
4
  import { deriveRevokeKey, revokeKeyPath, revokeRepoPath, revokeRepoURL } from "../revokeSource";
5
5
  import { 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:
@@ -186,16 +189,21 @@ export async function recordRevocation(config: {
186
189
  }
187
190
  state.revocations[fingerprint] = {
188
191
  fingerprint, revocationId, unrevokeSeenAt: 0, unrevokeId: "", unrevoked: false,
189
- reportedRemoved: false,
192
+ // The message below already says this machine has stopped accepting the key, so the one
193
+ // about noticing a revocation would only repeat it.
194
+ reportedRemoved: true,
190
195
  };
191
196
  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\`\`\``
197
+ let ended = describeAllEnded(await endAllSSHSessions());
198
+ // Said when the file is written, not here, so one event produces one message.
199
+ addChangeReason(
200
+ `**AUTHENTICATED ACCESS FROM AN UNAPPROVED IP: \`${attempt.ip}\`** The key was correct, so`
201
+ + ` either someone else has this key, or a developer's IP has changed.`
202
+ + `\nkey \`${keyLine && summarizeKey(keyLine) || fingerprint}\` (\`${fingerprint}\`)`
203
+ + `\ntried as user \`${attempt.user}\`, and is only allowed from \`${attempt.required}\``
204
+ + `\nThat key is now revoked everywhere.${ended}`
205
+ + `\nIf this really was an attack, IMMEDIATELY remove that key from \`${sourceURL}\`.`
206
+ + `\nIf it was legitimate use, run this in \`${sourceURL}\`: \`yarn unrevoke git\``
199
207
  );
200
208
  return true;
201
209
  }
@@ -249,9 +257,11 @@ export async function applyUnrevokes(sourceURLs: string[]) {
249
257
  revocation.unrevokeSeenAt = Date.now();
250
258
  revocation.unrevokeId = unrevokeId;
251
259
  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.`
260
+ // Nothing has changed yet, so nobody is told. Whoever published it was already told it
261
+ // takes an hour, and every machine seeing the same unrevoke would say so separately.
262
+ console.log(
263
+ `Holding the unrevoke ${unrevokeId} for ${revocation.fingerprint} until`
264
+ + ` ${messageTimestamp(new Date(revocation.unrevokeSeenAt + UNREVOKE_DELAY))}`
255
265
  );
256
266
  continue;
257
267
  }
@@ -261,10 +271,8 @@ export async function applyUnrevokes(sourceURLs: string[]) {
261
271
  revocation.unrevoked = true;
262
272
  revocation.reportedRemoved = false;
263
273
  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
- );
274
+ // Only means anything if the key comes back into the file, so it is said there.
275
+ addChangeReason(`the unrevoke of \`${revocation.fingerprint}\` has taken effect, ${unrevokeId}.`);
268
276
  }
269
277
  }
270
278
 
@@ -285,22 +293,37 @@ export async function removeRevokedKeys(keys: string[]) {
285
293
  }
286
294
  let state = getState();
287
295
  let allowed: string[] = [];
296
+ let dropped: string[] = [];
288
297
  for (let key of keys) {
289
298
  let fingerprint = keyFingerprint(key);
290
299
  if (!fingerprint || !revoked.has(fingerprint)) {
291
300
  allowed.push(key);
292
301
  continue;
293
302
  }
294
- // Said once, when the key actually goes, rather than every check for as long as it is gone.
303
+ dropped.push(fingerprint);
295
304
  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
- );
305
+ if (!revocation || revocation.reportedRemoved) {
306
+ continue;
303
307
  }
308
+ revocation.reportedRemoved = true;
309
+ await saveState();
310
+ // Nothing to do if the key is not in the file. It left long ago, and this is a machine that
311
+ // restarted and read the revocation back out of the repo.
312
+ if (!state.appliedKeys.includes(key)) {
313
+ continue;
314
+ }
315
+ // Whatever that key is holding open goes with it.
316
+ let ended = describeAllEnded(await endAllSSHSessions());
317
+ addChangeReason(`a revocation for \`${fingerprint}\` was published elsewhere.${ended}`);
318
+ }
319
+ // Said on every check, not once. A key being held out of authorized_keys is the current state
320
+ // of the machine, and someone reading the log to work out why a key does not work should find
321
+ // the answer there rather than having to know what to search the history for.
322
+ if (dropped.length) {
323
+ console.log(
324
+ `Dropped ${dropped.length} revoked key(s) from the merged set, ${allowed.length} left.`
325
+ + ` Revoked: ${dropped.join(", ")}`
326
+ );
304
327
  }
305
328
  return allowed;
306
329
  }