sliftutils 1.7.126 → 1.7.128
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +58 -30
- package/security/authorizedKeys/daemon/git.ts +3 -3
- package/security/authorizedKeys/daemon/revocation.ts +60 -31
- 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/revokeSource.ts +12 -2
- package/security/authorizedKeys/secureSSH.ts +116 -64
- package/security/authorizedKeys/sources.ts +47 -5
- package/security/authorizedKeys/unrevoke.ts +55 -20
- 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.128",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"securessh": "node ./bin/securessh.js",
|
|
34
34
|
"signfiles": "node ./bin/signfiles.js",
|
|
35
35
|
"derivekey": "node ./bin/derivekey.js",
|
|
36
|
-
"unrevoke": "node ./bin/unrevoke.js"
|
|
36
|
+
"unrevoke": "node ./bin/unrevoke.js",
|
|
37
|
+
"testnotify": "node ./bin/testnotify.js"
|
|
37
38
|
},
|
|
38
39
|
"bin": {
|
|
39
40
|
"filehoster": "./bin/filehoster.js",
|
|
@@ -56,7 +57,8 @@
|
|
|
56
57
|
"signfiles": "./bin/signfiles.js",
|
|
57
58
|
"derivekey": "./bin/derivekey.js",
|
|
58
59
|
"portsecuredaemon": "./bin/portsecuredaemon.js",
|
|
59
|
-
"unrevoke": "./bin/unrevoke.js"
|
|
60
|
+
"unrevoke": "./bin/unrevoke.js",
|
|
61
|
+
"testnotify": "./bin/testnotify.js"
|
|
60
62
|
},
|
|
61
63
|
"dependencies": {
|
|
62
64
|
"@types/chrome": "^0.0.237",
|
|
@@ -2,11 +2,6 @@ import crypto from "crypto";
|
|
|
2
2
|
import fs from "fs/promises";
|
|
3
3
|
import path from "path";
|
|
4
4
|
|
|
5
|
-
// PORTED CODE: security/authorizedKeys/daemon/portsecureDaemon.js contains a plain JS port of normalizeKeys,
|
|
6
|
-
// summarizeKey and readRepoKeys, so it can resolve the same keys with no dependencies. The two
|
|
7
|
-
// must agree on which keys a repo produces - if you change one, make the matching change in the
|
|
8
|
-
// other.
|
|
9
|
-
|
|
10
5
|
export function normalizeKeys(contents: string) {
|
|
11
6
|
return contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
|
|
12
7
|
}
|
|
@@ -23,14 +18,26 @@ export function keyFingerprint(keyLine: string) {
|
|
|
23
18
|
return "SHA256:" + crypto.createHash("sha256").update(Buffer.from(blob, "base64")).digest("base64").replace(/=+$/, "");
|
|
24
19
|
}
|
|
25
20
|
|
|
21
|
+
export const NO_RESTRICTION = "ANY ADDRESS (no from= restriction)";
|
|
22
|
+
|
|
23
|
+
/** The addresses a key may be used from, one by one, or undefined when the key carries no from=
|
|
24
|
+
at all. Undefined and an empty list are very different things, so they stay distinguishable. */
|
|
25
|
+
export function keyRestrictionList(keyLine: string) {
|
|
26
|
+
let match = keyLine.match(/from="([^"]*)"/);
|
|
27
|
+
if (!match) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return match[1].split(",").map(entry => entry.trim()).filter(entry => entry);
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
/** The addresses a key may be used from, which is the part of an authorized_keys line that
|
|
27
34
|
decides how much a stolen key is worth. A key with no restriction says so loudly. */
|
|
28
35
|
export function keyRestriction(keyLine: string) {
|
|
29
|
-
let
|
|
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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import os from "os";
|
|
3
3
|
import { configureDiscordNotifications, DEFAULT_WEBHOOK_FILE_PATH } from "../../notifications/discord";
|
|
4
|
-
import { sourceKeyPath, sourceRepoPath } from "../sources";
|
|
4
|
+
import { findSourceKey, legacySourceKeyPath, sourceKeyPath, sourceRepoPath } from "../sources";
|
|
5
5
|
import { cloneRepo, syncRepo } from "./git";
|
|
6
6
|
import {
|
|
7
7
|
CHECK_INTERVAL,
|
|
@@ -14,9 +14,10 @@ import { enforceRootKeys } from "./rootKeys";
|
|
|
14
14
|
import { enforceSSHDConfig } from "./sshdConfig";
|
|
15
15
|
import { getState, loadState, saveState, sourceState } from "./state";
|
|
16
16
|
import { resolveSourceKeys } from "./trust";
|
|
17
|
-
import { parseAuthLog, readNewAuthLog } from "./authLog";
|
|
17
|
+
import { parseAuthLog, readNewAuthLog, watchAuthLog } from "./authLog";
|
|
18
18
|
import { absorbRevocations, applyUnrevokes, recordRevocation, removeRevokedKeys, syncRevokeRepo } from "./revocation";
|
|
19
19
|
import { keyFingerprint } from "../authorizedKeys";
|
|
20
|
+
import { addChangeReason } from "./changes";
|
|
20
21
|
import { checkOtherUserKeys, seedUserKeys } from "./userKeys";
|
|
21
22
|
|
|
22
23
|
// portsecure authorized-keys daemon.
|
|
@@ -26,19 +27,21 @@ import { checkOtherUserKeys, seedUserKeys } from "./userKeys";
|
|
|
26
27
|
//
|
|
27
28
|
// The complete list of things that send a Discord message. Nothing else may be added to it
|
|
28
29
|
// without the user asking for that specific case - everything else goes to the log.
|
|
29
|
-
//
|
|
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[];
|
|
@@ -71,10 +74,11 @@ async function loadConfig(): Promise<DaemonConfig> {
|
|
|
71
74
|
process.exit(1);
|
|
72
75
|
}
|
|
73
76
|
for (let repoURL of parsed.repoSources) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
if (!await findSourceKey(repoURL)) {
|
|
78
|
+
console.error(
|
|
79
|
+
`portsecure: expected the private key for ${repoURL} at ${sourceKeyPath(repoURL)}`
|
|
80
|
+
+ ` or ${legacySourceKeyPath(repoURL)}, neither exists`
|
|
81
|
+
);
|
|
78
82
|
process.exit(1);
|
|
79
83
|
}
|
|
80
84
|
}
|
|
@@ -157,24 +161,41 @@ async function queueRefusedKeys(allowedKeys: string[]) {
|
|
|
157
161
|
await saveState();
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
// The log watcher and the periodic check both reach this, and two of them pushing to the same
|
|
165
|
+
// revoke repo at once would only fight each other.
|
|
166
|
+
let writingQueued = false;
|
|
167
|
+
|
|
160
168
|
/** Writes down everything queued that we have not managed to write down yet. */
|
|
161
169
|
async function writeQueuedRevocations() {
|
|
162
170
|
let state = getState();
|
|
163
|
-
if (!state.pendingRevocations.length) {
|
|
171
|
+
if (!state.pendingRevocations.length || writingQueued) {
|
|
164
172
|
return;
|
|
165
173
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
+
writingQueued = true;
|
|
175
|
+
try {
|
|
176
|
+
let remaining = [];
|
|
177
|
+
for (let pending of state.pendingRevocations) {
|
|
178
|
+
if (state.revocations[pending.fingerprint]) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
let recorded = await recordRevocation({ ...pending, hostLabel: config.hostLabel });
|
|
182
|
+
if (!recorded && !state.revocations[pending.fingerprint]) {
|
|
183
|
+
remaining.push(pending);
|
|
184
|
+
}
|
|
174
185
|
}
|
|
186
|
+
state.pendingRevocations = remaining;
|
|
187
|
+
await saveState();
|
|
188
|
+
} finally {
|
|
189
|
+
writingQueued = false;
|
|
175
190
|
}
|
|
176
|
-
|
|
177
|
-
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Everything the arrival of a refused login needs: read what is new, queue it, write it down.
|
|
194
|
+
Kept small, and off the merged key set the periodic check rebuilds, so reacting to a log line
|
|
195
|
+
cannot race that check. */
|
|
196
|
+
async function onAuthLogChanged() {
|
|
197
|
+
await queueRefusedKeys(getState().appliedKeys);
|
|
198
|
+
await writeQueuedRevocations();
|
|
178
199
|
}
|
|
179
200
|
|
|
180
201
|
/** Syncs one source. Returns whether the merged keys need reapplying. */
|
|
@@ -193,7 +214,7 @@ async function pollSource(repoURL: string) {
|
|
|
193
214
|
// Availability over tidiness: throw the working copy away and start again.
|
|
194
215
|
console.log(`Discarding the checkout of ${repoURL} and cloning from scratch`);
|
|
195
216
|
try {
|
|
196
|
-
await cloneRepo({ repoURL, repoPath: sourceRepoPath(repoURL), keyPath: sourceKeyPath(repoURL) });
|
|
217
|
+
await cloneRepo({ repoURL, repoPath: sourceRepoPath(repoURL), keyPath: await findSourceKey(repoURL) || sourceKeyPath(repoURL) });
|
|
197
218
|
repoFailureCounts[repoURL] = 0;
|
|
198
219
|
return true;
|
|
199
220
|
} catch (cloneError) {
|
|
@@ -212,6 +233,9 @@ async function pollSource(repoURL: string) {
|
|
|
212
233
|
if (!result.changed) {
|
|
213
234
|
return false;
|
|
214
235
|
}
|
|
236
|
+
// Said when the file is written, and only if it came out different. A commit that does not
|
|
237
|
+
// touch the keys is not worth telling anyone about.
|
|
238
|
+
addChangeReason(`\`${repoURL}\` moved to \`${result.remoteSha.slice(0, 12)}\`.`);
|
|
215
239
|
sourceState(repoURL).lastSha = result.remoteSha;
|
|
216
240
|
return true;
|
|
217
241
|
}
|
|
@@ -238,12 +262,11 @@ async function everyCheck() {
|
|
|
238
262
|
await applyUnrevokes(config.repoSources);
|
|
239
263
|
|
|
240
264
|
let mergedKeys = await readAllowedKeys();
|
|
241
|
-
|
|
265
|
+
// The log is watched, not polled. This is only the retry for anything that could not be
|
|
266
|
+
// written down when it happened, because the revoke repo was unreachable.
|
|
242
267
|
await writeQueuedRevocations();
|
|
243
268
|
|
|
244
|
-
|
|
245
|
-
// than as somebody having edited the file locally.
|
|
246
|
-
await enforceRootKeys({ keys: await removeRevokedKeys(mergedKeys), reason: anyChanged && "repo" || "manual" });
|
|
269
|
+
await enforceRootKeys(await removeRevokedKeys(mergedKeys));
|
|
247
270
|
await checkOtherUserKeys();
|
|
248
271
|
await enforceSSHDConfig();
|
|
249
272
|
}
|
|
@@ -292,12 +315,17 @@ export async function main() {
|
|
|
292
315
|
await syncRevokeRepo(repoURL);
|
|
293
316
|
}
|
|
294
317
|
await absorbRevocations(config.repoSources);
|
|
295
|
-
await enforceRootKeys(
|
|
318
|
+
await enforceRootKeys(await removeRevokedKeys(await readAllowedKeys()));
|
|
296
319
|
await enforceSSHDConfig();
|
|
297
320
|
|
|
298
321
|
// configureDiscordNotifications watches the webhook file on its own, so there is nothing to
|
|
299
322
|
// schedule for it here.
|
|
300
323
|
startInterval({ name: "check", intervalTime: CHECK_INTERVAL, run: everyCheck });
|
|
324
|
+
|
|
325
|
+
// A refused login is acted on when sshd writes it. The one pass here covers anything written
|
|
326
|
+
// while the daemon was not running.
|
|
327
|
+
let readAuthLogNow = watchAuthLog(onAuthLogChanged);
|
|
328
|
+
await readAuthLogNow();
|
|
301
329
|
}
|
|
302
330
|
|
|
303
331
|
process.on("uncaughtException", e => console.log(`Uncaught exception, staying up. ${e && e.stack || e}`));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { spawnPromise } from "../../helpers/spawn";
|
|
4
|
-
import { sourceKeyPath, sourceRepoPath } from "../sources";
|
|
4
|
+
import { findSourceKey, sourceKeyPath, sourceRepoPath } from "../sources";
|
|
5
5
|
import { GIT_TIMEOUT, MAX_ERROR_BODY_LENGTH } from "./paths";
|
|
6
6
|
import { sourceState } from "./state";
|
|
7
7
|
|
|
@@ -68,7 +68,7 @@ export async function currentBranch(config: { repoPath: string; keyPath: string
|
|
|
68
68
|
|
|
69
69
|
async function ensureSourceRepo(repoURL: string) {
|
|
70
70
|
let repoPath = sourceRepoPath(repoURL);
|
|
71
|
-
let keyPath = sourceKeyPath(repoURL);
|
|
71
|
+
let keyPath = await findSourceKey(repoURL) || sourceKeyPath(repoURL);
|
|
72
72
|
if (!await repoIsUsable({ repoPath, keyPath })) {
|
|
73
73
|
await cloneRepo({ repoURL, repoPath, keyPath });
|
|
74
74
|
}
|
|
@@ -82,7 +82,7 @@ async function ensureSourceRepo(repoURL: string) {
|
|
|
82
82
|
export async function syncRepo(repoURL: string) {
|
|
83
83
|
await ensureSourceRepo(repoURL);
|
|
84
84
|
let repoPath = sourceRepoPath(repoURL);
|
|
85
|
-
let keyPath = sourceKeyPath(repoURL);
|
|
85
|
+
let keyPath = await findSourceKey(repoURL) || sourceKeyPath(repoURL);
|
|
86
86
|
let branch = sourceState(repoURL).branch;
|
|
87
87
|
let localSha = (await runGit({ args: ["rev-parse", "HEAD"], cwd: repoPath, keyPath })).stdout.trim();
|
|
88
88
|
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { keyFingerprint, summarizeKey } from "../authorizedKeys";
|
|
4
|
-
import { deriveRevokeKey, revokeKeyPath, revokeRepoPath, revokeRepoURL } from "../revokeSource";
|
|
5
|
-
import { sourceKeyPath, sourceRepoPath } from "../sources";
|
|
4
|
+
import { deriveRevokeKey, findRevokeKey, REVOKE_KEY_LABEL, revokeKeyPath, revokeRepoPath, revokeRepoURL } from "../revokeSource";
|
|
5
|
+
import { findSourceKey, sourceKeyPath, sourceRepoPath } from "../sources";
|
|
6
6
|
import { cloneRepo, repoIsUsable, runGit } from "./git";
|
|
7
|
+
import { messageTimestamp } from "../../notifications/discord";
|
|
7
8
|
import { UNREVOKE_DELAY } from "./paths";
|
|
8
9
|
import { notify } from "./notify";
|
|
10
|
+
import { addChangeReason } from "./changes";
|
|
11
|
+
import { describeAllEnded, endAllSSHSessions } from "./sessions";
|
|
9
12
|
import { getState, saveState } from "./state";
|
|
10
13
|
|
|
11
14
|
// One revocation per key, ever. Naming the file after the fingerprint is what makes that true:
|
|
@@ -37,14 +40,20 @@ async function pathExists(filePath: string) {
|
|
|
37
40
|
/** The revoke repo's key is worked out from the source's, so nothing extra had to be uploaded and
|
|
38
41
|
nothing extra is stored anywhere it could be taken from. */
|
|
39
42
|
async function ensureRevokeKey(sourceURL: string) {
|
|
40
|
-
let
|
|
41
|
-
if (
|
|
42
|
-
return
|
|
43
|
+
let existing = await findRevokeKey(sourceURL);
|
|
44
|
+
if (existing) {
|
|
45
|
+
return existing;
|
|
46
|
+
}
|
|
47
|
+
let sourceKey = await findSourceKey(sourceURL);
|
|
48
|
+
if (!sourceKey) {
|
|
49
|
+
throw new Error(`Expected a key for ${sourceURL} at ${sourceKeyPath(sourceURL)}, no such file exists`);
|
|
43
50
|
}
|
|
44
|
-
let derived = deriveRevokeKey(await fs.readFile(
|
|
51
|
+
let derived = deriveRevokeKey(await fs.readFile(sourceKey, "utf8"));
|
|
52
|
+
let keyPath = revokeKeyPath(sourceURL);
|
|
45
53
|
await fs.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
46
54
|
await fs.writeFile(keyPath, derived.privateKeyFile, { mode: 0o600 });
|
|
47
|
-
|
|
55
|
+
await fs.writeFile(`${keyPath}.pub`, `${derived.publicKey} ${REVOKE_KEY_LABEL}\n`, { mode: 0o644 });
|
|
56
|
+
console.log(`Derived the revoke key for ${sourceURL} into ${keyPath}`);
|
|
48
57
|
return keyPath;
|
|
49
58
|
}
|
|
50
59
|
|
|
@@ -162,7 +171,7 @@ export async function recordRevocation(config: {
|
|
|
162
171
|
}
|
|
163
172
|
|
|
164
173
|
let repoPath = revokeRepoPath(sourceURL);
|
|
165
|
-
let keyPath =
|
|
174
|
+
let keyPath = await ensureRevokeKey(sourceURL);
|
|
166
175
|
let directory = path.join(repoPath, REVOCATIONS_DIR);
|
|
167
176
|
await fs.mkdir(directory, { recursive: true });
|
|
168
177
|
await fs.writeFile(path.join(directory, `${revocationId}.json`), JSON.stringify({
|
|
@@ -186,16 +195,21 @@ export async function recordRevocation(config: {
|
|
|
186
195
|
}
|
|
187
196
|
state.revocations[fingerprint] = {
|
|
188
197
|
fingerprint, revocationId, unrevokeSeenAt: 0, unrevokeId: "", unrevoked: false,
|
|
189
|
-
|
|
198
|
+
// The message below already says this machine has stopped accepting the key, so the one
|
|
199
|
+
// about noticing a revocation would only repeat it.
|
|
200
|
+
reportedRemoved: true,
|
|
190
201
|
};
|
|
191
202
|
await saveState();
|
|
192
|
-
await
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
+
|
|
197
|
-
+ `\
|
|
198
|
-
+ `\
|
|
203
|
+
let ended = describeAllEnded(await endAllSSHSessions());
|
|
204
|
+
// Said when the file is written, not here, so one event produces one message.
|
|
205
|
+
addChangeReason(
|
|
206
|
+
`**AUTHENTICATED ACCESS FROM AN UNAPPROVED IP: \`${attempt.ip}\`** The key was correct, so`
|
|
207
|
+
+ ` either someone else has this key, or a developer's IP has changed.`
|
|
208
|
+
+ `\nkey \`${keyLine && summarizeKey(keyLine) || fingerprint}\` (\`${fingerprint}\`)`
|
|
209
|
+
+ `\ntried as user \`${attempt.user}\`, and is only allowed from \`${attempt.required}\``
|
|
210
|
+
+ `\nThat key is now revoked everywhere.${ended}`
|
|
211
|
+
+ `\nIf this really was an attack, IMMEDIATELY remove that key from \`${sourceURL}\`.`
|
|
212
|
+
+ `\nIf it was legitimate use, run this in \`${sourceURL}\`: \`yarn unrevoke git\``
|
|
199
213
|
);
|
|
200
214
|
return true;
|
|
201
215
|
}
|
|
@@ -249,9 +263,11 @@ export async function applyUnrevokes(sourceURLs: string[]) {
|
|
|
249
263
|
revocation.unrevokeSeenAt = Date.now();
|
|
250
264
|
revocation.unrevokeId = unrevokeId;
|
|
251
265
|
await saveState();
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
266
|
+
// Nothing has changed yet, so nobody is told. Whoever published it was already told it
|
|
267
|
+
// takes an hour, and every machine seeing the same unrevoke would say so separately.
|
|
268
|
+
console.log(
|
|
269
|
+
`Holding the unrevoke ${unrevokeId} for ${revocation.fingerprint} until`
|
|
270
|
+
+ ` ${messageTimestamp(new Date(revocation.unrevokeSeenAt + UNREVOKE_DELAY))}`
|
|
255
271
|
);
|
|
256
272
|
continue;
|
|
257
273
|
}
|
|
@@ -261,10 +277,8 @@ export async function applyUnrevokes(sourceURLs: string[]) {
|
|
|
261
277
|
revocation.unrevoked = true;
|
|
262
278
|
revocation.reportedRemoved = false;
|
|
263
279
|
await saveState();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
+ ` if it is still in a keys repo.`
|
|
267
|
-
);
|
|
280
|
+
// Only means anything if the key comes back into the file, so it is said there.
|
|
281
|
+
addChangeReason(`the unrevoke of \`${revocation.fingerprint}\` has taken effect, ${unrevokeId}.`);
|
|
268
282
|
}
|
|
269
283
|
}
|
|
270
284
|
|
|
@@ -285,22 +299,37 @@ export async function removeRevokedKeys(keys: string[]) {
|
|
|
285
299
|
}
|
|
286
300
|
let state = getState();
|
|
287
301
|
let allowed: string[] = [];
|
|
302
|
+
let dropped: string[] = [];
|
|
288
303
|
for (let key of keys) {
|
|
289
304
|
let fingerprint = keyFingerprint(key);
|
|
290
305
|
if (!fingerprint || !revoked.has(fingerprint)) {
|
|
291
306
|
allowed.push(key);
|
|
292
307
|
continue;
|
|
293
308
|
}
|
|
294
|
-
|
|
309
|
+
dropped.push(fingerprint);
|
|
295
310
|
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
|
-
);
|
|
311
|
+
if (!revocation || revocation.reportedRemoved) {
|
|
312
|
+
continue;
|
|
303
313
|
}
|
|
314
|
+
revocation.reportedRemoved = true;
|
|
315
|
+
await saveState();
|
|
316
|
+
// Nothing to do if the key is not in the file. It left long ago, and this is a machine that
|
|
317
|
+
// restarted and read the revocation back out of the repo.
|
|
318
|
+
if (!state.appliedKeys.includes(key)) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
// Whatever that key is holding open goes with it.
|
|
322
|
+
let ended = describeAllEnded(await endAllSSHSessions());
|
|
323
|
+
addChangeReason(`a revocation for \`${fingerprint}\` was published elsewhere.${ended}`);
|
|
324
|
+
}
|
|
325
|
+
// Said on every check, not once. A key being held out of authorized_keys is the current state
|
|
326
|
+
// of the machine, and someone reading the log to work out why a key does not work should find
|
|
327
|
+
// the answer there rather than having to know what to search the history for.
|
|
328
|
+
if (dropped.length) {
|
|
329
|
+
console.log(
|
|
330
|
+
`Dropped ${dropped.length} revoked key(s) from the merged set, ${allowed.length} left.`
|
|
331
|
+
+ ` Revoked: ${dropped.join(", ")}`
|
|
332
|
+
);
|
|
304
333
|
}
|
|
305
334
|
return allowed;
|
|
306
335
|
}
|