@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.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Turning a password login into a key login.
3
+ *
4
+ * This is what `ssh-copy-id` does, except it runs inside pi and therefore
5
+ * works on Windows too, where neither ssh-copy-id nor ssh-keygen is normally
6
+ * present. The steps are deliberately conservative: the existing
7
+ * authorized_keys is read before anything is written, the key is only
8
+ * appended when it is not already there, and the password stays in the
9
+ * profile unless the caller asks for it to go -- losing both at once would
10
+ * lock the user out of their own host.
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 ssh2 from "ssh2";
17
+ import type { AuthorizeResult, SshProfile } from "./types.ts";
18
+ import { execCommand, shellQuote, withConnection } from "./clients/ssh-client.ts";
19
+ import { expandPath, updateProfile } from "./config.ts";
20
+ import {
21
+ defaultKeyComment,
22
+ formatPublicKeyLineFromBlob,
23
+ generateKeyPair,
24
+ keyFingerprint,
25
+ publicKeyBlob,
26
+ sameKeyMaterial,
27
+ } from "./keys.ts";
28
+
29
+ export interface AuthorizeOptions {
30
+ readonly profileName: string;
31
+ readonly profile: SshProfile;
32
+ /** Where the key lives. Defaults to ~/.ssh/id_ed25519_pi_<profile>. */
33
+ readonly keyPath?: string;
34
+ readonly comment?: string;
35
+ readonly acceptNewHostKey?: boolean;
36
+ /** Drop the stored password once a key-only login is proven to work. */
37
+ readonly removePassword?: boolean;
38
+ /**
39
+ * Absolute path of the remote authorized_keys, for hosts whose sshd is
40
+ * configured with a non-standard AuthorizedKeysFile. Defaults to
41
+ * ~/.ssh/authorized_keys on the remote account.
42
+ */
43
+ readonly authorizedKeysPath?: string;
44
+ readonly signal?: AbortSignal;
45
+ }
46
+
47
+ /** Dirname on the remote host, which is POSIX regardless of our platform. */
48
+ function posixDirname(remotePath: string): string {
49
+ const trimmed = remotePath.replace(/\/+$/, "");
50
+ const index = trimmed.lastIndexOf("/");
51
+ return index <= 0 ? "/" : trimmed.slice(0, index);
52
+ }
53
+
54
+ /** A profile name reduced to something safe to put in a file name. */
55
+ export function keyFileNameFor(profileName: string): string {
56
+ const slug = profileName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
57
+ return `id_ed25519_pi_${slug || "host"}`;
58
+ }
59
+
60
+ export function defaultKeyPath(profileName: string): string {
61
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
62
+ return path.join(home, ".ssh", keyFileNameFor(profileName));
63
+ }
64
+
65
+ interface LocalKey {
66
+ readonly privateKeyPath: string;
67
+ readonly publicKeyPath: string;
68
+ readonly publicKeyLine: string;
69
+ readonly fingerprint: string;
70
+ readonly created: boolean;
71
+ }
72
+
73
+ /**
74
+ * Use the key at `keyPath`, generating one if it is not there.
75
+ *
76
+ * An existing key is reused rather than replaced: overwriting it would
77
+ * silently invalidate every other host that already trusts it.
78
+ */
79
+ export function ensureLocalKey(
80
+ keyPath: string,
81
+ comment: string,
82
+ ): LocalKey {
83
+ const privateKeyPath = expandPath(keyPath);
84
+ const publicKeyPath = `${privateKeyPath}.pub`;
85
+
86
+ if (fs.existsSync(privateKeyPath)) {
87
+ const parsed = ssh2.utils.parseKey(fs.readFileSync(privateKeyPath));
88
+ if (parsed instanceof Error) {
89
+ throw new Error(
90
+ `The key at ${privateKeyPath} could not be read: ${parsed.message}. If it has a passphrase, this bootstrap cannot use it; point keyPath somewhere else.`,
91
+ );
92
+ }
93
+ const blob = (parsed as { getPublicSSH(): Buffer }).getPublicSSH();
94
+ const line = fs.existsSync(publicKeyPath)
95
+ ? fs.readFileSync(publicKeyPath, "utf-8").trim()
96
+ : formatPublicKeyLineFromBlob(blob, comment);
97
+
98
+ return {
99
+ privateKeyPath,
100
+ publicKeyPath,
101
+ publicKeyLine: line,
102
+ fingerprint: keyFingerprint(blob),
103
+ created: false,
104
+ };
105
+ }
106
+
107
+ const generated = generateKeyPair(comment);
108
+ fs.mkdirSync(path.dirname(privateKeyPath), { recursive: true, mode: 0o700 });
109
+ fs.writeFileSync(privateKeyPath, generated.privateKey, { mode: 0o600 });
110
+ fs.chmodSync(privateKeyPath, 0o600);
111
+ fs.writeFileSync(publicKeyPath, `${generated.publicKey}\n`, { mode: 0o644 });
112
+
113
+ return {
114
+ privateKeyPath,
115
+ publicKeyPath,
116
+ publicKeyLine: generated.publicKey,
117
+ fingerprint: generated.fingerprint,
118
+ created: true,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Put a public key into the remote authorized_keys, exactly once.
124
+ *
125
+ * Done over exec rather than SFTP so that ~ is expanded by the remote shell
126
+ * and the permissions are set in the same breath -- sshd silently ignores an
127
+ * authorized_keys that is group-writable.
128
+ */
129
+ export async function installPublicKey(
130
+ connection: Parameters<typeof execCommand>[0],
131
+ publicKeyLine: string,
132
+ options: { signal?: AbortSignal; authorizedKeysPath?: string } = {},
133
+ ): Promise<{ path: string; installed: boolean }> {
134
+ const { signal } = options;
135
+
136
+ // Without an override the remote shell expands $HOME, which is the only
137
+ // way to learn where the account actually lives. A host configured with a
138
+ // non-standard AuthorizedKeysFile needs to say so explicitly.
139
+ const file = options.authorizedKeysPath
140
+ ? shellQuote(options.authorizedKeysPath)
141
+ : '"$HOME/.ssh/authorized_keys"';
142
+ const dir = options.authorizedKeysPath
143
+ ? shellQuote(posixDirname(options.authorizedKeysPath))
144
+ : '"$HOME/.ssh"';
145
+
146
+ const prepare = await execCommand(
147
+ connection,
148
+ `umask 077 && mkdir -p ${dir} && touch ${file} && chmod 700 ${dir} && chmod 600 ${file} && printf '%s' ${file}`,
149
+ { signal, timeoutMs: 30_000 },
150
+ );
151
+ if (prepare.code !== 0) {
152
+ throw new Error(
153
+ `Could not prepare the remote .ssh directory: ${prepare.stderr.trim() || `exit ${prepare.code}`}`,
154
+ );
155
+ }
156
+ const authorizedKeysPath = prepare.stdout.trim() || "~/.ssh/authorized_keys";
157
+
158
+ const existing = await execCommand(connection, `cat ${file}`, {
159
+ signal,
160
+ timeoutMs: 30_000,
161
+ });
162
+ const alreadyThere = existing.stdout
163
+ .split(/\r?\n/)
164
+ .some((line) => sameKeyMaterial(line, publicKeyLine));
165
+
166
+ if (alreadyThere) {
167
+ return { path: authorizedKeysPath, installed: false };
168
+ }
169
+
170
+ const append = await execCommand(
171
+ connection,
172
+ `printf '%s\\n' ${shellQuote(publicKeyLine)} >> ${file}`,
173
+ { signal, timeoutMs: 30_000 },
174
+ );
175
+ if (append.code !== 0) {
176
+ throw new Error(
177
+ `Could not write to the remote authorized_keys: ${append.stderr.trim() || `exit ${append.code}`}`,
178
+ );
179
+ }
180
+ return { path: authorizedKeysPath, installed: true };
181
+ }
182
+
183
+ /**
184
+ * Generate a key if needed, install it on the host, switch the profile over,
185
+ * and prove that a key-only login works.
186
+ */
187
+ export async function authorizeKey(options: AuthorizeOptions): Promise<AuthorizeResult> {
188
+ const { profile, profileName } = options;
189
+ const comment = options.comment ?? defaultKeyComment(profile.user, profile.host);
190
+ const keyPath = options.keyPath ?? defaultKeyPath(profileName);
191
+
192
+ const local = ensureLocalKey(keyPath, comment);
193
+
194
+ // The first connection deliberately does not use the new key: it is not on
195
+ // the host yet, and falling back would hide a broken password.
196
+ const only = profile.password ? "password" : undefined;
197
+ const { path: authorizedKeysPath, installed } = await withConnection(
198
+ profile,
199
+ { signal: options.signal, acceptNewHostKey: options.acceptNewHostKey, only },
200
+ (connection) =>
201
+ installPublicKey(connection, local.publicKeyLine, {
202
+ signal: options.signal,
203
+ authorizedKeysPath: options.authorizedKeysPath,
204
+ }),
205
+ );
206
+
207
+ // Record the key before verifying, so a failed verification still leaves a
208
+ // usable profile that the user can retry with.
209
+ updateProfile(profileName, { privateKeyPath: local.privateKeyPath });
210
+
211
+ let verified = false;
212
+ try {
213
+ await withConnection(
214
+ { ...profile, privateKeyPath: local.privateKeyPath },
215
+ { signal: options.signal, only: "key" },
216
+ async () => undefined,
217
+ );
218
+ verified = true;
219
+ } catch {
220
+ verified = false;
221
+ }
222
+
223
+ // Only give up the password once the key is proven, and only on request.
224
+ if (verified && options.removePassword) {
225
+ updateProfile(profileName, { password: undefined });
226
+ }
227
+
228
+ return {
229
+ profile: profileName,
230
+ keyPath: local.privateKeyPath,
231
+ publicKeyPath: local.publicKeyPath,
232
+ fingerprint: local.fingerprint,
233
+ installed,
234
+ verified,
235
+ authorizedKeysPath,
236
+ };
237
+ }