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.
- package/bin/derivekey.js +10 -0
- package/bin/portsecuredaemon.js +13 -0
- package/bin/testnotify.js +9 -0
- package/bin/unrevoke.js +9 -0
- package/package.json +10 -3
- package/security/README.md +66 -2
- package/security/authorizedKeys/authorizedKeys.ts +68 -5
- package/security/authorizedKeys/daemon/authLog.ts +186 -0
- package/security/authorizedKeys/daemon/changes.ts +26 -0
- package/security/authorizedKeys/daemon/daemon.ts +335 -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 +329 -0
- package/security/authorizedKeys/daemon/rootKeys.ts +198 -0
- package/security/authorizedKeys/daemon/sessions.ts +139 -0
- package/security/authorizedKeys/daemon/sshdConfig.ts +85 -0
- package/security/authorizedKeys/daemon/state.ts +122 -0
- package/security/authorizedKeys/daemon/trust.ts +291 -0
- package/security/authorizedKeys/daemon/userKeys.ts +76 -0
- package/security/authorizedKeys/dist/authorizedKeys.ts.cache +111 -0
- package/security/authorizedKeys/dist/revokeSource.ts.cache +44 -0
- package/security/authorizedKeys/dist/secureSSH.ts.cache +539 -0
- package/security/authorizedKeys/dist/sources.ts.cache +24 -0
- package/security/authorizedKeys/dist/unrevoke.ts.cache +167 -0
- package/security/authorizedKeys/revokeSource.ts +40 -0
- package/security/authorizedKeys/secureSSH.ts +240 -38
- package/security/authorizedKeys/unrevoke.ts +175 -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 +18 -6
- 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/discord.ts +18 -1
- package/security/notifications/dist/discord.ts.cache +197 -0
- package/security/notifications/setupNotify.ts +17 -8
- package/security/notifications/testNotify.ts +48 -0
- package/security/signedFiles/dist/manifest.ts.cache +85 -0
- package/security/signedFiles/dist/signFiles.ts.cache +181 -0
- package/security/signedFiles/manifest.ts +38 -8
- package/security/signedFiles/signFiles.ts +123 -49
- 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
|
+
});
|
|
@@ -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/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.127",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -31,7 +31,10 @@
|
|
|
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",
|
|
37
|
+
"testnotify": "node ./bin/testnotify.js"
|
|
35
38
|
},
|
|
36
39
|
"bin": {
|
|
37
40
|
"filehoster": "./bin/filehoster.js",
|
|
@@ -51,7 +54,11 @@
|
|
|
51
54
|
"sliftsetup": "./builders/setupRun.js",
|
|
52
55
|
"setupnotify": "./bin/setupnotify.js",
|
|
53
56
|
"securessh": "./bin/securessh.js",
|
|
54
|
-
"signfiles": "./bin/signfiles.js"
|
|
57
|
+
"signfiles": "./bin/signfiles.js",
|
|
58
|
+
"derivekey": "./bin/derivekey.js",
|
|
59
|
+
"portsecuredaemon": "./bin/portsecuredaemon.js",
|
|
60
|
+
"unrevoke": "./bin/unrevoke.js",
|
|
61
|
+
"testnotify": "./bin/testnotify.js"
|
|
55
62
|
},
|
|
56
63
|
"dependencies": {
|
|
57
64
|
"@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,15 +1,45 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
1
2
|
import fs from "fs/promises";
|
|
2
3
|
import path from "path";
|
|
3
4
|
|
|
4
|
-
// PORTED CODE: security/authorizedKeys/daemon/portsecureDaemon.js contains a plain JS port of normalizeKeys,
|
|
5
|
-
// summarizeKey and readRepoKeys, so it can resolve the same keys with no dependencies. The two
|
|
6
|
-
// must agree on which keys a repo produces - if you change one, make the matching change in the
|
|
7
|
-
// other.
|
|
8
|
-
|
|
9
5
|
export function normalizeKeys(contents: string) {
|
|
10
6
|
return contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
|
|
11
7
|
}
|
|
12
8
|
|
|
9
|
+
/** The fingerprint ssh itself reports for a key, which is what the sshd log names and therefore
|
|
10
|
+
what a revocation is keyed by. Returns "" for a line that holds no key. */
|
|
11
|
+
export function keyFingerprint(keyLine: string) {
|
|
12
|
+
let parts = keyLine.trim().split(/\s+/);
|
|
13
|
+
let typeIndex = parts.findIndex(part => /^(ssh-|ecdsa-|sk-)/.test(part));
|
|
14
|
+
let blob = typeIndex >= 0 && parts[typeIndex + 1] || "";
|
|
15
|
+
if (!blob) {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
return "SHA256:" + crypto.createHash("sha256").update(Buffer.from(blob, "base64")).digest("base64").replace(/=+$/, "");
|
|
19
|
+
}
|
|
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
|
+
|
|
33
|
+
/** The addresses a key may be used from, which is the part of an authorized_keys line that
|
|
34
|
+
decides how much a stolen key is worth. A key with no restriction says so loudly. */
|
|
35
|
+
export function keyRestriction(keyLine: string) {
|
|
36
|
+
let list = keyRestrictionList(keyLine);
|
|
37
|
+
if (!list) {
|
|
38
|
+
return NO_RESTRICTION;
|
|
39
|
+
}
|
|
40
|
+
return list.join(",");
|
|
41
|
+
}
|
|
42
|
+
|
|
13
43
|
/** Enough to recognise whose key this is without printing the whole blob. */
|
|
14
44
|
export function summarizeKey(keyLine: string) {
|
|
15
45
|
let parts = keyLine.trim().split(/\s+/);
|
|
@@ -23,6 +53,39 @@ export function summarizeKey(keyLine: string) {
|
|
|
23
53
|
return `${type} ...${blob.slice(-12)}${comment && ` ${comment}` || ""}`;
|
|
24
54
|
}
|
|
25
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
|
+
|
|
26
89
|
/** Reads the authorized keys a repo checkout wants applied. Prefers a top level authorized_keys
|
|
27
90
|
file and otherwise concatenates every .pub at the top level. */
|
|
28
91
|
export async function readRepoKeys(repoPath: string) {
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { watch } from "fs";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { spawnPromise } from "../../helpers/spawn";
|
|
6
|
+
import { AUTH_LOG_PATH } from "./paths";
|
|
7
|
+
import { getState, saveState } from "./state";
|
|
8
|
+
import { Attempt } from "./revocation";
|
|
9
|
+
|
|
10
|
+
// sshd says an attempt was refused in one line and names the key in another, both for the same
|
|
11
|
+
// connection, so they are tied together by the process id the log puts on every line.
|
|
12
|
+
const REFUSED = /Authentication tried for (\S+) with correct key but not from a permitted host \(host=([^,]*), ip=([^,]*), required=([^)]*)\)/;
|
|
13
|
+
const FAILED_KEY = /Failed publickey for (\S+) from (\S+) port (\d+) ssh2: \S+ (SHA256:[A-Za-z0-9+/=]+)/;
|
|
14
|
+
const PROCESS_ID = /(?:sshd|sshd-session)\[(\d+)\]/;
|
|
15
|
+
// Enough of the head of the file to notice it was rotated out from under us.
|
|
16
|
+
const SIGNATURE_LENGTH = 512;
|
|
17
|
+
|
|
18
|
+
export type RefusedAttempt = { fingerprint: string; attempt: Attempt };
|
|
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
|
+
|
|
39
|
+
/** Pairs each refusal with the fingerprint sshd logged for the same connection. A refusal we
|
|
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. */
|
|
42
|
+
export function parseAuthLog(contents: string) {
|
|
43
|
+
for (let line of contents.split("\n")) {
|
|
44
|
+
let processMatch = line.match(PROCESS_ID);
|
|
45
|
+
if (!processMatch) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
let processId = processMatch[1];
|
|
49
|
+
let refused = line.match(REFUSED);
|
|
50
|
+
if (refused) {
|
|
51
|
+
let existing = refusals.get(processId) || [];
|
|
52
|
+
existing.push({ user: refused[1], ip: refused[3], required: refused[4], line: line.trim() });
|
|
53
|
+
refusals.set(processId, existing);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
let failed = line.match(FAILED_KEY);
|
|
57
|
+
if (failed) {
|
|
58
|
+
fingerprints.set(processId, { fingerprint: failed[4], port: failed[3] });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let attempts: RefusedAttempt[] = [];
|
|
63
|
+
for (let [processId, entries] of [...refusals]) {
|
|
64
|
+
let key = fingerprints.get(processId);
|
|
65
|
+
if (!key) {
|
|
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.
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (let entry of entries) {
|
|
71
|
+
attempts.push({
|
|
72
|
+
fingerprint: key.fingerprint,
|
|
73
|
+
attempt: {
|
|
74
|
+
ip: entry.ip,
|
|
75
|
+
user: entry.user,
|
|
76
|
+
port: key.port,
|
|
77
|
+
required: entry.required,
|
|
78
|
+
line: entry.line,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// Paired, so neither half is needed again.
|
|
83
|
+
refusals.delete(processId);
|
|
84
|
+
fingerprints.delete(processId);
|
|
85
|
+
}
|
|
86
|
+
forget(refusals);
|
|
87
|
+
forget(fingerprints);
|
|
88
|
+
return attempts;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Only what has been written since last time. A rotated file starts again from the beginning,
|
|
92
|
+
and a file that only grew is read from where we stopped. */
|
|
93
|
+
export async function readNewAuthLog() {
|
|
94
|
+
let state = getState();
|
|
95
|
+
let handle;
|
|
96
|
+
try {
|
|
97
|
+
handle = await fs.open(AUTH_LOG_PATH, "r");
|
|
98
|
+
} catch (e) {
|
|
99
|
+
// Some machines only keep this in the journal.
|
|
100
|
+
let result = await spawnPromise({
|
|
101
|
+
command: "journalctl",
|
|
102
|
+
args: ["-u", "ssh", "-u", "sshd", "--no-pager", "--since", "-10min"],
|
|
103
|
+
});
|
|
104
|
+
if (result.status !== 0) {
|
|
105
|
+
console.log(`No auth log to read: ${AUTH_LOG_PATH} is unreadable and journalctl exited ${result.status}`);
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
return result.stdout;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
let stats = await handle.stat();
|
|
112
|
+
let head = Buffer.alloc(Math.min(SIGNATURE_LENGTH, stats.size));
|
|
113
|
+
await handle.read(head, 0, head.length, 0);
|
|
114
|
+
let signature = crypto.createHash("sha256").update(head).digest("hex");
|
|
115
|
+
|
|
116
|
+
if (!state.authLogSignature) {
|
|
117
|
+
// First time we have ever looked. Start from the end: the log holds history from
|
|
118
|
+
// before this machine watched it, and revoking keys over refusals nobody was watching
|
|
119
|
+
// for could take away access that is still in use.
|
|
120
|
+
state.authLogOffset = stats.size;
|
|
121
|
+
state.authLogSignature = signature;
|
|
122
|
+
await saveState();
|
|
123
|
+
console.log(`Watching ${AUTH_LOG_PATH} from its current end, ${stats.size} bytes in`);
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
let offset = state.authLogOffset;
|
|
127
|
+
if (signature !== state.authLogSignature || stats.size < offset) {
|
|
128
|
+
// Rotated, or replaced. Everything in the new file is new.
|
|
129
|
+
offset = 0;
|
|
130
|
+
}
|
|
131
|
+
if (stats.size === offset) {
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
let contents = Buffer.alloc(stats.size - offset);
|
|
135
|
+
await handle.read(contents, 0, contents.length, offset);
|
|
136
|
+
state.authLogOffset = stats.size;
|
|
137
|
+
state.authLogSignature = signature;
|
|
138
|
+
await saveState();
|
|
139
|
+
return contents.toString("utf8");
|
|
140
|
+
} finally {
|
|
141
|
+
await handle.close();
|
|
142
|
+
}
|
|
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
|
+
}
|