sliftutils 1.7.125 → 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.
Files changed (47) hide show
  1. package/bin/derivekey.js +10 -0
  2. package/bin/portsecuredaemon.js +13 -0
  3. package/bin/testnotify.js +9 -0
  4. package/bin/unrevoke.js +9 -0
  5. package/package.json +10 -3
  6. package/security/README.md +66 -2
  7. package/security/authorizedKeys/authorizedKeys.ts +68 -5
  8. package/security/authorizedKeys/daemon/authLog.ts +186 -0
  9. package/security/authorizedKeys/daemon/changes.ts +26 -0
  10. package/security/authorizedKeys/daemon/daemon.ts +335 -0
  11. package/security/authorizedKeys/daemon/git.ts +119 -0
  12. package/security/authorizedKeys/daemon/notify.ts +25 -0
  13. package/security/authorizedKeys/daemon/paths.ts +26 -0
  14. package/security/authorizedKeys/daemon/portsecure.service +3 -2
  15. package/security/authorizedKeys/daemon/revocation.ts +329 -0
  16. package/security/authorizedKeys/daemon/rootKeys.ts +198 -0
  17. package/security/authorizedKeys/daemon/sessions.ts +139 -0
  18. package/security/authorizedKeys/daemon/sshdConfig.ts +85 -0
  19. package/security/authorizedKeys/daemon/state.ts +122 -0
  20. package/security/authorizedKeys/daemon/trust.ts +291 -0
  21. package/security/authorizedKeys/daemon/userKeys.ts +76 -0
  22. package/security/authorizedKeys/dist/authorizedKeys.ts.cache +111 -0
  23. package/security/authorizedKeys/dist/revokeSource.ts.cache +44 -0
  24. package/security/authorizedKeys/dist/secureSSH.ts.cache +539 -0
  25. package/security/authorizedKeys/dist/sources.ts.cache +24 -0
  26. package/security/authorizedKeys/dist/unrevoke.ts.cache +167 -0
  27. package/security/authorizedKeys/revokeSource.ts +40 -0
  28. package/security/authorizedKeys/secureSSH.ts +240 -38
  29. package/security/authorizedKeys/unrevoke.ts +175 -0
  30. package/security/helpers/dist/paths.ts.cache +28 -0
  31. package/security/helpers/dist/remoteSSH.ts.cache +90 -0
  32. package/security/helpers/dist/spawn.ts.cache +34 -0
  33. package/security/helpers/remoteSSH.ts +18 -6
  34. package/security/helpers/spawn.ts +5 -1
  35. package/security/keys/deriveKey.ts +72 -0
  36. package/security/keys/dist/deriveKey.ts.cache +72 -0
  37. package/security/keys/dist/sshKeyFile.ts.cache +153 -0
  38. package/security/keys/sshKeyFile.ts +156 -0
  39. package/security/notifications/discord.ts +18 -1
  40. package/security/notifications/dist/discord.ts.cache +197 -0
  41. package/security/notifications/setupNotify.ts +17 -8
  42. package/security/notifications/testNotify.ts +48 -0
  43. package/security/signedFiles/dist/manifest.ts.cache +85 -0
  44. package/security/signedFiles/dist/signFiles.ts.cache +181 -0
  45. package/security/signedFiles/manifest.ts +38 -8
  46. package/security/signedFiles/signFiles.ts +123 -49
  47. package/security/authorizedKeys/daemon/portsecureDaemon.js +0 -1032
@@ -4,24 +4,34 @@ import path from "path";
4
4
  import { DEFAULT_WEBHOOK_FILE_PATH, parseWebhookFile } from "../notifications/discord";
5
5
  import { normalizeKeys, readRepoKeys, summarizeKey } from "./authorizedKeys";
6
6
  import { sourceKeyPath, sourceRepoPath } from "./sources";
7
+ import { deriveRevokeKey, REVOKE_KEY_LABEL, revokeRepoURL } from "./revokeSource";
8
+ import { revokedKeysInRepo } from "./unrevoke";
7
9
  import { expandHome } from "../helpers/paths";
8
10
  import { spawnPromise } from "../helpers/spawn";
9
- import { readRemoteFile, remoteCommandExists, runOverSSH, SUDO_PREAMBLE, writeRemoteFile } from "../helpers/remoteSSH";
11
+ import { describeHost, readRemoteFile, remoteCommandExists, runOverSSH, SUDO_PREAMBLE, THIS_MACHINE, writeRemoteFile } from "../helpers/remoteSSH";
10
12
 
11
- const DAEMON_SOURCE = path.join(__dirname, "daemon", "portsecureDaemon.js");
12
13
  const SERVICE_SOURCE = path.join(__dirname, "daemon", "portsecure.service");
13
- const REMOTE_DAEMON_PATH = "/opt/portsecure/portsecure-daemon.js";
14
+ // The daemon runs from a checkout of this repo on the host, rather than from a copy we upload, so
15
+ // a host updates itself from github the same way anything else does. sliftutils is public, so this
16
+ // needs no key.
17
+ const SLIFTUTILS_URL = "https://github.com/sliftist/sliftutils.git";
18
+ const REMOTE_CHECKOUT_PATH = "/opt/portsecure/sliftutils";
14
19
  const REMOTE_SERVICE_PATH = "/etc/systemd/system/portsecure.service";
15
20
  const REMOTE_CONFIG_PATH = "/etc/portsecure/daemon.json";
16
21
  const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
