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.
- package/bin/testnotify.js +9 -0
- package/package.json +5 -3
- package/security/authorizedKeys/authorizedKeys.ts +49 -9
- package/security/authorizedKeys/daemon/authLog.ts +74 -5
- package/security/authorizedKeys/daemon/changes.ts +26 -0
- package/security/authorizedKeys/daemon/daemon.ts +51 -24
- package/security/authorizedKeys/daemon/revocation.ts +46 -23
- package/security/authorizedKeys/daemon/rootKeys.ts +88 -25
- package/security/authorizedKeys/daemon/sessions.ts +139 -0
- package/security/authorizedKeys/daemon/state.ts +16 -2
- package/security/authorizedKeys/dist/authorizedKeys.ts.cache +49 -11
- package/security/authorizedKeys/dist/secureSSH.ts.cache +18 -31
- package/security/authorizedKeys/dist/unrevoke.ts.cache +36 -14
- package/security/authorizedKeys/secureSSH.ts +77 -61
- package/security/authorizedKeys/unrevoke.ts +38 -12
- package/security/helpers/remoteSSH.ts +17 -6
- package/security/notifications/discord.ts +18 -1
- package/security/notifications/dist/discord.ts.cache +21 -4
- package/security/notifications/setupNotify.ts +17 -8
- package/security/notifications/testNotify.ts +48 -0
- package/security/signedFiles/dist/manifest.ts.cache +21 -4
- package/security/signedFiles/dist/signFiles.ts.cache +48 -13
- package/security/signedFiles/manifest.ts +19 -2
- package/security/signedFiles/signFiles.ts +51 -12
|
@@ -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.
|
|
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
|
|
30
|
-
if (!
|
|
31
|
-
return
|
|
36
|
+
let list = keyRestrictionList(keyLine);
|
|
37
|
+
if (!list) {
|
|
38
|
+
return NO_RESTRICTION;
|
|
32
39
|
}
|
|
33
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
+
|
|
197
|
-
+ `\
|
|
198
|
-
+ `\
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
303
|
+
dropped.push(fingerprint);
|
|
295
304
|
let revocation = state.revocations[fingerprint];
|
|
296
|
-
if (revocation
|
|
297
|
-
|
|
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
|
}
|