sliftutils 1.7.125 → 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/bin/derivekey.js +10 -0
- package/bin/portsecuredaemon.js +13 -0
- package/bin/unrevoke.js +9 -0
- package/package.json +8 -3
- package/security/README.md +66 -2
- package/security/authorizedKeys/authorizedKeys.ts +23 -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 +3 -2
- 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 +201 -15
- 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/remoteSSH.ts +3 -2
- 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/dist/discord.ts.cache +180 -0
- package/security/signedFiles/dist/manifest.ts.cache +68 -0
- package/security/signedFiles/dist/signFiles.ts.cache +146 -0
- package/security/signedFiles/manifest.ts +19 -6
- package/security/signedFiles/signFiles.ts +78 -43
- package/security/authorizedKeys/daemon/portsecureDaemon.js +0 -1032
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { spawnPromise } from "../../helpers/spawn";
|
|
4
|
+
import { sourceKeyPath, sourceRepoPath } from "../sources";
|
|
5
|
+
import { GIT_TIMEOUT, MAX_ERROR_BODY_LENGTH } from "./paths";
|
|
6
|
+
import { sourceState } from "./state";
|
|
7
|
+
|
|
8
|
+
async function pathExists(filePath: string) {
|
|
9
|
+
try {
|
|
10
|
+
await fs.access(filePath);
|
|
11
|
+
return true;
|
|
12
|
+
} catch (e) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** core.sshCommand keeps the key selection with the command instead of in the environment. */
|
|
18
|
+
export async function runGit(config: { args: string[]; cwd?: string; keyPath: string; allowFailure?: boolean }) {
|
|
19
|
+
let { args, cwd, keyPath, allowFailure } = config;
|
|
20
|
+
let sshCommand = `ssh -i ${keyPath} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
|
|
21
|
+
let result = await spawnPromise({
|
|
22
|
+
command: "git",
|
|
23
|
+
args: ["-c", `core.sshCommand=${sshCommand}`, ...args],
|
|
24
|
+
cwd,
|
|
25
|
+
timeoutTime: GIT_TIMEOUT,
|
|
26
|
+
});
|
|
27
|
+
if (result.error) {
|
|
28
|
+
throw new Error(`Expected git ${args.join(" ")} to run, failed with ${result.error.message}`);
|
|
29
|
+
}
|
|
30
|
+
if (result.status !== 0 && !allowFailure) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Expected git ${args.join(" ")} to exit 0, was ${result.status}. `
|
|
33
|
+
+ `${(result.stdout + result.stderr).trim().slice(0, MAX_ERROR_BODY_LENGTH)}`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function repoIsUsable(config: { repoPath: string; keyPath: string }) {
|
|
40
|
+
let { repoPath, keyPath } = config;
|
|
41
|
+
if (!await pathExists(path.join(repoPath, ".git"))) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
let result = await runGit({ args: ["rev-parse", "--git-dir"], cwd: repoPath, keyPath, allowFailure: true });
|
|
45
|
+
if (result.status !== 0) {
|
|
46
|
+
console.log(`Repo at ${repoPath} is not usable. ${(result.stdout + result.stderr).trim()}`);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Cloned beside the old checkout and swapped in, so a clone that fails leaves the copy we are
|
|
53
|
+
already using untouched rather than deleting the only keys we have. */
|
|
54
|
+
export async function cloneRepo(config: { repoURL: string; repoPath: string; keyPath: string }) {
|
|
55
|
+
let { repoURL, repoPath, keyPath } = config;
|
|
56
|
+
let incomingPath = `${repoPath}.incoming`;
|
|
57
|
+
await fs.rm(incomingPath, { recursive: true, force: true });
|
|
58
|
+
await fs.mkdir(path.dirname(repoPath), { recursive: true });
|
|
59
|
+
await runGit({ args: ["clone", repoURL, incomingPath], keyPath });
|
|
60
|
+
await fs.rm(repoPath, { recursive: true, force: true });
|
|
61
|
+
await fs.rename(incomingPath, repoPath);
|
|
62
|
+
console.log(`Cloned ${repoURL} into ${repoPath}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function currentBranch(config: { repoPath: string; keyPath: string }) {
|
|
66
|
+
return (await runGit({ args: ["rev-parse", "--abbrev-ref", "HEAD"], ...config })).stdout.trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function ensureSourceRepo(repoURL: string) {
|
|
70
|
+
let repoPath = sourceRepoPath(repoURL);
|
|
71
|
+
let keyPath = sourceKeyPath(repoURL);
|
|
72
|
+
if (!await repoIsUsable({ repoPath, keyPath })) {
|
|
73
|
+
await cloneRepo({ repoURL, repoPath, keyPath });
|
|
74
|
+
}
|
|
75
|
+
if (!sourceState(repoURL).branch) {
|
|
76
|
+
sourceState(repoURL).branch = await currentBranch({ repoPath, keyPath });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Returns what changed, so the caller can report it. A rewritten history is called out
|
|
81
|
+
separately - it means the remote no longer contains the commits we already had. */
|
|
82
|
+
export async function syncRepo(repoURL: string) {
|
|
83
|
+
await ensureSourceRepo(repoURL);
|
|
84
|
+
let repoPath = sourceRepoPath(repoURL);
|
|
85
|
+
let keyPath = sourceKeyPath(repoURL);
|
|
86
|
+
let branch = sourceState(repoURL).branch;
|
|
87
|
+
let localSha = (await runGit({ args: ["rev-parse", "HEAD"], cwd: repoPath, keyPath })).stdout.trim();
|
|
88
|
+
|
|
89
|
+
// A ref listing is a few hundred bytes and no objects, so the usual case of nothing having
|
|
90
|
+
// changed costs almost nothing and we only fetch when there is something to fetch.
|
|
91
|
+
let listing = (await runGit({ args: ["ls-remote", "origin", branch], cwd: repoPath, keyPath })).stdout;
|
|
92
|
+
let remoteSha = (listing.split(/\s+/)[0] || "").trim();
|
|
93
|
+
if (!remoteSha) {
|
|
94
|
+
throw new Error(`Expected origin to report a sha for ${branch}, listed ${listing.slice(0, MAX_ERROR_BODY_LENGTH)}`);
|
|
95
|
+
}
|
|
96
|
+
if (remoteSha === localSha && remoteSha === sourceState(repoURL).lastSha) {
|
|
97
|
+
return { changed: false, historyRewritten: false, remoteSha, previousSha: localSha };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await runGit({ args: ["fetch", "--prune", "origin", branch], cwd: repoPath, keyPath });
|
|
101
|
+
remoteSha = (await runGit({ args: ["rev-parse", `origin/${branch}`], cwd: repoPath, keyPath })).stdout.trim();
|
|
102
|
+
|
|
103
|
+
let previousSha = sourceState(repoURL).lastSha || localSha;
|
|
104
|
+
let historyRewritten = false;
|
|
105
|
+
if (previousSha && previousSha !== remoteSha) {
|
|
106
|
+
// If what we already had is no longer an ancestor of the remote tip, commits were removed
|
|
107
|
+
// or rewritten rather than added.
|
|
108
|
+
let ancestry = await runGit({
|
|
109
|
+
args: ["merge-base", "--is-ancestor", previousSha, remoteSha],
|
|
110
|
+
cwd: repoPath,
|
|
111
|
+
keyPath,
|
|
112
|
+
allowFailure: true,
|
|
113
|
+
});
|
|
114
|
+
historyRewritten = ancestry.status !== 0;
|
|
115
|
+
}
|
|
116
|
+
await runGit({ args: ["reset", "--hard", `origin/${branch}`], cwd: repoPath, keyPath });
|
|
117
|
+
await runGit({ args: ["clean", "-fdx"], cwd: repoPath, keyPath });
|
|
118
|
+
return { changed: true, historyRewritten, remoteSha, previousSha };
|
|
119
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import { sendDiscordNotification } from "../../notifications/discord";
|
|
3
|
+
|
|
4
|
+
let hostLabelValue = "";
|
|
5
|
+
|
|
6
|
+
export function setHostLabel(value: string) {
|
|
7
|
+
hostLabelValue = value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// DO NOT add new calls to this. Every message goes to a real Discord server someone reads, so a
|
|
11
|
+
// notification is only ever added when the user explicitly asks for that specific case. Startup,
|
|
12
|
+
// success, errors, retries and recoveries all belong in console.log instead. The complete list of
|
|
13
|
+
// cases that are allowed to notify is at the top of daemon.ts.
|
|
14
|
+
export async function notify(message: string) {
|
|
15
|
+
let full = `**portsecure [${hostLabelValue || os.hostname()}]**: ${message}`;
|
|
16
|
+
// Logged before it is sent, and whether or not it arrives, so the journal is a complete record
|
|
17
|
+
// of what this machine had to say even when Discord is unreachable or the webhook is wrong.
|
|
18
|
+
console.log(`Discord: ${full}`);
|
|
19
|
+
try {
|
|
20
|
+
await sendDiscordNotification(full);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
// A failed notification must never take the daemon down, the local log is the fallback.
|
|
23
|
+
console.log(`Failed to send the Discord notification above. ${e}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Locations the daemon owns. Fixed rather than configurable, so every machine looks the same and
|
|
2
|
+
// the config file only carries what genuinely differs between them.
|
|
3
|
+
|
|
4
|
+
export const CONFIG_PATH = "/etc/portsecure/daemon.json";
|
|
5
|
+
export const STATE_PATH = "/var/lib/portsecure/state.json";
|
|
6
|
+
export const KEYS_HISTORY_PATH = "/var/lib/portsecure/authorized-keys-history";
|
|
7
|
+
export const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
|
|
8
|
+
export const SSHD_CONFIG_PATH = "/etc/ssh/sshd_config";
|
|
9
|
+
export const SSHD_DROPIN_DIR = "/etc/ssh/sshd_config.d";
|
|
10
|
+
export const SSHD_DROPIN_PATH = "/etc/ssh/sshd_config.d/00-portsecure.conf";
|
|
11
|
+
export const PASSWD_PATH = "/etc/passwd";
|
|
12
|
+
export const AUTH_LOG_PATH = "/var/log/auth.log";
|
|
13
|
+
|
|
14
|
+
export const CHECK_INTERVAL = 60 * 1000;
|
|
15
|
+
export const WEBHOOK_CHECK_INTERVAL = 5 * 60 * 1000;
|
|
16
|
+
export const GIT_TIMEOUT = 120 * 1000;
|
|
17
|
+
export const MAX_ERROR_BODY_LENGTH = 500;
|
|
18
|
+
// A source that starts being signed by someone new is held at arm's length for this long, so a
|
|
19
|
+
// stolen signing key cannot push keys onto a machine before anyone notices the warning.
|
|
20
|
+
export const SIGNER_CHANGE_DELAY = 24 * 60 * 60 * 1000;
|
|
21
|
+
// An unrevoke waits this long before taking effect, so a compromised signing key cannot instantly
|
|
22
|
+
// undo the revocation that locked it out.
|
|
23
|
+
export const UNREVOKE_DELAY = 60 * 60 * 1000;
|
|
24
|
+
// After this many consecutive failures a repo is thrown away and cloned from scratch, which
|
|
25
|
+
// recovers from corruption and interrupted fetches. Counted in checks, so about a quarter hour.
|
|
26
|
+
export const MAX_REPO_FAILURES_BEFORE_RECLONE = 15;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
[Unit]
|
|
2
2
|
Description=portsecure authorized keys daemon
|
|
3
|
-
Documentation=https://github.com/sliftist/
|
|
3
|
+
Documentation=https://github.com/sliftist/sliftutils
|
|
4
4
|
After=network-online.target
|
|
5
5
|
Wants=network-online.target
|
|
6
6
|
|
|
7
7
|
[Service]
|
|
8
8
|
Type=simple
|
|
9
|
-
ExecStart=/usr/bin/env node /opt/portsecure/
|
|
9
|
+
ExecStart=/usr/bin/env node /opt/portsecure/sliftutils/bin/portsecuredaemon.js
|
|
10
|
+
WorkingDirectory=/opt/portsecure/sliftutils
|
|
10
11
|
User=root
|
|
11
12
|
# Availability is the point of this daemon, so it always comes back.
|
|
12
13
|
Restart=always
|
|
@@ -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
|
+
}
|