sliftutils 1.7.125 → 1.7.126
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/derivekey.js +10 -0
- package/bin/portsecuredaemon.js +13 -0
- package/bin/unrevoke.js +9 -0
- package/package.json +8 -3
- package/security/README.md +66 -2
- package/security/authorizedKeys/authorizedKeys.ts +23 -0
- package/security/authorizedKeys/daemon/authLog.ts +117 -0
- package/security/authorizedKeys/daemon/daemon.ts +308 -0
- package/security/authorizedKeys/daemon/git.ts +119 -0
- package/security/authorizedKeys/daemon/notify.ts +25 -0
- package/security/authorizedKeys/daemon/paths.ts +26 -0
- package/security/authorizedKeys/daemon/portsecure.service +3 -2
- package/security/authorizedKeys/daemon/revocation.ts +306 -0
- package/security/authorizedKeys/daemon/rootKeys.ts +135 -0
- package/security/authorizedKeys/daemon/sshdConfig.ts +85 -0
- package/security/authorizedKeys/daemon/state.ts +108 -0
- package/security/authorizedKeys/daemon/trust.ts +291 -0
- package/security/authorizedKeys/daemon/userKeys.ts +76 -0
- package/security/authorizedKeys/dist/authorizedKeys.ts.cache +73 -0
- package/security/authorizedKeys/dist/revokeSource.ts.cache +44 -0
- package/security/authorizedKeys/dist/secureSSH.ts.cache +552 -0
- package/security/authorizedKeys/dist/sources.ts.cache +24 -0
- package/security/authorizedKeys/dist/unrevoke.ts.cache +145 -0
- package/security/authorizedKeys/revokeSource.ts +40 -0
- package/security/authorizedKeys/secureSSH.ts +201 -15
- package/security/authorizedKeys/unrevoke.ts +149 -0
- package/security/helpers/dist/paths.ts.cache +28 -0
- package/security/helpers/dist/remoteSSH.ts.cache +90 -0
- package/security/helpers/dist/spawn.ts.cache +34 -0
- package/security/helpers/remoteSSH.ts +3 -2
- package/security/helpers/spawn.ts +5 -1
- package/security/keys/deriveKey.ts +72 -0
- package/security/keys/dist/deriveKey.ts.cache +72 -0
- package/security/keys/dist/sshKeyFile.ts.cache +153 -0
- package/security/keys/sshKeyFile.ts +156 -0
- package/security/notifications/dist/discord.ts.cache +180 -0
- package/security/signedFiles/dist/manifest.ts.cache +68 -0
- package/security/signedFiles/dist/signFiles.ts.cache +146 -0
- package/security/signedFiles/manifest.ts +19 -6
- package/security/signedFiles/signFiles.ts +78 -43
- package/security/authorizedKeys/daemon/portsecureDaemon.js +0 -1032
package/bin/derivekey.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// Derives a second ed25519 key from an existing one, so one key can stand behind several
|
|
4
|
+
// identities without any of them being stored.
|
|
5
|
+
require("typenode");
|
|
6
|
+
|
|
7
|
+
require("../security/keys/deriveKey").main().catch(e => {
|
|
8
|
+
console.error(`${e}`);
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
}).finally(() => process.exit());
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// The daemon that keeps a machine's root authorized_keys equal to its key repos. Installed and
|
|
4
|
+
// started by securessh, and normally only run by systemd.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately without the usual finally(process.exit) of the other entry points: this one is
|
|
7
|
+
// meant to keep running after main resolves, and exiting there would stop it dead on startup.
|
|
8
|
+
require("typenode");
|
|
9
|
+
|
|
10
|
+
require("../security/authorizedKeys/daemon/daemon").main().catch(e => {
|
|
11
|
+
console.error(`portsecure: failed to start. ${e && e.stack || e}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
package/bin/unrevoke.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// Undoes revocations, by writing a file into the keys repo naming the ones to undo.
|
|
4
|
+
require("typenode");
|
|
5
|
+
|
|
6
|
+
require("../security/authorizedKeys/unrevoke").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.
|
|
3
|
+
"version": "1.7.126",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"autohost": "node ./bin/autohost.js",
|
|
32
32
|
"setupnotify": "node ./bin/setupnotify.js",
|
|
33
33
|
"securessh": "node ./bin/securessh.js",
|
|
34
|
-
"signfiles": "node ./bin/signfiles.js"
|
|
34
|
+
"signfiles": "node ./bin/signfiles.js",
|
|
35
|
+
"derivekey": "node ./bin/derivekey.js",
|
|
36
|
+
"unrevoke": "node ./bin/unrevoke.js"
|
|
35
37
|
},
|
|
36
38
|
"bin": {
|
|
37
39
|
"filehoster": "./bin/filehoster.js",
|
|
@@ -51,7 +53,10 @@
|
|
|
51
53
|
"sliftsetup": "./builders/setupRun.js",
|
|
52
54
|
"setupnotify": "./bin/setupnotify.js",
|
|
53
55
|
"securessh": "./bin/securessh.js",
|
|
54
|
-
"signfiles": "./bin/signfiles.js"
|
|
56
|
+
"signfiles": "./bin/signfiles.js",
|
|
57
|
+
"derivekey": "./bin/derivekey.js",
|
|
58
|
+
"portsecuredaemon": "./bin/portsecuredaemon.js",
|
|
59
|
+
"unrevoke": "./bin/unrevoke.js"
|
|
55
60
|
},
|
|
56
61
|
"dependencies": {
|
|
57
62
|
"@types/chrome": "^0.0.237",
|
package/security/README.md
CHANGED
|
@@ -22,10 +22,18 @@ off every other way in.
|
|
|
22
22
|
yarn securessh <host> add <repo-private-key> [repo-url]
|
|
23
23
|
yarn securessh <host> remove [repo-url]
|
|
24
24
|
yarn securessh <host> list
|
|
25
|
+
yarn securessh <host> update
|
|
25
26
|
|
|
26
27
|
With no repo url, the repo you are standing in is used, as long as it holds keys and has an
|
|
27
28
|
origin to clone from.
|
|
28
29
|
|
|
30
|
+
The daemon runs on the host, so `update` is how a host picks up a newer build of it. It changes
|
|
31
|
+
nothing about which keys that host trusts.
|
|
32
|
+
|
|
33
|
+
`update` installs the daemon from this checkout, not from anything the host fetches, so it pulls
|
|
34
|
+
this checkout first and stops if that pull does not fast forward. It never installs code it
|
|
35
|
+
cannot account for.
|
|
36
|
+
|
|
29
37
|
Each source repo keeps its own deploy key. Both verbs refuse to run if the key you log in with
|
|
30
38
|
would not survive the change, since that would lock you out of the host permanently.
|
|
31
39
|
|
|
@@ -67,10 +75,66 @@ When a source starts being signed by a different key, the daemon warns on Discor
|
|
|
67
75
|
applying the keys it last accepted. It applies the new ones only after 24 hours of that same new
|
|
68
76
|
signer. Any different signer restarts the wait, so publishing twice in a row gains nothing, and a
|
|
69
77
|
return to the accepted signer cancels it. Losing a signature entirely counts as a change too, so
|
|
70
|
-
stripping it does not get anything through faster.
|
|
78
|
+
stripping it does not get anything through faster. Going the other way, from unsigned to signed,
|
|
79
|
+
is only ever an improvement and applies right away.
|
|
80
|
+
|
|
81
|
+
Every one of those messages names the public key, in the same `type base64` form `signfiles`
|
|
82
|
+
prints, or `<no public key>` when there is none.
|
|
71
83
|
|
|
72
84
|
A signature that does not verify, or a manifest that does not match the files on disk, is never
|
|
73
|
-
treated as an identity - that content is ignored and the last accepted keys stay.
|
|
85
|
+
treated as an identity - that content is ignored and the last accepted keys stay. Which of the
|
|
86
|
+
two it is gets reported:
|
|
87
|
+
|
|
88
|
+
- the signature is byte for byte the one we already accepted, so the repo changed and nobody
|
|
89
|
+
re-signed it. The changes are ignored until someone runs `signfiles` again.
|
|
90
|
+
- the signature did change and does not hold up, so it is corrupt.
|
|
91
|
+
|
|
92
|
+
Either way it is reported once, not every time it is polled.
|
|
93
|
+
|
|
94
|
+
## keys
|
|
95
|
+
|
|
96
|
+
Derives a second ed25519 key from an existing one, by mixing a label into the source key's secret.
|
|
97
|
+
The same label and source always give the same key, so a derived key is something you can work out
|
|
98
|
+
again rather than something you have to keep a backup of.
|
|
99
|
+
|
|
100
|
+
yarn derivekey <label> <source-key> <derived-key>
|
|
101
|
+
yarn derivekey revokegithubkey ~/authorized_keys_access/id_ed25519 ~/authorized_keys_access/id_ed25519_revoke
|
|
102
|
+
|
|
103
|
+
It writes an ordinary OpenSSH key pair, so the public key can be handed to anything that takes one.
|
|
104
|
+
`deriveEd25519Key` in `deriveKey.ts` is the part to import elsewhere, and `sshKeyFile.ts` reads and
|
|
105
|
+
writes the OpenSSH private key container that node itself cannot.
|
|
106
|
+
|
|
107
|
+
### Revoking keys
|
|
108
|
+
|
|
109
|
+
A key that is used from an address its `from=` restriction does not allow is revoked everywhere,
|
|
110
|
+
not just refused. The daemon reads sshd's log for exactly that refusal, takes the fingerprint sshd
|
|
111
|
+
names for the same connection, and writes a revocation to the source's revoke repo.
|
|
112
|
+
|
|
113
|
+
The revoke repo is derived from the source: `…/authorized_keys.git` gets `…/authorized_keys_revoked.git`,
|
|
114
|
+
reached with a key derived from the source's deploy key under the label `revokegithubkey`. Github
|
|
115
|
+
will not take one public key on two repos, which is why it is derived rather than reused, and it
|
|
116
|
+
means nothing extra has to be configured or uploaded - anything holding the source key can work it
|
|
117
|
+
out. `securessh add` checks that repo exists and is writable, and prints the deploy key to add if
|
|
118
|
+
it is not.
|
|
119
|
+
|
|
120
|
+
- One revocation per key, ever. The file is named after the fingerprint, and the key is checked
|
|
121
|
+
against local state before any network work, so a flood of unknown keys cannot become a flood of
|
|
122
|
+
commits.
|
|
123
|
+
- A revocation is sticky once seen. Deleting it from the repo does not bring the key back: the key
|
|
124
|
+
that writes revocations is on every server, so whoever stole one could otherwise erase the record
|
|
125
|
+
that locked them out. Recovering from that means making a new key, which you would want anyway.
|
|
126
|
+
- Each machine says so on Discord when a revoked key actually leaves its authorized_keys, once.
|
|
127
|
+
|
|
128
|
+
yarn unrevoke [keys-repo]
|
|
129
|
+
|
|
130
|
+
Run in the keys repo, or name one. It uses whatever git credentials the machine already has, since
|
|
131
|
+
the derived deploy key is for servers rather than for people. It reads the revoke repo and writes
|
|
132
|
+
one file under `unrevoked/` naming the revocations to undo, which then needs signing and pushing. Machines report when they see it, hold
|
|
133
|
+
it for an hour, then report again when it takes effect - so a signing key that was itself stolen
|
|
134
|
+
cannot instantly undo the revocation that shut it out.
|
|
135
|
+
|
|
136
|
+
Deleting a revoked key from the repo is usually the right answer instead. Both `signfiles` and
|
|
137
|
+
`securessh` refuse while a repo still holds a revoked key, and say which one it is.
|
|
74
138
|
|
|
75
139
|
## helpers
|
|
76
140
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
1
2
|
import fs from "fs/promises";
|
|
2
3
|
import path from "path";
|
|
3
4
|
|
|
@@ -10,6 +11,28 @@ export function normalizeKeys(contents: string) {
|
|
|
10
11
|
return contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/** The fingerprint ssh itself reports for a key, which is what the sshd log names and therefore
|
|
15
|
+
what a revocation is keyed by. Returns "" for a line that holds no key. */
|
|
16
|
+
export function keyFingerprint(keyLine: string) {
|
|
17
|
+
let parts = keyLine.trim().split(/\s+/);
|
|
18
|
+
let typeIndex = parts.findIndex(part => /^(ssh-|ecdsa-|sk-)/.test(part));
|
|
19
|
+
let blob = typeIndex >= 0 && parts[typeIndex + 1] || "";
|
|
20
|
+
if (!blob) {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
return "SHA256:" + crypto.createHash("sha256").update(Buffer.from(blob, "base64")).digest("base64").replace(/=+$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The addresses a key may be used from, which is the part of an authorized_keys line that
|
|
27
|
+
decides how much a stolen key is worth. A key with no restriction says so loudly. */
|
|
28
|
+
export function keyRestriction(keyLine: string) {
|
|
29
|
+
let match = keyLine.match(/from="([^"]*)"/);
|
|
30
|
+
if (!match) {
|
|
31
|
+
return "ANY ADDRESS (no from= restriction)";
|
|
32
|
+
}
|
|
33
|
+
return match[1];
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
/** Enough to recognise whose key this is without printing the whole blob. */
|
|
14
37
|
export function summarizeKey(keyLine: string) {
|
|
15
38
|
let parts = keyLine.trim().split(/\s+/);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import { spawnPromise } from "../../helpers/spawn";
|
|
4
|
+
import { AUTH_LOG_PATH } from "./paths";
|
|
5
|
+
import { getState, saveState } from "./state";
|
|
6
|
+
import { Attempt } from "./revocation";
|
|
7
|
+
|
|
8
|
+
// sshd says an attempt was refused in one line and names the key in another, both for the same
|
|
9
|
+
// connection, so they are tied together by the process id the log puts on every line.
|
|
10
|
+
const REFUSED = /Authentication tried for (\S+) with correct key but not from a permitted host \(host=([^,]*), ip=([^,]*), required=([^)]*)\)/;
|
|
11
|
+
const FAILED_KEY = /Failed publickey for (\S+) from (\S+) port (\d+) ssh2: \S+ (SHA256:[A-Za-z0-9+/=]+)/;
|
|
12
|
+
const PROCESS_ID = /(?:sshd|sshd-session)\[(\d+)\]/;
|
|
13
|
+
// Enough of the head of the file to notice it was rotated out from under us.
|
|
14
|
+
const SIGNATURE_LENGTH = 512;
|
|
15
|
+
|
|
16
|
+
export type RefusedAttempt = { fingerprint: string; attempt: Attempt };
|
|
17
|
+
|
|
18
|
+
/** 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. */
|
|
20
|
+
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
|
+
for (let line of contents.split("\n")) {
|
|
24
|
+
let processMatch = line.match(PROCESS_ID);
|
|
25
|
+
if (!processMatch) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
let processId = processMatch[1];
|
|
29
|
+
let refused = line.match(REFUSED);
|
|
30
|
+
if (refused) {
|
|
31
|
+
let existing = refusals.get(processId) || [];
|
|
32
|
+
existing.push({ user: refused[1], ip: refused[3], required: refused[4], line: line.trim() });
|
|
33
|
+
refusals.set(processId, existing);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
let failed = line.match(FAILED_KEY);
|
|
37
|
+
if (failed) {
|
|
38
|
+
fingerprints.set(processId, { fingerprint: failed[4], port: failed[3] });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let attempts: RefusedAttempt[] = [];
|
|
43
|
+
for (let [processId, entries] of refusals) {
|
|
44
|
+
let key = fingerprints.get(processId);
|
|
45
|
+
if (!key) {
|
|
46
|
+
console.log(`A refused attempt named no key, so nothing is being revoked for it: ${entries[0].line}`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (let entry of entries) {
|
|
50
|
+
attempts.push({
|
|
51
|
+
fingerprint: key.fingerprint,
|
|
52
|
+
attempt: {
|
|
53
|
+
ip: entry.ip,
|
|
54
|
+
user: entry.user,
|
|
55
|
+
port: key.port,
|
|
56
|
+
required: entry.required,
|
|
57
|
+
line: entry.line,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return attempts;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Only what has been written since last time. A rotated file starts again from the beginning,
|
|
66
|
+
and a file that only grew is read from where we stopped. */
|
|
67
|
+
export async function readNewAuthLog() {
|
|
68
|
+
let state = getState();
|
|
69
|
+
let handle;
|
|
70
|
+
try {
|
|
71
|
+
handle = await fs.open(AUTH_LOG_PATH, "r");
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// Some machines only keep this in the journal.
|
|
74
|
+
let result = await spawnPromise({
|
|
75
|
+
command: "journalctl",
|
|
76
|
+
args: ["-u", "ssh", "-u", "sshd", "--no-pager", "--since", "-10min"],
|
|
77
|
+
});
|
|
78
|
+
if (result.status !== 0) {
|
|
79
|
+
console.log(`No auth log to read: ${AUTH_LOG_PATH} is unreadable and journalctl exited ${result.status}`);
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
return result.stdout;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
let stats = await handle.stat();
|
|
86
|
+
let head = Buffer.alloc(Math.min(SIGNATURE_LENGTH, stats.size));
|
|
87
|
+
await handle.read(head, 0, head.length, 0);
|
|
88
|
+
let signature = crypto.createHash("sha256").update(head).digest("hex");
|
|
89
|
+
|
|
90
|
+
if (!state.authLogSignature) {
|
|
91
|
+
// First time we have ever looked. Start from the end: the log holds history from
|
|
92
|
+
// before this machine watched it, and revoking keys over refusals nobody was watching
|
|
93
|
+
// for could take away access that is still in use.
|
|
94
|
+
state.authLogOffset = stats.size;
|
|
95
|
+
state.authLogSignature = signature;
|
|
96
|
+
await saveState();
|
|
97
|
+
console.log(`Watching ${AUTH_LOG_PATH} from its current end, ${stats.size} bytes in`);
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
let offset = state.authLogOffset;
|
|
101
|
+
if (signature !== state.authLogSignature || stats.size < offset) {
|
|
102
|
+
// Rotated, or replaced. Everything in the new file is new.
|
|
103
|
+
offset = 0;
|
|
104
|
+
}
|
|
105
|
+
if (stats.size === offset) {
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
let contents = Buffer.alloc(stats.size - offset);
|
|
109
|
+
await handle.read(contents, 0, contents.length, offset);
|
|
110
|
+
state.authLogOffset = stats.size;
|
|
111
|
+
state.authLogSignature = signature;
|
|
112
|
+
await saveState();
|
|
113
|
+
return contents.toString("utf8");
|
|
114
|
+
} finally {
|
|
115
|
+
await handle.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import { configureDiscordNotifications, DEFAULT_WEBHOOK_FILE_PATH } from "../../notifications/discord";
|
|
4
|
+
import { sourceKeyPath, sourceRepoPath } from "../sources";
|
|
5
|
+
import { cloneRepo, syncRepo } from "./git";
|
|
6
|
+
import {
|
|
7
|
+
CHECK_INTERVAL,
|
|
8
|
+
CONFIG_PATH,
|
|
9
|
+
MAX_REPO_FAILURES_BEFORE_RECLONE,
|
|
10
|
+
ROOT_AUTHORIZED_KEYS,
|
|
11
|
+
} from "./paths";
|
|
12
|
+
import { notify, setHostLabel } from "./notify";
|
|
13
|
+
import { enforceRootKeys } from "./rootKeys";
|
|
14
|
+
import { enforceSSHDConfig } from "./sshdConfig";
|
|
15
|
+
import { getState, loadState, saveState, sourceState } from "./state";
|
|
16
|
+
import { resolveSourceKeys } from "./trust";
|
|
17
|
+
import { parseAuthLog, readNewAuthLog } from "./authLog";
|
|
18
|
+
import { absorbRevocations, applyUnrevokes, recordRevocation, removeRevokedKeys, syncRevokeRepo } from "./revocation";
|
|
19
|
+
import { keyFingerprint } from "../authorizedKeys";
|
|
20
|
+
import { checkOtherUserKeys, seedUserKeys } from "./userKeys";
|
|
21
|
+
|
|
22
|
+
// portsecure authorized-keys daemon.
|
|
23
|
+
//
|
|
24
|
+
// It owns root's authorized_keys: the contents come from one or more git repos, anything else is
|
|
25
|
+
// reverted, and password authentication is turned off so those repos are the only way in.
|
|
26
|
+
//
|
|
27
|
+
// The complete list of things that send a Discord message. Nothing else may be added to it
|
|
28
|
+
// 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.
|
|
32
|
+
// 4. A source's history was rewritten.
|
|
33
|
+
// 5. A source started being signed by a different key, so its new keys are being held.
|
|
34
|
+
// 6. A source is now signed when it was not before, applied right away.
|
|
35
|
+
// 7. A source changed without its signature being updated, so the change is ignored.
|
|
36
|
+
// 8. A source has a corrupted signature, so its contents are ignored.
|
|
37
|
+
// 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
|
+
|
|
43
|
+
export type DaemonConfig = {
|
|
44
|
+
repoSources: string[];
|
|
45
|
+
hostLabel: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let config: DaemonConfig = { repoSources: [], hostLabel: "" };
|
|
49
|
+
let repoFailureCounts: { [repoURL: string]: number } = {};
|
|
50
|
+
|
|
51
|
+
export function getConfig() {
|
|
52
|
+
return config;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function setConfig(value: DaemonConfig) {
|
|
56
|
+
config = value;
|
|
57
|
+
setHostLabel(value.hostLabel);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function loadConfig(): Promise<DaemonConfig> {
|
|
61
|
+
let contents;
|
|
62
|
+
try {
|
|
63
|
+
contents = await fs.readFile(CONFIG_PATH, "utf8");
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error(`portsecure: expected a config file at ${CONFIG_PATH}, ${e}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
let parsed = JSON.parse(contents) as { repoSources?: string[]; hostLabel?: string };
|
|
69
|
+
if (!Array.isArray(parsed.repoSources)) {
|
|
70
|
+
console.error(`portsecure: expected a repoSources array in ${CONFIG_PATH}, was ${JSON.stringify(parsed.repoSources)}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
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}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
repoSources: parsed.repoSources,
|
|
83
|
+
// The machine knows its own name, the config only overrides it when a nicer label helps.
|
|
84
|
+
hostLabel: parsed.hostLabel || os.hostname(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The union of every source, in source order, with duplicates dropped. A source that cannot be
|
|
89
|
+
read is skipped rather than emptying the merged set, so one broken repo cannot revoke the keys
|
|
90
|
+
that came from the others. */
|
|
91
|
+
export async function readAllowedKeys() {
|
|
92
|
+
let keys: string[] = [];
|
|
93
|
+
let seen = new Set<string>();
|
|
94
|
+
for (let repoURL of config.repoSources) {
|
|
95
|
+
let sourceKeys: string[];
|
|
96
|
+
try {
|
|
97
|
+
sourceKeys = await resolveSourceKeys(repoURL);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.log(`Skipping ${repoURL}, its checkout could not be read. ${e}`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (let key of sourceKeys) {
|
|
103
|
+
if (seen.has(key)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
seen.add(key);
|
|
107
|
+
keys.push(key);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return keys;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Which source contributed a key, so its revocation is written to that source's revoke repo. */
|
|
114
|
+
function sourceOfFingerprint(fingerprint: string) {
|
|
115
|
+
for (let repoURL of config.repoSources) {
|
|
116
|
+
if (sourceState(repoURL).acceptedKeys.some(key => keyFingerprint(key) === fingerprint)) {
|
|
117
|
+
return repoURL;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return config.repoSources[0] || "";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Anything sshd refused because of a from= restriction gets that key revoked everywhere. Reading
|
|
124
|
+
the log is cheap and local, and the fingerprint is checked against what we already revoked
|
|
125
|
+
before any network work happens.
|
|
126
|
+
|
|
127
|
+
A refusal is queued rather than acted on directly, because the log is read once and moves past:
|
|
128
|
+
if the revoke repo is unreachable at that moment, dropping the refusal would leave a key that
|
|
129
|
+
was misused accepted forever. The queue is retried until it is written down. */
|
|
130
|
+
async function queueRefusedKeys(allowedKeys: string[]) {
|
|
131
|
+
let contents = await readNewAuthLog();
|
|
132
|
+
if (!contents.trim()) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
let state = getState();
|
|
136
|
+
for (let found of parseAuthLog(contents)) {
|
|
137
|
+
// One key is revoked once, no matter how many addresses it was tried from.
|
|
138
|
+
if (state.revocations[found.fingerprint]) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (state.pendingRevocations.some(pending => pending.fingerprint === found.fingerprint)) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
let sourceURL = sourceOfFingerprint(found.fingerprint);
|
|
145
|
+
if (!sourceURL) {
|
|
146
|
+
console.log(`Nowhere to record the revocation of ${found.fingerprint}, no sources are configured`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
console.log(`Queued the revocation of ${found.fingerprint}, used from ${found.attempt.ip}`);
|
|
150
|
+
state.pendingRevocations.push({
|
|
151
|
+
fingerprint: found.fingerprint,
|
|
152
|
+
keyLine: allowedKeys.find(key => keyFingerprint(key) === found.fingerprint) || "",
|
|
153
|
+
sourceURL,
|
|
154
|
+
attempt: found.attempt,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
await saveState();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Writes down everything queued that we have not managed to write down yet. */
|
|
161
|
+
async function writeQueuedRevocations() {
|
|
162
|
+
let state = getState();
|
|
163
|
+
if (!state.pendingRevocations.length) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
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
|
+
}
|
|
175
|
+
}
|
|
176
|
+
state.pendingRevocations = remaining;
|
|
177
|
+
await saveState();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Syncs one source. Returns whether the merged keys need reapplying. */
|
|
181
|
+
async function pollSource(repoURL: string) {
|
|
182
|
+
let result;
|
|
183
|
+
try {
|
|
184
|
+
result = await syncRepo(repoURL);
|
|
185
|
+
repoFailureCounts[repoURL] = 0;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
let failures = (repoFailureCounts[repoURL] || 0) + 1;
|
|
188
|
+
repoFailureCounts[repoURL] = failures;
|
|
189
|
+
console.log(`Sync of ${repoURL} failed (${failures} in a row). ${e}`);
|
|
190
|
+
if (failures < MAX_REPO_FAILURES_BEFORE_RECLONE) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
// Availability over tidiness: throw the working copy away and start again.
|
|
194
|
+
console.log(`Discarding the checkout of ${repoURL} and cloning from scratch`);
|
|
195
|
+
try {
|
|
196
|
+
await cloneRepo({ repoURL, repoPath: sourceRepoPath(repoURL), keyPath: sourceKeyPath(repoURL) });
|
|
197
|
+
repoFailureCounts[repoURL] = 0;
|
|
198
|
+
return true;
|
|
199
|
+
} catch (cloneError) {
|
|
200
|
+
console.log(`${repoURL} cannot be reached or cloned, its last known keys stay in place. ${cloneError}`);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (result.historyRewritten) {
|
|
206
|
+
await notify(
|
|
207
|
+
`the history of \`${repoURL}\` was rewritten. Commit \`${result.previousSha.slice(0, 12)}\` is no`
|
|
208
|
+
+ ` longer an ancestor of \`${result.remoteSha.slice(0, 12)}\`, so history was force pushed or`
|
|
209
|
+
+ ` tampered with. The new state has been applied.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (!result.changed) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
sourceState(repoURL).lastSha = result.remoteSha;
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function everyCheck() {
|
|
220
|
+
let anyChanged = false;
|
|
221
|
+
for (let repoURL of config.repoSources) {
|
|
222
|
+
// One unreachable source must not stop the others from being checked.
|
|
223
|
+
try {
|
|
224
|
+
anyChanged = await pollSource(repoURL) || anyChanged;
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.log(`Polling ${repoURL} failed. ${e && (e as Error).stack || e}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (anyChanged) {
|
|
230
|
+
await saveState();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// What other machines have revoked, and anything published to undo a revocation.
|
|
234
|
+
for (let repoURL of config.repoSources) {
|
|
235
|
+
await syncRevokeRepo(repoURL);
|
|
236
|
+
}
|
|
237
|
+
await absorbRevocations(config.repoSources);
|
|
238
|
+
await applyUnrevokes(config.repoSources);
|
|
239
|
+
|
|
240
|
+
let mergedKeys = await readAllowedKeys();
|
|
241
|
+
await queueRefusedKeys(mergedKeys);
|
|
242
|
+
await writeQueuedRevocations();
|
|
243
|
+
|
|
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" });
|
|
247
|
+
await checkOtherUserKeys();
|
|
248
|
+
await enforceSSHDConfig();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function startInterval(config: { intervalTime: number; run: () => Promise<void>; name: string }) {
|
|
252
|
+
let { intervalTime, run, name } = config;
|
|
253
|
+
let running = false;
|
|
254
|
+
let tick = async () => {
|
|
255
|
+
if (running) {
|
|
256
|
+
console.log(`Skipping ${name}, the previous run has not finished`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
running = true;
|
|
260
|
+
try {
|
|
261
|
+
await run();
|
|
262
|
+
} catch (e) {
|
|
263
|
+
// Every scheduled job swallows its own errors, the daemon must outlive any single one.
|
|
264
|
+
console.log(`${name} failed. ${e && (e as Error).stack || e}`);
|
|
265
|
+
}
|
|
266
|
+
running = false;
|
|
267
|
+
};
|
|
268
|
+
setInterval(tick, intervalTime);
|
|
269
|
+
return tick;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function main() {
|
|
273
|
+
setConfig(await loadConfig());
|
|
274
|
+
await loadState();
|
|
275
|
+
await configureDiscordNotifications({ filePath: DEFAULT_WEBHOOK_FILE_PATH });
|
|
276
|
+
|
|
277
|
+
console.log(`Starting, ${config.repoSources.length} source(s), keys applied to ${ROOT_AUTHORIZED_KEYS}`);
|
|
278
|
+
|
|
279
|
+
// A first pass has to happen before the intervals, so a machine is correct immediately after
|
|
280
|
+
// boot rather than a minute later.
|
|
281
|
+
for (let repoURL of config.repoSources) {
|
|
282
|
+
try {
|
|
283
|
+
sourceState(repoURL).lastSha = (await syncRepo(repoURL)).remoteSha;
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.log(`Initial sync of ${repoURL} failed, continuing with whatever is on disk. ${e}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
await saveState();
|
|
289
|
+
await seedUserKeys();
|
|
290
|
+
|
|
291
|
+
for (let repoURL of config.repoSources) {
|
|
292
|
+
await syncRevokeRepo(repoURL);
|
|
293
|
+
}
|
|
294
|
+
await absorbRevocations(config.repoSources);
|
|
295
|
+
await enforceRootKeys({ keys: await removeRevokedKeys(await readAllowedKeys()), reason: "repo" });
|
|
296
|
+
await enforceSSHDConfig();
|
|
297
|
+
|
|
298
|
+
// configureDiscordNotifications watches the webhook file on its own, so there is nothing to
|
|
299
|
+
// schedule for it here.
|
|
300
|
+
startInterval({ name: "check", intervalTime: CHECK_INTERVAL, run: everyCheck });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
process.on("uncaughtException", e => console.log(`Uncaught exception, staying up. ${e && e.stack || e}`));
|
|
304
|
+
process.on("unhandledRejection", e => console.log(`Unhandled rejection, staying up. ${e}`));
|
|
305
|
+
process.on("SIGTERM", () => {
|
|
306
|
+
console.log("Received SIGTERM, exiting");
|
|
307
|
+
process.exit(0);
|
|
308
|
+
});
|