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
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { keyFingerprint, summarizeKey } from "../authorizedKeys";
|
|
4
|
+
import { deriveRevokeKey, revokeKeyPath, revokeRepoPath, revokeRepoURL } from "../revokeSource";
|
|
5
|
+
import { sourceKeyPath, sourceRepoPath } from "../sources";
|
|
6
|
+
import { cloneRepo, repoIsUsable, runGit } from "./git";
|
|
7
|
+
import { messageTimestamp } from "../../notifications/discord";
|
|
8
|
+
import { UNREVOKE_DELAY } from "./paths";
|
|
9
|
+
import { notify } from "./notify";
|
|
10
|
+
import { addChangeReason } from "./changes";
|
|
11
|
+
import { describeAllEnded, endAllSSHSessions } from "./sessions";
|
|
12
|
+
import { getState, saveState } from "./state";
|
|
13
|
+
|
|
14
|
+
// One revocation per key, ever. Naming the file after the fingerprint is what makes that true:
|
|
15
|
+
// a second attempt from a different address lands on a name that already exists.
|
|
16
|
+
const REVOCATIONS_DIR = "revocations";
|
|
17
|
+
const UNREVOKES_DIR = "unrevoked";
|
|
18
|
+
|
|
19
|
+
export type Attempt = {
|
|
20
|
+
ip: string;
|
|
21
|
+
user: string;
|
|
22
|
+
port: string;
|
|
23
|
+
required: string;
|
|
24
|
+
line: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function revocationIdOf(fingerprint: string) {
|
|
28
|
+
return fingerprint.replace(/^SHA256:/, "").replace(/[^A-Za-z0-9]+/g, "-");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function pathExists(filePath: string) {
|
|
32
|
+
try {
|
|
33
|
+
await fs.access(filePath);
|
|
34
|
+
return true;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The revoke repo's key is worked out from the source's, so nothing extra had to be uploaded and
|
|
41
|
+
nothing extra is stored anywhere it could be taken from. */
|
|
42
|
+
async function ensureRevokeKey(sourceURL: string) {
|
|
43
|
+
let keyPath = revokeKeyPath(sourceURL);
|
|
44
|
+
if (await pathExists(keyPath)) {
|
|
45
|
+
return keyPath;
|
|
46
|
+
}
|
|
47
|
+
let derived = deriveRevokeKey(await fs.readFile(sourceKeyPath(sourceURL), "utf8"));
|
|
48
|
+
await fs.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
49
|
+
await fs.writeFile(keyPath, derived.privateKeyFile, { mode: 0o600 });
|
|
50
|
+
console.log(`Derived the revoke key for ${sourceURL}`);
|
|
51
|
+
return keyPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Brings the revoke checkout up to date. Returns false when it cannot be reached, so a missing
|
|
55
|
+
revoke repo degrades to "keep what we already know" rather than stopping everything. */
|
|
56
|
+
export async function syncRevokeRepo(sourceURL: string) {
|
|
57
|
+
let repoURL = revokeRepoURL(sourceURL);
|
|
58
|
+
let repoPath = revokeRepoPath(sourceURL);
|
|
59
|
+
let keyPath = await ensureRevokeKey(sourceURL);
|
|
60
|
+
try {
|
|
61
|
+
if (!await repoIsUsable({ repoPath, keyPath })) {
|
|
62
|
+
await cloneRepo({ repoURL, repoPath, keyPath });
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
let localHead = await runGit({ args: ["rev-parse", "HEAD"], cwd: repoPath, keyPath, allowFailure: true });
|
|
66
|
+
if (localHead.status !== 0) {
|
|
67
|
+
// A revoke repo with no commits yet. Nothing has ever been revoked, which is the state
|
|
68
|
+
// every one of these starts in, so there is nothing to pull and nothing to say.
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
let head = (await runGit({ args: ["rev-parse", "--abbrev-ref", "HEAD"], cwd: repoPath, keyPath })).stdout.trim();
|
|
72
|
+
let localSha = localHead.stdout.trim();
|
|
73
|
+
// A ref listing is a few hundred bytes and no objects, so the usual case of nothing having
|
|
74
|
+
// been revoked anywhere costs almost nothing.
|
|
75
|
+
let listing = (await runGit({ args: ["ls-remote", "origin", head], cwd: repoPath, keyPath })).stdout;
|
|
76
|
+
let remoteSha = (listing.split(/\s+/)[0] || "").trim();
|
|
77
|
+
if (remoteSha && remoteSha === localSha) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
await runGit({ args: ["fetch", "--prune", "origin"], cwd: repoPath, keyPath });
|
|
81
|
+
await runGit({ args: ["reset", "--hard", `origin/${head}`], cwd: repoPath, keyPath });
|
|
82
|
+
await runGit({ args: ["clean", "-fdx"], cwd: repoPath, keyPath });
|
|
83
|
+
return true;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.log(`Could not sync ${repoURL}. ${e}`);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function readRevocationFiles(sourceURL: string) {
|
|
91
|
+
let directory = path.join(revokeRepoPath(sourceURL), REVOCATIONS_DIR);
|
|
92
|
+
if (!await pathExists(directory)) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
let revocations: { fingerprint: string; revocationId: string }[] = [];
|
|
96
|
+
for (let name of (await fs.readdir(directory)).sort()) {
|
|
97
|
+
if (!name.endsWith(".json")) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
let parsed = JSON.parse(await fs.readFile(path.join(directory, name), "utf8"));
|
|
102
|
+
if (parsed.fingerprint) {
|
|
103
|
+
revocations.push({ fingerprint: parsed.fingerprint, revocationId: parsed.revocationId || name.replace(/\.json$/, "") });
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.log(`Ignoring unreadable revocation ${name}. ${e}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return revocations;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Unrevokes live in the source repo, so they are covered by its signature. */
|
|
113
|
+
export async function readUnrevokeIds(sourceURL: string) {
|
|
114
|
+
let directory = path.join(sourceRepoPath(sourceURL), UNREVOKES_DIR);
|
|
115
|
+
if (!await pathExists(directory)) {
|
|
116
|
+
return new Map<string, string>();
|
|
117
|
+
}
|
|
118
|
+
let ids = new Map<string, string>();
|
|
119
|
+
for (let name of (await fs.readdir(directory)).sort()) {
|
|
120
|
+
if (!name.endsWith(".json")) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
let parsed = JSON.parse(await fs.readFile(path.join(directory, name), "utf8"));
|
|
125
|
+
for (let revocationId of parsed.revocationIds || []) {
|
|
126
|
+
ids.set(revocationId, name.replace(/\.json$/, ""));
|
|
127
|
+
}
|
|
128
|
+
} catch (e) {
|
|
129
|
+
console.log(`Ignoring unreadable unrevoke ${name}. ${e}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return ids;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Writes a revocation, unless this key is already revoked. Checked twice: against what this
|
|
136
|
+
machine already knows, which needs no network, and again against the repo after pulling it,
|
|
137
|
+
so a flood of unknown keys cannot turn into a flood of commits. */
|
|
138
|
+
export async function recordRevocation(config: {
|
|
139
|
+
sourceURL: string;
|
|
140
|
+
fingerprint: string;
|
|
141
|
+
keyLine: string;
|
|
142
|
+
attempt: Attempt;
|
|
143
|
+
hostLabel: string;
|
|
144
|
+
}) {
|
|
145
|
+
let { sourceURL, fingerprint, keyLine, attempt, hostLabel } = config;
|
|
146
|
+
let state = getState();
|
|
147
|
+
if (state.revocations[fingerprint]) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (!await syncRevokeRepo(sourceURL)) {
|
|
151
|
+
console.log(`Cannot record the revocation of ${fingerprint}, ${revokeRepoURL(sourceURL)} is unreachable`);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
let revocationId = revocationIdOf(fingerprint);
|
|
155
|
+
let alreadyThere = (await readRevocationFiles(sourceURL)).some(entry => entry.fingerprint === fingerprint);
|
|
156
|
+
if (alreadyThere) {
|
|
157
|
+
// Another machine got there first, which is the normal outcome when several see the same
|
|
158
|
+
// attempt. Record it locally so we never look again.
|
|
159
|
+
state.revocations[fingerprint] = {
|
|
160
|
+
fingerprint, revocationId, unrevokeSeenAt: 0, unrevokeId: "", unrevoked: false,
|
|
161
|
+
reportedRemoved: false,
|
|
162
|
+
};
|
|
163
|
+
await saveState();
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let repoPath = revokeRepoPath(sourceURL);
|
|
168
|
+
let keyPath = revokeKeyPath(sourceURL);
|
|
169
|
+
let directory = path.join(repoPath, REVOCATIONS_DIR);
|
|
170
|
+
await fs.mkdir(directory, { recursive: true });
|
|
171
|
+
await fs.writeFile(path.join(directory, `${revocationId}.json`), JSON.stringify({
|
|
172
|
+
revocationId,
|
|
173
|
+
fingerprint,
|
|
174
|
+
key: keyLine,
|
|
175
|
+
revokedAt: new Date().toISOString(),
|
|
176
|
+
revokedBy: hostLabel,
|
|
177
|
+
reason: "used from an address its from= restriction does not allow",
|
|
178
|
+
attempt,
|
|
179
|
+
}, undefined, 4) + "\n");
|
|
180
|
+
|
|
181
|
+
await runGit({ args: ["add", "-A"], cwd: repoPath, keyPath });
|
|
182
|
+
await runGit({ args: ["-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", `revoke ${revocationId}`], cwd: repoPath, keyPath });
|
|
183
|
+
let push = await runGit({ args: ["push", "origin", "HEAD"], cwd: repoPath, keyPath, allowFailure: true });
|
|
184
|
+
if (push.status !== 0) {
|
|
185
|
+
// Most likely another machine pushed the same revocation first. The next check will pull
|
|
186
|
+
// it and record it, so there is nothing to retry here.
|
|
187
|
+
console.log(`Could not push the revocation of ${fingerprint}, will pick it up on the next check. ${(push.stdout + push.stderr).trim()}`);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
state.revocations[fingerprint] = {
|
|
191
|
+
fingerprint, revocationId, unrevokeSeenAt: 0, unrevokeId: "", unrevoked: false,
|
|
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,
|
|
195
|
+
};
|
|
196
|
+
await saveState();
|
|
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\``
|
|
207
|
+
);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Takes everything the revoke repos list into local state. Once here a revocation never leaves,
|
|
212
|
+
even if the file is deleted: the key that writes revocations is on every server, so an attacker
|
|
213
|
+
holding it could otherwise erase the record that locked them out. */
|
|
214
|
+
export async function absorbRevocations(sourceURLs: string[]) {
|
|
215
|
+
let state = getState();
|
|
216
|
+
let changed = false;
|
|
217
|
+
for (let sourceURL of sourceURLs) {
|
|
218
|
+
for (let entry of await readRevocationFiles(sourceURL)) {
|
|
219
|
+
if (state.revocations[entry.fingerprint]) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
state.revocations[entry.fingerprint] = {
|
|
223
|
+
fingerprint: entry.fingerprint,
|
|
224
|
+
revocationId: entry.revocationId,
|
|
225
|
+
unrevokeSeenAt: 0,
|
|
226
|
+
unrevokeId: "",
|
|
227
|
+
unrevoked: false,
|
|
228
|
+
reportedRemoved: false,
|
|
229
|
+
};
|
|
230
|
+
changed = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (changed) {
|
|
234
|
+
await saveState();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** An unrevoke is held for an hour before it counts, so a signing key that was itself compromised
|
|
239
|
+
cannot instantly undo the revocation that shut it out. */
|
|
240
|
+
export async function applyUnrevokes(sourceURLs: string[]) {
|
|
241
|
+
let state = getState();
|
|
242
|
+
let unrevokeIds = new Map<string, string>();
|
|
243
|
+
for (let sourceURL of sourceURLs) {
|
|
244
|
+
for (let [revocationId, unrevokeId] of await readUnrevokeIds(sourceURL)) {
|
|
245
|
+
unrevokeIds.set(revocationId, unrevokeId);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (let revocation of Object.values(state.revocations)) {
|
|
249
|
+
if (revocation.unrevoked) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
let unrevokeId = unrevokeIds.get(revocation.revocationId);
|
|
253
|
+
if (!unrevokeId) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!revocation.unrevokeSeenAt) {
|
|
257
|
+
revocation.unrevokeSeenAt = Date.now();
|
|
258
|
+
revocation.unrevokeId = unrevokeId;
|
|
259
|
+
await saveState();
|
|
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))}`
|
|
265
|
+
);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (Date.now() - revocation.unrevokeSeenAt < UNREVOKE_DELAY) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
revocation.unrevoked = true;
|
|
272
|
+
revocation.reportedRemoved = false;
|
|
273
|
+
await saveState();
|
|
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}.`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function revokedFingerprints() {
|
|
280
|
+
return new Set(
|
|
281
|
+
Object.values(getState().revocations)
|
|
282
|
+
.filter(revocation => !revocation.unrevoked)
|
|
283
|
+
.map(revocation => revocation.fingerprint)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Drops revoked keys from the merged set, and says so the first time a key actually disappears -
|
|
288
|
+
which is the thing worth knowing, rather than the mere existence of a revocation. */
|
|
289
|
+
export async function removeRevokedKeys(keys: string[]) {
|
|
290
|
+
let revoked = revokedFingerprints();
|
|
291
|
+
if (!revoked.size) {
|
|
292
|
+
return keys;
|
|
293
|
+
}
|
|
294
|
+
let state = getState();
|
|
295
|
+
let allowed: string[] = [];
|
|
296
|
+
let dropped: string[] = [];
|
|
297
|
+
for (let key of keys) {
|
|
298
|
+
let fingerprint = keyFingerprint(key);
|
|
299
|
+
if (!fingerprint || !revoked.has(fingerprint)) {
|
|
300
|
+
allowed.push(key);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
dropped.push(fingerprint);
|
|
304
|
+
let revocation = state.revocations[fingerprint];
|
|
305
|
+
if (!revocation || revocation.reportedRemoved) {
|
|
306
|
+
continue;
|
|
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
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return allowed;
|
|
329
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { keyFingerprint, keyRestriction, keyRestrictionList, NO_RESTRICTION, summarizeKey } from "../authorizedKeys";
|
|
4
|
+
import { KEYS_HISTORY_PATH, ROOT_AUTHORIZED_KEYS } from "./paths";
|
|
5
|
+
import { notify } from "./notify";
|
|
6
|
+
import { getState, saveState } from "./state";
|
|
7
|
+
import { takeChangeReasons } from "./changes";
|
|
8
|
+
|
|
9
|
+
const KEY_FILE_HEADER = "# Managed by portsecure. Manual changes are reverted and reported.";
|
|
10
|
+
|
|
11
|
+
async function pathExists(filePath: string) {
|
|
12
|
+
try {
|
|
13
|
+
await fs.access(filePath);
|
|
14
|
+
return true;
|
|
15
|
+
} catch (e) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Keys are matched by the key itself, not by the text of the line, so changing which addresses a
|
|
21
|
+
key may be used from reads as that one key changing rather than as one key leaving and another
|
|
22
|
+
arriving. A line we cannot read a key out of falls back to the whole line. */
|
|
23
|
+
function keyIdentity(keyLine: string) {
|
|
24
|
+
return keyFingerprint(keyLine) || keyLine;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Which addresses a key gained or lost, rather than two long lists to compare by eye. Returns ""
|
|
28
|
+
when the addresses did not change at all. Losing the from= altogether is the loudest case there
|
|
29
|
+
is, so it is spelled out rather than shown as a list of removals. */
|
|
30
|
+
function describeAddressChange(config: { previousLine: string; currentLine: string }) {
|
|
31
|
+
let { previousLine, currentLine } = config;
|
|
32
|
+
let previous = keyRestrictionList(previousLine);
|
|
33
|
+
let current = keyRestrictionList(currentLine);
|
|
34
|
+
if (!previous && !current) {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
if (previous && !current) {
|
|
38
|
+
return ` was ${previous.join(",")}\n now ${NO_RESTRICTION}`;
|
|
39
|
+
}
|
|
40
|
+
if (!previous && current) {
|
|
41
|
+
return ` was ${NO_RESTRICTION}\n now restricted to ${current.join(",")}`;
|
|
42
|
+
}
|
|
43
|
+
let removed = (previous || []).filter(address => !(current || []).includes(address));
|
|
44
|
+
let added = (current || []).filter(address => !(previous || []).includes(address));
|
|
45
|
+
if (!removed.length && !added.length) {
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
let lines = [
|
|
49
|
+
...removed.map(address => ` no longer allowed from ${address}`),
|
|
50
|
+
...added.map(address => ` now also allowed from ${address}`),
|
|
51
|
+
];
|
|
52
|
+
lines.push(` still allowed from ${(current || []).filter(address => !added.includes(address)).join(",") || "nothing"}`);
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function describeKeyDifference(config: { before: string[]; after: string[] }) {
|
|
57
|
+
let { before, after } = config;
|
|
58
|
+
let beforeByKey = new Map(before.map(key => [keyIdentity(key), key]));
|
|
59
|
+
let afterByKey = new Map(after.map(key => [keyIdentity(key), key]));
|
|
60
|
+
|
|
61
|
+
let lines: string[] = [];
|
|
62
|
+
for (let [identity, keyLine] of afterByKey) {
|
|
63
|
+
if (!beforeByKey.has(identity)) {
|
|
64
|
+
lines.push(`+ added ${summarizeKey(keyLine)}\n from ${keyRestriction(keyLine)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (let [identity, keyLine] of beforeByKey) {
|
|
68
|
+
if (!afterByKey.has(identity)) {
|
|
69
|
+
lines.push(`- removed ${summarizeKey(keyLine)}\n from ${keyRestriction(keyLine)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (let [identity, previousLine] of beforeByKey) {
|
|
73
|
+
let currentLine = afterByKey.get(identity);
|
|
74
|
+
if (!currentLine || currentLine === previousLine) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let addressChange = describeAddressChange({ previousLine, currentLine });
|
|
78
|
+
if (addressChange) {
|
|
79
|
+
lines.push(`~ changed ${summarizeKey(currentLine)}\n${addressChange}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
lines.push(
|
|
83
|
+
`~ changed ${summarizeKey(currentLine)}\n from ${keyRestriction(currentLine)}\n`
|
|
84
|
+
+ ` its options or comment changed`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (!lines.length) {
|
|
88
|
+
return "(no keys differ)";
|
|
89
|
+
}
|
|
90
|
+
return lines.join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function readAuthorizedKeysFile(filePath: string) {
|
|
94
|
+
if (!await pathExists(filePath)) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
let contents = await fs.readFile(filePath, "utf8");
|
|
98
|
+
return contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function writeAuthorizedKeysFile(config: { filePath: string; keys: string[] }) {
|
|
102
|
+
let { filePath, keys } = config;
|
|
103
|
+
let directory = path.dirname(filePath);
|
|
104
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
105
|
+
await fs.chmod(directory, 0o700);
|
|
106
|
+
// Written to a temporary file first, so an interrupted write can never leave root with a
|
|
107
|
+
// truncated authorized_keys and no way back in.
|
|
108
|
+
let temporaryPath = `${filePath}.portsecure-tmp`;
|
|
109
|
+
await fs.writeFile(temporaryPath, `${KEY_FILE_HEADER}\n${keys.join("\n")}\n`, { mode: 0o600 });
|
|
110
|
+
await fs.rename(temporaryPath, filePath);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Keeps a copy of whatever is about to be overwritten, named for the moment it was replaced.
|
|
114
|
+
Recovers a key that was clobbered by mistake, and doubles as the history of who had access.
|
|
115
|
+
The very first archive is the most valuable one, since it holds the keys from before portsecure
|
|
116
|
+
took the file over. */
|
|
117
|
+
export async function archiveAuthorizedKeys(config: { filePath: string; reason: string }) {
|
|
118
|
+
let { filePath, reason } = config;
|
|
119
|
+
if (!await pathExists(filePath)) {
|
|
120
|
+
return "";
|
|
121
|
+
}
|
|
122
|
+
let contents = await fs.readFile(filePath, "utf8");
|
|
123
|
+
await fs.mkdir(KEYS_HISTORY_PATH, { recursive: true, mode: 0o700 });
|
|
124
|
+
await fs.chmod(KEYS_HISTORY_PATH, 0o700);
|
|
125
|
+
let stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
126
|
+
let archivePath = path.join(KEYS_HISTORY_PATH, `${stamp}-${reason}.authorized_keys`);
|
|
127
|
+
let attempt = 1;
|
|
128
|
+
while (await pathExists(archivePath)) {
|
|
129
|
+
attempt++;
|
|
130
|
+
archivePath = path.join(KEYS_HISTORY_PATH, `${stamp}-${reason}-${attempt}.authorized_keys`);
|
|
131
|
+
}
|
|
132
|
+
await fs.writeFile(archivePath, contents, { mode: 0o600 });
|
|
133
|
+
return archivePath;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function sameKeys(one: string[], two: string[]) {
|
|
137
|
+
return one.length === two.length && one.every((key, index) => key === two[index]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Puts the allowed keys in place, and says which of the two things happened.
|
|
141
|
+
|
|
142
|
+
Whether this was our own doing is decided by comparing what we want against what we last wrote,
|
|
143
|
+
not by guessing from whether a repo happened to change. A revocation changes what we want
|
|
144
|
+
without any repo changing, and reading that as somebody having edited the file was both wrong
|
|
145
|
+
and alarming. */
|
|
146
|
+
export async function enforceRootKeys(keys: string[]) {
|
|
147
|
+
// An empty set is written out like any other. If every key is revoked then nobody should be
|
|
148
|
+
// getting in, and the way back is to put a key in the repo, which is already being watched.
|
|
149
|
+
if (!keys.length) {
|
|
150
|
+
console.log(`No keys are allowed, so ${ROOT_AUTHORIZED_KEYS} is being emptied`);
|
|
151
|
+
}
|
|
152
|
+
let state = getState();
|
|
153
|
+
let currentKeys = await readAuthorizedKeysFile(ROOT_AUTHORIZED_KEYS);
|
|
154
|
+
if (!state.appliedKeys.length) {
|
|
155
|
+
// Nothing recorded yet, on a first start or an upgrade. Whatever is in the file is taken as
|
|
156
|
+
// ours, so the first check reports what actually changes rather than reintroducing every
|
|
157
|
+
// key that was already there.
|
|
158
|
+
state.appliedKeys = currentKeys;
|
|
159
|
+
await saveState();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (sameKeys(currentKeys, keys)) {
|
|
163
|
+
// Nothing came of whatever was decided this pass, so none of it is reported.
|
|
164
|
+
takeChangeReasons();
|
|
165
|
+
if (!sameKeys(state.appliedKeys, keys)) {
|
|
166
|
+
state.appliedKeys = keys;
|
|
167
|
+
await saveState();
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let weChangedIt = !sameKeys(state.appliedKeys, keys);
|
|
173
|
+
let previouslyApplied = state.appliedKeys;
|
|
174
|
+
// Still archived, it is just not worth a line in the message. Whoever needs the old file knows
|
|
175
|
+
// where the history is.
|
|
176
|
+
await archiveAuthorizedKeys({
|
|
177
|
+
filePath: ROOT_AUTHORIZED_KEYS,
|
|
178
|
+
reason: weChangedIt && "update" || "reverted",
|
|
179
|
+
});
|
|
180
|
+
await writeAuthorizedKeysFile({ filePath: ROOT_AUTHORIZED_KEYS, keys });
|
|
181
|
+
state.appliedKeys = keys;
|
|
182
|
+
await saveState();
|
|
183
|
+
|
|
184
|
+
let reasons = takeChangeReasons();
|
|
185
|
+
let why = reasons.length && `\n\n${reasons.join("\n\n")}` || "";
|
|
186
|
+
if (weChangedIt) {
|
|
187
|
+
await notify(
|
|
188
|
+
`applied authorized key changes:`
|
|
189
|
+
+ `\n\`\`\`\n${describeKeyDifference({ before: previouslyApplied, after: keys })}\n\`\`\`${why}`
|
|
190
|
+
);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await notify(
|
|
194
|
+
`root's authorized_keys was edited by something other than portsecure. The edit below has`
|
|
195
|
+
+ ` been undone, and the keys from the repos are back in place.`
|
|
196
|
+
+ `\n\`\`\`\n${describeKeyDifference({ before: keys, after: currentKeys })}\n\`\`\`${why}`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { spawnPromise } from "../../helpers/spawn";
|
|
3
|
+
import { AUTH_LOG_PATH } from "./paths";
|
|
4
|
+
|
|
5
|
+
// Taking a key out of authorized_keys only stops the next login. Whoever is already connected with
|
|
6
|
+
// it stays connected for as long as they like, which would make revoking a key that is being
|
|
7
|
+
// actively misused close to pointless.
|
|
8
|
+
//
|
|
9
|
+
// sshd names the key it accepted and the process handling that connection on the same line, so the
|
|
10
|
+
// sessions belonging to one key can be ended without touching anyone else's.
|
|
11
|
+
const ACCEPTED = /(?:sshd|sshd-session)\[(\d+)\]:\s+Accepted publickey for (\S+) from (\S+) port (\d+) ssh2: \S+ (SHA256:[A-Za-z0-9+/=]+)/;
|
|
12
|
+
|
|
13
|
+
export type KeySession = { processId: number; user: string; ip: string; port: string };
|
|
14
|
+
|
|
15
|
+
export function parseAcceptedSessions(contents: string, fingerprint: string) {
|
|
16
|
+
let sessions: KeySession[] = [];
|
|
17
|
+
for (let line of contents.split("\n")) {
|
|
18
|
+
let match = line.match(ACCEPTED);
|
|
19
|
+
if (!match || match[5] !== fingerprint) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
sessions.push({ processId: Number(match[1]), user: match[2], ip: match[3], port: match[4] });
|
|
23
|
+
}
|
|
24
|
+
return sessions;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Process ids are reused, so one is only killed when it is still an ssh session. Killing whatever
|
|
28
|
+
happens to hold that number now would be far worse than missing a session. */
|
|
29
|
+
async function isSSHSession(processId: number) {
|
|
30
|
+
try {
|
|
31
|
+
let name = await fs.readFile(`/proc/${processId}/comm`, "utf8");
|
|
32
|
+
return name.trim().startsWith("sshd");
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The whole log, not only what is new, because the session being ended may have been established
|
|
39
|
+
long before the key was misused. */
|
|
40
|
+
async function readWholeAuthLog() {
|
|
41
|
+
try {
|
|
42
|
+
return await fs.readFile(AUTH_LOG_PATH, "utf8");
|
|
43
|
+
} catch (e) {
|
|
44
|
+
let result = await spawnPromise({
|
|
45
|
+
command: "journalctl",
|
|
46
|
+
args: ["-u", "ssh", "-u", "sshd", "--no-pager", "--since", "-30days"],
|
|
47
|
+
});
|
|
48
|
+
return result.status === 0 && result.stdout || "";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Ends every live session that authenticated with this key. Returns what was ended, so the
|
|
53
|
+
revocation can say so rather than leaving it to be discovered. */
|
|
54
|
+
export async function endSessionsUsingKey(fingerprint: string) {
|
|
55
|
+
let ended: KeySession[] = [];
|
|
56
|
+
for (let session of parseAcceptedSessions(await readWholeAuthLog(), fingerprint)) {
|
|
57
|
+
if (session.processId <= 1 || session.processId === process.pid) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!await isSSHSession(session.processId)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
process.kill(session.processId, "SIGKILL");
|
|
65
|
+
ended.push(session);
|
|
66
|
+
console.log(`Ended ssh session ${session.processId} (${session.user} from ${session.ip}), it used ${fingerprint}`);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// Gone between looking and killing, which is the outcome we wanted anyway.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return ended;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether this process is one connection. The listener is not a connection - it is the process
|
|
75
|
+
that accepts them - so it is not a candidate here at all.
|
|
76
|
+
|
|
77
|
+
Every openssh since 9.8 runs each connection as its own sshd-session. On older ones connections
|
|
78
|
+
are named sshd too, and the listener is the one systemd started directly with -D. */
|
|
79
|
+
async function isConnectionProcess(processId: number) {
|
|
80
|
+
let name: string;
|
|
81
|
+
try {
|
|
82
|
+
name = (await fs.readFile(`/proc/${processId}/comm`, "utf8")).trim();
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
if (name === "sshd-session") {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (name !== "sshd") {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
let commandLine = await fs.readFile(`/proc/${processId}/cmdline`, "utf8").catch(() => "");
|
|
93
|
+
let parentId = Number((await fs.readFile(`/proc/${processId}/stat`, "utf8").catch(() => "")).split(") ")[1]?.split(" ")[1] || 0);
|
|
94
|
+
return !commandLine.includes("-D") && parentId !== 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Disconnects every ssh session on the machine.
|
|
98
|
+
|
|
99
|
+
Not only the ones using the key that was just taken away: a session that predates the key being
|
|
100
|
+
removed is exactly as dangerous, and working out which sessions are still entitled to be here
|
|
101
|
+
is guesswork. Anyone who still has access can reconnect in a second, and anyone who does not
|
|
102
|
+
should not be here. */
|
|
103
|
+
export async function endAllSSHSessions() {
|
|
104
|
+
let ended: number[] = [];
|
|
105
|
+
for (let entry of await fs.readdir("/proc")) {
|
|
106
|
+
let processId = Number(entry);
|
|
107
|
+
if (!processId || processId <= 1 || processId === process.pid) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!await isConnectionProcess(processId)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
process.kill(processId, "SIGKILL");
|
|
115
|
+
ended.push(processId);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// Gone between looking and killing, which is the outcome we wanted anyway.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (ended.length) {
|
|
121
|
+
console.log(`Disconnected ${ended.length} ssh session(s): ${ended.join(", ")}`);
|
|
122
|
+
}
|
|
123
|
+
return ended;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function describeAllEnded(ended: number[]) {
|
|
127
|
+
if (!ended.length) {
|
|
128
|
+
return " Nothing was connected.";
|
|
129
|
+
}
|
|
130
|
+
return ` Every ssh session on this machine was disconnected, ${ended.length} of them.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function describeEndedSessions(ended: KeySession[]) {
|
|
134
|
+
if (!ended.length) {
|
|
135
|
+
return " Nothing was connected with it.";
|
|
136
|
+
}
|
|
137
|
+
return ` ${ended.length} live session(s) using it were killed: `
|
|
138
|
+
+ ended.map(session => `${session.user}@${session.ip}:${session.port}`).join(", ") + ".";
|
|
139
|
+
}
|