@patimweb/pi-ssh 1.0.0
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/.node-version +1 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/index.ts +99 -0
- package/package.json +52 -0
- package/release.config.cjs +16 -0
- package/skills/ssh-key-setup/SKILL.md +71 -0
- package/skills/ssh-remote-work/SKILL.md +81 -0
- package/src/authorize.ts +237 -0
- package/src/clients/ssh-client.ts +490 -0
- package/src/config.ts +189 -0
- package/src/doctor.ts +212 -0
- package/src/formatting/formatters.ts +133 -0
- package/src/keys.ts +191 -0
- package/src/known-hosts.ts +189 -0
- package/src/tools/ssh-authorize.ts +88 -0
- package/src/tools/ssh-doctor.ts +26 -0
- package/src/tools/ssh-download.ts +51 -0
- package/src/tools/ssh-exec.ts +76 -0
- package/src/tools/ssh-keygen.ts +102 -0
- package/src/tools/ssh-list.ts +49 -0
- package/src/tools/ssh-profile.ts +77 -0
- package/src/tools/ssh-setup.ts +96 -0
- package/src/tools/ssh-status.ts +70 -0
- package/src/tools/ssh-upload.ts +51 -0
- package/src/types.ts +144 -0
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment report.
|
|
3
|
+
*
|
|
4
|
+
* The point of this module is to answer "what do I have to install to use
|
|
5
|
+
* this?" with evidence rather than a guess. The short answer is nothing:
|
|
6
|
+
* ssh2 is a pure JavaScript SSH implementation and keys are generated in
|
|
7
|
+
* process, so no `ssh`, `ssh-keygen` or `ssh-copy-id` binary is required on
|
|
8
|
+
* any platform. What the checks below find are the optional conveniences and
|
|
9
|
+
* the two things that genuinely do break a connection: an unreadable key file
|
|
10
|
+
* and a known_hosts that cannot be written.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { defaultKnownHostsPath } from "./known-hosts.ts";
|
|
17
|
+
|
|
18
|
+
export type CheckStatus = "ok" | "note" | "problem";
|
|
19
|
+
|
|
20
|
+
export interface Check {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly status: CheckStatus;
|
|
23
|
+
readonly detail: string;
|
|
24
|
+
/** What to do about it, when there is something to do. */
|
|
25
|
+
readonly remedy?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function checkNodeVersion(): Check {
|
|
29
|
+
const major = Number.parseInt(process.versions.node.split(".")[0], 10);
|
|
30
|
+
return major >= 20
|
|
31
|
+
? { name: "Node", status: "ok", detail: `v${process.versions.node}` }
|
|
32
|
+
: {
|
|
33
|
+
name: "Node",
|
|
34
|
+
status: "problem",
|
|
35
|
+
detail: `v${process.versions.node} is older than this extension supports`,
|
|
36
|
+
remedy: "Run pi on Node 20 or newer.",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function checkSsh2(): Check {
|
|
41
|
+
try {
|
|
42
|
+
const pkg = JSON.parse(
|
|
43
|
+
fs.readFileSync(
|
|
44
|
+
new URL("../node_modules/ssh2/package.json", import.meta.url),
|
|
45
|
+
"utf8",
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
return {
|
|
49
|
+
name: "SSH implementation",
|
|
50
|
+
status: "ok",
|
|
51
|
+
detail: `ssh2 ${pkg.version}, pure JavaScript -- no ssh binary needed`,
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return {
|
|
55
|
+
name: "SSH implementation",
|
|
56
|
+
status: "ok",
|
|
57
|
+
detail: "ssh2 (bundled dependency), pure JavaScript -- no ssh binary needed",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function checkKeyGeneration(): Check {
|
|
63
|
+
return {
|
|
64
|
+
name: "Key generation",
|
|
65
|
+
status: "ok",
|
|
66
|
+
detail: "ed25519 keys are generated in process; ssh-keygen is not required",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function checkSshDirectory(): Check {
|
|
71
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
72
|
+
const dir = path.join(home, ".ssh");
|
|
73
|
+
|
|
74
|
+
if (!fs.existsSync(dir)) {
|
|
75
|
+
return {
|
|
76
|
+
name: "~/.ssh",
|
|
77
|
+
status: "note",
|
|
78
|
+
detail: `${dir} does not exist yet; it will be created when first needed`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Permissions only matter on POSIX; Windows uses ACLs and reports 0666.
|
|
83
|
+
if (process.platform !== "win32") {
|
|
84
|
+
const mode = fs.statSync(dir).mode & 0o777;
|
|
85
|
+
if (mode & 0o077) {
|
|
86
|
+
return {
|
|
87
|
+
name: "~/.ssh",
|
|
88
|
+
status: "note",
|
|
89
|
+
detail: `${dir} is group- or world-accessible (mode ${mode.toString(8)})`,
|
|
90
|
+
remedy: `chmod 700 ${dir} -- OpenSSH refuses some keys otherwise.`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { name: "~/.ssh", status: "ok", detail: dir };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function checkKnownHosts(): Check {
|
|
98
|
+
const file = defaultKnownHostsPath();
|
|
99
|
+
if (!fs.existsSync(file)) {
|
|
100
|
+
return {
|
|
101
|
+
name: "known_hosts",
|
|
102
|
+
status: "note",
|
|
103
|
+
detail: `${file} does not exist; the first connection to each host will have to be confirmed`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK);
|
|
108
|
+
} catch {
|
|
109
|
+
return {
|
|
110
|
+
name: "known_hosts",
|
|
111
|
+
status: "problem",
|
|
112
|
+
detail: `${file} is not readable and writable`,
|
|
113
|
+
remedy: "Host keys cannot be verified or recorded until that is fixed.",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const lines = fs
|
|
117
|
+
.readFileSync(file, "utf-8")
|
|
118
|
+
.split(/\r?\n/)
|
|
119
|
+
.filter((line) => line.trim() && !line.startsWith("#")).length;
|
|
120
|
+
return {
|
|
121
|
+
name: "known_hosts",
|
|
122
|
+
status: "ok",
|
|
123
|
+
detail: `${file} (${lines} host${lines === 1 ? "" : "s"} on record)`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function checkConfigDirectory(): Check {
|
|
128
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
129
|
+
const dir = path.join(home, ".pi");
|
|
130
|
+
const file = path.join(dir, "ssh-config.json");
|
|
131
|
+
|
|
132
|
+
if (!fs.existsSync(file)) {
|
|
133
|
+
return { name: "Stored hosts", status: "note", detail: "no hosts configured yet" };
|
|
134
|
+
}
|
|
135
|
+
if (process.platform !== "win32") {
|
|
136
|
+
const mode = fs.statSync(file).mode & 0o777;
|
|
137
|
+
if (mode !== 0o600) {
|
|
138
|
+
return {
|
|
139
|
+
name: "Stored hosts",
|
|
140
|
+
status: "problem",
|
|
141
|
+
detail: `${file} has mode ${mode.toString(8)} and may contain passwords`,
|
|
142
|
+
remedy: `chmod 600 ${file}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { name: "Stored hosts", status: "ok", detail: `${file} (owner only)` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Is an `ssh` command available? Purely informational: this extension does
|
|
151
|
+
* not use it, but a user who wants to reuse a generated key from a terminal
|
|
152
|
+
* will want to know.
|
|
153
|
+
*/
|
|
154
|
+
function checkOpenSshClient(): Check {
|
|
155
|
+
const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
156
|
+
const names = process.platform === "win32" ? ["ssh.exe"] : ["ssh"];
|
|
157
|
+
|
|
158
|
+
for (const dir of dirs) {
|
|
159
|
+
for (const name of names) {
|
|
160
|
+
try {
|
|
161
|
+
const candidate = path.join(dir, name);
|
|
162
|
+
if (fs.existsSync(candidate)) {
|
|
163
|
+
return {
|
|
164
|
+
name: "OpenSSH client (optional)",
|
|
165
|
+
status: "ok",
|
|
166
|
+
detail: `${candidate} -- keys made here work with it too`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
/* an unreadable PATH entry is not interesting */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
name: "OpenSSH client (optional)",
|
|
177
|
+
status: "note",
|
|
178
|
+
detail: "no ssh command on PATH",
|
|
179
|
+
remedy:
|
|
180
|
+
process.platform === "win32"
|
|
181
|
+
? "Not needed by this extension. To use keys from a terminal too: Settings -> Apps -> Optional features -> OpenSSH Client."
|
|
182
|
+
: "Not needed by this extension. Install the openssh-client package if you also want to ssh by hand.",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function runChecks(): Check[] {
|
|
187
|
+
return [
|
|
188
|
+
checkNodeVersion(),
|
|
189
|
+
checkSsh2(),
|
|
190
|
+
checkKeyGeneration(),
|
|
191
|
+
checkOpenSshClient(),
|
|
192
|
+
checkSshDirectory(),
|
|
193
|
+
checkKnownHosts(),
|
|
194
|
+
checkConfigDirectory(),
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function formatChecks(checks: ReadonlyArray<Check>): string {
|
|
199
|
+
const symbol = { ok: "ok ", note: "note", problem: "FAIL" } as const;
|
|
200
|
+
const lines = checks.map((check) => {
|
|
201
|
+
const head = `[${symbol[check.status]}] ${check.name}: ${check.detail}`;
|
|
202
|
+
return check.remedy ? `${head}\n ${check.remedy}` : head;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const problems = checks.filter((check) => check.status === "problem").length;
|
|
206
|
+
const summary =
|
|
207
|
+
problems > 0
|
|
208
|
+
? `${problems} problem(s) need attention before this will work reliably.`
|
|
209
|
+
: "Nothing needs to be installed: this extension speaks SSH itself and generates its own keys.";
|
|
210
|
+
|
|
211
|
+
return [`Environment (${process.platform}, ${process.arch}):`, ...lines, "", summary].join("\n");
|
|
212
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatters.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that transform domain data into display strings.
|
|
5
|
+
* No emojis, no side effects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
AuthorizeResult,
|
|
10
|
+
ExecResult,
|
|
11
|
+
RemoteEntry,
|
|
12
|
+
ServerIdentity,
|
|
13
|
+
SshProfile,
|
|
14
|
+
TransferResult,
|
|
15
|
+
} from "../types.ts";
|
|
16
|
+
|
|
17
|
+
export function formatBytes(bytes: number): string {
|
|
18
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "unknown size";
|
|
19
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
20
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
21
|
+
let value = bytes / 1024;
|
|
22
|
+
let unit = 0;
|
|
23
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
24
|
+
value /= 1024;
|
|
25
|
+
unit += 1;
|
|
26
|
+
}
|
|
27
|
+
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** How a profile authenticates, without revealing any secret. */
|
|
31
|
+
export function describeAuth(profile: SshProfile): string {
|
|
32
|
+
const methods: string[] = [];
|
|
33
|
+
if (profile.privateKeyPath) methods.push(`key ${profile.privateKeyPath}`);
|
|
34
|
+
if (profile.password) methods.push("password (stored)");
|
|
35
|
+
return methods.length > 0 ? methods.join(", ") : "none configured";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatProfileStatus(
|
|
39
|
+
profiles: Record<string, SshProfile>,
|
|
40
|
+
active: string | null,
|
|
41
|
+
): string {
|
|
42
|
+
const names = Object.keys(profiles);
|
|
43
|
+
if (names.length === 0) {
|
|
44
|
+
return "No SSH hosts configured. Use ssh_setup with a host, user, and a password or key path.";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const lines: string[] = [];
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
const profile = profiles[name];
|
|
50
|
+
const marker = name === active ? "*" : " ";
|
|
51
|
+
const port = profile.port === 22 ? "" : `:${profile.port}`;
|
|
52
|
+
lines.push(`${marker} ${name}: ${profile.user}@${profile.host}${port}`);
|
|
53
|
+
lines.push(` auth: ${describeAuth(profile)}`);
|
|
54
|
+
if (profile.strictHostKey === false) {
|
|
55
|
+
lines.push(" host key checking is OFF for this profile");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return [`SSH hosts (${names.length}), * marks the active one:`, ...lines].join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatIdentity(identity: ServerIdentity): string {
|
|
63
|
+
return [
|
|
64
|
+
`Connected: ${identity.user}@${identity.host}:${identity.port}`,
|
|
65
|
+
`Authenticated with: ${identity.authMethod}`,
|
|
66
|
+
`Host key: ${identity.keyType} ${identity.fingerprint} (${identity.hostKeyVerdict})`,
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatExecResult(result: ExecResult): string {
|
|
71
|
+
const sections: string[] = [];
|
|
72
|
+
const status =
|
|
73
|
+
result.code === 0
|
|
74
|
+
? "exit 0"
|
|
75
|
+
: result.signal
|
|
76
|
+
? `killed by ${result.signal}`
|
|
77
|
+
: `exit ${result.code}`;
|
|
78
|
+
|
|
79
|
+
sections.push(`$ ${result.command}`);
|
|
80
|
+
sections.push(`[${status}, ${result.durationMs} ms]`);
|
|
81
|
+
|
|
82
|
+
if (result.stdout.trim()) {
|
|
83
|
+
sections.push("", result.stdout.replace(/\s+$/, ""));
|
|
84
|
+
}
|
|
85
|
+
if (result.stderr.trim()) {
|
|
86
|
+
sections.push("", "stderr:", result.stderr.replace(/\s+$/, ""));
|
|
87
|
+
}
|
|
88
|
+
if (!result.stdout.trim() && !result.stderr.trim()) {
|
|
89
|
+
sections.push("", "(no output)");
|
|
90
|
+
}
|
|
91
|
+
return sections.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function formatDirectory(
|
|
95
|
+
entries: ReadonlyArray<RemoteEntry>,
|
|
96
|
+
remotePath: string,
|
|
97
|
+
): string {
|
|
98
|
+
if (entries.length === 0) {
|
|
99
|
+
return `${remotePath} is empty.`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lines = entries.map((entry) => {
|
|
103
|
+
const suffix = entry.type === "directory" ? "/" : entry.type === "symlink" ? "@" : "";
|
|
104
|
+
const size = entry.type === "directory" ? "" : ` ${formatBytes(entry.size)}`;
|
|
105
|
+
const date = entry.modified ? ` ${entry.modified.slice(0, 10)}` : "";
|
|
106
|
+
return `${entry.mode}${date}${size} ${entry.name}${suffix}`;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return [`${remotePath} (${entries.length} entries):`, ...lines].join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatTransfer(result: TransferResult, direction: "up" | "down"): string {
|
|
113
|
+
return direction === "up"
|
|
114
|
+
? `Uploaded ${result.localPath} to ${result.remotePath} (${formatBytes(result.size)}).`
|
|
115
|
+
: `Downloaded ${result.remotePath} to ${result.localPath} (${formatBytes(result.size)}).`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatAuthorizeResult(result: AuthorizeResult): string {
|
|
119
|
+
const lines = [
|
|
120
|
+
result.installed
|
|
121
|
+
? `Key installed on the remote host and recorded in profile "${result.profile}".`
|
|
122
|
+
: `The key was already in ${result.authorizedKeysPath}; profile "${result.profile}" now uses it.`,
|
|
123
|
+
"",
|
|
124
|
+
`Private key: ${result.keyPath}`,
|
|
125
|
+
`Public key: ${result.publicKeyPath}`,
|
|
126
|
+
`Fingerprint: ${result.fingerprint}`,
|
|
127
|
+
"",
|
|
128
|
+
result.verified
|
|
129
|
+
? "Verified: a fresh connection authenticated with the key alone, so no password is needed from now on."
|
|
130
|
+
: "Warning: the key was installed but a key-only login could not be verified. The password is still in the profile as a fallback.",
|
|
131
|
+
];
|
|
132
|
+
return lines.join("\n");
|
|
133
|
+
}
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH key generation, in process.
|
|
3
|
+
*
|
|
4
|
+
* `ssh-keygen` is not something to depend on: it is absent on plenty of
|
|
5
|
+
* Windows installs, and the whole point of the key bootstrap is that it works
|
|
6
|
+
* without the user preparing anything first. Node can generate and sign with
|
|
7
|
+
* ed25519; what it cannot do is write OpenSSH's key formats, so those are
|
|
8
|
+
* encoded here.
|
|
9
|
+
*
|
|
10
|
+
* The output is byte-for-byte what ssh-keygen produces for an unencrypted
|
|
11
|
+
* ed25519 key, so the result also works with the plain `ssh` command.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as crypto from "node:crypto";
|
|
15
|
+
|
|
16
|
+
const KEY_TYPE = "ssh-ed25519";
|
|
17
|
+
const AUTH_MAGIC = Buffer.from("openssh-key-v1\0", "binary");
|
|
18
|
+
|
|
19
|
+
// --- SSH wire primitives --------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** An SSH "string": a 32-bit big-endian length followed by the bytes. */
|
|
22
|
+
function sshString(value: Buffer | string): Buffer {
|
|
23
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf-8");
|
|
24
|
+
const length = Buffer.alloc(4);
|
|
25
|
+
length.writeUInt32BE(bytes.length, 0);
|
|
26
|
+
return Buffer.concat([length, bytes]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function uint32(value: number): Buffer {
|
|
30
|
+
const buffer = Buffer.alloc(4);
|
|
31
|
+
buffer.writeUInt32BE(value >>> 0, 0);
|
|
32
|
+
return buffer;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Read an SSH string, returning it and the offset after it. */
|
|
36
|
+
function readSshString(buffer: Buffer, offset: number): [Buffer, number] {
|
|
37
|
+
const length = buffer.readUInt32BE(offset);
|
|
38
|
+
const start = offset + 4;
|
|
39
|
+
return [buffer.subarray(start, start + length), start + length];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Public keys ----------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** The wire-format blob for an ed25519 public key. */
|
|
45
|
+
export function publicKeyBlob(publicKeyRaw: Buffer): Buffer {
|
|
46
|
+
return Buffer.concat([sshString(KEY_TYPE), sshString(publicKeyRaw)]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One authorized_keys / .pub line: "ssh-ed25519 <base64> <comment>". */
|
|
50
|
+
export function formatPublicKeyLine(publicKeyRaw: Buffer, comment: string): string {
|
|
51
|
+
const encoded = publicKeyBlob(publicKeyRaw).toString("base64");
|
|
52
|
+
const suffix = comment.trim() ? ` ${comment.trim()}` : "";
|
|
53
|
+
return `${KEY_TYPE} ${encoded}${suffix}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One line built from an already-encoded key blob, e.g. one ssh2 parsed. */
|
|
57
|
+
export function formatPublicKeyLineFromBlob(blob: Buffer, comment: string): string {
|
|
58
|
+
const suffix = comment.trim() ? ` ${comment.trim()}` : "";
|
|
59
|
+
return `${keyBlobType(blob)} ${blob.toString("base64")}${suffix}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The fingerprint OpenSSH shows: SHA256 over the key blob, base64, unpadded.
|
|
64
|
+
*/
|
|
65
|
+
export function keyFingerprint(blob: Buffer): string {
|
|
66
|
+
const digest = crypto.createHash("sha256").update(blob).digest("base64");
|
|
67
|
+
return `SHA256:${digest.replace(/=+$/, "")}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Pull the algorithm name out of a wire-format key blob. */
|
|
71
|
+
export function keyBlobType(blob: Buffer): string {
|
|
72
|
+
try {
|
|
73
|
+
const [type] = readSshString(blob, 0);
|
|
74
|
+
return type.toString("utf-8");
|
|
75
|
+
} catch {
|
|
76
|
+
return "unknown";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Private keys ---------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
function toPem(body: Buffer): string {
|
|
83
|
+
const encoded = body.toString("base64").replace(/(.{70})/g, "$1\n").replace(/\n$/, "");
|
|
84
|
+
return `-----BEGIN OPENSSH PRIVATE KEY-----\n${encoded}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Encode an unencrypted ed25519 private key in OpenSSH's container format.
|
|
89
|
+
*
|
|
90
|
+
* The layout is: magic, cipher/kdf names (all "none" here), the public key,
|
|
91
|
+
* then a "private" section holding a repeated check integer, the key pair,
|
|
92
|
+
* the comment, and padding to the cipher block size.
|
|
93
|
+
*/
|
|
94
|
+
function encodeOpenSshPrivateKey(
|
|
95
|
+
publicKeyRaw: Buffer,
|
|
96
|
+
privateSeed: Buffer,
|
|
97
|
+
comment: string,
|
|
98
|
+
): string {
|
|
99
|
+
const pubBlob = publicKeyBlob(publicKeyRaw);
|
|
100
|
+
|
|
101
|
+
// The two check integers must match; a wrong passphrase shows up as a
|
|
102
|
+
// mismatch when the section is encrypted. Unencrypted, they are a checksum.
|
|
103
|
+
const check = crypto.randomBytes(4);
|
|
104
|
+
const privateSection: Buffer[] = [
|
|
105
|
+
check,
|
|
106
|
+
check,
|
|
107
|
+
sshString(KEY_TYPE),
|
|
108
|
+
sshString(publicKeyRaw),
|
|
109
|
+
// OpenSSH stores seed and public key together as the 64-byte private key.
|
|
110
|
+
sshString(Buffer.concat([privateSeed, publicKeyRaw])),
|
|
111
|
+
sshString(comment),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
let body = Buffer.concat(privateSection);
|
|
115
|
+
// Pad to a multiple of 8 with 1, 2, 3, ... as OpenSSH does.
|
|
116
|
+
const blockSize = 8;
|
|
117
|
+
const padding: number[] = [];
|
|
118
|
+
for (let i = 1; (body.length + padding.length) % blockSize !== 0; i += 1) {
|
|
119
|
+
padding.push(i);
|
|
120
|
+
}
|
|
121
|
+
body = Buffer.concat([body, Buffer.from(padding)]);
|
|
122
|
+
|
|
123
|
+
return toPem(
|
|
124
|
+
Buffer.concat([
|
|
125
|
+
AUTH_MAGIC,
|
|
126
|
+
sshString("none"), // ciphername
|
|
127
|
+
sshString("none"), // kdfname
|
|
128
|
+
sshString(""), // kdfoptions
|
|
129
|
+
uint32(1), // number of keys
|
|
130
|
+
sshString(pubBlob),
|
|
131
|
+
sshString(body),
|
|
132
|
+
]),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface GeneratedKeyPair {
|
|
137
|
+
/** OpenSSH private key, ready to write to a file or hand to ssh2. */
|
|
138
|
+
readonly privateKey: string;
|
|
139
|
+
/** One authorized_keys line. */
|
|
140
|
+
readonly publicKey: string;
|
|
141
|
+
readonly fingerprint: string;
|
|
142
|
+
readonly comment: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Generate an ed25519 key pair.
|
|
147
|
+
*
|
|
148
|
+
* ed25519 rather than RSA: every OpenSSH released in the last decade accepts
|
|
149
|
+
* it, the keys are short enough to paste, and generation is instant.
|
|
150
|
+
*/
|
|
151
|
+
export function generateKeyPair(comment: string): GeneratedKeyPair {
|
|
152
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
153
|
+
|
|
154
|
+
// The JWK export is the only way to get the raw 32-byte values out of Node.
|
|
155
|
+
const publicJwk = publicKey.export({ format: "jwk" }) as { x: string };
|
|
156
|
+
const privateJwk = privateKey.export({ format: "jwk" }) as { d: string };
|
|
157
|
+
|
|
158
|
+
const publicRaw = Buffer.from(publicJwk.x, "base64url");
|
|
159
|
+
const seed = Buffer.from(privateJwk.d, "base64url");
|
|
160
|
+
|
|
161
|
+
if (publicRaw.length !== 32 || seed.length !== 32) {
|
|
162
|
+
throw new Error("Unexpected ed25519 key size from the crypto module.");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
privateKey: encodeOpenSshPrivateKey(publicRaw, seed, comment),
|
|
167
|
+
publicKey: formatPublicKeyLine(publicRaw, comment),
|
|
168
|
+
fingerprint: keyFingerprint(publicKeyBlob(publicRaw)),
|
|
169
|
+
comment,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A default comment identifying where the key was made. */
|
|
174
|
+
export function defaultKeyComment(user: string, host: string): string {
|
|
175
|
+
return `pi-ssh ${user}@${host}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Compare two authorized_keys lines by their key material, ignoring the
|
|
180
|
+
* comment and any options prefix, so re-running the bootstrap does not append
|
|
181
|
+
* a second copy of the same key.
|
|
182
|
+
*/
|
|
183
|
+
export function sameKeyMaterial(a: string, b: string): boolean {
|
|
184
|
+
const material = (line: string) => {
|
|
185
|
+
const parts = line.trim().split(/\s+/);
|
|
186
|
+
const index = parts.findIndex((part) => /^(ssh|ecdsa|sk)-/.test(part));
|
|
187
|
+
return index === -1 ? "" : parts.slice(index, index + 2).join(" ");
|
|
188
|
+
};
|
|
189
|
+
const left = material(a);
|
|
190
|
+
return left.length > 0 && left === material(b);
|
|
191
|
+
}
|