17
22
  const SERVICE_NAME = "portsecure";
18
23
  const MAX_ERROR_BODY_LENGTH = 500;
19
- const VERBS = ["add", "remove", "list"];
20
- // The repo url is optional, and defaults to the repo the command is run from.
24
+ const VERBS = ["add", "remove", "list", "update"];
25
+ // The host is optional, and without one everything happens on this machine. The repo url is
26
+ // optional too, and defaults to the repo the command is run from.
21
27
  const USAGE = `Usage:
22
- yarn securessh <host> add <repo-private-key> [repo-url]
23
- yarn securessh <host> remove [repo-url]
24
- yarn securessh <host> list`;
28
+ yarn securessh [host] add <repo-private-key> [repo-url]
29
+ yarn securessh [host] remove [repo-url]
30
+ yarn securessh [host] list
31
+ yarn securessh [host] update
32
+
33
+ With no host it acts on this machine, and still installs from github rather than from wherever
34
+ this was run.`;
25
35
 
26
36
  async function pathExists(filePath: string) {
27
37
  try {
@@ -41,7 +51,7 @@ async function runLocal(config: { command: string; args: string[]; cwd?: string;
41
51
  if (result.status !== 0 && !allowFailure) {
42
52
  throw new Error(
43
53
  `Expected ${command} ${args.join(" ")} to exit 0, was ${result.status}. `
44
- + `${(result.stderr || "").slice(0, MAX_ERROR_BODY_LENGTH)}`
54
+ + `${(result.stdout + result.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
45
55
  );
46
56
  }
47
57
  return result;
