sliftutils 1.7.124 → 1.7.126
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -1
- package/bin/derivekey.js +10 -0
- package/bin/portsecuredaemon.js +13 -0
- package/bin/securessh.js +10 -0
- package/bin/setupnotify.js +9 -0
- package/bin/signfiles.js +10 -0
- package/bin/unrevoke.js +9 -0
- package/package.json +14 -3
- package/security/README.md +141 -0
- package/security/authorizedKeys/authorizedKeys.ts +66 -0
- package/security/authorizedKeys/daemon/authLog.ts +117 -0
- package/security/authorizedKeys/daemon/daemon.ts +308 -0
- package/security/authorizedKeys/daemon/git.ts +119 -0
- package/security/authorizedKeys/daemon/notify.ts +25 -0
- package/security/authorizedKeys/daemon/paths.ts +26 -0
- package/security/authorizedKeys/daemon/portsecure.service +19 -0
- package/security/authorizedKeys/daemon/revocation.ts +306 -0
- package/security/authorizedKeys/daemon/rootKeys.ts +135 -0
- package/security/authorizedKeys/daemon/sshdConfig.ts +85 -0
- package/security/authorizedKeys/daemon/state.ts +108 -0
- package/security/authorizedKeys/daemon/trust.ts +291 -0
- package/security/authorizedKeys/daemon/userKeys.ts +76 -0
- package/security/authorizedKeys/dist/authorizedKeys.ts.cache +73 -0
- package/security/authorizedKeys/dist/revokeSource.ts.cache +44 -0
- package/security/authorizedKeys/dist/secureSSH.ts.cache +552 -0
- package/security/authorizedKeys/dist/sources.ts.cache +24 -0
- package/security/authorizedKeys/dist/unrevoke.ts.cache +145 -0
- package/security/authorizedKeys/revokeSource.ts +40 -0
- package/security/authorizedKeys/secureSSH.ts +613 -0
- package/security/authorizedKeys/sources.ts +20 -0
- package/security/authorizedKeys/unrevoke.ts +149 -0
- package/security/helpers/dist/paths.ts.cache +28 -0
- package/security/helpers/dist/remoteSSH.ts.cache +90 -0
- package/security/helpers/dist/spawn.ts.cache +34 -0
- package/security/helpers/paths.ts +20 -0
- package/security/helpers/remoteSSH.ts +95 -0
- package/security/helpers/spawn.ts +36 -0
- 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 +190 -0
- package/security/notifications/dist/discord.ts.cache +180 -0
- package/security/notifications/remoteWebhook.ts +85 -0
- package/security/notifications/setupNotify.ts +29 -0
- package/security/signedFiles/dist/manifest.ts.cache +68 -0
- package/security/signedFiles/dist/signFiles.ts.cache +146 -0
- package/security/signedFiles/manifest.ts +69 -0
- package/security/signedFiles/signFiles.ts +151 -0
- package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +17 -20
|
@@ -0,0 +1,306 @@
|
|
|
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 { UNREVOKE_DELAY } from "./paths";
|
|
8
|
+
import { notify } from "./notify";
|
|
9
|
+
import { getState, saveState } from "./state";
|
|
10
|
+
|
|
11
|
+
// One revocation per key, ever. Naming the file after the fingerprint is what makes that true:
|
|
12
|
+
// a second attempt from a different address lands on a name that already exists.
|
|
13
|
+
const REVOCATIONS_DIR = "revocations";
|
|
14
|
+
const UNREVOKES_DIR = "unrevoked";
|
|
15
|
+
|
|
16
|
+
export type Attempt = {
|
|
17
|
+
ip: string;
|
|
18
|
+
user: string;
|
|
19
|
+
port: string;
|
|
20
|
+
required: string;
|
|
21
|
+
line: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function revocationIdOf(fingerprint: string) {
|
|
25
|
+
return fingerprint.replace(/^SHA256:/, "").replace(/[^A-Za-z0-9]+/g, "-");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function pathExists(filePath: string) {
|
|
29
|
+
try {
|
|
30
|
+
await fs.access(filePath);
|
|
31
|
+
return true;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The revoke repo's key is worked out from the source's, so nothing extra had to be uploaded and
|
|
38
|
+
nothing extra is stored anywhere it could be taken from. */
|
|
39
|
+
async function ensureRevokeKey(sourceURL: string) {
|
|
40
|
+
let keyPath = revokeKeyPath(sourceURL);
|
|
41
|
+
if (await pathExists(keyPath)) {
|
|
42
|
+
return keyPath;
|
|
43
|
+
}
|
|
44
|
+
let derived = deriveRevokeKey(await fs.readFile(sourceKeyPath(sourceURL), "utf8"));
|
|
45
|
+
await fs.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
46
|
+
await fs.writeFile(keyPath, derived.privateKeyFile, { mode: 0o600 });
|
|
47
|
+
console.log(`Derived the revoke key for ${sourceURL}`);
|
|
48
|
+
return keyPath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Brings the revoke checkout up to date. Returns false when it cannot be reached, so a missing
|
|
52
|
+
revoke repo degrades to "keep what we already know" rather than stopping everything. */
|
|
53
|
+
export async function syncRevokeRepo(sourceURL: string) {
|
|
54
|
+
let repoURL = revokeRepoURL(sourceURL);
|
|
55
|
+
let repoPath = revokeRepoPath(sourceURL);
|
|
56
|
+
let keyPath = await ensureRevokeKey(sourceURL);
|
|
57
|
+
try {
|
|
58
|
+
if (!await repoIsUsable({ repoPath, keyPath })) {
|
|
59
|
+
await cloneRepo({ repoURL, repoPath, keyPath });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
let localHead = await runGit({ args: ["rev-parse", "HEAD"], cwd: repoPath, keyPath, allowFailure: true });
|
|
63
|
+
if (localHead.status !== 0) {
|
|
64
|
+
// A revoke repo with no commits yet. Nothing has ever been revoked, which is the state
|
|
65
|
+
// every one of these starts in, so there is nothing to pull and nothing to say.
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
let head = (await runGit({ args: ["rev-parse", "--abbrev-ref", "HEAD"], cwd: repoPath, keyPath })).stdout.trim();
|
|
69
|
+
let localSha = localHead.stdout.trim();
|
|
70
|
+
// A ref listing is a few hundred bytes and no objects, so the usual case of nothing having
|
|
71
|
+
// been revoked anywhere costs almost nothing.
|
|
72
|
+
let listing = (await runGit({ args: ["ls-remote", "origin", head], cwd: repoPath, keyPath })).stdout;
|
|
73
|
+
let remoteSha = (listing.split(/\s+/)[0] || "").trim();
|
|
74
|
+
if (remoteSha && remoteSha === localSha) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
await runGit({ args: ["fetch", "--prune", "origin"], cwd: repoPath, keyPath });
|
|
78
|
+
await runGit({ args: ["reset", "--hard", `origin/${head}`], cwd: repoPath, keyPath });
|
|
79
|
+
await runGit({ args: ["clean", "-fdx"], cwd: repoPath, keyPath });
|
|
80
|
+
return true;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.log(`Could not sync ${repoURL}. ${e}`);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function readRevocationFiles(sourceURL: string) {
|
|
88
|
+
let directory = path.join(revokeRepoPath(sourceURL), REVOCATIONS_DIR);
|
|
89
|
+
if (!await pathExists(directory)) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
let revocations: { fingerprint: string; revocationId: string }[] = [];
|
|
93
|
+
for (let name of (await fs.readdir(directory)).sort()) {
|
|
94
|
+
if (!name.endsWith(".json")) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
let parsed = JSON.parse(await fs.readFile(path.join(directory, name), "utf8"));
|
|
99
|
+
if (parsed.fingerprint) {
|
|
100
|
+
revocations.push({ fingerprint: parsed.fingerprint, revocationId: parsed.revocationId || name.replace(/\.json$/, "") });
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.log(`Ignoring unreadable revocation ${name}. ${e}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return revocations;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Unrevokes live in the source repo, so they are covered by its signature. */
|
|
110
|
+
export async function readUnrevokeIds(sourceURL: string) {
|
|
111
|
+
let directory = path.join(sourceRepoPath(sourceURL), UNREVOKES_DIR);
|
|
112
|
+
if (!await pathExists(directory)) {
|
|
113
|
+
return new Map<string, string>();
|
|
114
|
+
}
|
|
115
|
+
let ids = new Map<string, string>();
|
|
116
|
+
for (let name of (await fs.readdir(directory)).sort()) {
|
|
117
|
+
if (!name.endsWith(".json")) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
let parsed = JSON.parse(await fs.readFile(path.join(directory, name), "utf8"));
|
|
122
|
+
for (let revocationId of parsed.revocationIds || []) {
|
|
123
|
+
ids.set(revocationId, name.replace(/\.json$/, ""));
|
|
124
|
+
}
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.log(`Ignoring unreadable unrevoke ${name}. ${e}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return ids;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Writes a revocation, unless this key is already revoked. Checked twice: against what this
|
|
133
|
+
machine already knows, which needs no network, and again against the repo after pulling it,
|
|
134
|
+
so a flood of unknown keys cannot turn into a flood of commits. */
|
|
135
|
+
export async function recordRevocation(config: {
|
|
136
|
+
sourceURL: string;
|
|
137
|
+
fingerprint: string;
|
|
138
|
+
keyLine: string;
|
|
139
|
+
attempt: Attempt;
|
|
140
|
+
hostLabel: string;
|
|
141
|
+
}) {
|
|
142
|
+
let { sourceURL, fingerprint, keyLine, attempt, hostLabel } = config;
|
|
143
|
+
let state = getState();
|
|
144
|
+
if (state.revocations[fingerprint]) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
if (!await syncRevokeRepo(sourceURL)) {
|
|
148
|
+
console.log(`Cannot record the revocation of ${fingerprint}, ${revokeRepoURL(sourceURL)} is unreachable`);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
let revocationId = revocationIdOf(fingerprint);
|
|
152
|
+
let alreadyThere = (await readRevocationFiles(sourceURL)).some(entry => entry.fingerprint === fingerprint);
|
|
153
|
+
if (alreadyThere) {
|
|
154
|
+
// Another machine got there first, which is the normal outcome when several see the same
|
|
155
|
+
// attempt. Record it locally so we never look again.
|
|
156
|
+
state.revocations[fingerprint] = {
|
|
157
|
+
fingerprint, revocationId, unrevokeSeenAt: 0, unrevokeId: "", unrevoked: false,
|
|
158
|
+
reportedRemoved: false,
|
|
159
|
+
};
|
|
160
|
+
await saveState();
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let repoPath = revokeRepoPath(sourceURL);
|
|
165
|
+
let keyPath = revokeKeyPath(sourceURL);
|
|
166
|
+
let directory = path.join(repoPath, REVOCATIONS_DIR);
|
|
167
|
+
await fs.mkdir(directory, { recursive: true });
|
|
168
|
+
await fs.writeFile(path.join(directory, `${revocationId}.json`), JSON.stringify({
|
|
169
|
+
revocationId,
|
|
170
|
+
fingerprint,
|
|
171
|
+
key: keyLine,
|
|
172
|
+
revokedAt: new Date().toISOString(),
|
|
173
|
+
revokedBy: hostLabel,
|
|
174
|
+
reason: "used from an address its from= restriction does not allow",
|
|
175
|
+
attempt,
|
|
176
|
+
}, undefined, 4) + "\n");
|
|
177
|
+
|
|
178
|
+
await runGit({ args: ["add", "-A"], cwd: repoPath, keyPath });
|
|
179
|
+
await runGit({ args: ["-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", `revoke ${revocationId}`], cwd: repoPath, keyPath });
|
|
180
|
+
let push = await runGit({ args: ["push", "origin", "HEAD"], cwd: repoPath, keyPath, allowFailure: true });
|
|
181
|
+
if (push.status !== 0) {
|
|
182
|
+
// Most likely another machine pushed the same revocation first. The next check will pull
|
|
183
|
+
// it and record it, so there is nothing to retry here.
|
|
184
|
+
console.log(`Could not push the revocation of ${fingerprint}, will pick it up on the next check. ${(push.stdout + push.stderr).trim()}`);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
state.revocations[fingerprint] = {
|
|
188
|
+
fingerprint, revocationId, unrevokeSeenAt: 0, unrevokeId: "", unrevoked: false,
|
|
189
|
+
reportedRemoved: false,
|
|
190
|
+
};
|
|
191
|
+
await saveState();
|
|
192
|
+
await notify(
|
|
193
|
+
`revoked \`${keyLine && summarizeKey(keyLine) || fingerprint}\` (\`${fingerprint}\`) after it was`
|
|
194
|
+
+ ` used from \`${attempt.ip}\`, which its from= restriction does not allow (user`
|
|
195
|
+
+ ` \`${attempt.user}\`, allowed \`${attempt.required}\`). Every machine will stop accepting it.`
|
|
196
|
+
+ `\n\nIf this really was an attack, IMMEDIATELY remove that key from \`${sourceURL}\`.`
|
|
197
|
+
+ `\nIf it was legitimate use, run this in \`${sourceURL}\` and deploy it as normal:`
|
|
198
|
+
+ `\n\`\`\`\nyarn unrevoke\nyarn signfiles git\n\`\`\``
|
|
199
|
+
);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Takes everything the revoke repos list into local state. Once here a revocation never leaves,
|
|
204
|
+
even if the file is deleted: the key that writes revocations is on every server, so an attacker
|
|
205
|
+
holding it could otherwise erase the record that locked them out. */
|
|
206
|
+
export async function absorbRevocations(sourceURLs: string[]) {
|
|
207
|
+
let state = getState();
|
|
208
|
+
let changed = false;
|
|
209
|
+
for (let sourceURL of sourceURLs) {
|
|
210
|
+
for (let entry of await readRevocationFiles(sourceURL)) {
|
|
211
|
+
if (state.revocations[entry.fingerprint]) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
state.revocations[entry.fingerprint] = {
|
|
215
|
+
fingerprint: entry.fingerprint,
|
|
216
|
+
revocationId: entry.revocationId,
|
|
217
|
+
unrevokeSeenAt: 0,
|
|
218
|
+
unrevokeId: "",
|
|
219
|
+
unrevoked: false,
|
|
220
|
+
reportedRemoved: false,
|
|
221
|
+
};
|
|
222
|
+
changed = true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (changed) {
|
|
226
|
+
await saveState();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** An unrevoke is held for an hour before it counts, so a signing key that was itself compromised
|
|
231
|
+
cannot instantly undo the revocation that shut it out. */
|
|
232
|
+
export async function applyUnrevokes(sourceURLs: string[]) {
|
|
233
|
+
let state = getState();
|
|
234
|
+
let unrevokeIds = new Map<string, string>();
|
|
235
|
+
for (let sourceURL of sourceURLs) {
|
|
236
|
+
for (let [revocationId, unrevokeId] of await readUnrevokeIds(sourceURL)) {
|
|
237
|
+
unrevokeIds.set(revocationId, unrevokeId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (let revocation of Object.values(state.revocations)) {
|
|
241
|
+
if (revocation.unrevoked) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
let unrevokeId = unrevokeIds.get(revocation.revocationId);
|
|
245
|
+
if (!unrevokeId) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!revocation.unrevokeSeenAt) {
|
|
249
|
+
revocation.unrevokeSeenAt = Date.now();
|
|
250
|
+
revocation.unrevokeId = unrevokeId;
|
|
251
|
+
await saveState();
|
|
252
|
+
await notify(
|
|
253
|
+
`an unrevoke for \`${revocation.fingerprint}\` was published as \`${unrevokeId}\`.`
|
|
254
|
+
+ ` It will be applied in ${Math.round(UNREVOKE_DELAY / 60000)} minutes, not now.`
|
|
255
|
+
);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (Date.now() - revocation.unrevokeSeenAt < UNREVOKE_DELAY) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
revocation.unrevoked = true;
|
|
262
|
+
revocation.reportedRemoved = false;
|
|
263
|
+
await saveState();
|
|
264
|
+
await notify(
|
|
265
|
+
`the unrevoke of \`${revocation.fingerprint}\` has been applied. The key is accepted again`
|
|
266
|
+
+ ` if it is still in a keys repo.`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function revokedFingerprints() {
|
|
272
|
+
return new Set(
|
|
273
|
+
Object.values(getState().revocations)
|
|
274
|
+
.filter(revocation => !revocation.unrevoked)
|
|
275
|
+
.map(revocation => revocation.fingerprint)
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Drops revoked keys from the merged set, and says so the first time a key actually disappears -
|
|
280
|
+
which is the thing worth knowing, rather than the mere existence of a revocation. */
|
|
281
|
+
export async function removeRevokedKeys(keys: string[]) {
|
|
282
|
+
let revoked = revokedFingerprints();
|
|
283
|
+
if (!revoked.size) {
|
|
284
|
+
return keys;
|
|
285
|
+
}
|
|
286
|
+
let state = getState();
|
|
287
|
+
let allowed: string[] = [];
|
|
288
|
+
for (let key of keys) {
|
|
289
|
+
let fingerprint = keyFingerprint(key);
|
|
290
|
+
if (!fingerprint || !revoked.has(fingerprint)) {
|
|
291
|
+
allowed.push(key);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
// Said once, when the key actually goes, rather than every check for as long as it is gone.
|
|
295
|
+
let revocation = state.revocations[fingerprint];
|
|
296
|
+
if (revocation && !revocation.reportedRemoved) {
|
|
297
|
+
revocation.reportedRemoved = true;
|
|
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
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return allowed;
|
|
306
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { keyFingerprint, keyRestriction, summarizeKey } from "../authorizedKeys";
|
|
4
|
+
import { KEYS_HISTORY_PATH, ROOT_AUTHORIZED_KEYS } from "./paths";
|
|
5
|
+
import { notify } from "./notify";
|
|
6
|
+
|
|
7
|
+
const KEY_FILE_HEADER = "# Managed by portsecure. Manual changes are reverted and reported.";
|
|
8
|
+
|
|
9
|
+
async function pathExists(filePath: string) {
|
|
10
|
+
try {
|
|
11
|
+
await fs.access(filePath);
|
|
12
|
+
return true;
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Keys are matched by the key itself, not by the text of the line, so changing which addresses a
|
|
19
|
+
key may be used from reads as that one key changing rather than as one key leaving and another
|
|
20
|
+
arriving. A line we cannot read a key out of falls back to the whole line. */
|
|
21
|
+
function keyIdentity(keyLine: string) {
|
|
22
|
+
return keyFingerprint(keyLine) || keyLine;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function describeKeyDifference(config: { before: string[]; after: string[] }) {
|
|
26
|
+
let { before, after } = config;
|
|
27
|
+
let beforeByKey = new Map(before.map(key => [keyIdentity(key), key]));
|
|
28
|
+
let afterByKey = new Map(after.map(key => [keyIdentity(key), key]));
|
|
29
|
+
|
|
30
|
+
let lines: string[] = [];
|
|
31
|
+
for (let [identity, keyLine] of afterByKey) {
|
|
32
|
+
if (!beforeByKey.has(identity)) {
|
|
33
|
+
lines.push(`+ added ${summarizeKey(keyLine)}\n from ${keyRestriction(keyLine)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (let [identity, keyLine] of beforeByKey) {
|
|
37
|
+
if (!afterByKey.has(identity)) {
|
|
38
|
+
lines.push(`- removed ${summarizeKey(keyLine)}\n from ${keyRestriction(keyLine)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (let [identity, previousLine] of beforeByKey) {
|
|
42
|
+
let currentLine = afterByKey.get(identity);
|
|
43
|
+
if (!currentLine || currentLine === previousLine) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
let previousFrom = keyRestriction(previousLine);
|
|
47
|
+
let currentFrom = keyRestriction(currentLine);
|
|
48
|
+
if (previousFrom !== currentFrom) {
|
|
49
|
+
lines.push(
|
|
50
|
+
`~ changed ${summarizeKey(currentLine)}\n from ${previousFrom}\n`
|
|
51
|
+
+ ` to ${currentFrom}`
|
|
52
|
+
);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
lines.push(
|
|
56
|
+
`~ changed ${summarizeKey(currentLine)}\n from ${currentFrom}\n`
|
|
57
|
+
+ ` its options or comment changed`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (!lines.length) {
|
|
61
|
+
return "(no keys differ)";
|
|
62
|
+
}
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function readAuthorizedKeysFile(filePath: string) {
|
|
67
|
+
if (!await pathExists(filePath)) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
let contents = await fs.readFile(filePath, "utf8");
|
|
71
|
+
return contents.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function writeAuthorizedKeysFile(config: { filePath: string; keys: string[] }) {
|
|
75
|
+
let { filePath, keys } = config;
|
|
76
|
+
let directory = path.dirname(filePath);
|
|
77
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
78
|
+
await fs.chmod(directory, 0o700);
|
|
79
|
+
// Written to a temporary file first, so an interrupted write can never leave root with a
|
|
80
|
+
// truncated authorized_keys and no way back in.
|
|
81
|
+
let temporaryPath = `${filePath}.portsecure-tmp`;
|
|
82
|
+
await fs.writeFile(temporaryPath, `${KEY_FILE_HEADER}\n${keys.join("\n")}\n`, { mode: 0o600 });
|
|
83
|
+
await fs.rename(temporaryPath, filePath);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Keeps a copy of whatever is about to be overwritten, named for the moment it was replaced.
|
|
87
|
+
Recovers a key that was clobbered by mistake, and doubles as the history of who had access.
|
|
88
|
+
The very first archive is the most valuable one, since it holds the keys from before portsecure
|
|
89
|
+
took the file over. */
|
|
90
|
+
export async function archiveAuthorizedKeys(config: { filePath: string; reason: string }) {
|
|
91
|
+
let { filePath, reason } = config;
|
|
92
|
+
if (!await pathExists(filePath)) {
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
let contents = await fs.readFile(filePath, "utf8");
|
|
96
|
+
await fs.mkdir(KEYS_HISTORY_PATH, { recursive: true, mode: 0o700 });
|
|
97
|
+
await fs.chmod(KEYS_HISTORY_PATH, 0o700);
|
|
98
|
+
let stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
99
|
+
let archivePath = path.join(KEYS_HISTORY_PATH, `${stamp}-${reason}.authorized_keys`);
|
|
100
|
+
let attempt = 1;
|
|
101
|
+
while (await pathExists(archivePath)) {
|
|
102
|
+
attempt++;
|
|
103
|
+
archivePath = path.join(KEYS_HISTORY_PATH, `${stamp}-${reason}-${attempt}.authorized_keys`);
|
|
104
|
+
}
|
|
105
|
+
await fs.writeFile(archivePath, contents, { mode: 0o600 });
|
|
106
|
+
return archivePath;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Puts the allowed keys back in place if anything else changed them. */
|
|
110
|
+
export async function enforceRootKeys(config: { keys: string[]; reason: string }) {
|
|
111
|
+
let { keys, reason } = config;
|
|
112
|
+
if (!keys.length) {
|
|
113
|
+
// No sources, or none of them readable. Writing an empty file would lock everyone out, so
|
|
114
|
+
// whatever access is already in place stays exactly as it is.
|
|
115
|
+
console.log(`No keys came from any source, leaving ${ROOT_AUTHORIZED_KEYS} as it is`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
let currentKeys = await readAuthorizedKeysFile(ROOT_AUTHORIZED_KEYS);
|
|
119
|
+
let matches = currentKeys.length === keys.length && currentKeys.every((key, index) => key === keys[index]);
|
|
120
|
+
if (matches) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
let archivePath = await archiveAuthorizedKeys({ filePath: ROOT_AUTHORIZED_KEYS, reason });
|
|
124
|
+
await writeAuthorizedKeysFile({ filePath: ROOT_AUTHORIZED_KEYS, keys });
|
|
125
|
+
let difference = describeKeyDifference({ before: currentKeys, after: keys });
|
|
126
|
+
let archiveNote = archivePath && `\nThe previous file is kept at \`${archivePath}\`.` || "";
|
|
127
|
+
if (reason === "repo") {
|
|
128
|
+
await notify(`root's authorized_keys was updated from the keys repo.\n\`\`\`\n${difference}\n\`\`\`${archiveNote}`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
await notify(
|
|
132
|
+
`root's authorized_keys was changed outside portsecure and has been reverted to the keys repo.`
|
|
133
|
+
+ `\n\`\`\`\n${difference}\n\`\`\`${archiveNote}`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { spawnPromise } from "../../helpers/spawn";
|
|
3
|
+
import { MAX_ERROR_BODY_LENGTH, SSHD_CONFIG_PATH, SSHD_DROPIN_DIR, SSHD_DROPIN_PATH } from "./paths";
|
|
4
|
+
|
|
5
|
+
// VERBOSE is what makes sshd name the key a refused attempt used, which is the whole basis for
|
|
6
|
+
// revoking it. Without it the log says an attempt was refused but not by whom.
|
|
7
|
+
const SSHD_DROPIN_CONTENTS = `# Managed by portsecure. Manual changes are reverted and reported.
|
|
8
|
+
# Keys come from the portsecure repo, so no other authentication method may be used.
|
|
9
|
+
PasswordAuthentication no
|
|
10
|
+
PermitEmptyPasswords no
|
|
11
|
+
KbdInteractiveAuthentication no
|
|
12
|
+
ChallengeResponseAuthentication no
|
|
13
|
+
PubkeyAuthentication yes
|
|
14
|
+
PermitRootLogin prohibit-password
|
|
15
|
+
LogLevel VERBOSE
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
async function pathExists(filePath: string) {
|
|
19
|
+
try {
|
|
20
|
+
await fs.access(filePath);
|
|
21
|
+
return true;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function sshdConfigIncludesDropinDir() {
|
|
28
|
+
let contents = await fs.readFile(SSHD_CONFIG_PATH, "utf8");
|
|
29
|
+
return contents.split("\n").some(line => {
|
|
30
|
+
let trimmed = line.trim();
|
|
31
|
+
return trimmed.startsWith("Include") && trimmed.includes("sshd_config.d");
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function restartSSHD() {
|
|
36
|
+
for (let unit of ["ssh", "sshd"]) {
|
|
37
|
+
let result = await spawnPromise({ command: "systemctl", args: ["reload-or-restart", unit] });
|
|
38
|
+
if (result.status === 0) {
|
|
39
|
+
return unit;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`Expected to reload ssh or sshd, neither unit could be reloaded`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Turns off every non key based way in. Validates before reloading, because a bad sshd config
|
|
46
|
+
that gets applied is exactly how a machine becomes unreachable. */
|
|
47
|
+
export async function enforceSSHDConfig() {
|
|
48
|
+
let existing = "";
|
|
49
|
+
if (await pathExists(SSHD_DROPIN_PATH)) {
|
|
50
|
+
existing = await fs.readFile(SSHD_DROPIN_PATH, "utf8");
|
|
51
|
+
}
|
|
52
|
+
let includeMissing = !await sshdConfigIncludesDropinDir();
|
|
53
|
+
if (existing === SSHD_DROPIN_CONTENTS && !includeMissing) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let originalConfig = await fs.readFile(SSHD_CONFIG_PATH, "utf8");
|
|
58
|
+
await fs.mkdir(SSHD_DROPIN_DIR, { recursive: true });
|
|
59
|
+
await fs.writeFile(SSHD_DROPIN_PATH, SSHD_DROPIN_CONTENTS, { mode: 0o644 });
|
|
60
|
+
if (includeMissing) {
|
|
61
|
+
// sshd takes the first value it sees for most keywords, so the include has to come before
|
|
62
|
+
// any setting it is meant to override.
|
|
63
|
+
await fs.writeFile(SSHD_CONFIG_PATH, `Include ${SSHD_DROPIN_DIR}/*.conf\n${originalConfig}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let validation = await spawnPromise({ command: "sshd", args: ["-t"] });
|
|
67
|
+
if (validation.error) {
|
|
68
|
+
validation = await spawnPromise({ command: "/usr/sbin/sshd", args: ["-t"] });
|
|
69
|
+
}
|
|
70
|
+
if (validation.status !== 0) {
|
|
71
|
+
// Roll back rather than leave a config that sshd would refuse on its next start.
|
|
72
|
+
await fs.rm(SSHD_DROPIN_PATH, { force: true });
|
|
73
|
+
if (includeMissing) {
|
|
74
|
+
await fs.writeFile(SSHD_CONFIG_PATH, originalConfig);
|
|
75
|
+
}
|
|
76
|
+
console.log(
|
|
77
|
+
`sshd rejected the portsecure config, rolled it back. `
|
|
78
|
+
+ `${(validation.stdout + validation.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let unit = await restartSSHD();
|
|
84
|
+
console.log(`Password authentication disabled, reloaded ${unit}`);
|
|
85
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { STATE_PATH } from "./paths";
|
|
4
|
+
|
|
5
|
+
/** What we last decided to trust for one source, kept on disk so nobody can tell us a different
|
|
6
|
+
story about what we saw last time. */
|
|
7
|
+
export type SourceState = {
|
|
8
|
+
lastSha: string;
|
|
9
|
+
branch: string;
|
|
10
|
+
accepted: boolean;
|
|
11
|
+
acceptedSigner: string;
|
|
12
|
+
acceptedKeys: string[];
|
|
13
|
+
acceptedManifestHash: string;
|
|
14
|
+
acceptedSignatureHash: string;
|
|
15
|
+
pendingSigner: string;
|
|
16
|
+
pendingSince: number;
|
|
17
|
+
reportedProblem: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A key this machine has stopped accepting. Kept even when the revoke repo no longer lists it:
|
|
21
|
+
the deploy key that writes revocations lives on every server, so an attacker who took one could
|
|
22
|
+
otherwise delete the revocation that locked them out. */
|
|
23
|
+
export type RevocationState = {
|
|
24
|
+
fingerprint: string;
|
|
25
|
+
revocationId: string;
|
|
26
|
+
// Set once an unrevoke has been seen, so the wait can be served out across restarts.
|
|
27
|
+
unrevokeSeenAt: number;
|
|
28
|
+
unrevokeId: string;
|
|
29
|
+
unrevoked: boolean;
|
|
30
|
+
// Whether we have said that this key actually left root's authorized_keys. The removal is the
|
|
31
|
+
// thing worth reporting, and it is worth reporting exactly once.
|
|
32
|
+
reportedRemoved: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A refusal we saw but could not write down yet. Held until it is recorded, because the log is
|
|
36
|
+
read once and moves on: losing one of these to a repo that happened to be unreachable would
|
|
37
|
+
leave a key that was misused accepted forever. */
|
|
38
|
+
export type PendingRevocation = {
|
|
39
|
+
fingerprint: string;
|
|
40
|
+
keyLine: string;
|
|
41
|
+
sourceURL: string;
|
|
42
|
+
attempt: { ip: string; user: string; port: string; required: string; line: string };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type DaemonState = {
|
|
46
|
+
sources: { [repoURL: string]: SourceState };
|
|
47
|
+
userKeyHashes: { [userName: string]: string };
|
|
48
|
+
revocations: { [fingerprint: string]: RevocationState };
|
|
49
|
+
pendingRevocations: PendingRevocation[];
|
|
50
|
+
// Where we had read up to in the auth log, so a restart does not re-report old attempts.
|
|
51
|
+
authLogOffset: number;
|
|
52
|
+
authLogSignature: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let state: DaemonState = {
|
|
56
|
+
sources: {},
|
|
57
|
+
userKeyHashes: {},
|
|
58
|
+
revocations: {},
|
|
59
|
+
pendingRevocations: [],
|
|
60
|
+
authLogOffset: 0,
|
|
61
|
+
authLogSignature: "",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function getState() {
|
|
65
|
+
return state;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Per source progress, created on first use so a newly added source starts clean. */
|
|
69
|
+
export function sourceState(repoURL: string) {
|
|
70
|
+
let existing = state.sources[repoURL];
|
|
71
|
+
if (existing) {
|
|
72
|
+
return existing;
|
|
73
|
+
}
|
|
74
|
+
let created: SourceState = {
|
|
75
|
+
lastSha: "",
|
|
76
|
+
branch: "",
|
|
77
|
+
accepted: false,
|
|
78
|
+
acceptedSigner: "",
|
|
79
|
+
acceptedKeys: [],
|
|
80
|
+
acceptedManifestHash: "",
|
|
81
|
+
acceptedSignatureHash: "",
|
|
82
|
+
pendingSigner: "",
|
|
83
|
+
pendingSince: 0,
|
|
84
|
+
reportedProblem: "",
|
|
85
|
+
};
|
|
86
|
+
state.sources[repoURL] = created;
|
|
87
|
+
return created;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function loadState() {
|
|
91
|
+
let contents;
|
|
92
|
+
try {
|
|
93
|
+
contents = await fs.readFile(STATE_PATH, "utf8");
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
state = Object.assign(state, JSON.parse(contents));
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// Corrupt state only costs us one duplicate notification, so it is not worth failing over.
|
|
101
|
+
console.log(`Ignoring unreadable state file ${STATE_PATH}. ${e}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function saveState() {
|
|
106
|
+
await fs.mkdir(path.dirname(STATE_PATH), { recursive: true });
|
|
107
|
+
await fs.writeFile(STATE_PATH, JSON.stringify(state, undefined, 4), { mode: 0o600 });
|
|
108
|
+
}
|