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,69 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { spawnPromise } from "../helpers/spawn";
|
|
5
|
+
|
|
6
|
+
// PORTED CODE: security/authorizedKeys/daemon/portsecureDaemon.js contains a plain JS port of the
|
|
7
|
+
// verifying half of this file, so it can check a signature with no dependencies. Both sides must
|
|
8
|
+
// agree on the manifest shape and on which files it covers - if you change one, make the matching
|
|
9
|
+
// change in the other.
|
|
10
|
+
|
|
11
|
+
export const MANIFEST_NAME = "signedfiles.json";
|
|
12
|
+
export const SIGNATURE_NAME = "signedfiles.json.sig";
|
|
13
|
+
// ssh signatures are namespaced, so a signature made for one purpose cannot be replayed as another.
|
|
14
|
+
export const SIGN_NAMESPACE = "signfiles";
|
|
15
|
+
export const MANIFEST_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
export type Manifest = {
|
|
18
|
+
version: number;
|
|
19
|
+
files: { path: string; size: number; sha256: string }[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Everything in the repo that is not ignored, which is exactly what a clone of it will contain.
|
|
23
|
+
The manifest and its signature are left out, since they cannot describe themselves. */
|
|
24
|
+
export async function listRepoFiles(repoPath: string) {
|
|
25
|
+
let result = await spawnPromise({
|
|
26
|
+
command: "git",
|
|
27
|
+
args: ["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
28
|
+
cwd: repoPath,
|
|
29
|
+
});
|
|
30
|
+
if (result.status !== 0) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Expected to list the files in ${repoPath}, git ls-files exited ${result.status}. `
|
|
33
|
+
+ `${(result.stdout + result.stderr).trim()}`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return result.stdout.split("\n")
|
|
37
|
+
.map(line => line.trim())
|
|
38
|
+
.filter(line => line && line !== MANIFEST_NAME && line !== SIGNATURE_NAME)
|
|
39
|
+
.sort();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A Windows checkout turns LF into CRLF, so the same commit hashes differently there than it
|
|
43
|
+
does on the machine that pulls it. Normalising first makes the digest describe the content
|
|
44
|
+
rather than whichever checkout produced it. latin1 round trips every byte, so this is safe on
|
|
45
|
+
files that are not text. */
|
|
46
|
+
export function normalizeContent(contents: Buffer) {
|
|
47
|
+
return Buffer.from(contents.toString("latin1").replace(/\r\n/g, "\n"), "latin1");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The size and hash of a file's normalised content. The size is the normalised one on purpose,
|
|
51
|
+
so it agrees with the hash rather than with whatever the local checkout happens to hold. */
|
|
52
|
+
export async function digestFile(filePath: string) {
|
|
53
|
+
let contents = normalizeContent(await fs.readFile(filePath));
|
|
54
|
+
return { size: contents.length, sha256: crypto.createHash("sha256").update(contents).digest("hex") };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function buildManifest(repoPath: string) {
|
|
58
|
+
let files: Manifest["files"] = [];
|
|
59
|
+
for (let relativePath of await listRepoFiles(repoPath)) {
|
|
60
|
+
let digest = await digestFile(path.join(repoPath, relativePath));
|
|
61
|
+
files.push({ path: relativePath, size: digest.size, sha256: digest.sha256 });
|
|
62
|
+
}
|
|
63
|
+
return { version: MANIFEST_VERSION, files };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Sorted keys and a trailing newline, so the same tree always produces the same bytes to sign. */
|
|
67
|
+
export function formatManifest(manifest: Manifest) {
|
|
68
|
+
return JSON.stringify(manifest, undefined, 4) + "\n";
|
|
69
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { runPromise } from "socket-function/src/runPromise";
|
|
5
|
+
import { expandHome } from "../helpers/paths";
|
|
6
|
+
import { buildManifest, formatManifest, MANIFEST_NAME, SIGNATURE_NAME, SIGN_NAMESPACE } from "./manifest";
|
|
7
|
+
import { revokedKeysInRepo } from "../authorizedKeys/unrevoke";
|
|
8
|
+
|
|
9
|
+
// A hardware backed key is the entire point. A key sitting on disk is compromised the moment the
|
|
10
|
+
// machine is, and then the signature proves nothing, so this is what we make when asked to make one.
|
|
11
|
+
const DEFAULT_KEY_TYPE = "ed25519-sk";
|
|
12
|
+
const DEFAULT_KEY_PATH = "~/.ssh/signfiles_ed25519_sk";
|
|
13
|
+
const GIT_KEYWORD = "git";
|
|
14
|
+
const COMMIT_MESSAGE = "deploying signed files";
|
|
15
|
+
const MAX_ERROR_BODY_LENGTH = 500;
|
|
16
|
+
const USAGE = `Usage: yarn signfiles [signing-key] [${GIT_KEYWORD}]
|
|
17
|
+
|
|
18
|
+
Signs the files of the repo in the current directory. With no key, a hardware backed
|
|
19
|
+
${DEFAULT_KEY_TYPE} key at ${DEFAULT_KEY_PATH} is used, and created if it does not exist.
|
|
20
|
+
Pass ${GIT_KEYWORD} to also commit and push the result.`;
|
|
21
|
+
|
|
22
|
+
/** runPromise takes a command line rather than an argument list, so anything holding a path has to
|
|
23
|
+
survive the shell. Double quotes work on both cmd and posix shells. */
|
|
24
|
+
function quote(value: string) {
|
|
25
|
+
return `"${value}"`;
|
|
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
|
+
/** Creating this needs the security key plugged in, and a touch. */
|
|
38
|
+
async function ensureDefaultKey() {
|
|
39
|
+
let keyPath = expandHome(DEFAULT_KEY_PATH);
|
|
40
|
+
if (await pathExists(keyPath)) {
|
|
41
|
+
return keyPath;
|
|
42
|
+
}
|
|
43
|
+
console.log(`No signing key at ${keyPath}, creating an ${DEFAULT_KEY_TYPE} one.`);
|
|
44
|
+
console.log(`Plug your security key in - you will be asked to touch it.`);
|
|
45
|
+
await fs.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
46
|
+
await runPromise(`ssh-keygen -t ${DEFAULT_KEY_TYPE} -f ${quote(keyPath)} -N "" -C signfiles`);
|
|
47
|
+
return keyPath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The public key in the form the daemon reports it, so what is printed here can be compared
|
|
51
|
+
against what arrives on Discord. */
|
|
52
|
+
async function publicKeyOf(keyPath: string) {
|
|
53
|
+
let contents = await fs.readFile(`${keyPath}.pub`, "utf8");
|
|
54
|
+
let [keyType, keyBody] = contents.trim().split(/\s+/);
|
|
55
|
+
if (!keyType || !keyBody) {
|
|
56
|
+
throw new Error(`Expected a public key in ${keyPath}.pub, was ${contents.slice(0, MAX_ERROR_BODY_LENGTH)}`);
|
|
57
|
+
}
|
|
58
|
+
return `${keyType} ${keyBody}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Signing a keys repo that still holds a revoked key would publish it back to every machine that
|
|
62
|
+
took it out. Only applies to a repo that holds keys and has a revoke repo to check - signing
|
|
63
|
+
anything else is none of this function's business. */
|
|
64
|
+
async function refuseRevokedKeys(repoPath: string) {
|
|
65
|
+
if (!await pathExists(path.join(repoPath, "authorized_keys"))) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let originURL = (await runPromise("git remote get-url origin", { cwd: repoPath, quiet: true, nothrow: true })).trim();
|
|
69
|
+
if (!originURL) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let revoked;
|
|
73
|
+
try {
|
|
74
|
+
revoked = await revokedKeysInRepo({ repoPath, sourceURL: originURL });
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// No revoke repo, or no access to it. Nothing has been revoked that we can see.
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!revoked.length) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Expected ${repoPath} to hold no revoked keys, it holds ${revoked.length}:\n`
|
|
84
|
+
+ revoked.map(entry => ` ${entry.revocation.fingerprint} revoked by ${entry.revocation.revokedBy || "?"}`
|
|
85
|
+
+ ` after use from ${entry.revocation.attempt?.ip || "?"}`).join("\n")
|
|
86
|
+
+ `\nDelete them from authorized_keys, or run "yarn unrevoke" here to allow them again.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseArgs(argv: string[]) {
|
|
91
|
+
let pushToGit = argv.includes(GIT_KEYWORD);
|
|
92
|
+
let positional = argv.filter(arg => arg !== GIT_KEYWORD);
|
|
93
|
+
if (positional.length > 1) {
|
|
94
|
+
throw new Error(`Expected at most a signing key, was ${positional.length} argument(s): ${positional.join(" ")}\n${USAGE}`);
|
|
95
|
+
}
|
|
96
|
+
return { keyPath: positional[0], pushToGit };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function main() {
|
|
100
|
+
let { keyPath, pushToGit } = parseArgs(process.argv.slice(2));
|
|
101
|
+
|
|
102
|
+
let repoPath = (await runPromise("git rev-parse --show-toplevel", { quiet: true })).trim();
|
|
103
|
+
if (!repoPath) {
|
|
104
|
+
throw new Error(`Expected the current directory to be inside a git repo, it is not.\n${USAGE}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let signingKey = keyPath && expandHome(keyPath) || await ensureDefaultKey();
|
|
108
|
+
if (!await pathExists(signingKey)) {
|
|
109
|
+
throw new Error(`Expected a signing key at ${signingKey}, no such file exists`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await refuseRevokedKeys(repoPath);
|
|
113
|
+
|
|
114
|
+
let manifest = await buildManifest(repoPath);
|
|
115
|
+
console.log(`${MANIFEST_NAME} covers ${manifest.files.length} file(s) in ${repoPath}`);
|
|
116
|
+
|
|
117
|
+
// The manifest is built and signed away from the repo, and only moved in once both exist.
|
|
118
|
+
// Landing a new manifest next to an old signature produces a pair that can never verify, and
|
|
119
|
+
// the daemon reading it can only treat that as tampering.
|
|
120
|
+
let stagingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "signfiles-"));
|
|
121
|
+
let stagedManifest = path.join(stagingDirectory, MANIFEST_NAME);
|
|
122
|
+
let stagedSignature = `${stagedManifest}.sig`;
|
|
123
|
+
await fs.writeFile(stagedManifest, formatManifest(manifest));
|
|
124
|
+
|
|
125
|
+
// Signing happens before any git work, so a failed push never costs a second touch of the key.
|
|
126
|
+
await runPromise(`ssh-keygen -Y sign -f ${quote(signingKey)} -n ${SIGN_NAMESPACE} ${quote(stagedManifest)}`);
|
|
127
|
+
// Checked here rather than left for a machine that has already pulled it to discover.
|
|
128
|
+
await runPromise(
|
|
129
|
+
`ssh-keygen -Y check-novalidate -n ${SIGN_NAMESPACE} -s ${quote(stagedSignature)} < ${quote(stagedManifest)}`
|
|
130
|
+
);
|
|
131
|
+
await fs.copyFile(stagedManifest, path.join(repoPath, MANIFEST_NAME));
|
|
132
|
+
await fs.copyFile(stagedSignature, path.join(repoPath, SIGNATURE_NAME));
|
|
133
|
+
await fs.rm(stagingDirectory, { recursive: true, force: true });
|
|
134
|
+
console.log(`Signed with ${await publicKeyOf(signingKey)}`);
|
|
135
|
+
|
|
136
|
+
if (!pushToGit) {
|
|
137
|
+
console.log(`Commit and push ${MANIFEST_NAME} and ${SIGNATURE_NAME} for anything to see them.`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await runPromise(`git add -A`, { cwd: repoPath });
|
|
141
|
+
// Nothing staged is not worth stopping on. git commit calls that a failure, but it only means
|
|
142
|
+
// the signature matches the one already committed, so there is nothing to deploy.
|
|
143
|
+
let staged = await runPromise(`git status --porcelain`, { cwd: repoPath, quiet: true });
|
|
144
|
+
if (!staged.trim()) {
|
|
145
|
+
console.log(`Nothing changed, ${MANIFEST_NAME} and ${SIGNATURE_NAME} are already committed.`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
await runPromise(`git commit -m ${quote(COMMIT_MESSAGE)}`, { cwd: repoPath });
|
|
149
|
+
await runPromise(`git push`, { cwd: repoPath });
|
|
150
|
+
console.log(`Committed and pushed.`);
|
|
151
|
+
}
|