sliftutils 1.7.127 → 1.7.128
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/security/authorizedKeys/daemon/daemon.ts +7 -6
- package/security/authorizedKeys/daemon/git.ts +3 -3
- package/security/authorizedKeys/daemon/revocation.ts +14 -8
- package/security/authorizedKeys/revokeSource.ts +12 -2
- package/security/authorizedKeys/secureSSH.ts +41 -5
- package/security/authorizedKeys/sources.ts +47 -5
- package/security/authorizedKeys/unrevoke.ts +17 -8
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import os from "os";
|
|
3
3
|
import { configureDiscordNotifications, DEFAULT_WEBHOOK_FILE_PATH } from "../../notifications/discord";
|
|
4
|
-
import { sourceKeyPath, sourceRepoPath } from "../sources";
|
|
4
|
+
import { findSourceKey, legacySourceKeyPath, sourceKeyPath, sourceRepoPath } from "../sources";
|
|
5
5
|
import { cloneRepo, syncRepo } from "./git";
|
|
6
6
|
import {
|
|
7
7
|
CHECK_INTERVAL,
|
|
@@ -74,10 +74,11 @@ async function loadConfig(): Promise<DaemonConfig> {
|
|
|
74
74
|
process.exit(1);
|
|
75
75
|
}
|
|
76
76
|
for (let repoURL of parsed.repoSources) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
if (!await findSourceKey(repoURL)) {
|
|
78
|
+
console.error(
|
|
79
|
+
`portsecure: expected the private key for ${repoURL} at ${sourceKeyPath(repoURL)}`
|
|
80
|
+
+ ` or ${legacySourceKeyPath(repoURL)}, neither exists`
|
|
81
|
+
);
|
|
81
82
|
process.exit(1);
|
|
82
83
|
}
|
|
83
84
|
}
|
|
@@ -213,7 +214,7 @@ async function pollSource(repoURL: string) {
|
|
|
213
214
|
// Availability over tidiness: throw the working copy away and start again.
|
|
214
215
|
console.log(`Discarding the checkout of ${repoURL} and cloning from scratch`);
|
|
215
216
|
try {
|
|
216
|
-
await cloneRepo({ repoURL, repoPath: sourceRepoPath(repoURL), keyPath: sourceKeyPath(repoURL) });
|
|
217
|
+
await cloneRepo({ repoURL, repoPath: sourceRepoPath(repoURL), keyPath: await findSourceKey(repoURL) || sourceKeyPath(repoURL) });
|
|
217
218
|
repoFailureCounts[repoURL] = 0;
|
|
218
219
|
return true;
|
|
219
220
|
} catch (cloneError) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { spawnPromise } from "../../helpers/spawn";
|
|
4
|
-
import { sourceKeyPath, sourceRepoPath } from "../sources";
|
|
4
|
+
import { findSourceKey, sourceKeyPath, sourceRepoPath } from "../sources";
|
|
5
5
|
import { GIT_TIMEOUT, MAX_ERROR_BODY_LENGTH } from "./paths";
|
|
6
6
|
import { sourceState } from "./state";
|
|
7
7
|
|
|
@@ -68,7 +68,7 @@ export async function currentBranch(config: { repoPath: string; keyPath: string
|
|
|
68
68
|
|
|
69
69
|
async function ensureSourceRepo(repoURL: string) {
|
|
70
70
|
let repoPath = sourceRepoPath(repoURL);
|
|
71
|
-
let keyPath = sourceKeyPath(repoURL);
|
|
71
|
+
let keyPath = await findSourceKey(repoURL) || sourceKeyPath(repoURL);
|
|
72
72
|
if (!await repoIsUsable({ repoPath, keyPath })) {
|
|
73
73
|
await cloneRepo({ repoURL, repoPath, keyPath });
|
|
74
74
|
}
|
|
@@ -82,7 +82,7 @@ async function ensureSourceRepo(repoURL: string) {
|
|
|
82
82
|
export async function syncRepo(repoURL: string) {
|
|
83
83
|
await ensureSourceRepo(repoURL);
|
|
84
84
|
let repoPath = sourceRepoPath(repoURL);
|
|
85
|
-
let keyPath = sourceKeyPath(repoURL);
|
|
85
|
+
let keyPath = await findSourceKey(repoURL) || sourceKeyPath(repoURL);
|
|
86
86
|
let branch = sourceState(repoURL).branch;
|
|
87
87
|
let localSha = (await runGit({ args: ["rev-parse", "HEAD"], cwd: repoPath, keyPath })).stdout.trim();
|
|
88
88
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { keyFingerprint, summarizeKey } from "../authorizedKeys";
|
|
4
|
-
import { deriveRevokeKey, revokeKeyPath, revokeRepoPath, revokeRepoURL } from "../revokeSource";
|
|
5
|
-
import { sourceKeyPath, sourceRepoPath } from "../sources";
|
|
4
|
+
import { deriveRevokeKey, findRevokeKey, REVOKE_KEY_LABEL, revokeKeyPath, revokeRepoPath, revokeRepoURL } from "../revokeSource";
|
|
5
|
+
import { findSourceKey, sourceKeyPath, sourceRepoPath } from "../sources";
|
|
6
6
|
import { cloneRepo, repoIsUsable, runGit } from "./git";
|
|
7
7
|
import { messageTimestamp } from "../../notifications/discord";
|
|
8
8
|
import { UNREVOKE_DELAY } from "./paths";
|
|
@@ -40,14 +40,20 @@ async function pathExists(filePath: string) {
|
|
|
40
40
|
/** The revoke repo's key is worked out from the source's, so nothing extra had to be uploaded and
|
|
41
41
|
nothing extra is stored anywhere it could be taken from. */
|
|
42
42
|
async function ensureRevokeKey(sourceURL: string) {
|
|
43
|
-
let
|
|
44
|
-
if (
|
|
45
|
-
return
|
|
43
|
+
let existing = await findRevokeKey(sourceURL);
|
|
44
|
+
if (existing) {
|
|
45
|
+
return existing;
|
|
46
|
+
}
|
|
47
|
+
let sourceKey = await findSourceKey(sourceURL);
|
|
48
|
+
if (!sourceKey) {
|
|
49
|
+
throw new Error(`Expected a key for ${sourceURL} at ${sourceKeyPath(sourceURL)}, no such file exists`);
|
|
46
50
|
}
|
|
47
|
-
let derived = deriveRevokeKey(await fs.readFile(
|
|
51
|
+
let derived = deriveRevokeKey(await fs.readFile(sourceKey, "utf8"));
|
|
52
|
+
let keyPath = revokeKeyPath(sourceURL);
|
|
48
53
|
await fs.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
49
54
|
await fs.writeFile(keyPath, derived.privateKeyFile, { mode: 0o600 });
|
|
50
|
-
|
|
55
|
+
await fs.writeFile(`${keyPath}.pub`, `${derived.publicKey} ${REVOKE_KEY_LABEL}\n`, { mode: 0o644 });
|
|
56
|
+
console.log(`Derived the revoke key for ${sourceURL} into ${keyPath}`);
|
|
51
57
|
return keyPath;
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -165,7 +171,7 @@ export async function recordRevocation(config: {
|
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
let repoPath = revokeRepoPath(sourceURL);
|
|
168
|
-
let keyPath =
|
|
174
|
+
let keyPath = await ensureRevokeKey(sourceURL);
|
|
169
175
|
let directory = path.join(repoPath, REVOCATIONS_DIR);
|
|
170
176
|
await fs.mkdir(directory, { recursive: true });
|
|
171
177
|
await fs.writeFile(path.join(directory, `${revocationId}.json`), JSON.stringify({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { deriveEd25519Key } from "../keys/deriveKey";
|
|
2
2
|
import { formatOpenSSHPrivateKey, parseOpenSSHPrivateKey } from "../keys/sshKeyFile";
|
|
3
|
-
import
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { findKey, keysDir, LEGACY_REPO_KEYS_DIR, REPOS_DIR, sourceName } from "./sources";
|
|
4
5
|
|
|
5
6
|
// A source's revocations live in a repo beside it. Derived rather than configured, so adding a
|
|
6
7
|
// source brings its revoke repo with it and there is nothing extra to pass on the command line.
|
|
@@ -15,7 +16,16 @@ export function revokeRepoURL(sourceURL: string) {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export function revokeKeyPath(sourceURL: string) {
|
|
18
|
-
return `${
|
|
19
|
+
return path.join(keysDir(), `${sourceName(sourceURL)}${REVOKED_SUFFIX}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function legacyRevokeKeyPath(sourceURL: string) {
|
|
23
|
+
return `${LEGACY_REPO_KEYS_DIR}/${sourceName(sourceURL)}${REVOKED_SUFFIX}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Wherever this source's revoke key already is, or nothing if it has not been derived yet. */
|
|
27
|
+
export async function findRevokeKey(sourceURL: string) {
|
|
28
|
+
return await findKey({ current: revokeKeyPath(sourceURL), legacy: legacyRevokeKeyPath(sourceURL) });
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
export function revokeRepoPath(sourceURL: string) {
|
|
@@ -3,8 +3,9 @@ import os from "os";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { DEFAULT_WEBHOOK_FILE_PATH, parseWebhookFile } from "../notifications/discord";
|
|
5
5
|
import { normalizeKeys, readRepoKeys, summarizeKey } from "./authorizedKeys";
|
|
6
|
-
import { sourceKeyPath, sourceRepoPath } from "./sources";
|
|
6
|
+
import { findSourceKey, KEYS_DIR_NAME, sourceKeyPath, sourceName, sourceRepoPath } from "./sources";
|
|
7
7
|
import { deriveRevokeKey, REVOKE_KEY_LABEL, revokeRepoURL } from "./revokeSource";
|
|
8
|
+
import { legacySourceKeyPath } from "./sources";
|
|
8
9
|
import { revokedKeysInRepo } from "./unrevoke";
|
|
9
10
|
import { expandHome } from "../helpers/paths";
|
|
10
11
|
import { spawnPromise } from "../helpers/spawn";
|
|
@@ -125,6 +126,27 @@ async function fingerprintKeys(keys: string[]) {
|
|
|
125
126
|
return fingerprints;
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
/** Where the daemon on that machine keeps its keys: the home of the user it runs as. Asked of the
|
|
130
|
+
machine itself, because the answer is not the same everywhere and certainly not the same as the
|
|
131
|
+
home of whoever is running this. */
|
|
132
|
+
async function daemonKeysDir(host: string) {
|
|
133
|
+
let result = await runOverSSH({
|
|
134
|
+
host,
|
|
135
|
+
script: `${SUDO_PREAMBLE}\n$SUDO getent passwd root | cut -d: -f6`,
|
|
136
|
+
});
|
|
137
|
+
let home = result.stdout.trim();
|
|
138
|
+
if (!home) {
|
|
139
|
+
throw new Error(`Expected ${describeHost(host)} to report a home directory for root, it reported nothing`);
|
|
140
|
+
}
|
|
141
|
+
return `${home}/${KEYS_DIR_NAME}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The public half of a private key, as ssh-keygen derives it. */
|
|
145
|
+
async function publicKeyOf(privateKeyPath: string) {
|
|
146
|
+
let result = await runLocal({ command: "ssh-keygen", args: ["-y", "-f", privateKeyPath] });
|
|
147
|
+
return `${result.stdout.trim()}\n`;
|
|
148
|
+
}
|
|
149
|
+
|
|
128
150
|
async function cloneRepoForInspection(config: { repoURL: string; keyPath: string }) {
|
|
129
151
|
let { repoURL, keyPath } = config;
|
|
130
152
|
let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "portsecure-repo-"));
|
|
@@ -184,8 +206,9 @@ async function ensureRevokeReposExist(repoSources: string[]) {
|
|
|
184
206
|
// and the key derived from it is the one credential that repo is guaranteed to accept.
|
|
185
207
|
// Anywhere else, a person is running this and has their own access.
|
|
186
208
|
let ownKey = "";
|
|
187
|
-
|
|
188
|
-
|
|
209
|
+
let localSourceKey = await findSourceKey(repoURL);
|
|
210
|
+
if (localSourceKey) {
|
|
211
|
+
let derived = deriveRevokeKey(await fs.readFile(localSourceKey, "utf8"));
|
|
189
212
|
ownKey = path.join(temporaryDirectory, "key");
|
|
190
213
|
await fs.writeFile(ownKey, derived.privateKeyFile, { mode: 0o600 });
|
|
191
214
|
}
|
|
@@ -467,13 +490,26 @@ async function addSource(config: { host: string; keyPath: string; repoURL: strin
|
|
|
467
490
|
let webhookURL = await requireRemoteWebhook(host);
|
|
468
491
|
console.log(`${describeHost(host)} notifies ${webhookURL}`);
|
|
469
492
|
|
|
493
|
+
// Into the home of whoever the daemon runs as, which is where it looks. Asked of the host
|
|
494
|
+
// rather than worked out here: this may be running on a machine with no such user, and with a
|
|
495
|
+
// home directory in an entirely different shape.
|
|
496
|
+
let hostKeysDirectory = await daemonKeysDir(host);
|
|
470
497
|
await writeRemoteFile({
|
|
471
498
|
host,
|
|
472
|
-
filePath:
|
|
499
|
+
filePath: `${hostKeysDirectory}/${sourceName(repoURL)}`,
|
|
473
500
|
contents: await fs.readFile(keyPath, "utf8"),
|
|
474
501
|
fileMode: "600",
|
|
475
502
|
directoryMode: "700",
|
|
476
503
|
});
|
|
504
|
+
// The public half too, so the deploy key that a repo was given can be looked up on the machine
|
|
505
|
+
// using it, rather than only in whatever github shows.
|
|
506
|
+
await writeRemoteFile({
|
|
507
|
+
host,
|
|
508
|
+
filePath: `${hostKeysDirectory}/${sourceName(repoURL)}.pub`,
|
|
509
|
+
contents: await publicKeyOf(keyPath),
|
|
510
|
+
fileMode: "644",
|
|
511
|
+
directoryMode: "700",
|
|
512
|
+
});
|
|
477
513
|
|
|
478
514
|
let repoSources = remoteConfig.repoSources.filter(source => source !== repoURL);
|
|
479
515
|
repoSources.push(repoURL);
|
|
@@ -519,7 +555,7 @@ async function removeSource(config: { host: string; repoURL: string }) {
|
|
|
519
555
|
await runOverSSH({
|
|
520
556
|
host,
|
|
521
557
|
script: `${SUDO_PREAMBLE}
|
|
522
|
-
$SUDO rm -f "${sourceKeyPath(repoURL)}"
|
|
558
|
+
$SUDO rm -f "${sourceKeyPath(repoURL)}" "${sourceKeyPath(repoURL)}.pub" "${legacySourceKeyPath(repoURL)}"
|
|
523
559
|
$SUDO rm -rf "${sourceRepoPath(repoURL)}"`,
|
|
524
560
|
});
|
|
525
561
|
await installDaemon({ host, hostLabel: remoteConfig.hostLabel, repoSources });
|
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// PORTED CODE: nothing here is duplicated any more, the daemon imports it directly. Kept in one
|
|
6
|
+
// place because the daemon and the deploy have to agree on where a source's key and checkout live.
|
|
7
|
+
|
|
8
|
+
// The user's own folder, so this works the same on a machine that has no /etc. The daemon runs as
|
|
9
|
+
// root, so on a host this is root's home.
|
|
10
|
+
export const KEYS_DIR_NAME = "authorized_keys";
|
|
11
|
+
// Where keys used to go. Still read, so a host set up before this keeps working, but nothing is
|
|
12
|
+
// written here any more.
|
|
13
|
+
export const LEGACY_REPO_KEYS_DIR = "/etc/portsecure/repo-keys";
|
|
6
14
|
export const REPOS_DIR = "/var/lib/portsecure/authorized-keys-repos";
|
|
7
15
|
|
|
16
|
+
export function keysDir() {
|
|
17
|
+
return path.join(os.homedir(), KEYS_DIR_NAME);
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
/** A repo url reduced to something usable as a file name. Derived rather than configured, so the
|
|
9
21
|
daemon and the deploy script always agree on where a source's key and checkout live. */
|
|
10
22
|
export function sourceName(repoURL: string) {
|
|
@@ -12,9 +24,39 @@ export function sourceName(repoURL: string) {
|
|
|
12
24
|
}
|
|
13
25
|
|
|
14
26
|
export function sourceKeyPath(repoURL: string) {
|
|
15
|
-
return
|
|
27
|
+
return path.join(keysDir(), sourceName(repoURL));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function legacySourceKeyPath(repoURL: string) {
|
|
31
|
+
return `${LEGACY_REPO_KEYS_DIR}/${sourceName(repoURL)}`;
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
export function sourceRepoPath(repoURL: string) {
|
|
19
35
|
return `${REPOS_DIR}/${sourceName(repoURL)}`;
|
|
20
36
|
}
|
|
37
|
+
|
|
38
|
+
async function pathExists(filePath: string) {
|
|
39
|
+
try {
|
|
40
|
+
await fs.access(filePath);
|
|
41
|
+
return true;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The key for a source, wherever it happens to be. The user folder is where they go now, and the
|
|
48
|
+
old location is still read so a host that predates the move keeps working. */
|
|
49
|
+
export async function findKey(config: { current: string; legacy: string }) {
|
|
50
|
+
let { current, legacy } = config;
|
|
51
|
+
if (await pathExists(current)) {
|
|
52
|
+
return current;
|
|
53
|
+
}
|
|
54
|
+
if (await pathExists(legacy)) {
|
|
55
|
+
return legacy;
|
|
56
|
+
}
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function findSourceKey(repoURL: string) {
|
|
61
|
+
return await findKey({ current: sourceKeyPath(repoURL), legacy: legacySourceKeyPath(repoURL) });
|
|
62
|
+
}
|
|
@@ -3,7 +3,8 @@ import os from "os";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { runPromise } from "socket-function/src/runPromise";
|
|
5
5
|
import { keyFingerprint, normalizeKeys } from "./authorizedKeys";
|
|
6
|
-
import { revokeRepoURL } from "./revokeSource";
|
|
6
|
+
import { deriveRevokeKey, revokeRepoURL } from "./revokeSource";
|
|
7
|
+
import { findSourceKey } from "./sources";
|
|
7
8
|
import { signRepo } from "../signedFiles/signFiles";
|
|
8
9
|
import { readRepoKeys } from "./authorizedKeys";
|
|
9
10
|
import { expandHome } from "../helpers/paths";
|
|
@@ -31,17 +32,25 @@ export type Revocation = {
|
|
|
31
32
|
attempt?: { ip?: string; user?: string; required?: string };
|
|
32
33
|
};
|
|
33
34
|
|
|
34
|
-
/** Every revocation the revoke repo lists. Cloned read only into a temp directory
|
|
35
|
-
|
|
36
|
-
running this has their own access to the repo.
|
|
35
|
+
/** Every revocation the revoke repo lists. Cloned read only into a temp directory.
|
|
36
|
+
|
|
37
|
+
A person running this has their own access to the repo, so their credentials are what is used.
|
|
38
|
+
On a machine that is already a portsecure host there may be no such credentials, but the source
|
|
39
|
+
deploy key is right there, and the key derived from it is one that repo definitely accepts. */
|
|
37
40
|
export async function readRemoteRevocations(config: { sourceURL: string }) {
|
|
38
41
|
let { sourceURL } = config;
|
|
39
42
|
let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "unrevoke-"));
|
|
40
43
|
let repoPath = path.join(temporaryDirectory, "repo");
|
|
41
|
-
let
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
let cloneArgs = ["clone", "--depth", "1", revokeRepoURL(sourceURL), repoPath];
|
|
45
|
+
let sourceKey = await findSourceKey(sourceURL);
|
|
46
|
+
if (sourceKey) {
|
|
47
|
+
let derived = deriveRevokeKey(await fs.readFile(sourceKey, "utf8"));
|
|
48
|
+
let derivedKeyPath = path.join(temporaryDirectory, "key");
|
|
49
|
+
await fs.writeFile(derivedKeyPath, derived.privateKeyFile, { mode: 0o600 });
|
|
50
|
+
let sshCommand = `ssh -i ${derivedKeyPath} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
|
|
51
|
+
cloneArgs = ["-c", `core.sshCommand=${sshCommand}`, ...cloneArgs];
|
|
52
|
+
}
|
|
53
|
+
let clone = await spawnPromise({ command: "git", args: cloneArgs });
|
|
45
54
|
if (clone.status !== 0) {
|
|
46
55
|
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
47
56
|
throw new Error(
|