* Copyright (C) 2015 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit, { SpawnOptions } from "cockpit";
import lister from "credentials-ssh-private-keys.sh";
import remove_key from "credentials-ssh-remove-key.sh";
const _ = cockpit.gettext;
export interface Key {
type: string;
comment: string;
data: string;
name?: string;
loaded?: boolean;
agent_only?: boolean;
size?: number | null;
fingerprint?: string;
}
export class KeyLoadError extends Error {
sent_password: boolean;
constructor(sent_password: boolean, message: string) {
super(message);
this.sent_password = sent_password;
}
}
export class Keys extends EventTarget {
path: string | null = null;
items: Record<string, Key> = { };
#p_have_path: Promise<void>;
constructor() {
super();
this.#p_have_path = cockpit.user()
.then(user => {
this.path = user.home + '/.ssh';
this.#refresh();
});
}
#proc: cockpit.Spawn<string> | null = null;
#timeout: number | null = null;
#refresh(): void {
if (this.#proc || !this.path)
return;
if (this.#timeout)
window.clearTimeout(this.#timeout);
this.#timeout = null;
this.#proc = cockpit.script(lister, [this.path], { err: "message" });
this.#proc
.then(data => this.#process(data))
.catch(ex => console.warn("failed to list keys in home directory: " + ex.message))
.finally(() => {
this.#proc = null;
if (!this.#timeout)
this.#timeout = window.setTimeout(() => this.#refresh(), 5000);
});
}
#process(data: string): void {
const blocks = data.split('\v');
let key: Key | undefined;
const items = { };
blocks[0].trim().split("\n")
.forEach(line => {
key = this.#parse_key(line, items);
if (key)
key.loaded = true;
});
blocks.slice(1).forEach((block, i) => {
switch (i % 3) {
case 0:
key = this.#parse_key(block, items);
break;
case 1:
if (key) {
block = block.trim();
if (block.slice(-4) === ".pub")
key.name = block.slice(0, -4);
else if (block)
key.name = block;
else
key.agent_only = true;
}
break;
case 2:
if (key)
this.#parse_info(block, key);
break;
}
});
this.items = items;
this.dispatchEvent(new CustomEvent("changed"));
}
#parse_key(line: string, items: Record<string, Key>): Key | undefined {
const parts = line.trim().split(" ");
let id;
let type;
let comment;
if (!isNaN(parseInt(parts[0], 10))) {
id = parts[2];
type = "RSA1";
comment = parts.slice(3).join(" ");
} else if (parts[0].indexOf("ssh-") === 0) {
id = parts[1];
type = parts[0].substring(4).toUpperCase();
comment = parts.slice(2).join(" ");
} else if (parts[0].indexOf("ecdsa-") === 0) {
id = parts[1];
type = "ECDSA";
comment = parts.slice(2).join(" ");
} else {
return;
}
let key = items[id];
if (key) {
key.type = type;
key.comment = comment;
key.data = line;
} else {
key = items[id] = {
type,
comment,
data: line,
};
}
return key;
}
#parse_info(line: string, key: Key): void {
const parts = line.trim().split(" ")
.filter(n => !!n);
key.size = parseInt(parts[0], 10);
if (isNaN(key.size))
key.size = null;
key.fingerprint = parts[1];
if (!key.name && parts[2] && parts[2].indexOf("/") !== -1)
key.name = parts[2];
}
async #run_keygen(file: string, new_type: string | null, old_pass: string | null, new_pass: string): Promise<void> {
const old_exps = [/.*Enter old passphrase: $/];
const new_exps = [/.*Enter passphrase.*/, /.*Enter new passphrase.*/, /.*Enter same passphrase again: $/];
const bad_exps = [/.*failed: passphrase is too short.*/];
let buffer = "";
let sent_new = false;
let failure = _("No such file or directory");
console.assert((new_type == null) !== (old_pass == null));
const cmd = ["ssh-keygen", "-f", file];
if (new_type)
cmd.push("-t", new_type);
else
cmd.push("-p");
await this.#p_have_path;
cockpit.assert(this.path);
const proc = cockpit.spawn(cmd, { pty: true, environ: ["LC_ALL=C"], err: "out", directory: this.path });
proc.stream(data => {
buffer += data;
if (old_pass && old_exps.some(exp => exp.test(buffer))) {
buffer = "";
failure = _("Old password not accepted");
proc.input(old_pass + "\n", true);
return;
}
if (new_exps.some(exp => exp.test(buffer))) {
buffer = "";
proc.input(new_pass + "\n", true);
failure = _("Failed to change password");
sent_new = true;
return;
}
if (sent_new && bad_exps.some(exp => exp.test(buffer))) {
failure = _("New password was not accepted");
}
});
const timeout = window.setTimeout(() => {
failure = _("Prompting via ssh-keygen timed out");
proc.close("terminated");
}, 10 * 1000);
try {
await proc;
} catch (ex) {
if (ex instanceof cockpit.ProcessError && ex.exit_status)
throw new Error(failure);
throw ex;
} finally {
window.clearInterval(timeout);
}
}
async change(name: string, old_pass: string, new_pass: string): Promise<void> {
await this.#run_keygen(name, null, old_pass, new_pass);
}
async create(name: string, type: string, new_pass: string): Promise<void> {
await cockpit.script('dir=$(dirname "$1"); test -e "$dir" || mkdir -m 700 "$dir"', [name]);
await this.#run_keygen(name, type, null, new_pass);
}
async get_pubkey(name: string): Promise<string> {
return await cockpit.file(name + ".pub").read();
}
async load(name: string, password: string): Promise<void> {
const ask_exp = /.*Enter passphrase for .*/;
const perm_exp = /.*UNPROTECTED PRIVATE KEY FILE.*/;
const bad_exp = /.*Bad passphrase.*/;
let buffer = "";
let output = "";
let failure = _("Not a valid private key");
let sent_password = false;
await this.#p_have_path;
cockpit.assert(this.path);
const proc = cockpit.spawn(["ssh-add", name],
{ pty: true, environ: ["LC_ALL=C"], err: "out", directory: this.path });
const timeout = window.setTimeout(() => {
failure = _("Prompting via ssh-add timed out");
proc.close("terminated");
}, 10 * 1000);
proc.stream(data => {
buffer += data;
output += data;
if (perm_exp.test(buffer)) {
failure = _("Invalid file permissions");
buffer = "";
} else if (ask_exp.test(buffer)) {
buffer = "";
failure = _("Password not accepted");
proc.input(password + "\n", true);
sent_password = true;
} else if (bad_exp.test(buffer)) {
buffer = "";
proc.input("\n", true);
}
});
try {
await proc;
this.#refresh();
} catch (error) {
console.log(output);
let ex: KeyLoadError | unknown;
if (error instanceof cockpit.ProcessError && error.exit_status) {
ex = new KeyLoadError(sent_password, failure);
} else if (error instanceof Error) {
ex = new KeyLoadError(sent_password, error.message);
} else {
ex = error;
}
throw ex;
} finally {
window.clearTimeout(timeout);
}
}
async unload(key: Key): Promise<void> {
await this.#p_have_path;
cockpit.assert(this.path);
const options: SpawnOptions & { binary?: false; } = { pty: true, err: "message", directory: this.path };
if (key.name && !key.agent_only)
await cockpit.spawn(["ssh-add", "-d", key.name], options);
else
await cockpit.script(remove_key, [key.data], options);
this.#refresh();
}
close() {
if (this.#proc)
this.#proc.close();
if (this.#timeout)
window.clearTimeout(this.#timeout);
this.#timeout = null;
}
}
export function keys_instance() {
return new Keys();
}