Line data Source code
1 342 : /*
2 : * Copyright (C) 2015 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 342 : import cockpit, { SpawnOptions } from "cockpit";
7 :
8 : // @ts-expect-error: magic verbatim string import, not a JS module
9 : import lister from "credentials-ssh-private-keys.sh";
10 : // @ts-expect-error: magic verbatim string import, not a JS module
11 : import remove_key from "credentials-ssh-remove-key.sh";
12 :
13 342 : const _ = cockpit.gettext;
14 :
15 : export interface Key {
16 : type: string;
17 : comment: string;
18 : data: string;
19 : name?: string;
20 : loaded?: boolean;
21 : agent_only?: boolean;
22 : size?: number | null;
23 : fingerprint?: string;
24 : }
25 :
26 342 : export class KeyLoadError extends Error {
27 : sent_password: boolean;
28 :
29 2 : constructor(sent_password: boolean, message: string) {
30 2 : super(message);
31 2 : this.sent_password = sent_password;
32 2 : }
33 342 : }
34 :
35 10 : export class Keys extends EventTarget {
36 10 : path: string | null = null;
37 10 : items: Record<string, Key> = { };
38 :
39 10 : #p_have_path: Promise<void>;
40 :
41 10 : constructor() {
42 10 : super();
43 10 : this.#p_have_path = cockpit.user()
44 10 : .then(user => {
45 10 : this.path = user.home + '/.ssh';
46 10 : this.#refresh();
47 10 : });
48 10 : }
49 :
50 10 : #proc: cockpit.Spawn<string> | null = null;
51 10 : #timeout: number | null = null;
52 :
53 10 : #refresh(): void {
54 10 : if (this.#proc || !this.path)
55 10 : return;
56 :
57 10 : if (this.#timeout)
58 7 : window.clearTimeout(this.#timeout);
59 10 : this.#timeout = null;
60 :
61 10 : this.#proc = cockpit.script(lister, [this.path], { err: "message" });
62 10 : this.#proc
63 7 : .then(data => this.#process(data))
64 4 : .catch(ex => console.warn("failed to list keys in home directory: " + ex.message))
65 10 : .finally(() => {
66 10 : this.#proc = null;
67 :
68 10 : if (!this.#timeout)
69 2 : this.#timeout = window.setTimeout(() => this.#refresh(), 5000);
70 10 : });
71 10 : }
72 :
73 7 : #process(data: string): void {
74 7 : const blocks = data.split('\v');
75 7 : let key: Key | undefined;
76 7 : const items = { };
77 :
78 : /* First block is the data from ssh agent */
79 7 : blocks[0].trim().split("\n")
80 7 : .forEach(line => {
81 7 : key = this.#parse_key(line, items);
82 7 : if (key)
83 6 : key.loaded = true;
84 7 : });
85 :
86 : /* Next come individual triples of blocks */
87 7 : blocks.slice(1).forEach((block, i) => {
88 7 : switch (i % 3) {
89 7 : case 0:
90 7 : key = this.#parse_key(block, items);
91 7 : break;
92 7 : case 1:
93 7 : if (key) {
94 7 : block = block.trim();
95 7 : if (block.slice(-4) === ".pub")
96 6 : key.name = block.slice(0, -4);
97 6 : else if (block)
98 1 : key.name = block;
99 : else
100 6 : key.agent_only = true;
101 7 : }
102 7 : break;
103 7 : case 2:
104 7 : if (key)
105 7 : this.#parse_info(block, key);
106 7 : break;
107 7 : }
108 7 : });
109 :
110 7 : this.items = items;
111 7 : this.dispatchEvent(new CustomEvent("changed"));
112 7 : }
113 :
114 7 : #parse_key(line: string, items: Record<string, Key>): Key | undefined {
115 7 : const parts = line.trim().split(" ");
116 7 : let id;
117 7 : let type;
118 7 : let comment;
119 :
120 : /* SSHv1 keys */
121 1 : if (!isNaN(parseInt(parts[0], 10))) {
122 1 : id = parts[2];
123 1 : type = "RSA1";
124 1 : comment = parts.slice(3).join(" ");
125 1 : } else if (parts[0].indexOf("ssh-") === 0) {
126 7 : id = parts[1];
127 7 : type = parts[0].substring(4).toUpperCase();
128 7 : comment = parts.slice(2).join(" ");
129 1 : } else if (parts[0].indexOf("ecdsa-") === 0) {
130 1 : id = parts[1];
131 1 : type = "ECDSA";
132 1 : comment = parts.slice(2).join(" ");
133 1 : } else {
134 5 : return;
135 5 : }
136 :
137 7 : let key = items[id];
138 6 : if (key) {
139 6 : key.type = type;
140 6 : key.comment = comment;
141 6 : key.data = line;
142 6 : } else {
143 7 : key = items[id] = {
144 7 : type,
145 7 : comment,
146 7 : data: line,
147 7 : };
148 7 : }
149 :
150 7 : return key;
151 7 : }
152 :
153 7 : #parse_info(line: string, key: Key): void {
154 7 : const parts = line.trim().split(" ")
155 7 : .filter(n => !!n);
156 :
157 7 : key.size = parseInt(parts[0], 10);
158 7 : if (isNaN(key.size))
159 2 : key.size = null;
160 :
161 7 : key.fingerprint = parts[1];
162 :
163 6 : if (!key.name && parts[2] && parts[2].indexOf("/") !== -1)
164 3 : key.name = parts[2];
165 7 : }
166 :
167 1 : async #run_keygen(file: string, new_type: string | null, old_pass: string | null, new_pass: string): Promise<void> {
168 1 : const old_exps = [/.*Enter old passphrase: $/];
169 1 : const new_exps = [/.*Enter passphrase.*/, /.*Enter new passphrase.*/, /.*Enter same passphrase again: $/];
170 1 : const bad_exps = [/.*failed: passphrase is too short.*/];
171 :
172 1 : let buffer = "";
173 1 : let sent_new = false;
174 1 : let failure = _("No such file or directory");
175 :
176 : // Exactly one of new_type or old_pass must be given
177 1 : console.assert((new_type == null) != (old_pass == null));
178 :
179 1 : const cmd = ["ssh-keygen", "-f", file];
180 1 : if (new_type)
181 0 : cmd.push("-t", new_type);
182 : else
183 0 : cmd.push("-p");
184 :
185 1 : await this.#p_have_path;
186 1 : cockpit.assert(this.path);
187 :
188 1 : const proc = cockpit.spawn(cmd, { pty: true, environ: ["LC_ALL=C"], err: "out", directory: this.path });
189 :
190 1 : proc.stream(data => {
191 1 : buffer += data;
192 0 : if (old_pass && old_exps.some(exp => exp.test(buffer))) {
193 0 : buffer = "";
194 0 : failure = _("Old password not accepted");
195 0 : proc.input(old_pass + "\n", true);
196 0 : return;
197 0 : }
198 :
199 1 : if (new_exps.some(exp => exp.test(buffer))) {
200 1 : buffer = "";
201 1 : proc.input(new_pass + "\n", true);
202 1 : failure = _("Failed to change password");
203 1 : sent_new = true;
204 1 : return;
205 1 : }
206 :
207 1 : if (sent_new && bad_exps.some(exp => exp.test(buffer))) {
208 0 : failure = _("New password was not accepted");
209 0 : }
210 1 : });
211 :
212 0 : const timeout = window.setTimeout(() => {
213 0 : failure = _("Prompting via ssh-keygen timed out");
214 0 : proc.close("terminated");
215 0 : }, 10 * 1000);
216 :
217 1 : try {
218 1 : await proc;
219 0 : } catch (ex) {
220 0 : if (ex instanceof cockpit.ProcessError && ex.exit_status)
221 0 : throw new Error(failure);
222 0 : throw ex;
223 0 : } finally {
224 1 : window.clearInterval(timeout);
225 1 : }
226 1 : }
227 :
228 0 : async change(name: string, old_pass: string, new_pass: string): Promise<void> {
229 0 : await this.#run_keygen(name, null, old_pass, new_pass);
230 0 : }
231 :
232 1 : async create(name: string, type: string, new_pass: string): Promise<void> {
233 : // ensure ~/.ssh directory exists
234 1 : await cockpit.script('dir=$(dirname "$1"); test -e "$dir" || mkdir -m 700 "$dir"', [name]);
235 1 : await this.#run_keygen(name, type, null, new_pass);
236 1 : }
237 :
238 1 : async get_pubkey(name: string): Promise<string> {
239 1 : return await cockpit.file(name + ".pub").read();
240 1 : }
241 :
242 4 : async load(name: string, password: string): Promise<void> {
243 4 : const ask_exp = /.*Enter passphrase for .*/;
244 4 : const perm_exp = /.*UNPROTECTED PRIVATE KEY FILE.*/;
245 4 : const bad_exp = /.*Bad passphrase.*/;
246 :
247 4 : let buffer = "";
248 4 : let output = "";
249 4 : let failure = _("Not a valid private key");
250 4 : let sent_password = false;
251 :
252 4 : await this.#p_have_path;
253 4 : cockpit.assert(this.path);
254 :
255 4 : const proc = cockpit.spawn(["ssh-add", name],
256 4 : { pty: true, environ: ["LC_ALL=C"], err: "out", directory: this.path });
257 :
258 0 : const timeout = window.setTimeout(() => {
259 0 : failure = _("Prompting via ssh-add timed out");
260 0 : proc.close("terminated");
261 0 : }, 10 * 1000);
262 :
263 4 : proc.stream(data => {
264 4 : buffer += data;
265 4 : output += data;
266 0 : if (perm_exp.test(buffer)) {
267 0 : failure = _("Invalid file permissions");
268 0 : buffer = "";
269 0 : } else if (ask_exp.test(buffer)) {
270 4 : buffer = "";
271 4 : failure = _("Password not accepted");
272 4 : proc.input(password + "\n", true);
273 4 : sent_password = true;
274 1 : } else if (bad_exp.test(buffer)) {
275 1 : buffer = "";
276 1 : proc.input("\n", true);
277 1 : }
278 4 : });
279 :
280 4 : try {
281 4 : await proc;
282 4 : this.#refresh();
283 2 : } catch (error) {
284 2 : console.log(output);
285 2 : let ex: KeyLoadError | unknown;
286 2 : if (error instanceof cockpit.ProcessError && error.exit_status) {
287 2 : ex = new KeyLoadError(sent_password, failure);
288 0 : } else if (error instanceof Error) {
289 0 : ex = new KeyLoadError(sent_password, error.message);
290 0 : } else {
291 0 : ex = error;
292 0 : }
293 2 : throw ex;
294 2 : } finally {
295 4 : window.clearTimeout(timeout);
296 4 : }
297 4 : }
298 :
299 1 : async unload(key: Key): Promise<void> {
300 1 : await this.#p_have_path;
301 1 : cockpit.assert(this.path);
302 :
303 1 : const options: SpawnOptions & { binary?: false; } = { pty: true, err: "message", directory: this.path };
304 :
305 1 : if (key.name && !key.agent_only)
306 0 : await cockpit.spawn(["ssh-add", "-d", key.name], options);
307 : else
308 1 : await cockpit.script(remove_key, [key.data], options);
309 :
310 1 : this.#refresh();
311 1 : }
312 :
313 7 : close() {
314 7 : if (this.#proc)
315 3 : this.#proc.close();
316 7 : if (this.#timeout)
317 7 : window.clearTimeout(this.#timeout);
318 7 : this.#timeout = null;
319 7 : }
320 342 : }
321 :
322 10 : export function keys_instance() {
323 10 : return new Keys();
324 10 : }
|