LCOV - code coverage report
Current view: top level - pkg/lib - credentials.ts Coverage Total Hit
Test: cockpit Lines: 85.3 % 245 209
Test Date: 2026-06-25 09:20:42

            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 : }
        

Generated by: LCOV version 2.0-1