@@ -69,7 +79,7 @@ async function gitWithKey(config: { keyPath: string; args: string[]; cwd?: strin
69
79
  if (result.status !== 0 && !allowFailure) {
70
80
  throw new Error(
71
81
  `Expected git ${args.join(" ")} to exit 0, was ${result.status}. `
72
- + `${(result.stderr || "").slice(0, MAX_ERROR_BODY_LENGTH)}`
82
+ + `${(result.stdout + result.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
73
83
  );
74
84
  }
75
85
  return result;
@@ -158,6 +168,118 @@ async function resolveRepoURL(passedURL: string | undefined) {
158
168
  return repoURL;
159
169
  }
160
170
 
171
+ /** Every source's revoke repo has to exist and hold at least one commit, or the hosts using it
172
+ cannot record a revocation. Checked with this machine's own git credentials, since update is
173
+ not given any deploy key, and an empty repo is initialised rather than merely complained about.
174
+
175
+ A host that cannot write a revocation silently keeps accepting a key it just saw being misused,
176
+ which is the one failure this whole thing exists to prevent, so it is checked on every deploy
177
+ and not only when a source is first added. */
178
+ async function ensureRevokeReposExist(repoSources: string[]) {
179
+ for (let repoURL of repoSources) {
180
+ let revokeURL = revokeRepoURL(repoURL);
181
+ let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-revoke-"));
182
+ let checkoutPath = path.join(temporaryDirectory, "repo");
183
+ // On a machine that is already a portsecure host, the source's deploy key is right there,
184
+ // and the key derived from it is the one credential that repo is guaranteed to accept.
185
+ // Anywhere else, a person is running this and has their own access.
186
+ let ownKey = "";
187
+ if (await pathExists(sourceKeyPath(repoURL))) {
188
+ let derived = deriveRevokeKey(await fs.readFile(sourceKeyPath(repoURL), "utf8"));
189
+ ownKey = path.join(temporaryDirectory, "key");
190
+ await fs.writeFile(ownKey, derived.privateKeyFile, { mode: 0o600 });
191
+ }
192
+ let cloneRevoke = async (allowFailure: boolean) => ownKey
193
+ && await gitWithKey({ keyPath: ownKey, args: ["clone", revokeURL, checkoutPath], allowFailure })
194
+ || await runLocal({ command: "git", args: ["clone", revokeURL, checkoutPath], allowFailure });
195
+ let clone = await cloneRevoke(true);
196
+ if (clone.status !== 0) {
197
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
198
+ throw new Error(
199
+ `Expected ${revokeURL} to exist, it does not, so ${repoURL} has nowhere to record a`
200
+ + ` revocation.\nCreate it, then run "yarn securessh <host> add" for that source to`
201
+ + ` register its deploy key.\n${(clone.stdout + clone.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
202
+ );
203
+ }
204
+ let head = await runLocal({ command: "git", args: ["-C", checkoutPath, "rev-parse", "HEAD"], allowFailure: true });
205
+ if (head.status !== 0) {
206
+ console.log(`${revokeURL} is empty, giving it a first commit`);
207
+ await fs.writeFile(path.join(checkoutPath, "README.md"),
208
+ `# revoked keys\n\nWritten by portsecure. Each file under revocations/ is one key that was used from an\n`
209
+ + `address it is not allowed from, and is no longer accepted anywhere.\n`);
210
+ for (let args of [
211
+ ["-C", checkoutPath, "add", "-A"],
212
+ ["-C", checkoutPath, "-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", "initialise revoke repo"],
213
+ ]) {
214
+ await runLocal({ command: "git", args });
215
+ }
216
+ let push = ["-C", checkoutPath, "push", "origin", "HEAD"];
217
+ if (ownKey) {
218
+ await gitWithKey({ keyPath: ownKey, args: push });
219
+ } else {
220
+ await runLocal({ command: "git", args: push });
221
+ }
222
+ }
223
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
224
+ console.log(`${revokeURL} is ready`);
225
+ }
226
+ }
227
+
228
+ /** The revoke repo has to exist and be writable before a host is set up, because a host that
229
+ cannot write a revocation cannot revoke a key that is being misused. The key for it is derived
230
+ from the source's, since github will not take one public key on two repos. */
231
+ async function ensureRevokeRepo(config: { keyPath: string; repoURL: string }) {
232
+ let { keyPath, repoURL } = config;
233
+ let revokeURL = revokeRepoURL(repoURL);
234
+ let derived = deriveRevokeKey(await fs.readFile(keyPath, "utf8"));
235
+
236
+ let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-revoke-"));
237
+ let derivedKeyPath = path.join(temporaryDirectory, "key");
238
+ await fs.writeFile(derivedKeyPath, derived.privateKeyFile, { mode: 0o600 });
239
+ let checkoutPath = path.join(temporaryDirectory, "repo");
240
+
241
+ let explain = (problem: string) => new Error(
242
+ `${problem}\n`
243
+ + `Create ${revokeURL} and add this as a deploy key WITH WRITE ACCESS:\n`
244
+ + ` ${derived.publicKey} ${REVOKE_KEY_LABEL}\n`
245
+ + `It has to be this key: it is derived from ${keyPath}, and github will not accept the same`
246
+ + ` public key on two repositories.`
247
+ );
248
+
249
+ console.log(`Checking ${revokeURL}`);
250
+ let clone = await gitWithKey({ keyPath: derivedKeyPath, args: ["clone", revokeURL, checkoutPath], allowFailure: true });
251
+ if (clone.status !== 0) {
252
+ throw explain(`Expected ${revokeURL} to be readable with the derived key, it is not.`);
253
+ }
254
+
255
+ // An empty repo has no branch for the daemon to clone, so it gets its first commit here. That
256
+ // doubles as the proof that we can write to it.
257
+ let head = await gitWithKey({ keyPath: derivedKeyPath, args: ["rev-parse", "HEAD"], cwd: checkoutPath, allowFailure: true });
258
+ if (head.status !== 0) {
259
+ await fs.writeFile(path.join(checkoutPath, "README.md"),
260
+ `# revoked keys\n\nWritten by portsecure. Each file under revocations/ is one key that was used from an\n`
261
+ + `address it is not allowed from, and is no longer accepted anywhere.\n`);
262
+ for (let args of [
263
+ ["add", "-A"],
264
+ ["-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", "initialise revoke repo"],
265
+ ]) {
266
+ await gitWithKey({ keyPath: derivedKeyPath, args, cwd: checkoutPath });
267
+ }
268
+ let push = await gitWithKey({ keyPath: derivedKeyPath, args: ["push", "origin", "HEAD"], cwd: checkoutPath, allowFailure: true });
269
+ if (push.status !== 0) {
270
+ throw explain(`Expected write access to ${revokeURL}, the first push was refused.\n${(push.stdout + push.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`);
271
+ }
272
+ } else {
273
+ let dryRun = await gitWithKey({ keyPath: derivedKeyPath, args: ["push", "--dry-run", "origin", "HEAD"], cwd: checkoutPath, allowFailure: true });
274
+ if (dryRun.status !== 0) {
275
+ throw explain(`Expected write access to ${revokeURL}, a dry run push was refused.\n${(dryRun.stdout + dryRun.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`);
276
+ }
277
+ }
278
+
279
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
280
+ console.log(`${revokeURL} is writable`);
281
+ }
282
+
161
283
  async function readRemoteConfig(host: string) {
162
284
  let contents = await readRemoteFile({ host, filePath: REMOTE_CONFIG_PATH });
163
285
  if (!contents) {
@@ -187,11 +309,41 @@ fi`,
187
309
 
188
310
  async function installDaemon(config: { host: string; hostLabel: string; repoSources: string[] }) {
189
311
  let { host, hostLabel, repoSources } = config;
190
- for (let command of ["node", "git"]) {
312
+ for (let command of ["node", "git", "yarn"]) {
191
313
  if (!await remoteCommandExists({ host, command })) {
192
314
  throw new Error(`Expected ${command} to be installed on ${host}, it is not. Install it and rerun.`);
193
315
  }
194
316
  }
317
+
318
+ // The checkout is brought to the latest commit rather than a copy being pushed, so what runs on
319
+ // the host is exactly what is on github.
320
+ console.log(`Updating ${REMOTE_CHECKOUT_PATH} on ${describeHost(host)}`);
321
+ await runOverSSH({
322
+ host,
323
+ script: `${SUDO_PREAMBLE}
324
+ set -e
325
+ $SUDO mkdir -p "${path.posix.dirname(REMOTE_CHECKOUT_PATH)}"
326
+ if $SUDO test -d "${REMOTE_CHECKOUT_PATH}/.git"; then
327
+ # Stashed first, the way machine-alwaysup does it. Anything sitting modified in a host's
328
+ # checkout is either an accident or somebody editing the daemon in place, and either way it is
329
+ # worth keeping rather than quietly erasing. The identity is passed inline because a host has
330
+ # no git config of its own and stash writes a commit.
331
+ $SUDO git -C "${REMOTE_CHECKOUT_PATH}" add --all
332
+ $SUDO git -C "${REMOTE_CHECKOUT_PATH}" -c user.email=portsecure@localhost -c user.name=portsecure stash
333
+ $SUDO git -C "${REMOTE_CHECKOUT_PATH}" fetch --prune origin
334
+ # A stash and a pull cannot cross a branch that has diverged, and a drifted checkout must not be
335
+ # able to leave a host running old code, so it is put on the remote's state rather than merged
336
+ # with it. set-head so that is the remote's default branch now, not the one it had at clone.
337
+ $SUDO git -C "${REMOTE_CHECKOUT_PATH}" remote set-head origin --auto
338
+ $SUDO git -C "${REMOTE_CHECKOUT_PATH}" reset --hard origin/HEAD
339
+ else
340
+ $SUDO rm -rf "${REMOTE_CHECKOUT_PATH}"
341
+ $SUDO git clone "${SLIFTUTILS_URL}" "${REMOTE_CHECKOUT_PATH}"
342
+ fi
343
+ $SUDO yarn --cwd "${REMOTE_CHECKOUT_PATH}" install --production --non-interactive
344
+ # The single file daemon this replaced, left over on a host set up by an older version.
345
+ $SUDO rm -f /opt/portsecure/portsecure-daemon.js`,
346
+ });
195
347
  await writeRemoteFile({
196
348
  host,
197
349
  filePath: REMOTE_CONFIG_PATH,
@@ -201,13 +353,6 @@ async function installDaemon(config: { host: string; hostLabel: string; repoSour
201
353
  fileMode: "600",
202
354
  directoryMode: "700",
203
355
  });
204
- await writeRemoteFile({
205
- host,
206
- filePath: REMOTE_DAEMON_PATH,
207
- contents: await fs.readFile(DAEMON_SOURCE, "utf8"),
208
- fileMode: "755",
209
- directoryMode: "755",
210
- });
211
356
  await writeRemoteFile({
212
357
  host,
213
358
  filePath: REMOTE_SERVICE_PATH,
@@ -254,7 +399,7 @@ async function requireRemoteWebhook(host: string) {
254
399
  throw new Error(
255
400
  `Expected a Discord webhook at ${DEFAULT_WEBHOOK_FILE_PATH} on ${host}, no such file exists.`
256
401
  + ` The daemon will not start without one.\n`
257
- + `Set it up first:\n yarn setupnotify ${host} <discord-webhook-url>`
402
+ + `Set it up first:\n yarn setupnotify ${host} <discord-webhook-url>`.replace(" <discord", host && " <discord" || "<discord")
258
403
  );
259
404
  }
260
405
  return parseWebhookFile({ contents, sourceName: `${host}:${DEFAULT_WEBHOOK_FILE_PATH}` });
@@ -271,24 +416,29 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
271
416
  if (reachable.status !== 0) {
272
417
  throw new Error(
273
418
  `Expected ${repoURL} to be reachable with ${keyPath}, git ls-remote failed.`
274
- + ` The daemon would have no way to fetch keys.\n${reachable.stderr.slice(0, MAX_ERROR_BODY_LENGTH)}`
419
+ + ` The daemon would have no way to fetch keys.\n`
420
+ + `${(reachable.stdout + reachable.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
275
421
  );
276
422
  }
277
423
 
278
424
  let remoteConfig = await readRemoteConfig(host);
279
425
  if (remoteConfig.repoSources.includes(repoURL)) {
280
- console.log(`${host} already has ${repoURL}, refreshing its key and the daemon.`);
426
+ console.log(`${describeHost(host)} already has ${repoURL}, refreshing its key and the daemon.`);
281
427
  }
282
428
 
283
- // The merged result is what root ends up with, so our own key has to be somewhere in it.
284
- console.log(`Checking our access to ${host} survives the merged keys`);
285
- let ourFingerprint = await findAuthenticatingFingerprint(host);
429
+ // The merged result is what root ends up with, so our own key has to be somewhere in it. On
430
+ // this machine there is no ssh session to preserve, so there is nothing to check.
431
+ let ourFingerprint = "";
432
+ if (host) {
433
+ console.log(`Checking our access to ${describeHost(host)} survives the merged keys`);
434
+ ourFingerprint = await findAuthenticatingFingerprint(host);
435
+ }
286
436
  let inspectionPath = await cloneRepoForInspection({ repoURL, keyPath });
287
437
  let newKeys = await readRepoKeys(inspectionPath);
288
438
  // Whatever is applied on the host came from the existing sources, so it stays in the merge.
289
439
  let existingKeys = normalizeKeys(await readRemoteFile({ host, filePath: ROOT_AUTHORIZED_KEYS }) || "");
290
440
  let mergedFingerprints = await fingerprintKeys([...existingKeys, ...newKeys]);
291
- if (!mergedFingerprints.includes(ourFingerprint)) {
441
+ if (ourFingerprint && !mergedFingerprints.includes(ourFingerprint)) {
292
442
  throw new Error(
293
443
  `Expected the key we use for ${host} to be in the merged keys, it is not.\n`
294
444
  + `Ours: ${ourFingerprint}\n`
@@ -297,10 +447,25 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
297
447
  + ` lock you out of ${host}. Add your public key to ${repoURL} first.`
298
448
  );
299
449
  }
300
- console.log(`Our key ${ourFingerprint} is in the merged keys, access will survive.`);
450
+ if (ourFingerprint) {
451
+ console.log(`Our key ${ourFingerprint} is in the merged keys, access will survive.`);
452
+ }
453
+
454
+ await ensureRevokeRepo({ keyPath, repoURL });
455
+
456
+ // Deploying a repo that still holds a revoked key would hand it back to every machine.
457
+ let revoked = await revokedKeysInRepo({ repoPath: inspectionPath, sourceURL: repoURL });
458
+ if (revoked.length) {
459
+ throw new Error(
460
+ `Expected ${repoURL} to hold no revoked keys, it holds ${revoked.length}:\n`
461
+ + revoked.map(entry => ` ${entry.revocation.fingerprint} revoked by`
462
+ + ` ${entry.revocation.revokedBy || "?"} after use from ${entry.revocation.attempt?.ip || "?"}`).join("\n")
463
+ + `\nDelete them from the repo, or run "yarn unrevoke" there to allow them again.`
464
+ );
465
+ }
301
466
 
302
467
  let webhookURL = await requireRemoteWebhook(host);
303
- console.log(`${host} notifies ${webhookURL}`);
468
+ console.log(`${describeHost(host)} notifies ${webhookURL}`);
304
469
 
305
470
  await writeRemoteFile({
306
471
  host,
@@ -313,7 +478,7 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
313
478
  let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
314
479
  repoSources.push(repoURL);
315
480
  await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
316
- console.log(`${repoURL} added to ${host}. ${repoSources.length} source(s) now merged.`);
481
+ console.log(`${repoURL} added to ${describeHost(host)}. ${repoSources.length} source(s) now merged.`);
317
482
  }
318
483
 
319
484
  async function removeSource(config: { host: string; repoURL: string }) {
@@ -327,9 +492,10 @@ async function removeSource(config: { host: string; repoURL: string }) {
327
492
  }
328
493
  let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
329
494
 
330
- if (repoSources.length) {
495
+ // On this machine there is no ssh session to preserve, so there is nothing to check.
496
+ if (repoSources.length && host) {
331
497
  // The keys left over are what root gets, so our own key has to be among them.
332
- console.log(`Checking our access to ${host} survives without ${repoURL}`);
498
+ console.log(`Checking our access to ${describeHost(host)} survives without ${repoURL}`);
333
499
  let ourFingerprint = await findAuthenticatingFingerprint(host);
334
500
  let remainingKeys: string[] = [];
335
501
  for (let source of repoSources) {
@@ -357,18 +523,42 @@ $SUDO rm -f "${sourceKeyPath(repoURL)}"
357
523
  $SUDO rm -rf "${sourceRepoPath(repoURL)}"`,
358
524
  });
359
525
  await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
360
- console.log(`${repoURL} removed from ${host}. ${repoSources.length} source(s) left.`);
526
+ console.log(`${repoURL} removed from ${describeHost(host)}. ${repoSources.length} source(s) left.`);
361
527
  }
362
528
 
363
529
  /** Answers "who can log into this box, and which repo says so". The paths the daemon uses are
364
530
  left out on purpose, they are plumbing rather than something to act on. */
531
+
532
+ /** Pushes the current daemon onto a host that already has one, for when this code has moved on.
533
+ Nothing about which keys the host trusts is touched. */
534
+ async function updateDaemon(host: string) {
535
+ let contents = await readRemoteFile({ host, filePath: REMOTE_CONFIG_PATH });
536
+ if (!contents) {
537
+ throw new Error(
538
+ `Expected ${host} to already have portsecure, ${REMOTE_CONFIG_PATH} does not exist.\n`
539
+ + `Set it up with:\n yarn securessh ${host} add <repo-private-key> [repo-url]`
540
+ );
541
+ }
542
+ let parsed = JSON.parse(contents) as { hostLabel?: string; repoSources?: string[] };
543
+ let repoSources = parsed.repoSources || [];
544
+ await requireRemoteWebhook(host);
545
+ await ensureRevokeReposExist(repoSources);
546
+ await installDaemon({ host, hostLabel: parsed.hostLabel || host, repoSources });
547
+ console.log(`Updated the daemon on ${describeHost(host)}, and restarted it.`);
548
+ console.log(`Its ${repoSources.length} key source(s) were left as they are, along with the keys and`);
549
+ console.log(`signers it has already accepted. Only the daemon itself changed:`);
550
+ for (let repoURL of repoSources) {
551
+ console.log(` ${repoURL}`);
552
+ }
553
+ }
554
+
365
555
  async function listSources(host: string) {
366
556
  let remoteConfig = await readRemoteConfig(host);
367
557
  if (!remoteConfig.repoSources.length) {
368
- console.log(`${host} has no key sources. root's authorized_keys is left exactly as it is.`);
558
+ console.log(`${describeHost(host)} has no key sources. root's authorized_keys is left exactly as it is.`);
369
559
  return;
370
560
  }
371
- console.log(`${host} lets root log in with the keys from ${remoteConfig.repoSources.length} repo(s):`);
561
+ console.log(`${describeHost(host)} lets root log in with the keys from ${remoteConfig.repoSources.length} repo(s):`);
372
562
  let merged = new Set<string>();
373
563
  for (let repoURL of remoteConfig.repoSources) {
374
564
  let keys = await readRemoteSourceKeys({ host, repoURL });
@@ -399,11 +589,16 @@ function parseArgs(argv: string[]) {
399
589
  throw new Error(`Expected one of ${VERBS.join(", ")}, was ${verbs.join(" and ")}\n${USAGE}`);
400
590
  }
401
591
  let verb = verbs[0];
402
- let [host, ...rest] = argv.filter(arg => arg !== verb);
403
- if (!host) {
404
- throw new Error(`Expected a host, was nothing\n${USAGE}`);
405
- }
406
- return { verb, host, rest };
592
+ let positional = argv.filter(arg => arg !== verb);
593
+ // A host, when there is one, comes first and is a bare name or address. Everything else that
594
+ // can appear here is a path or a repo url, and those all carry a slash, which is what tells
595
+ // them apart. So no host at all means this machine.
596
+ let host = THIS_MACHINE;
597
+ if (positional.length && !/[\/~\\]/.test(positional[0])) {
598
+ host = positional[0];
599
+ positional = positional.slice(1);
600
+ }
601
+ return { verb, host, rest: positional };
407
602
  }
408
603
 
409
604
  export async function main() {
@@ -413,6 +608,13 @@ export async function main() {
413
608
  await listSources(host);
414
609
  return;
415
610
  }
611
+ if (verb === "update") {
612
+ if (rest.length) {
613
+ throw new Error(`Expected nothing after update, was ${rest.length} argument(s)\n${USAGE}`);
614
+ }
615
+ await updateDaemon(host);
616
+ return;
617
+ }
416
618
  if (verb === "add") {
417
619
  if (!rest.length || rest.length > 2) {
418
620
  throw new Error(`Expected a private key and optionally a repo url, was ${rest.length} argument(s)\n${USAGE}`);
@@ -0,0 +1,175 @@
1
+ import fs from "fs/promises";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { runPromise } from "socket-function/src/runPromise";
5
+ import { keyFingerprint, normalizeKeys } from "./authorizedKeys";
6
+ import { revokeRepoURL } from "./revokeSource";
7
+ import { signRepo } from "../signedFiles/signFiles";
8
+ import { readRepoKeys } from "./authorizedKeys";
9
+ import { expandHome } from "../helpers/paths";
10
+ import { spawnPromise } from "../helpers/spawn";
11
+
12
+ const UNREVOKES_DIR = "unrevoked";
13
+ const REVOCATIONS_DIR = "revocations";
14
+ const GIT_KEYWORD = "git";
15
+ const COMMIT_MESSAGE = "unrevoke keys";
16
+ const USAGE = `Usage: yarn unrevoke [keys-repo] [${GIT_KEYWORD}]
17
+
18
+ Run this in a keys repo, or name one. It reads that repo's revoke repo and writes one unrevoke file
19
+ naming every revocation in it, so the keys are accepted again once each machine's hour long wait
20
+ passes. It signs the result, and with ${GIT_KEYWORD} it commits and pushes it too.
21
+
22
+ Keys that were revoked should normally be deleted from the repo instead. Unrevoking only matters
23
+ for a key you still want.`;
24
+
25
+ export type Revocation = {
26
+ revocationId: string;
27
+ fingerprint: string;
28
+ key?: string;
29
+ revokedAt?: string;
30
+ revokedBy?: string;
31
+ attempt?: { ip?: string; user?: string; required?: string };
32
+ };
33
+
34
+ /** Every revocation the revoke repo lists. Cloned read only into a temp directory, with whatever
35
+ credentials this machine already has - the derived deploy key is for servers, and a person
36
+ running this has their own access to the repo. */
37
+ export async function readRemoteRevocations(config: { sourceURL: string }) {
38
+ let { sourceURL } = config;
39
+ let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "unrevoke-"));
40
+ let repoPath = path.join(temporaryDirectory, "repo");
41
+ let clone = await spawnPromise({
42
+ command: "git",
43
+ args: ["clone", "--depth", "1", revokeRepoURL(sourceURL), repoPath],
44
+ });
45
+ if (clone.status !== 0) {
46
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
47
+ throw new Error(
48
+ `Expected to be able to read ${revokeRepoURL(sourceURL)}.\n`
49
+ + `${(clone.stdout + clone.stderr).trim()}`
50
+ );
51
+ }
52
+ let revocations: Revocation[] = [];
53
+ let directory = path.join(repoPath, REVOCATIONS_DIR);
54
+ try {
55
+ for (let name of (await fs.readdir(directory)).sort()) {
56
+ if (name.endsWith(".json")) {
57
+ revocations.push(JSON.parse(await fs.readFile(path.join(directory, name), "utf8")));
58
+ }
59
+ }
60
+ } catch (e) {
61
+ // No revocations directory at all just means nothing has ever been revoked.
62
+ }
63
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
64
+ return revocations;
65
+ }
66
+
67
+ /** Which keys in a repo are revoked. What signfiles and securessh refuse over. */
68
+ export async function revokedKeysInRepo(config: { repoPath: string; sourceURL: string }) {
69
+ let { repoPath, sourceURL } = config;
70
+ let revocations = await readRemoteRevocations({ sourceURL });
71
+ let revokedFingerprints = new Set(revocations.map(revocation => revocation.fingerprint));
72
+ let unrevoked = new Set<string>();
73
+ try {
74
+ let directory = path.join(repoPath, UNREVOKES_DIR);
75
+ for (let name of await fs.readdir(directory)) {
76
+ if (!name.endsWith(".json")) {
77
+ continue;
78
+ }
79
+ let parsed = JSON.parse(await fs.readFile(path.join(directory, name), "utf8"));
80
+ for (let revocationId of parsed.revocationIds || []) {
81
+ unrevoked.add(revocationId);
82
+ }
83
+ }
84
+ } catch (e) {
85
+ // Nothing has been unrevoked.
86
+ }
87
+ let stillRevoked = revocations.filter(revocation => !unrevoked.has(revocation.revocationId));
88
+ let keys = normalizeKeys(await fs.readFile(path.join(repoPath, "authorized_keys"), "utf8").catch(() => ""));
89
+ return stillRevoked
90
+ .filter(revocation => keys.some(key => keyFingerprint(key) === revocation.fingerprint))
91
+ .map(revocation => ({ revocation, key: keys.find(key => keyFingerprint(key) === revocation.fingerprint) || "" }));
92
+ }
93
+
94
+ /** The repo named, or the one we are standing in. Checked the same way securessh checks it: a git
95
+ repo that actually holds keys and has an origin to clone from. */
96
+ async function resolveKeysRepo(named: string | undefined) {
97
+ let cwd = named && expandHome(named) || undefined;
98
+ // spawnPromise rather than runPromise, because these two are read for their value. runPromise
99
+ // returns stdout and stderr joined, so one git warning ends up glued to the front of the path.
100
+ let topLevel = await spawnPromise({ command: "git", args: ["rev-parse", "--show-toplevel"], cwd });
101
+ let repoPath = topLevel.stdout.trim();
102
+ if (topLevel.status !== 0 || !repoPath) {
103
+ throw new Error(
104
+ `Expected ${named || "the current directory"} to be inside a git repo, it is not.\n`
105
+ + `${(topLevel.stdout + topLevel.stderr).trim()}\n${USAGE}`
106
+ );
107
+ }
108
+ try {
109
+ await readRepoKeys(repoPath);
110
+ } catch (e) {
111
+ throw new Error(`Expected ${repoPath} to be a keys repo, it holds no keys.\n${e}\n${USAGE}`);
112
+ }
113
+ let origin = await spawnPromise({ command: "git", args: ["remote", "get-url", "origin"], cwd: repoPath });
114
+ let originURL = origin.stdout.trim();
115
+ if (origin.status !== 0 || !originURL) {
116
+ throw new Error(`Expected ${repoPath} to have an origin remote, it has none.\n${USAGE}`);
117
+ }
118
+ return { repoPath, originURL };
119
+ }
120
+
121
+ export async function main() {
122
+ let argv = process.argv.slice(2);
123
+ let pushToGit = argv.includes(GIT_KEYWORD);
124
+ let positional = argv.filter(arg => arg !== GIT_KEYWORD);
125
+ if (positional.length > 1) {
126
+ throw new Error(`Expected at most a keys repo, was ${positional.length} argument(s)\n${USAGE}`);
127
+ }
128
+ let { repoPath, originURL } = await resolveKeysRepo(positional[0]);
129
+
130
+ let revocations = await readRemoteRevocations({ sourceURL: originURL });
131
+ if (!revocations.length) {
132
+ console.log(`${revokeRepoURL(originURL)} lists no revocations, so there is nothing to undo.`);
133
+ return;
134
+ }
135
+
136
+ let directory = path.join(repoPath, UNREVOKES_DIR);
137
+ await fs.mkdir(directory, { recursive: true });
138
+ let stamp = new Date().toISOString().replace(/[:.]/g, "-");
139
+ let unrevokeId = `${stamp}-unrevoke`;
140
+ await fs.writeFile(path.join(directory, `${unrevokeId}.json`), JSON.stringify({
141
+ unrevokeId,
142
+ createdAt: new Date().toISOString(),
143
+ // Named one by one rather than as a blanket "allow everything again", so this file only
144
+ // ever undoes the revocations that existed when it was written.
145
+ revocationIds: revocations.map(revocation => revocation.revocationId),
146
+ revocations: revocations.map(revocation => ({
147
+ revocationId: revocation.revocationId,
148
+ fingerprint: revocation.fingerprint,
149
+ revokedAt: revocation.revokedAt,
150
+ revokedBy: revocation.revokedBy,
151
+ attempt: revocation.attempt,
152
+ })),
153
+ }, undefined, 4) + "\n");
154
+
155
+ console.log(`Wrote ${UNREVOKES_DIR}/${unrevokeId}.json covering ${revocations.length} revocation(s):`);
156
+ for (let revocation of revocations) {
157
+ console.log(` ${revocation.fingerprint} revoked by ${revocation.revokedBy || "?"} from ${revocation.attempt?.ip || "?"}`);
158
+ }
159
+
160
+ // Signed here rather than left as a step to remember. An unrevoke nobody signed does nothing at
161
+ // all, and the repo would sit there looking done while every machine ignored it.
162
+ await signRepo({ repoPath });
163
+
164
+ if (!pushToGit) {
165
+ console.log(`\nCommit and push it, and each machine will wait an hour after seeing it before`);
166
+ console.log(`those keys work again:`);
167
+ console.log(`\`\`\`\ngit add -A\ngit commit -m "${COMMIT_MESSAGE}"\ngit push\n\`\`\``);
168
+ return;
169
+ }
170
+ await runPromise(`git add -A`, { cwd: repoPath });
171
+ await runPromise(`git commit -m "${COMMIT_MESSAGE}"`, { cwd: repoPath });
172
+ await runPromise(`git push`, { cwd: repoPath });
173
+ console.log(`\nCommitted and pushed. Each machine waits an hour after seeing it before those`);
174
+ console.log(`keys work again.`);
175
+ }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule && mod.default) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true , configurable: true});
6
+ //exports.expandHome = void 0;
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ /** Windows shells do not expand ~ themselves, and hunting down your home folder by hand is
10
+ annoying, so we expand it on every platform. Both separators are accepted, because a Windows
11
+ user may type either one. */
12
+ function expandHome(filePath) {
13
+ if (filePath === "~") {
14
+ return os_1.default.homedir();
15
+ }
16
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
17
+ return path_1.default.join(os_1.default.homedir(), filePath.slice(2));
18
+ }
19
+ if (filePath.startsWith("~")) {
20
+ // ~otheruser needs an account database we cannot read portably, and quietly resolving it
21
+ // to the wrong home would be worse than refusing.
22
+ throw new Error(`Expected ~ or an ordinary path, was ${filePath}. Referring to another user's home with ~name is not supported.`);
23
+ }
24
+ return path_1.default.resolve(filePath);
25
+ }
26
+ exports.expandHome = expandHome;
27
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGF0aHMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJwYXRocy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7QUFBQSw0Q0FBb0I7QUFDcEIsZ0RBQXdCO0FBRXhCOztnQ0FFZ0M7QUFDaEMsU0FBZ0IsVUFBVSxDQUFDLFFBQWdCO0lBQ3ZDLElBQUksUUFBUSxLQUFLLEdBQUcsRUFBRSxDQUFDO1FBQ25CLE9BQU8sWUFBRSxDQUFDLE9BQU8sRUFBRSxDQUFDO0lBQ3hCLENBQUM7SUFDRCxJQUFJLFFBQVEsQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLElBQUksUUFBUSxDQUFDLFVBQVUsQ0FBQyxLQUFLLENBQUMsRUFBRSxDQUFDO1FBQzFELE9BQU8sY0FBSSxDQUFDLElBQUksQ0FBQyxZQUFFLENBQUMsT0FBTyxFQUFFLEVBQUUsUUFBUSxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDO0lBQ3RELENBQUM7SUFDRCxJQUFJLFFBQVEsQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFDLEVBQUUsQ0FBQztRQUMzQix5RkFBeUY7UUFDekYsa0RBQWtEO1FBQ2xELE1BQU0sSUFBSSxLQUFLLENBQUMsdUNBQXVDLFFBQVEsaUVBQWlFLENBQUMsQ0FBQztJQUN0SSxDQUFDO0lBQ0QsT0FBTyxjQUFJLENBQUMsT0FBTyxDQUFDLFFBQVEsQ0FBQyxDQUFDO0FBQ2xDLENBQUM7QUFiRCxnQ0FhQyIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBvcyBmcm9tIFwib3NcIjtcbmltcG9ydCBwYXRoIGZyb20gXCJwYXRoXCI7XG5cbi8qKiBXaW5kb3dzIHNoZWxscyBkbyBub3QgZXhwYW5kIH4gdGhlbXNlbHZlcywgYW5kIGh1bnRpbmcgZG93biB5b3VyIGhvbWUgZm9sZGVyIGJ5IGhhbmQgaXNcbiAgICBhbm5veWluZywgc28gd2UgZXhwYW5kIGl0IG9uIGV2ZXJ5IHBsYXRmb3JtLiBCb3RoIHNlcGFyYXRvcnMgYXJlIGFjY2VwdGVkLCBiZWNhdXNlIGEgV2luZG93c1xuICAgIHVzZXIgbWF5IHR5cGUgZWl0aGVyIG9uZS4gKi9cbmV4cG9ydCBmdW5jdGlvbiBleHBhbmRIb21lKGZpbGVQYXRoOiBzdHJpbmcpIHtcbiAgICBpZiAoZmlsZVBhdGggPT09IFwiflwiKSB7XG4gICAgICAgIHJldHVybiBvcy5ob21lZGlyKCk7XG4gICAgfVxuICAgIGlmIChmaWxlUGF0aC5zdGFydHNXaXRoKFwifi9cIikgfHwgZmlsZVBhdGguc3RhcnRzV2l0aChcIn5cXFxcXCIpKSB7XG4gICAgICAgIHJldHVybiBwYXRoLmpvaW4ob3MuaG9tZWRpcigpLCBmaWxlUGF0aC5zbGljZSgyKSk7XG4gICAgfVxuICAgIGlmIChmaWxlUGF0aC5zdGFydHNXaXRoKFwiflwiKSkge1xuICAgICAgICAvLyB+b3RoZXJ1c2VyIG5lZWRzIGFuIGFjY291bnQgZGF0YWJhc2Ugd2UgY2Fubm90IHJlYWQgcG9ydGFibHksIGFuZCBxdWlldGx5IHJlc29sdmluZyBpdFxuICAgICAgICAvLyB0byB0aGUgd3JvbmcgaG9tZSB3b3VsZCBiZSB3b3JzZSB0aGFuIHJlZnVzaW5nLlxuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoYEV4cGVjdGVkIH4gb3IgYW4gb3JkaW5hcnkgcGF0aCwgd2FzICR7ZmlsZVBhdGh9LiBSZWZlcnJpbmcgdG8gYW5vdGhlciB1c2VyJ3MgaG9tZSB3aXRoIH5uYW1lIGlzIG5vdCBzdXBwb3J0ZWQuYCk7XG4gICAgfVxuICAgIHJldHVybiBwYXRoLnJlc29sdmUoZmlsZVBhdGgpO1xufVxuIl19
28
+ /* _JS_SOURCE_HASH = "aa5698729d5def416d177e211ac514eed7d8c8fcb796c68dcc520ddf0d83526b"; */