Line data Source code
1 125 : /*
2 : * Copyright (C) 2015 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 125 : 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 125 : 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 125 : 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 125 : }
34 :
35 3 : export class Keys extends EventTarget {
36 3 : path: string | null = null;
37 3 : items: Record<string, Key> = { };
38 :
39 3 : #p_have_path: Promise<void>;
40 :
41 3 : constructor() {
42 3 : super();
43 3 : this.#p_have_path = cockpit.user()
44 3 : .then(user => {
45 3 : this.path = user.home + '/.ssh';
46 3 : this.#refresh();
47 3 : });
48 3 : }
49 :
50 3 : #proc: cockpit.Spawn<string> | null = null;
51 3 : #timeout: number | null = null;
52 :
53 3 : #refresh(): void {
54 3 : if (this.#proc || !this.path)
55 3 : return;
56 :
57 3 : if (this.#timeout)
58 3 : window.clearTimeout(this.#timeout);
59 3 : this.#timeout = null;
60 :
61 3 : this.#proc = cockpit.script(lister, [this.path], { err: "message" });
62 3 : this.#proc
63 3 : .then(data => this.#process(data))
64 1 : .catch(ex => console.warn("failed to list keys in home directory: " + ex.message))
65 3 : .finally(() => {
66 3 : this.#proc = null;
67 :
68 3 : if (!this.#timeout)
69 2 : this.#timeout = window.setTimeout(() => this.#refresh(), 5000);
70 3 : });
71 3 : }
72 :
73 3 : #process(data: string): void {
74 3 : const blocks = data.split('\v');
75 3 : let key: Key | undefined;
76 3 : const items = { };
77 :
78 : /* First block is the data from ssh agent */
79 3 : blocks[0].trim().split("\n")
80 3 : .forEach(line => {
81 3 : key = this.#parse_key(line, items);
82 3 : if (key)
83 3 : key.loaded = true;
84 3 : });
85 :
86 : /* Next come individual triples of blocks */
87 3 : blocks.slice(1).forEach((block, i) => {
88 3 : switch (i % 3) {
89 3 : case 0:
90 3 : key = this.#parse_key(block, items);
91 3 : break;
92 3 : case 1:
93 3 : if (key) {
94 3 : block = block.trim();
95 3 : if (block.slice(-4) === ".pub")
96 3 : key.name = block.slice(0, -4);
97 3 : else if (block)
98 0 : key.name = block;
99 : else
100 3 : key.agent_only = true;
101 3 : }
102 3 : break;
103 3 : case 2:
104 3 : if (key)
105 3 : this.#parse_info(block, key);
106 3 : break;
107 3 : }
108 3 : });
109 :
110 3 : this.items = items;
111 3 : this.dispatchEvent(new CustomEvent("changed"));
112 3 : }
113 :
114 3 : #parse_key(line: string, items: Record<string, Key>): Key | undefined {
115 3 : const parts = line.trim().split(" ");
116 3 : let id;
117 3 : let type;
118 3 : let comment;
119 :
120 : /* SSHv1 keys */
121 0 : if (!isNaN(parseInt(parts[0], 10))) {
122 0 : id = parts[2];
123 0 : type = "RSA1";
124 0 : comment = parts.slice(3).join(" ");
125 0 : } else if (parts[0].indexOf("ssh-") === 0) {
126 3 : id = parts[1];
127 3 : type = parts[0].substring(4).toUpperCase();
128 3 : comment = parts.slice(2).join(" ");
129 0 : } else if (parts[0].indexOf("ecdsa-") === 0) {
130 0 : id = parts[1];
131 0 : type = "ECDSA";
132 0 : comment = parts.slice(2).join(" ");
133 0 : } else {
134 2 : return;
135 2 : }
136 :
137 3 : let key = items[id];
138 3 : if (key) {
139 3 : key.type = type;
140 3 : key.comment = comment;
141 3 : key.data = line;
142 3 : } else {
143 3 : key = items[id] = {
144 3 : type,
145 3 : comment,
146 3 : data: line,
147 3 : };
148 3 : }
149 :
150 3 : return key;
151 3 : }
152 :
153 3 : #parse_info(line: string, key: Key): void {
154 3 : const parts = line.trim().split(" ")
155 3 : .filter(n => !!n);
156 :
157 3 : key.size = parseInt(parts[0], 10);
158 3 : if (isNaN(key.size))
159 1 : key.size = null;
160 :
161 3 : key.fingerprint = parts[1];
162 :
163 3 : if (!key.name && parts[2] && parts[2].indexOf("/") !== -1)
164 1 : key.name = parts[2];
165 3 : }
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 2 : async load(name: string, password: string): Promise<void> {
243 2 : const ask_exp = /.*Enter passphrase for .*/;
244 2 : const perm_exp = /.*UNPROTECTED PRIVATE KEY FILE.*/;
245 2 : const bad_exp = /.*Bad passphrase.*/;
246 :
247 2 : let buffer = "";
248 2 : let output = "";
249 2 : let failure = _("Not a valid private key");
250 2 : let sent_password = false;
251 :
252 2 : await this.#p_have_path;
253 2 : cockpit.assert(this.path);
254 :
255 2 : const proc = cockpit.spawn(["ssh-add", name],
256 2 : { 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 2 : proc.stream(data => {
264 2 : buffer += data;
265 2 : 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 2 : buffer = "";
271 2 : failure = _("Password not accepted");
272 2 : proc.input(password + "\n", true);
273 2 : sent_password = true;
274 1 : } else if (bad_exp.test(buffer)) {
275 1 : buffer = "";
276 1 : proc.input("\n", true);
277 1 : }
278 2 : });
279 :
280 2 : try {
281 2 : await proc;
282 2 : 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 2 : window.clearTimeout(timeout);
296 2 : }
297 2 : }
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 1 : close() {
314 1 : if (this.#proc)
315 1 : this.#proc.close();
316 1 : if (this.#timeout)
317 1 : window.clearTimeout(this.#timeout);
318 1 : this.#timeout = null;
319 1 : }
320 125 : }
321 :
322 3 : export function keys_instance() {
323 3 : return new Keys();
324 3 : }
|